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

Merge pull request #30215 from overleaf/ar/convert-real-time-to-esm

[real-time] convert real time to esm

GitOrigin-RevId: 7cc530cc977549d3274be42585735e1fd72cad5f
Andrew Rumble 7 месяцев назад
Родитель
Сommit
3073c94522
82 измененных файлов с 6891 добавлено и 5822 удалено
  1. 972 60
      package-lock.json
  2. 29 28
      services/real-time/app.js
  3. 5 2
      services/real-time/app/js/AuthorizationManager.js
  4. 5 5
      services/real-time/app/js/ChannelManager.js
  5. 7 7
      services/real-time/app/js/ConnectedUsersManager.js
  6. 4 4
      services/real-time/app/js/DeploymentManager.js
  7. 11 10
      services/real-time/app/js/DocumentUpdaterController.js
  8. 12 12
      services/real-time/app/js/DocumentUpdaterManager.js
  9. 2 2
      services/real-time/app/js/DrainManager.js
  10. 2 2
      services/real-time/app/js/Errors.js
  11. 4 4
      services/real-time/app/js/EventLogger.js
  12. 4 5
      services/real-time/app/js/HealthCheckManager.js
  13. 5 5
      services/real-time/app/js/HttpApiController.js
  14. 2 1
      services/real-time/app/js/HttpController.js
  15. 3 3
      services/real-time/app/js/RedisClientManager.js
  16. 5 5
      services/real-time/app/js/RoomManager.js
  17. 19 15
      services/real-time/app/js/Router.js
  18. 5 3
      services/real-time/app/js/SafeJsonParse.js
  19. 7 5
      services/real-time/app/js/SessionSockets.js
  20. 8 6
      services/real-time/app/js/WebApiManager.js
  21. 2 2
      services/real-time/app/js/WebsocketAddressManager.js
  22. 14 11
      services/real-time/app/js/WebsocketController.js
  23. 12 11
      services/real-time/app/js/WebsocketLoadBalancer.js
  24. 2 0
      services/real-time/buildscript.txt
  25. 0 0
      services/real-time/config/settings.defaults.cjs
  26. 0 0
      services/real-time/config/settings.test.cjs
  27. 2 0
      services/real-time/docker-compose.ci.yml
  28. 1 0
      services/real-time/docker-compose.yml
  29. 6 5
      services/real-time/package.json
  30. 6 7
      services/real-time/test/acceptance/js/ApplyUpdateTests.js
  31. 5 6
      services/real-time/test/acceptance/js/ClientTrackingTests.js
  32. 5 6
      services/real-time/test/acceptance/js/DrainManagerTests.js
  33. 8 9
      services/real-time/test/acceptance/js/EarlyDisconnect.js
  34. 20 19
      services/real-time/test/acceptance/js/HttpControllerTests.js
  35. 5 6
      services/real-time/test/acceptance/js/JoinDocTests.js
  36. 5 6
      services/real-time/test/acceptance/js/JoinProjectTests.js
  37. 8 9
      services/real-time/test/acceptance/js/LeaveDocTests.js
  38. 6 7
      services/real-time/test/acceptance/js/LeaveProjectTests.js
  39. 7 7
      services/real-time/test/acceptance/js/MatrixTests.js
  40. 6 7
      services/real-time/test/acceptance/js/PubSubRace.js
  41. 6 8
      services/real-time/test/acceptance/js/ReceiveEditorEventTests.js
  42. 8 10
      services/real-time/test/acceptance/js/ReceiveUpdateTests.js
  43. 4 4
      services/real-time/test/acceptance/js/RouterTests.js
  44. 5 5
      services/real-time/test/acceptance/js/SessionSocketsTests.js
  45. 3 3
      services/real-time/test/acceptance/js/SessionTests.js
  46. 7 5
      services/real-time/test/acceptance/js/helpers/FixturesManager.js
  47. 4 3
      services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js
  48. 5 4
      services/real-time/test/acceptance/js/helpers/MockWebServer.js
  49. 12 10
      services/real-time/test/acceptance/js/helpers/RealTimeClient.js
  50. 0 61
      services/real-time/test/acceptance/js/helpers/RealtimeServer.js
  51. 6 6
      services/real-time/test/acceptance/libs/XMLHttpRequest.js
  52. 6 19
      services/real-time/test/setup.js
  53. 479 0
      services/real-time/test/unit/js/AuthorizationManager.test.js
  54. 0 428
      services/real-time/test/unit/js/AuthorizationManagerTests.js
  55. 445 0
      services/real-time/test/unit/js/ChannelManager.test.js
  56. 0 432
      services/real-time/test/unit/js/ChannelManagerTests.js
  57. 688 0
      services/real-time/test/unit/js/ConnectedUsersManager.test.js
  58. 0 648
      services/real-time/test/unit/js/ConnectedUsersManagerTests.js
  59. 271 0
      services/real-time/test/unit/js/DocumentUpdaterController.test.js
  60. 0 259
      services/real-time/test/unit/js/DocumentUpdaterControllerTests.js
  61. 422 0
      services/real-time/test/unit/js/DocumentUpdaterManager.test.js
  62. 0 423
      services/real-time/test/unit/js/DocumentUpdaterManagerTests.js
  63. 126 0
      services/real-time/test/unit/js/DrainManager.test.js
  64. 0 127
      services/real-time/test/unit/js/DrainManagerTests.js
  65. 120 0
      services/real-time/test/unit/js/EventLogger.test.js
  66. 0 153
      services/real-time/test/unit/js/EventLoggerTests.js
  67. 403 0
      services/real-time/test/unit/js/RoomManager.test.js
  68. 0 412
      services/real-time/test/unit/js/RoomManagerTests.js
  69. 50 0
      services/real-time/test/unit/js/SafeJsonParse.test.js
  70. 0 55
      services/real-time/test/unit/js/SafeJsonParseTest.js
  71. 314 0
      services/real-time/test/unit/js/SessionSockets.test.js
  72. 0 280
      services/real-time/test/unit/js/SessionSocketsTests.js
  73. 255 0
      services/real-time/test/unit/js/WebApiManager.test.js
  74. 0 268
      services/real-time/test/unit/js/WebApiManagerTests.js
  75. 27 28
      services/real-time/test/unit/js/WebsocketAddressManager.test.js
  76. 1750 0
      services/real-time/test/unit/js/WebsocketController.test.js
  77. 0 1698
      services/real-time/test/unit/js/WebsocketControllerTests.js
  78. 181 168
      services/real-time/test/unit/js/WebsocketLoadBalancer.test.js
  79. 2 2
      services/real-time/test/unit/js/helpers/MockClient.js
  80. 34 0
      services/real-time/test/unit/setup.js
  81. 2 1
      services/real-time/tsconfig.json
  82. 25 0
      services/real-time/vitest.config.unit.cjs

+ 972 - 60
package-lock.json

@@ -613,36 +613,6 @@
         "typescript": "^5.0.4"
       }
     },
-    "libraries/promise-utils/node_modules/chai": {
-      "version": "4.3.10",
-      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz",
-      "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==",
-      "dev": true,
-      "dependencies": {
-        "assertion-error": "^1.1.0",
-        "check-error": "^1.0.3",
-        "deep-eql": "^4.1.3",
-        "get-func-name": "^2.0.2",
-        "loupe": "^2.3.6",
-        "pathval": "^1.1.1",
-        "type-detect": "^4.0.8"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "libraries/promise-utils/node_modules/deep-eql": {
-      "version": "4.1.3",
-      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
-      "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==",
-      "dev": true,
-      "dependencies": {
-        "type-detect": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=6"
-      }
-    },
     "libraries/promise-utils/node_modules/p-limit": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -11596,6 +11566,23 @@
         "node": ">=18"
       }
     },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+      "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/sunos-x64": {
       "version": "0.25.3",
       "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz",
@@ -25684,33 +25671,35 @@
       }
     },
     "node_modules/chai": {
-      "version": "4.3.6",
-      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz",
-      "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==",
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
+      "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "assertion-error": "^1.1.0",
-        "check-error": "^1.0.2",
-        "deep-eql": "^3.0.1",
-        "get-func-name": "^2.0.0",
-        "loupe": "^2.3.1",
+        "check-error": "^1.0.3",
+        "deep-eql": "^4.1.3",
+        "get-func-name": "^2.0.2",
+        "loupe": "^2.3.6",
         "pathval": "^1.1.1",
-        "type-detect": "^4.0.5"
+        "type-detect": "^4.1.0"
       },
       "engines": {
         "node": ">=4"
       }
     },
     "node_modules/chai-as-promised": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz",
-      "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==",
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.2.tgz",
+      "integrity": "sha512-aBDHZxRzYnUYuIAIPBH2s511DjlKPzXNlXSGFC8CwmroWQLfrW0LtE1nK3MAwwNhJPa9raEjNCmRoFpG0Hurdw==",
       "dev": true,
+      "license": "WTFPL",
       "dependencies": {
         "check-error": "^1.0.2"
       },
       "peerDependencies": {
-        "chai": ">= 2.1.2 < 5"
+        "chai": ">= 2.1.2 < 6"
       }
     },
     "node_modules/chai-exclude": {
@@ -25836,6 +25825,16 @@
       "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
       "dev": true
     },
+    "node_modules/chai/node_modules/type-detect": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
+      "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/chalk": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -28188,15 +28187,16 @@
       "integrity": "sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug=="
     },
     "node_modules/deep-eql": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
-      "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==",
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
+      "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "type-detect": "^4.0.0"
       },
       "engines": {
-        "node": ">=0.12"
+        "node": ">=6"
       }
     },
     "node_modules/deep-equal": {
@@ -57962,31 +57962,752 @@
         "mocha": "^11.1.0",
         "mocha-junit-reporter": "^2.2.1",
         "mocha-multi-reporters": "^1.5.1",
-        "sandboxed-module": "~0.3.0",
         "sinon": "^9.2.4",
-        "sinon-chai": "^3.7.0",
+        "sinon-chai": "4.0.1",
         "timekeeper": "0.0.4",
         "typescript": "^5.0.4",
-        "uid-safe": "^2.1.5"
+        "uid-safe": "^2.1.5",
+        "vitest": "^4.0.0"
       }
     },
-    "services/real-time/node_modules/sandboxed-module": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/sandboxed-module/-/sandboxed-module-0.3.0.tgz",
-      "integrity": "sha1-8fvvvYCaT2kHO9B8rm/H2y6vX2o=",
+    "services/real-time/node_modules/@esbuild/aix-ppc64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+      "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/android-arm": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+      "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/android-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+      "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/android-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+      "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/darwin-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+      "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/darwin-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+      "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/freebsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+      "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/linux-arm": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+      "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/linux-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+      "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/linux-ia32": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+      "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+      "cpu": [
+        "ia32"
+      ],
       "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/linux-loong64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+      "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/linux-mips64el": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+      "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/linux-ppc64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+      "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/linux-riscv64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+      "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/linux-s390x": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+      "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/linux-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+      "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/netbsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+      "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/openbsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+      "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/sunos-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+      "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/win32-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+      "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/win32-ia32": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+      "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@esbuild/win32-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+      "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@opentelemetry/api": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
+      "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "peer": true,
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "services/real-time/node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "license": "MIT",
       "dependencies": {
-        "require-like": "0.1.2",
-        "stack-trace": "0.0.6"
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
       }
     },
-    "services/real-time/node_modules/stack-trace": {
-      "version": "0.0.6",
-      "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.6.tgz",
-      "integrity": "sha1-HnGb1qJin/CcGJ4Xqe+QKpT8XbA=",
+    "services/real-time/node_modules/@vitest/expect": {
+      "version": "4.0.16",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz",
+      "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==",
       "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@standard-schema/spec": "^1.0.0",
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "4.0.16",
+        "@vitest/utils": "4.0.16",
+        "chai": "^6.2.1",
+        "tinyrainbow": "^3.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "services/real-time/node_modules/@vitest/expect/node_modules/chai": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+      "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+      "dev": true,
+      "license": "MIT",
       "engines": {
-        "node": "*"
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/@vitest/mocker": {
+      "version": "4.0.16",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz",
+      "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "4.0.16",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.21"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^6.0.0 || ^7.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "services/real-time/node_modules/@vitest/pretty-format": {
+      "version": "4.0.16",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz",
+      "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^3.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "services/real-time/node_modules/@vitest/runner": {
+      "version": "4.0.16",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz",
+      "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "4.0.16",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "services/real-time/node_modules/@vitest/snapshot": {
+      "version": "4.0.16",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz",
+      "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.0.16",
+        "magic-string": "^0.30.21",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "services/real-time/node_modules/@vitest/spy": {
+      "version": "4.0.16",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz",
+      "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "services/real-time/node_modules/@vitest/utils": {
+      "version": "4.0.16",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz",
+      "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.0.16",
+        "tinyrainbow": "^3.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "services/real-time/node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "services/real-time/node_modules/chai": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+      "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "assertion-error": "^2.0.1",
+        "check-error": "^2.1.1",
+        "deep-eql": "^5.0.1",
+        "loupe": "^3.1.0",
+        "pathval": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/chai-as-promised": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.2.tgz",
+      "integrity": "sha512-1GadL+sEJVLzDjcawPM4kjfnL+p/9vrxiEUonowKOAzvVg0PixJUdtuDzdkDeQhK3zfOE76GqGkZIQ7/Adcrqw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "check-error": "^2.1.1"
+      },
+      "peerDependencies": {
+        "chai": ">= 2.1.2 < 7"
+      }
+    },
+    "services/real-time/node_modules/check-error": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
+      "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      }
+    },
+    "services/real-time/node_modules/deep-eql": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+      "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "services/real-time/node_modules/esbuild": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+      "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.27.2",
+        "@esbuild/android-arm": "0.27.2",
+        "@esbuild/android-arm64": "0.27.2",
+        "@esbuild/android-x64": "0.27.2",
+        "@esbuild/darwin-arm64": "0.27.2",
+        "@esbuild/darwin-x64": "0.27.2",
+        "@esbuild/freebsd-arm64": "0.27.2",
+        "@esbuild/freebsd-x64": "0.27.2",
+        "@esbuild/linux-arm": "0.27.2",
+        "@esbuild/linux-arm64": "0.27.2",
+        "@esbuild/linux-ia32": "0.27.2",
+        "@esbuild/linux-loong64": "0.27.2",
+        "@esbuild/linux-mips64el": "0.27.2",
+        "@esbuild/linux-ppc64": "0.27.2",
+        "@esbuild/linux-riscv64": "0.27.2",
+        "@esbuild/linux-s390x": "0.27.2",
+        "@esbuild/linux-x64": "0.27.2",
+        "@esbuild/netbsd-arm64": "0.27.2",
+        "@esbuild/netbsd-x64": "0.27.2",
+        "@esbuild/openbsd-arm64": "0.27.2",
+        "@esbuild/openbsd-x64": "0.27.2",
+        "@esbuild/openharmony-arm64": "0.27.2",
+        "@esbuild/sunos-x64": "0.27.2",
+        "@esbuild/win32-arm64": "0.27.2",
+        "@esbuild/win32-ia32": "0.27.2",
+        "@esbuild/win32-x64": "0.27.2"
+      }
+    },
+    "services/real-time/node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "services/real-time/node_modules/loupe": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+      "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "services/real-time/node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "services/real-time/node_modules/pathval": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+      "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.16"
+      }
+    },
+    "services/real-time/node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "services/real-time/node_modules/sinon-chai": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-4.0.1.tgz",
+      "integrity": "sha512-xMKEEV3cYHC1G+boyr7QEqi80gHznYsxVdC9CdjP5JnCWz/jPGuXQzJz3PtBcb0CcHAxar15Y5sjLBoAs6a0yA==",
+      "dev": true,
+      "license": "(BSD-2-Clause OR WTFPL)",
+      "peerDependencies": {
+        "chai": "^5.0.0 || ^6.0.0",
+        "sinon": ">=4.0.0"
       }
     },
     "services/real-time/node_modules/timekeeper": {
@@ -57995,6 +58716,197 @@
       "integrity": "sha1-kNt58X2Ni1NiFUOJSSuXJ2LP0nY=",
       "dev": true
     },
+    "services/real-time/node_modules/tinyexec": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+      "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "services/real-time/node_modules/tinyrainbow": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+      "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "services/real-time/node_modules/vite": {
+      "version": "7.3.1",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+      "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.27.0",
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3",
+        "postcss": "^8.5.6",
+        "rollup": "^4.43.0",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "lightningcss": "^1.21.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "services/real-time/node_modules/vitest": {
+      "version": "4.0.16",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz",
+      "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/expect": "4.0.16",
+        "@vitest/mocker": "4.0.16",
+        "@vitest/pretty-format": "4.0.16",
+        "@vitest/runner": "4.0.16",
+        "@vitest/snapshot": "4.0.16",
+        "@vitest/spy": "4.0.16",
+        "@vitest/utils": "4.0.16",
+        "es-module-lexer": "^1.7.0",
+        "expect-type": "^1.2.2",
+        "magic-string": "^0.30.21",
+        "obug": "^2.1.1",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.3",
+        "std-env": "^3.10.0",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^1.0.2",
+        "tinyglobby": "^0.2.15",
+        "tinyrainbow": "^3.0.3",
+        "vite": "^6.0.0 || ^7.0.0",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@opentelemetry/api": "^1.9.0",
+        "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+        "@vitest/browser-playwright": "4.0.16",
+        "@vitest/browser-preview": "4.0.16",
+        "@vitest/browser-webdriverio": "4.0.16",
+        "@vitest/ui": "4.0.16",
+        "happy-dom": "*",
+        "jsdom": "*"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser-playwright": {
+          "optional": true
+        },
+        "@vitest/browser-preview": {
+          "optional": true
+        },
+        "@vitest/browser-webdriverio": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        }
+      }
+    },
+    "services/real-time/node_modules/yaml": {
+      "version": "2.8.2",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+      "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+      "dev": true,
+      "license": "ISC",
+      "optional": true,
+      "peer": true,
+      "bin": {
+        "yaml": "bin.mjs"
+      },
+      "engines": {
+        "node": ">= 14.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/eemeli"
+      }
+    },
     "services/references": {
       "name": "@overleaf/references",
       "dependencies": {

+ 29 - 28
services/real-time/app.js

@@ -1,30 +1,34 @@
 // Metrics must be initialized before importing anything else
-require('@overleaf/metrics/initialize')
+import '@overleaf/metrics/initialize.js'
+
+import Metrics from '@overleaf/metrics'
+import Settings from '@overleaf/settings'
+import async from 'async'
+import logger from '@overleaf/logger'
+import express from 'express'
+import session from 'express-session'
+import redis from '@overleaf/redis-wrapper'
+import ConnectRedis from 'connect-redis'
+import SessionSockets from './app/js/SessionSockets.js'
+import CookieParser from 'cookie-parser'
+import DrainManager from './app/js/DrainManager.js'
+import HealthCheckManager from './app/js/HealthCheckManager.js'
+import DeploymentManager from './app/js/DeploymentManager.js'
+import Path from 'node:path'
+import socketIO from 'socket.io'
+import socketIOClient from 'socket.io-client'
+import http from 'node:http'
+import Router from './app/js/Router.js'
+import WebsocketLoadBalancer from './app/js/WebsocketLoadBalancer.js'
+import DocumentUpdaterController from './app/js/DocumentUpdaterController.js'
 
-const Metrics = require('@overleaf/metrics')
-const Settings = require('@overleaf/settings')
-const async = require('async')
-
-const logger = require('@overleaf/logger')
 logger.initialize('real-time')
 Metrics.event_loop.monitor(logger)
 Metrics.open_sockets.monitor()
 
-const express = require('express')
-const session = require('express-session')
-const redis = require('@overleaf/redis-wrapper')
-
 const sessionRedisClient = redis.createClient(Settings.redis.websessions)
 
-const RedisStore = require('connect-redis')(session)
-const SessionSockets = require('./app/js/SessionSockets')
-const CookieParser = require('cookie-parser')
-
-const DrainManager = require('./app/js/DrainManager')
-const HealthCheckManager = require('./app/js/HealthCheckManager')
-const DeploymentManager = require('./app/js/DeploymentManager')
-
-const Path = require('node:path')
+const RedisStore = ConnectRedis(session)
 
 // NOTE: debug is invoked for every blob that is put on the wire
 const socketIoLogger = {
@@ -45,9 +49,9 @@ DeploymentManager.initialise()
 // Set up socket.io server
 const app = express()
 
-const server = require('node:http').createServer(app)
+const server = http.createServer(app)
 server.keepAliveTimeout = Settings.keepAliveTimeoutMs
-const io = require('socket.io').listen(server, {
+const io = socketIO.listen(server, {
   logger: socketIoLogger,
 })
 
@@ -127,7 +131,7 @@ io.configure(function () {
 // The express sendFile method correctly handles conditional
 // requests using the last-modified time and etag (which is
 // a combination of mtime and size)
-const socketIOClientFolder = require('socket.io-client').dist
+const socketIOClientFolder = socketIOClient.dist
 app.get('/socket.io/socket.io.js', function (req, res) {
   res.sendFile(Path.join(socketIOClientFolder, 'socket.io.min.js'))
 })
@@ -156,9 +160,7 @@ app.get('/debug/events', function (req, res) {
   res.send(`debug mode will log next ${Settings.debugEvents} events`)
 })
 
-const rclient = require('@overleaf/redis-wrapper').createClient(
-  Settings.redis.realtime
-)
+const rclient = redis.createClient(Settings.redis.realtime)
 
 function healthCheck(req, res) {
   rclient.healthCheck(function (error) {
@@ -190,13 +192,10 @@ app.get('/health_check/redis', healthCheck)
 // log http requests for routes defined from this point onwards
 app.use(Metrics.http.monitor(logger))
 
-const Router = require('./app/js/Router')
 Router.configure(app, io, sessionSockets)
 
-const WebsocketLoadBalancer = require('./app/js/WebsocketLoadBalancer')
 WebsocketLoadBalancer.listenForEditorEvents(io)
 
-const DocumentUpdaterController = require('./app/js/DocumentUpdaterController')
 DocumentUpdaterController.listenForUpdatesFromDocumentUpdater(io)
 
 const { port } = Settings.internal.realTime
@@ -352,3 +351,5 @@ if (Settings.continualPubsubTraffic) {
 
   runPubSubTraffic()
 }
+
+export default app

+ 5 - 2
services/real-time/app/js/AuthorizationManager.js

@@ -1,7 +1,10 @@
-const { NotAuthorizedError } = require('./Errors')
+import Errors from './Errors.js'
+
+const { NotAuthorizedError } = Errors
 
 let AuthorizationManager
-module.exports = AuthorizationManager = {
+
+export default AuthorizationManager = {
   assertClientCanViewProject(client, callback) {
     AuthorizationManager._assertClientHasPrivilegeLevel(
       client,

+ 5 - 5
services/real-time/app/js/ChannelManager.js

@@ -1,7 +1,7 @@
-const logger = require('@overleaf/logger')
-const metrics = require('@overleaf/metrics')
-const settings = require('@overleaf/settings')
-const OError = require('@overleaf/o-error')
+import logger from '@overleaf/logger'
+import metrics from '@overleaf/metrics'
+import settings from '@overleaf/settings'
+import OError from '@overleaf/o-error'
 
 const ClientMap = new Map() // for each redis client, store a Map of subscribed channels (channelname -> subscribe promise)
 
@@ -9,7 +9,7 @@ const ClientMap = new Map() // for each redis client, store a Map of subscribed
 // that we never subscribe to a channel multiple times. The socket.io side is
 // handled by RoomManager.
 
-module.exports = {
+export default {
   getClientMapEntry(rclient) {
     // return the per-client channel map if it exists, otherwise create and
     // return an empty map for the client.

+ 7 - 7
services/real-time/app/js/ConnectedUsersManager.js

@@ -1,9 +1,9 @@
-const async = require('async')
-const Settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-const redis = require('@overleaf/redis-wrapper')
-const OError = require('@overleaf/o-error')
-const Metrics = require('@overleaf/metrics')
+import async from 'async'
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import redis from '@overleaf/redis-wrapper'
+import OError from '@overleaf/o-error'
+import Metrics from '@overleaf/metrics'
 const rclient = redis.createClient(Settings.redis.realtime)
 const Keys = Settings.redis.realtime.key_schema
 
@@ -28,7 +28,7 @@ function recordProjectNotEmptySinceMetric(res, status) {
   Metrics.histogram('project_not_empty_since', diff, BUCKETS, { status })
 }
 
-module.exports = {
+export default {
   countConnectedClients(projectId, callback) {
     rclient.scard(Keys.clientsInProject({ project_id: projectId }), callback)
   },

+ 4 - 4
services/real-time/app/js/DeploymentManager.js

@@ -1,6 +1,6 @@
-const logger = require('@overleaf/logger')
-const settings = require('@overleaf/settings')
-const fs = require('node:fs')
+import logger from '@overleaf/logger'
+import settings from '@overleaf/settings'
+import fs from 'node:fs'
 
 // Monitor a status file (e.g. /etc/real_time_status) periodically and close the
 // service if the file contents don't contain the matching deployment colour.
@@ -45,7 +45,7 @@ function checkStatusFileSync() {
   }
 }
 
-module.exports = {
+export default {
   initialise() {
     if (statusFile && deploymentColour) {
       logger.info(

+ 11 - 10
services/real-time/app/js/DocumentUpdaterController.js

@@ -1,15 +1,16 @@
-const logger = require('@overleaf/logger')
-const settings = require('@overleaf/settings')
-const RedisClientManager = require('./RedisClientManager')
-const SafeJsonParse = require('./SafeJsonParse')
-const EventLogger = require('./EventLogger')
-const HealthCheckManager = require('./HealthCheckManager')
-const RoomManager = require('./RoomManager')
-const ChannelManager = require('./ChannelManager')
-const metrics = require('@overleaf/metrics')
+import logger from '@overleaf/logger'
+import settings from '@overleaf/settings'
+import RedisClientManager from './RedisClientManager.js'
+import SafeJsonParse from './SafeJsonParse.js'
+import EventLogger from './EventLogger.js'
+import HealthCheckManager from './HealthCheckManager.js'
+import RoomManager from './RoomManager.js'
+import ChannelManager from './ChannelManager.js'
+import metrics from '@overleaf/metrics'
 
 let DocumentUpdaterController
-module.exports = DocumentUpdaterController = {
+
+export default DocumentUpdaterController = {
   // DocumentUpdaterController is responsible for updates that come via Redis
   // Pub/Sub from the document updater.
   rclientList: RedisClientManager.createClientList(settings.redis.pubsub),

+ 12 - 12
services/real-time/app/js/DocumentUpdaterManager.js

@@ -1,19 +1,19 @@
-const request = require('request')
-const _ = require('lodash')
-const OError = require('@overleaf/o-error')
-const logger = require('@overleaf/logger')
-const settings = require('@overleaf/settings')
-const metrics = require('@overleaf/metrics')
+import request from 'request'
+import _ from 'lodash'
+import OError from '@overleaf/o-error'
+import logger from '@overleaf/logger'
+import settings from '@overleaf/settings'
+import metrics from '@overleaf/metrics'
+import RedisWrapper from '@overleaf/redis-wrapper'
+import Errors from './Errors.js'
+
 const {
   ClientRequestedMissingOpsError,
   DocumentUpdaterRequestFailedError,
   NullBytesInOpError,
   UpdateTooLargeError,
-} = require('./Errors')
-
-const rclient = require('@overleaf/redis-wrapper').createClient(
-  settings.redis.documentupdater
-)
+} = Errors
+const rclient = RedisWrapper.createClient(settings.redis.documentupdater)
 const Keys = settings.redis.documentupdater.key_schema
 
 const DocumentUpdaterManager = {
@@ -154,4 +154,4 @@ const DocumentUpdaterManager = {
   },
 }
 
-module.exports = DocumentUpdaterManager
+export default DocumentUpdaterManager

+ 2 - 2
services/real-time/app/js/DrainManager.js

@@ -1,6 +1,6 @@
-const logger = require('@overleaf/logger')
+import logger from '@overleaf/logger'
 
-module.exports = {
+export default {
   startDrainTimeWindow(io, minsToDrain, callback) {
     const drainPerMin = io.sockets.clients().length / minsToDrain
     // enforce minimum drain rate

+ 2 - 2
services/real-time/app/js/Errors.js

@@ -1,4 +1,4 @@
-const OError = require('@overleaf/o-error')
+import OError from '@overleaf/o-error'
 
 class ClientRequestedMissingOpsError extends OError {
   constructor(statusCode, info = {}) {
@@ -87,7 +87,7 @@ class WebApiRequestFailedError extends OError {
   }
 }
 
-module.exports = {
+export default {
   CodedError,
   CorruptedJoinProjectResponseError,
   ClientRequestedMissingOpsError,

+ 4 - 4
services/real-time/app/js/EventLogger.js

@@ -1,7 +1,7 @@
+import logger from '@overleaf/logger'
+import metrics from '@overleaf/metrics'
+import settings from '@overleaf/settings'
 let EventLogger
-const logger = require('@overleaf/logger')
-const metrics = require('@overleaf/metrics')
-const settings = require('@overleaf/settings')
 
 // keep track of message counters to detect duplicate and out of order events
 // messsage ids have the format "UNIQUEHOSTKEY-COUNTER"
@@ -13,7 +13,7 @@ let EVENT_LAST_CLEAN_TIMESTAMP = 0
 // counter for debug logs
 let COUNTER = 0
 
-module.exports = EventLogger = {
+export default EventLogger = {
   MAX_STALE_TIME_IN_MS: 3600 * 1000,
 
   debugEvent(channel, message) {

+ 4 - 5
services/real-time/app/js/HealthCheckManager.js

@@ -1,7 +1,6 @@
-const metrics = require('@overleaf/metrics')
-const logger = require('@overleaf/logger')
-
-const os = require('node:os')
+import metrics from '@overleaf/metrics'
+import logger from '@overleaf/logger'
+import os from 'node:os'
 const HOST = os.hostname()
 const PID = process.pid
 let COUNT = 0
@@ -9,7 +8,7 @@ let COUNT = 0
 const CHANNEL_MANAGER = {} // hash of event checkers by channel name
 const CHANNEL_ERROR = {} // error status by channel name
 
-module.exports = class HealthCheckManager {
+export default class HealthCheckManager {
   // create an instance of this class which checks that an event with a unique
   // id is received only once within a timeout
   constructor(channel, timeout) {

+ 5 - 5
services/real-time/app/js/HttpApiController.js

@@ -1,9 +1,9 @@
-const WebsocketLoadBalancer = require('./WebsocketLoadBalancer')
-const DrainManager = require('./DrainManager')
-const ConnectedUsersManager = require('./ConnectedUsersManager')
-const logger = require('@overleaf/logger')
+import WebsocketLoadBalancer from './WebsocketLoadBalancer.js'
+import DrainManager from './DrainManager.js'
+import ConnectedUsersManager from './ConnectedUsersManager.js'
+import logger from '@overleaf/logger'
 
-module.exports = {
+export default {
   countConnectedClients(req, res) {
     const { projectId } = req.params
     ConnectedUsersManager.countConnectedClients(

+ 2 - 1
services/real-time/app/js/HttpController.js

@@ -1,5 +1,6 @@
 let HttpController
-module.exports = HttpController = {
+
+export default HttpController = {
   // The code in this controller is hard to unit test because of a lot of
   // dependencies on internal socket.io methods. It is not critical to the running
   // of Overleaf, and is only used for getting stats about connected clients,

+ 3 - 3
services/real-time/app/js/RedisClientManager.js

@@ -1,7 +1,7 @@
-const redis = require('@overleaf/redis-wrapper')
-const logger = require('@overleaf/logger')
+import redis from '@overleaf/redis-wrapper'
+import logger from '@overleaf/logger'
 
-module.exports = {
+export default {
   createClientList(...configs) {
     // create a dynamic list of redis clients, excluding any configurations which are not defined
     return configs.filter(Boolean).map(x => {

+ 5 - 5
services/real-time/app/js/RoomManager.js

@@ -1,7 +1,7 @@
-const logger = require('@overleaf/logger')
-const metrics = require('@overleaf/metrics')
-const { EventEmitter } = require('node:events')
-const OError = require('@overleaf/o-error')
+import logger from '@overleaf/logger'
+import metrics from '@overleaf/metrics'
+import { EventEmitter } from 'node:events'
+import OError from '@overleaf/o-error'
 
 const IdMap = new Map() // keep track of whether ids are from projects or docs
 const RoomEvents = new EventEmitter() // emits {project,doc}-active and {project,doc}-empty events
@@ -16,7 +16,7 @@ const RoomEvents = new EventEmitter() // emits {project,doc}-active and {project
 //
 // The pubsub side is handled by ChannelManager
 
-module.exports = {
+export default {
   joinProject(client, projectId, callback) {
     this.joinEntity(client, 'project', projectId, callback)
   },

+ 19 - 15
services/real-time/app/js/Router.js

@@ -1,17 +1,20 @@
-const metrics = require('@overleaf/metrics')
-const logger = require('@overleaf/logger')
-const settings = require('@overleaf/settings')
-const WebsocketController = require('./WebsocketController')
-const HttpController = require('./HttpController')
-const HttpApiController = require('./HttpApiController')
-const WebsocketAddressManager = require('./WebsocketAddressManager')
-const bodyParser = require('body-parser')
-const base64id = require('base64id')
-const { UnexpectedArgumentsError } = require('./Errors')
-const { z, zz } = require('@overleaf/validation-tools')
-const { isZodErrorLike } = require('zod-validation-error')
-
-const HOSTNAME = require('node:os').hostname()
+import metrics from '@overleaf/metrics'
+import logger from '@overleaf/logger'
+import settings from '@overleaf/settings'
+import WebsocketController from './WebsocketController.js'
+import HttpController from './HttpController.js'
+import HttpApiController from './HttpApiController.js'
+import WebsocketAddressManager from './WebsocketAddressManager.js'
+import bodyParser from 'body-parser'
+import base64id from 'base64id'
+import Errors from './Errors.js'
+import { z, zz } from '@overleaf/validation-tools'
+import { isZodErrorLike } from 'zod-validation-error'
+import os from 'node:os'
+
+const { UnexpectedArgumentsError } = Errors
+
+const HOSTNAME = os.hostname()
 const SERVER_PING_INTERVAL = 15000
 const SERVER_PING_LATENCY_THRESHOLD = 5000
 
@@ -27,7 +30,8 @@ const applyOtUpdateSchema = z.object({
 })
 
 let Router
-module.exports = Router = {
+
+export default Router = {
   _handleError(callback, error, client, method, attrs) {
     attrs = attrs || {}
     for (const key of ['project_id', 'user_id']) {

+ 5 - 3
services/real-time/app/js/SafeJsonParse.js

@@ -1,7 +1,9 @@
-const Settings = require('@overleaf/settings')
-const { DataTooLargeToParseError } = require('./Errors')
+import Settings from '@overleaf/settings'
+import Errors from './Errors.js'
 
-module.exports = {
+const { DataTooLargeToParseError } = Errors
+
+export default {
   parse(data, callback) {
     if (data.length > Settings.maxUpdateSize) {
       return callback(new DataTooLargeToParseError(data))

+ 7 - 5
services/real-time/app/js/SessionSockets.js

@@ -1,9 +1,11 @@
-const metrics = require('@overleaf/metrics')
-const OError = require('@overleaf/o-error')
-const { EventEmitter } = require('node:events')
-const { MissingSessionError } = require('./Errors')
+import metrics from '@overleaf/metrics'
+import OError from '@overleaf/o-error'
+import { EventEmitter } from 'node:events'
+import Errors from './Errors.js'
 
-module.exports = function (io, sessionStore, cookieParser, cookieName) {
+const { MissingSessionError } = Errors
+
+export default function (io, sessionStore, cookieParser, cookieName) {
   const missingSessionError = new MissingSessionError()
 
   const sessionSockets = new EventEmitter()

+ 8 - 6
services/real-time/app/js/WebApiManager.js

@@ -1,15 +1,17 @@
-const request = require('request')
-const OError = require('@overleaf/o-error')
-const settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
+import request from 'request'
+import OError from '@overleaf/o-error'
+import settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import Errors from './Errors.js'
+
 const {
   CodedError,
   CorruptedJoinProjectResponseError,
   NotAuthorizedError,
   WebApiRequestFailedError,
-} = require('./Errors')
+} = Errors
 
-module.exports = {
+export default {
   joinProject(projectId, user, callback) {
     const userId = user._id
     logger.debug({ projectId, userId }, 'sending join project request to web')

+ 2 - 2
services/real-time/app/js/WebsocketAddressManager.js

@@ -1,6 +1,6 @@
-const proxyaddr = require('proxy-addr')
+import proxyaddr from 'proxy-addr'
 
-module.exports = class WebsocketAddressManager {
+export default class WebsocketAddressManager {
   constructor(behindProxy, trustedProxyIps) {
     if (behindProxy) {
       // parse trustedProxyIps comma-separated list the same way as express

+ 14 - 11
services/real-time/app/js/WebsocketController.js

@@ -1,19 +1,21 @@
-const OError = require('@overleaf/o-error')
-const logger = require('@overleaf/logger')
-const metrics = require('@overleaf/metrics')
-const WebApiManager = require('./WebApiManager')
-const AuthorizationManager = require('./AuthorizationManager')
-const DocumentUpdaterManager = require('./DocumentUpdaterManager')
-const ConnectedUsersManager = require('./ConnectedUsersManager')
-const WebsocketLoadBalancer = require('./WebsocketLoadBalancer')
-const RoomManager = require('./RoomManager')
+import OError from '@overleaf/o-error'
+import logger from '@overleaf/logger'
+import metrics from '@overleaf/metrics'
+import WebApiManager from './WebApiManager.js'
+import AuthorizationManager from './AuthorizationManager.js'
+import DocumentUpdaterManager from './DocumentUpdaterManager.js'
+import ConnectedUsersManager from './ConnectedUsersManager.js'
+import WebsocketLoadBalancer from './WebsocketLoadBalancer.js'
+import RoomManager from './RoomManager.js'
+import Errors from './Errors.js'
+
 const {
   CodedError,
   JoinLeaveEpochMismatchError,
   NotAuthorizedError,
   NotJoinedError,
   ClientRequestedMissingOpsError,
-} = require('./Errors')
+} = Errors
 
 const JOIN_DOC_CATCH_UP_LENGTH_BUCKETS = [
   0, 5, 10, 25, 50, 100, 150, 200, 250, 500, 1000,
@@ -35,7 +37,8 @@ const JOIN_DOC_CATCH_UP_AGE = [
 ].map(x => x * 1000)
 
 let WebsocketController
-module.exports = WebsocketController = {
+
+export default WebsocketController = {
   // If the protocol version changes when the client reconnects,
   // it will force a full refresh of the page. Useful for non-backwards
   // compatible protocol changes. Use only in extreme need.

+ 12 - 11
services/real-time/app/js/WebsocketLoadBalancer.js

@@ -1,13 +1,13 @@
-const Settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-const Metrics = require('@overleaf/metrics')
-const RedisClientManager = require('./RedisClientManager')
-const SafeJsonParse = require('./SafeJsonParse')
-const EventLogger = require('./EventLogger')
-const HealthCheckManager = require('./HealthCheckManager')
-const RoomManager = require('./RoomManager')
-const ChannelManager = require('./ChannelManager')
-const ConnectedUsersManager = require('./ConnectedUsersManager')
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import Metrics from '@overleaf/metrics'
+import RedisClientManager from './RedisClientManager.js'
+import SafeJsonParse from './SafeJsonParse.js'
+import EventLogger from './EventLogger.js'
+import HealthCheckManager from './HealthCheckManager.js'
+import RoomManager from './RoomManager.js'
+import ChannelManager from './ChannelManager.js'
+import ConnectedUsersManager from './ConnectedUsersManager.js'
 
 const RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST = [
   'otUpdateApplied',
@@ -32,7 +32,8 @@ for (let i = 5; i <= 22; i++) {
 }
 
 let WebsocketLoadBalancer
-module.exports = WebsocketLoadBalancer = {
+
+export default WebsocketLoadBalancer = {
   rclientPubList: RedisClientManager.createClientList(Settings.redis.pubsub),
   rclientSubList: RedisClientManager.createClientList(Settings.redis.pubsub),
 

+ 2 - 0
services/real-time/buildscript.txt

@@ -6,3 +6,5 @@ real-time
 --node-version=22.18.0
 --pipeline-owner=🚉 Platform
 --public-repo=False
+--test-unit-vitest=True
+--tsconfig-extra-includes=vitest.config.unit.cjs

+ 0 - 0
services/real-time/config/settings.defaults.js → services/real-time/config/settings.defaults.cjs


+ 0 - 0
services/real-time/config/settings.test.js → services/real-time/config/settings.test.cjs


+ 2 - 0
services/real-time/docker-compose.ci.yml

@@ -8,12 +8,14 @@ services:
     user: node
     volumes:
       - ./reports:/overleaf/services/real-time/reports
+      - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json
     command: npm run test:unit:_run
     environment:
       CI:
       MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf
       NODE_ENV: test
       NODE_OPTIONS: "--unhandled-rejections=strict"
+      VITEST_NO_CACHE: true
       REDIS_HOST: redis_test
       QUEUES_REDIS_HOST: redis_test
       HISTORY_REDIS_HOST: redis_test

+ 1 - 0
services/real-time/docker-compose.yml

@@ -9,6 +9,7 @@ services:
       - .:/overleaf/services/real-time
       - ../../node_modules:/overleaf/node_modules
       - ../../libraries:/overleaf/libraries
+      - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json
     working_dir: /overleaf/services/real-time
     environment:
       MOCHA_GREP: ${MOCHA_GREP}

+ 6 - 5
services/real-time/package.json

@@ -3,12 +3,13 @@
   "description": "The socket.io layer of Overleaf for real-time editor interactions",
   "private": true,
   "main": "app.js",
+  "type": "module",
   "scripts": {
     "start": "node app.js",
     "test:acceptance:_run": "mocha --recursive --timeout 15000 --exit $@ test/acceptance/js",
     "test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP",
-    "test:unit:_run": "mocha --recursive --exit $@ test/unit/js",
-    "test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP",
+    "test:unit:_run": "vitest --config ./vitest.config.unit.cjs",
+    "test:unit": "npm run test:unit:_run",
     "nodemon": "node --watch app.js",
     "lint": "eslint --max-warnings 0 --format unix .",
     "format": "prettier --list-different $PWD/'**/{*.*js,*.ts}'",
@@ -46,11 +47,11 @@
     "mocha": "^11.1.0",
     "mocha-junit-reporter": "^2.2.1",
     "mocha-multi-reporters": "^1.5.1",
-    "sandboxed-module": "~0.3.0",
     "sinon": "^9.2.4",
-    "sinon-chai": "^3.7.0",
+    "sinon-chai": "4.0.1",
     "timekeeper": "0.0.4",
     "typescript": "^5.0.4",
-    "uid-safe": "^2.1.5"
+    "uid-safe": "^2.1.5",
+    "vitest": "^4.0.0"
   }
 }

+ 6 - 7
services/real-time/test/acceptance/js/ApplyUpdateTests.js

@@ -10,14 +10,13 @@
  * DS201: Simplify complex destructure assignments
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const async = require('async')
-const { expect } = require('chai')
+import async from 'async'
 
-const RealTimeClient = require('./helpers/RealTimeClient')
-const FixturesManager = require('./helpers/FixturesManager')
-
-const settings = require('@overleaf/settings')
-const redis = require('@overleaf/redis-wrapper')
+import { expect } from 'chai'
+import RealTimeClient from './helpers/RealTimeClient.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import settings from '@overleaf/settings'
+import redis from '@overleaf/redis-wrapper'
 const rclient = redis.createClient(settings.redis.documentupdater)
 
 const redisSettings = settings.redis

+ 5 - 6
services/real-time/test/acceptance/js/ClientTrackingTests.js

@@ -10,13 +10,12 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const { expect } = require('chai')
+import { expect } from 'chai'
 
-const RealTimeClient = require('./helpers/RealTimeClient')
-const MockWebServer = require('./helpers/MockWebServer')
-const FixturesManager = require('./helpers/FixturesManager')
-
-const async = require('async')
+import RealTimeClient from './helpers/RealTimeClient.js'
+import MockWebServer from './helpers/MockWebServer.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import async from 'async'
 
 describe('clientTracking', function () {
   describe('when another logged in user joins a project', function () {

+ 5 - 6
services/real-time/test/acceptance/js/DrainManagerTests.js

@@ -5,13 +5,12 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const RealTimeClient = require('./helpers/RealTimeClient')
-const FixturesManager = require('./helpers/FixturesManager')
+import RealTimeClient from './helpers/RealTimeClient.js'
 
-const { expect } = require('chai')
-
-const async = require('async')
-const request = require('request')
+import FixturesManager from './helpers/FixturesManager.js'
+import { expect } from 'chai'
+import async from 'async'
+import request from 'request'
 
 const drain = function (rate, callback) {
   request.post(

+ 8 - 9
services/real-time/test/acceptance/js/EarlyDisconnect.js

@@ -9,16 +9,15 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const async = require('async')
-const { expect } = require('chai')
+import async from 'async'
 
-const RealTimeClient = require('./helpers/RealTimeClient')
-const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
-const MockWebServer = require('./helpers/MockWebServer')
-const FixturesManager = require('./helpers/FixturesManager')
-
-const settings = require('@overleaf/settings')
-const redis = require('@overleaf/redis-wrapper')
+import { expect } from 'chai'
+import RealTimeClient from './helpers/RealTimeClient.js'
+import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js'
+import MockWebServer from './helpers/MockWebServer.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import settings from '@overleaf/settings'
+import redis from '@overleaf/redis-wrapper'
 const rclient = redis.createClient(settings.redis.pubsub)
 const rclientRT = redis.createClient(settings.redis.realtime)
 const KeysRT = settings.redis.realtime.key_schema

+ 20 - 19
services/real-time/test/acceptance/js/HttpControllerTests.js

@@ -5,20 +5,21 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const async = require('async')
-const { expect } = require('chai')
-const request = require('request').defaults({
+import async from 'async'
+import Request from 'request'
+import { expect } from 'chai'
+import RealTimeClient from './helpers/RealTimeClient.js'
+import FixturesManager from './helpers/FixturesManager.js'
+
+const request = Request.defaults({
   baseUrl: 'http://127.0.0.1:3026',
 })
 
-const RealTimeClient = require('./helpers/RealTimeClient')
-const FixturesManager = require('./helpers/FixturesManager')
-
 describe('HttpControllerTests', function () {
   describe('without a user', function () {
-    return it('should return 404 for the client view', function (done) {
+    it('should return 404 for the client view', function (done) {
       const clientId = 'not-existing'
-      return request.get(
+      request.get(
         {
           url: `/clients/${clientId}`,
           json: true,
@@ -28,36 +29,36 @@ describe('HttpControllerTests', function () {
             return done(error)
           }
           expect(response.statusCode).to.equal(404)
-          return done()
+          done()
         }
       )
     })
   })
 
-  return describe('with a user and after joining a project', function () {
+  describe('with a user and after joining a project', function () {
     before(function (done) {
-      return async.series(
+      async.series(
         [
           cb => {
-            return FixturesManager.setUpProject(
+            FixturesManager.setUpProject(
               {
                 privilegeLevel: 'owner',
               },
               (error, { project_id: projectId, user_id: userId }) => {
                 this.project_id = projectId
                 this.user_id = userId
-                return cb(error)
+                cb(error)
               }
             )
           },
 
           cb => {
-            return FixturesManager.setUpDoc(
+            FixturesManager.setUpDoc(
               this.project_id,
               {},
               (error, { doc_id: docId }) => {
                 this.doc_id = docId
-                return cb(error)
+                cb(error)
               }
             )
           },
@@ -67,15 +68,15 @@ describe('HttpControllerTests', function () {
           },
 
           cb => {
-            return this.client.emit('joinDoc', this.doc_id, cb)
+            this.client.emit('joinDoc', this.doc_id, cb)
           },
         ],
         done
       )
     })
 
-    return it('should send a client view', function (done) {
-      return request.get(
+    it('should send a client view', function (done) {
+      request.get(
         {
           url: `/clients/${this.client.socket.sessionid}`,
           json: true,
@@ -97,7 +98,7 @@ describe('HttpControllerTests', function () {
             user_id: this.user_id,
             rooms: [this.project_id, this.doc_id],
           })
-          return done()
+          done()
         }
       )
     })

+ 5 - 6
services/real-time/test/acceptance/js/JoinDocTests.js

@@ -9,13 +9,12 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const { expect } = require('chai')
+import { expect } from 'chai'
 
-const RealTimeClient = require('./helpers/RealTimeClient')
-const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
-const FixturesManager = require('./helpers/FixturesManager')
-
-const async = require('async')
+import RealTimeClient from './helpers/RealTimeClient.js'
+import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import async from 'async'
 
 describe('joinDoc', function () {
   before(function () {

+ 5 - 6
services/real-time/test/acceptance/js/JoinProjectTests.js

@@ -6,13 +6,12 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const { expect } = require('chai')
+import { expect } from 'chai'
 
-const RealTimeClient = require('./helpers/RealTimeClient')
-const MockWebServer = require('./helpers/MockWebServer')
-const FixturesManager = require('./helpers/FixturesManager')
-
-const async = require('async')
+import RealTimeClient from './helpers/RealTimeClient.js'
+import MockWebServer from './helpers/MockWebServer.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import async from 'async'
 
 describe('joinProject', function () {
   describe('when authorized', function () {

+ 8 - 9
services/real-time/test/acceptance/js/LeaveDocTests.js

@@ -11,15 +11,14 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const { expect } = require('chai')
-const sinon = require('sinon')
-
-const RealTimeClient = require('./helpers/RealTimeClient')
-const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
-const FixturesManager = require('./helpers/FixturesManager')
-const logger = require('@overleaf/logger')
-
-const async = require('async')
+import { expect } from 'chai'
+
+import sinon from 'sinon'
+import RealTimeClient from './helpers/RealTimeClient.js'
+import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import logger from '@overleaf/logger'
+import async from 'async'
 
 describe('leaveDoc', function () {
   before(function () {

+ 6 - 7
services/real-time/test/acceptance/js/LeaveProjectTests.js

@@ -9,14 +9,13 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const RealTimeClient = require('./helpers/RealTimeClient')
-const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
-const FixturesManager = require('./helpers/FixturesManager')
+import RealTimeClient from './helpers/RealTimeClient.js'
 
-const async = require('async')
-
-const settings = require('@overleaf/settings')
-const redis = require('@overleaf/redis-wrapper')
+import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import async from 'async'
+import settings from '@overleaf/settings'
+import redis from '@overleaf/redis-wrapper'
 const rclient = redis.createClient(settings.redis.pubsub)
 
 describe('leaveProject', function () {

+ 7 - 7
services/real-time/test/acceptance/js/MatrixTests.js

@@ -45,16 +45,16 @@ There is additional meta-data that UserItems and SessionItems may use to skip
   SessionItem: { needsOwnProject: true }
  */
 
-const { expect } = require('chai')
-const async = require('async')
+import { expect } from 'chai'
 
-const RealTimeClient = require('./helpers/RealTimeClient')
-const FixturesManager = require('./helpers/FixturesManager')
-const MockWebServer = require('./helpers/MockWebServer')
+import async from 'async'
+import RealTimeClient from './helpers/RealTimeClient.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import MockWebServer from './helpers/MockWebServer.js'
+import settings from '@overleaf/settings'
+import redis from '@overleaf/redis-wrapper'
 
-const settings = require('@overleaf/settings')
 const Keys = settings.redis.documentupdater.key_schema
-const redis = require('@overleaf/redis-wrapper')
 const rclient = redis.createClient(settings.redis.pubsub)
 
 function getPendingUpdates(docId, cb) {

+ 6 - 7
services/real-time/test/acceptance/js/PubSubRace.js

@@ -8,14 +8,13 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const RealTimeClient = require('./helpers/RealTimeClient')
-const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer')
-const FixturesManager = require('./helpers/FixturesManager')
+import RealTimeClient from './helpers/RealTimeClient.js'
 
-const async = require('async')
-
-const settings = require('@overleaf/settings')
-const redis = require('@overleaf/redis-wrapper')
+import MockDocUpdaterServer from './helpers/MockDocUpdaterServer.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import async from 'async'
+import settings from '@overleaf/settings'
+import redis from '@overleaf/redis-wrapper'
 const rclient = redis.createClient(settings.redis.pubsub)
 
 describe('PubSubRace', function () {

+ 6 - 8
services/real-time/test/acceptance/js/ReceiveEditorEventTests.js

@@ -9,15 +9,13 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const { expect } = require('chai')
+import { expect } from 'chai'
 
-const RealTimeClient = require('./helpers/RealTimeClient')
-const FixturesManager = require('./helpers/FixturesManager')
-
-const async = require('async')
-
-const settings = require('@overleaf/settings')
-const redis = require('@overleaf/redis-wrapper')
+import RealTimeClient from './helpers/RealTimeClient.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import async from 'async'
+import settings from '@overleaf/settings'
+import redis from '@overleaf/redis-wrapper'
 const rclient = redis.createClient(settings.redis.pubsub)
 
 describe('receiveEditorEvent', function () {

+ 8 - 10
services/real-time/test/acceptance/js/ReceiveUpdateTests.js

@@ -9,16 +9,14 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const { expect } = require('chai')
-
-const RealTimeClient = require('./helpers/RealTimeClient')
-const MockWebServer = require('./helpers/MockWebServer')
-const FixturesManager = require('./helpers/FixturesManager')
-
-const async = require('async')
-
-const settings = require('@overleaf/settings')
-const redis = require('@overleaf/redis-wrapper')
+import { expect } from 'chai'
+
+import RealTimeClient from './helpers/RealTimeClient.js'
+import MockWebServer from './helpers/MockWebServer.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import async from 'async'
+import settings from '@overleaf/settings'
+import redis from '@overleaf/redis-wrapper'
 const rclient = redis.createClient(settings.redis.pubsub)
 
 describe('receiveUpdate', function () {

+ 4 - 4
services/real-time/test/acceptance/js/RouterTests.js

@@ -5,11 +5,11 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const async = require('async')
-const { expect } = require('chai')
+import async from 'async'
 
-const RealTimeClient = require('./helpers/RealTimeClient')
-const FixturesManager = require('./helpers/FixturesManager')
+import { expect } from 'chai'
+import RealTimeClient from './helpers/RealTimeClient.js'
+import FixturesManager from './helpers/FixturesManager.js'
 
 describe('Router', function () {
   return describe('joinProject', function () {

+ 5 - 5
services/real-time/test/acceptance/js/SessionSocketsTests.js

@@ -1,8 +1,8 @@
-const RealTimeClient = require('./helpers/RealTimeClient')
-const FixturesManager = require('./helpers/FixturesManager')
-const Settings = require('@overleaf/settings')
-const signature = require('cookie-signature')
-const { expect } = require('chai')
+import RealTimeClient from './helpers/RealTimeClient.js'
+import FixturesManager from './helpers/FixturesManager.js'
+import Settings from '@overleaf/settings'
+import signature from 'cookie-signature'
+import { expect } from 'chai'
 
 describe('SessionSockets', function () {
   beforeEach(function (done) {

+ 3 - 3
services/real-time/test/acceptance/js/SessionTests.js

@@ -10,10 +10,10 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const { expect } = require('chai')
+import { expect } from 'chai'
 
-const FixturesManager = require('./helpers/FixturesManager')
-const RealTimeClient = require('./helpers/RealTimeClient')
+import FixturesManager from './helpers/FixturesManager.js'
+import RealTimeClient from './helpers/RealTimeClient.js'
 
 describe('Session', function () {
   return describe('with an established session', function () {

+ 7 - 5
services/real-time/test/acceptance/js/helpers/FixturesManager.js

@@ -6,12 +6,14 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
+import RealTimeClient from './RealTimeClient.js'
+import MockWebServer from './MockWebServer.js'
+import MockDocUpdaterServer from './MockDocUpdaterServer.js'
+import crypto from 'node:crypto'
+
 let FixturesManager
-const RealTimeClient = require('./RealTimeClient')
-const MockWebServer = require('./MockWebServer')
-const MockDocUpdaterServer = require('./MockDocUpdaterServer')
 
-module.exports = FixturesManager = {
+export default FixturesManager = {
   setUpProject(options, callback) {
     if (options == null) {
       options = {}
@@ -151,7 +153,7 @@ module.exports = FixturesManager = {
   },
 
   getRandomId() {
-    return require('node:crypto')
+    return crypto
       .createHash('sha1')
       .update(Math.random().toString())
       .digest('hex')

+ 4 - 3
services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js

@@ -9,11 +9,12 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
+import sinon from 'sinon'
+import express from 'express'
+
 let MockDocUpdaterServer
-const sinon = require('sinon')
-const express = require('express')
 
-module.exports = MockDocUpdaterServer = {
+export default MockDocUpdaterServer = {
   docs: {},
 
   createMockDoc(projectId, docId, data) {

+ 5 - 4
services/real-time/test/acceptance/js/helpers/MockWebServer.js

@@ -9,12 +9,13 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
+import sinon from 'sinon'
+import express from 'express'
+import bodyParser from 'body-parser'
+
 let MockWebServer
-const sinon = require('sinon')
-const express = require('express')
-const bodyParser = require('body-parser')
 
-module.exports = MockWebServer = {
+export default MockWebServer = {
   projects: {},
   privileges: {},
   userMetadata: {},

+ 12 - 10
services/real-time/test/acceptance/js/helpers/RealTimeClient.js

@@ -1,14 +1,16 @@
-const { XMLHttpRequest } = require('../../libs/XMLHttpRequest')
-const io = require('socket.io-client')
+import io from 'socket.io-client'
 
-const Settings = require('@overleaf/settings')
-const redis = require('@overleaf/redis-wrapper')
-const rclient = redis.createClient(Settings.redis.websessions)
+import Settings from '@overleaf/settings'
+import redis from '@overleaf/redis-wrapper'
+
+import uidSafe from 'uid-safe'
+import signature from 'cookie-signature'
+import { callbackify } from 'node:util'
+import { fetchJson, fetchNothing } from '@overleaf/fetch-utils'
+import { XMLHttpRequest } from '../../libs/XMLHttpRequest.js'
 
-const uid = require('uid-safe').sync
-const signature = require('cookie-signature')
-const { callbackify } = require('node:util')
-const { fetchJson, fetchNothing } = require('@overleaf/fetch-utils')
+const rclient = redis.createClient(Settings.redis.websessions)
+const uid = uidSafe.sync
 
 io.util.request = function () {
   const xhr = new XMLHttpRequest()
@@ -150,4 +152,4 @@ const Client = {
   },
 }
 
-module.exports = Client
+export default Client

+ 0 - 61
services/real-time/test/acceptance/js/helpers/RealtimeServer.js

@@ -1,61 +0,0 @@
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS101: Remove unnecessary use of Array.from
- * DS102: Remove unnecessary code created because of implicit returns
- * DS103: Rewrite code to no longer use __guard__
- * DS205: Consider reworking code to avoid use of IIFEs
- * DS207: Consider shorter variations of null checks
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const app = require('../../../../app')
-const logger = require('@overleaf/logger')
-const Settings = require('@overleaf/settings')
-
-module.exports = {
-  running: false,
-  initing: false,
-  callbacks: [],
-  ensureRunning(callback) {
-    if (callback == null) {
-      callback = function () {}
-    }
-    if (this.running) {
-      return callback()
-    } else if (this.initing) {
-      return this.callbacks.push(callback)
-    } else {
-      this.initing = true
-      this.callbacks.push(callback)
-      return app.listen(
-        __guard__(
-          Settings.internal != null ? Settings.internal.realtime : undefined,
-          x => x.port
-        ),
-        '127.0.0.1',
-        error => {
-          if (error != null) {
-            throw error
-          }
-          this.running = true
-          logger.info('clsi running in dev mode')
-
-          return (() => {
-            const result = []
-            for (callback of Array.from(this.callbacks)) {
-              result.push(callback())
-            }
-            return result
-          })()
-        }
-      )
-    }
-  },
-}
-
-function __guard__(value, transform) {
-  return typeof value !== 'undefined' && value !== null
-    ? transform(value)
-    : undefined
-}

+ 6 - 6
services/real-time/test/acceptance/libs/XMLHttpRequest.js

@@ -11,17 +11,17 @@
  * @license MIT
  */
 
-const { URL } = require('node:url')
-const spawn = require('node:child_process').spawn
-const fs = require('node:fs')
+import { URL } from 'node:url'
+import http from 'node:http'
+import https from 'node:https'
+import { spawn } from 'node:child_process'
+import fs from 'node:fs'
 
-exports.XMLHttpRequest = function () {
+export const XMLHttpRequest = function () {
   /**
    * Private variables
    */
   const self = this
-  const http = require('node:http')
-  const https = require('node:https')
 
   // Holds http.js objects
   let request

+ 6 - 19
services/real-time/test/setup.js

@@ -1,8 +1,8 @@
-const chai = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const chaiAsPromised = require('chai-as-promised')
-const sinonChai = require('sinon-chai')
+import * as chai from 'chai'
+import sinon from 'sinon'
+import chaiAsPromised from 'chai-as-promised'
+import sinonChai from 'sinon-chai'
+import '../app.js'
 
 // Chai configuration
 chai.should()
@@ -22,21 +22,8 @@ const stubs = {
   },
 }
 
-// SandboxedModule configuration
-SandboxedModule.configure({
-  requires: {
-    '@overleaf/logger': stubs.logger,
-  },
-  globals: { Buffer, JSON, console, process },
-  sourceTransformers: {
-    removeNodePrefix: function (source) {
-      return source.replace(/require\(['"]node:/g, "require('")
-    },
-  },
-})
-
 // Mocha hooks
-exports.mochaHooks = {
+export const mochaHooks = {
   beforeEach() {
     this.logger = stubs.logger
   },

+ 479 - 0
services/real-time/test/unit/js/AuthorizationManager.test.js

@@ -0,0 +1,479 @@
+import { beforeEach, describe, chai, expect, it } from 'vitest'
+import sinon from 'sinon'
+
+chai.should()
+
+const modulePath = '../../../app/js/AuthorizationManager'
+
+describe('AuthorizationManager', () => {
+  beforeEach(async ctx => {
+    ctx.client = { ol_context: {} }
+
+    ctx.AuthorizationManager = (await import(modulePath)).default
+  })
+
+  describe('assertClientCanViewProject', () => {
+    it('should allow the readOnly privilegeLevel', async ctx => {
+      await new Promise((resolve, reject) => {
+        ctx.client.ol_context.privilege_level = 'readOnly'
+        ctx.AuthorizationManager.assertClientCanViewProject(
+          ctx.client,
+          error => {
+            expect(error).to.be.null
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should allow the readAndWrite privilegeLevel', async ctx => {
+      await new Promise((resolve, reject) => {
+        ctx.client.ol_context.privilege_level = 'readAndWrite'
+        ctx.AuthorizationManager.assertClientCanViewProject(
+          ctx.client,
+          error => {
+            expect(error).to.be.null
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should allow the review privilegeLevel', async ctx => {
+      await new Promise((resolve, reject) => {
+        ctx.client.ol_context.privilege_level = 'review'
+        ctx.AuthorizationManager.assertClientCanViewProject(
+          ctx.client,
+          error => {
+            expect(error).to.be.null
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should allow the owner privilegeLevel', async ctx => {
+      await new Promise((resolve, reject) => {
+        ctx.client.ol_context.privilege_level = 'owner'
+        ctx.AuthorizationManager.assertClientCanViewProject(
+          ctx.client,
+          error => {
+            expect(error).to.be.null
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should return an error with any other privilegeLevel', async ctx => {
+      await new Promise((resolve, reject) => {
+        ctx.client.ol_context.privilege_level = 'unknown'
+        ctx.AuthorizationManager.assertClientCanViewProject(
+          ctx.client,
+          error => {
+            error.message.should.equal('not authorized')
+            resolve()
+          }
+        )
+      })
+    })
+  })
+
+  describe('assertClientCanEditProject', () => {
+    it('should not allow the readOnly privilegeLevel', async ctx => {
+      await new Promise((resolve, reject) => {
+        ctx.client.ol_context.privilege_level = 'readOnly'
+        ctx.AuthorizationManager.assertClientCanEditProject(
+          ctx.client,
+          error => {
+            error.message.should.equal('not authorized')
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should allow the readAndWrite privilegeLevel', async ctx => {
+      await new Promise((resolve, reject) => {
+        ctx.client.ol_context.privilege_level = 'readAndWrite'
+        ctx.AuthorizationManager.assertClientCanEditProject(
+          ctx.client,
+          error => {
+            expect(error).to.be.null
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should allow the owner privilegeLevel', async ctx => {
+      await new Promise((resolve, reject) => {
+        ctx.client.ol_context.privilege_level = 'owner'
+        ctx.AuthorizationManager.assertClientCanEditProject(
+          ctx.client,
+          error => {
+            expect(error).to.be.null
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should return an error with any other privilegeLevel', async ctx => {
+      await new Promise((resolve, reject) => {
+        ctx.client.ol_context.privilege_level = 'unknown'
+        ctx.AuthorizationManager.assertClientCanEditProject(
+          ctx.client,
+          error => {
+            error.message.should.equal('not authorized')
+            resolve()
+          }
+        )
+      })
+    })
+  })
+
+  // check doc access for project
+
+  describe('assertClientCanViewProjectAndDoc', () => {
+    beforeEach(ctx => {
+      ctx.doc_id = '12345'
+      ctx.callback = sinon.stub()
+      ctx.client.ol_context = {}
+    })
+
+    describe('when not authorised at the project level', () => {
+      beforeEach(ctx => {
+        ctx.client.ol_context.privilege_level = 'unknown'
+      })
+
+      it('should not allow access', ctx => {
+        ctx.AuthorizationManager.assertClientCanViewProjectAndDoc(
+          ctx.client,
+          ctx.doc_id,
+          err => err.message.should.equal('not authorized')
+        )
+      })
+
+      describe('even when authorised at the doc level', () => {
+        beforeEach(async ctx => {
+          await new Promise((resolve, reject) => {
+            ctx.AuthorizationManager.addAccessToDoc(
+              ctx.client,
+              ctx.doc_id,
+              err => {
+                if (err) return reject(err)
+                resolve()
+              }
+            )
+          })
+        })
+
+        it('should not allow access', ctx => {
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            err => err.message.should.equal('not authorized')
+          )
+        })
+      })
+    })
+
+    describe('when authorised at the project level', () => {
+      beforeEach(ctx => {
+        ctx.client.ol_context.privilege_level = 'readOnly'
+      })
+
+      describe('and not authorised at the document level', () => {
+        it('should not allow access', ctx => {
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            err => err.message.should.equal('not authorized')
+          )
+        })
+      })
+
+      describe('and authorised at the document level', () => {
+        beforeEach(async ctx => {
+          await new Promise((resolve, reject) => {
+            ctx.AuthorizationManager.addAccessToDoc(
+              ctx.client,
+              ctx.doc_id,
+              err => {
+                if (err) return reject(err)
+                resolve()
+              }
+            )
+          })
+        })
+
+        it('should allow access', ctx => {
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            ctx.callback
+          )
+          ctx.callback.calledWith(null).should.equal(true)
+        })
+      })
+
+      describe('when document authorisation is added and then removed', () => {
+        beforeEach(async ctx => {
+          await new Promise((resolve, reject) => {
+            ctx.AuthorizationManager.addAccessToDoc(
+              ctx.client,
+              ctx.doc_id,
+              () => {
+                ctx.AuthorizationManager.removeAccessToDoc(
+                  ctx.client,
+                  ctx.doc_id,
+                  err => {
+                    if (err) return reject(err)
+                    resolve()
+                  }
+                )
+              }
+            )
+          })
+        })
+
+        it('should deny access', ctx => {
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            err => err.message.should.equal('not authorized')
+          )
+        })
+      })
+    })
+  })
+
+  describe('assertClientCanEditProjectAndDoc', () => {
+    beforeEach(ctx => {
+      ctx.doc_id = '12345'
+      ctx.callback = sinon.stub()
+      ctx.client.ol_context = {}
+    })
+
+    describe('when not authorised at the project level', () => {
+      beforeEach(ctx => {
+        ctx.client.ol_context.privilege_level = 'readOnly'
+      })
+
+      it('should not allow access', ctx => {
+        ctx.AuthorizationManager.assertClientCanEditProjectAndDoc(
+          ctx.client,
+          ctx.doc_id,
+          err => err.message.should.equal('not authorized')
+        )
+      })
+
+      describe('even when authorised at the doc level', () => {
+        beforeEach(async ctx => {
+          await new Promise((resolve, reject) => {
+            ctx.AuthorizationManager.addAccessToDoc(
+              ctx.client,
+              ctx.doc_id,
+              err => {
+                if (err) return reject(err)
+                resolve()
+              }
+            )
+          })
+        })
+
+        it('should not allow access', ctx => {
+          ctx.AuthorizationManager.assertClientCanEditProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            err => err.message.should.equal('not authorized')
+          )
+        })
+      })
+    })
+
+    describe('when authorised at the project level', () => {
+      beforeEach(ctx => {
+        ctx.client.ol_context.privilege_level = 'readAndWrite'
+      })
+
+      describe('and not authorised at the document level', () => {
+        it('should not allow access', ctx => {
+          ctx.AuthorizationManager.assertClientCanEditProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            err => err.message.should.equal('not authorized')
+          )
+        })
+      })
+
+      describe('and authorised at the document level', () => {
+        beforeEach(async ctx => {
+          await new Promise((resolve, reject) => {
+            ctx.AuthorizationManager.addAccessToDoc(
+              ctx.client,
+              ctx.doc_id,
+              err => {
+                if (err) return reject(err)
+                resolve()
+              }
+            )
+          })
+        })
+
+        it('should allow access', ctx => {
+          ctx.AuthorizationManager.assertClientCanEditProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            ctx.callback
+          )
+          ctx.callback.calledWith(null).should.equal(true)
+        })
+      })
+
+      describe('when document authorisation is added and then removed', () => {
+        beforeEach(async ctx => {
+          await new Promise((resolve, reject) => {
+            ctx.AuthorizationManager.addAccessToDoc(
+              ctx.client,
+              ctx.doc_id,
+              () => {
+                ctx.AuthorizationManager.removeAccessToDoc(
+                  ctx.client,
+                  ctx.doc_id,
+                  err => {
+                    if (err) return reject(err)
+                    resolve()
+                  }
+                )
+              }
+            )
+          })
+        })
+
+        it('should deny access', ctx => {
+          ctx.AuthorizationManager.assertClientCanEditProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            err => err.message.should.equal('not authorized')
+          )
+        })
+      })
+    })
+  })
+
+  describe('assertClientCanReviewProjectAndDoc', () => {
+    beforeEach(ctx => {
+      ctx.doc_id = '12345'
+      ctx.callback = sinon.stub()
+      ctx.client.ol_context = {}
+    })
+
+    describe('when not authorised at the project level', () => {
+      beforeEach(ctx => {
+        ctx.client.ol_context.privilege_level = 'readOnly'
+      })
+
+      it('should not allow access', ctx => {
+        ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc(
+          ctx.client,
+          ctx.doc_id,
+          err => err.message.should.equal('not authorized')
+        )
+      })
+
+      describe('even when authorised at the doc level', () => {
+        beforeEach(async ctx => {
+          await new Promise((resolve, reject) => {
+            ctx.AuthorizationManager.addAccessToDoc(
+              ctx.client,
+              ctx.doc_id,
+              err => {
+                if (err) return reject(err)
+                resolve()
+              }
+            )
+          })
+        })
+
+        it('should not allow access', ctx => {
+          ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            err => err.message.should.equal('not authorized')
+          )
+        })
+      })
+    })
+
+    describe('when authorised at the project level', () => {
+      beforeEach(ctx => {
+        ctx.client.ol_context.privilege_level = 'review'
+      })
+
+      describe('and not authorised at the document level', () => {
+        it('should not allow access', ctx => {
+          ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            err => err.message.should.equal('not authorized')
+          )
+        })
+      })
+
+      describe('and authorised at the document level', () => {
+        beforeEach(async ctx => {
+          await new Promise((resolve, reject) => {
+            ctx.AuthorizationManager.addAccessToDoc(
+              ctx.client,
+              ctx.doc_id,
+              err => {
+                if (err) return reject(err)
+                resolve()
+              }
+            )
+          })
+        })
+
+        it('should allow access', ctx => {
+          ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            ctx.callback
+          )
+          ctx.callback.calledWith(null).should.equal(true)
+        })
+      })
+
+      describe('when document authorisation is added and then removed', () => {
+        beforeEach(async ctx => {
+          await new Promise((resolve, reject) => {
+            ctx.AuthorizationManager.addAccessToDoc(
+              ctx.client,
+              ctx.doc_id,
+              () => {
+                ctx.AuthorizationManager.removeAccessToDoc(
+                  ctx.client,
+                  ctx.doc_id,
+                  err => {
+                    if (err) return reject(err)
+                    resolve()
+                  }
+                )
+              }
+            )
+          })
+        })
+
+        it('should deny access', ctx => {
+          ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc(
+            ctx.client,
+            ctx.doc_id,
+            err => err.message.should.equal('not authorized')
+          )
+        })
+      })
+    })
+  })
+})

+ 0 - 428
services/real-time/test/unit/js/AuthorizationManagerTests.js

@@ -1,428 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const { expect } = require('chai')
-const sinon = require('sinon')
-const SandboxedModule = require('sandboxed-module')
-const path = require('node: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 review privilegeLevel', function (done) {
-      this.client.ol_context.privilege_level = 'review'
-      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')
-          )
-        })
-      })
-    })
-  })
-
-  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')
-          )
-        })
-      })
-    })
-  })
-
-  return describe('assertClientCanReviewProjectAndDoc', 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.assertClientCanReviewProjectAndDoc(
-          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.assertClientCanReviewProjectAndDoc(
-            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 = 'review')
-      })
-
-      describe('and not authorised at the document level', function () {
-        return it('should not allow access', function () {
-          return this.AuthorizationManager.assertClientCanReviewProjectAndDoc(
-            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.assertClientCanReviewProjectAndDoc(
-            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.assertClientCanReviewProjectAndDoc(
-            this.client,
-            this.doc_id,
-            err => err.message.should.equal('not authorized')
-          )
-        })
-      })
-    })
-  })
-})

+ 445 - 0
services/real-time/test/unit/js/ChannelManager.test.js

@@ -0,0 +1,445 @@
+import { vi, expect, describe, beforeEach, it } from 'vitest'
+
+import sinon from 'sinon'
+
+const modulePath = '../../../app/js/ChannelManager.js'
+
+describe('ChannelManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.rclient = {}
+    ctx.other_rclient = {}
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {}),
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: (ctx.metrics = {
+        inc: sinon.stub(),
+        summary: sinon.stub(),
+      }),
+    }))
+
+    ctx.ChannelManager = (await import(modulePath)).default
+  })
+
+  describe('subscribe', function () {
+    describe('when there is no existing subscription for this redis client', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.rclient.subscribe = sinon.stub().resolves()
+          ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          setTimeout(resolve)
+        })
+      })
+
+      it('should subscribe to the redis channel', function (ctx) {
+        ctx.rclient.subscribe
+          .calledWithExactly('applied-ops:1234567890abcdef')
+          .should.equal(true)
+      })
+    })
+
+    describe('when there is an existing subscription for this redis client', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.rclient.subscribe = sinon.stub().resolves()
+          ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          setTimeout(resolve)
+        })
+      })
+
+      it('should subscribe to the redis channel again', function (ctx) {
+        ctx.rclient.subscribe.callCount.should.equal(2)
+      })
+    })
+
+    describe('when subscribe errors', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.rclient.subscribe = sinon
+            .stub()
+            .onFirstCall()
+            .rejects(new Error('some redis error'))
+            .onSecondCall()
+            .resolves()
+          const p = ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          p.then(() =>
+            reject(new Error('should not subscribe but fail'))
+          ).catch(err => {
+            err.message.should.equal('failed to subscribe to channel')
+            err.cause.message.should.equal('some redis error')
+            ctx.ChannelManager.getClientMapEntry(ctx.rclient)
+              .has('applied-ops:1234567890abcdef')
+              .should.equal(false)
+            ctx.ChannelManager.subscribe(
+              ctx.rclient,
+              'applied-ops',
+              '1234567890abcdef'
+            )
+            // subscribe is wrapped in Promise, delay other assertions
+            setTimeout(resolve)
+          })
+        })
+      })
+
+      it('should have recorded the error', function (ctx) {
+        expect(
+          ctx.metrics.inc.calledWithExactly('subscribe.failed.applied-ops')
+        ).to.equal(true)
+      })
+
+      it('should subscribe again', function (ctx) {
+        ctx.rclient.subscribe.callCount.should.equal(2)
+      })
+
+      it('should cleanup', function (ctx) {
+        ctx.ChannelManager.getClientMapEntry(ctx.rclient)
+          .has('applied-ops:1234567890abcdef')
+          .should.equal(false)
+      })
+    })
+
+    describe('when subscribe errors and the clientChannelMap entry was replaced', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.rclient.subscribe = sinon
+            .stub()
+            .onFirstCall()
+            .rejects(new Error('some redis error'))
+            .onSecondCall()
+            .resolves()
+          ctx.first = ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          // ignore error
+          ctx.first.catch(() => {})
+          expect(
+            ctx.ChannelManager.getClientMapEntry(ctx.rclient).get(
+              'applied-ops:1234567890abcdef'
+            )
+          ).to.equal(ctx.first)
+
+          ctx.rclient.unsubscribe = sinon.stub().resolves()
+          ctx.ChannelManager.unsubscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          ctx.second = ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          // should get replaced immediately
+          expect(
+            ctx.ChannelManager.getClientMapEntry(ctx.rclient).get(
+              'applied-ops:1234567890abcdef'
+            )
+          ).to.equal(ctx.second)
+
+          // let the first subscribe error -> unsubscribe -> subscribe
+          setTimeout(resolve)
+        })
+      })
+
+      it('should cleanup the second subscribePromise', function (ctx) {
+        expect(
+          ctx.ChannelManager.getClientMapEntry(ctx.rclient).has(
+            'applied-ops:1234567890abcdef'
+          )
+        ).to.equal(false)
+      })
+    })
+
+    describe('when there is an existing subscription for another redis client but not this one', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.other_rclient.subscribe = sinon.stub().resolves()
+          ctx.ChannelManager.subscribe(
+            ctx.other_rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          ctx.rclient.subscribe = sinon.stub().resolves() // discard the original stub
+          ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          setTimeout(resolve)
+        })
+      })
+
+      it('should subscribe to the redis channel on this redis client', function (ctx) {
+        ctx.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(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.rclient.unsubscribe = sinon.stub().resolves()
+          ctx.ChannelManager.unsubscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          setTimeout(resolve)
+        })
+      })
+
+      it('should unsubscribe from the redis channel', function (ctx) {
+        ctx.rclient.unsubscribe.called.should.equal(true)
+      })
+    })
+
+    describe('when there is an existing subscription for this another redis client but not this one', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.other_rclient.subscribe = sinon.stub().resolves()
+          ctx.rclient.unsubscribe = sinon.stub().resolves()
+          ctx.ChannelManager.subscribe(
+            ctx.other_rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          ctx.ChannelManager.unsubscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          setTimeout(resolve)
+        })
+      })
+
+      it('should still unsubscribe from the redis channel on this client', function (ctx) {
+        ctx.rclient.unsubscribe.called.should.equal(true)
+      })
+    })
+
+    describe('when unsubscribe errors and completes', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.rclient.subscribe = sinon.stub().resolves()
+          ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          ctx.rclient.unsubscribe = sinon
+            .stub()
+            .rejects(new Error('some redis error'))
+          ctx.ChannelManager.unsubscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          setTimeout(resolve)
+          return null
+        })
+      })
+
+      it('should have cleaned up', function (ctx) {
+        ctx.ChannelManager.getClientMapEntry(ctx.rclient)
+          .has('applied-ops:1234567890abcdef')
+          .should.equal(false)
+      })
+
+      it('should not error out when subscribing again', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          const p = ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          p.then(() => resolve()).catch(reject)
+        })
+      })
+    })
+
+    describe('when unsubscribe errors and another client subscribes at the same time', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.rclient.subscribe = sinon.stub().resolves()
+          ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          let rejectSubscribe
+          ctx.rclient.unsubscribe = () =>
+            new Promise((resolve, reject) => (rejectSubscribe = reject))
+          ctx.ChannelManager.unsubscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+
+          setTimeout(() => {
+            // delay, actualUnsubscribe should not see the new subscribe request
+            ctx.ChannelManager.subscribe(
+              ctx.rclient,
+              'applied-ops',
+              '1234567890abcdef'
+            )
+              .then(() => setTimeout(resolve))
+              .catch(reject)
+            setTimeout(() =>
+              // delay, rejectSubscribe is not defined immediately
+              rejectSubscribe(new Error('redis error'))
+            )
+          })
+        })
+      })
+
+      it('should have recorded the error', function (ctx) {
+        expect(
+          ctx.metrics.inc.calledWithExactly('unsubscribe.failed.applied-ops')
+        ).to.equal(true)
+      })
+
+      it('should have subscribed', function (ctx) {
+        ctx.rclient.subscribe.called.should.equal(true)
+      })
+
+      it('should have discarded the finished Promise', function (ctx) {
+        ctx.ChannelManager.getClientMapEntry(ctx.rclient)
+          .has('applied-ops:1234567890abcdef')
+          .should.equal(false)
+      })
+    })
+
+    describe('when there is an existing subscription for this redis client', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.rclient.subscribe = sinon.stub().resolves()
+          ctx.rclient.unsubscribe = sinon.stub().resolves()
+          ctx.ChannelManager.subscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          ctx.ChannelManager.unsubscribe(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef'
+          )
+          setTimeout(resolve)
+        })
+      })
+
+      it('should unsubscribe from the redis channel', function (ctx) {
+        ctx.rclient.unsubscribe
+          .calledWithExactly('applied-ops:1234567890abcdef')
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('publish', function () {
+    describe("when the channel is 'all'", function () {
+      beforeEach(function (ctx) {
+        ctx.rclient.publish = sinon.stub()
+        ctx.ChannelManager.publish(
+          ctx.rclient,
+          'applied-ops',
+          'all',
+          'random-message'
+        )
+      })
+
+      it('should publish on the base channel', function (ctx) {
+        ctx.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 (ctx) {
+          ctx.rclient.publish = sinon.stub()
+          ctx.settings.publishOnIndividualChannels = false
+          ctx.ChannelManager.publish(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef',
+            'random-message'
+          )
+        })
+
+        it('should publish on the per-id channel', function (ctx) {
+          ctx.rclient.publish
+            .calledWithExactly('applied-ops', 'random-message')
+            .should.equal(true)
+          ctx.rclient.publish.calledOnce.should.equal(true)
+        })
+      })
+
+      describe('when the individual channel setting is true', function () {
+        beforeEach(function (ctx) {
+          ctx.rclient.publish = sinon.stub()
+          ctx.settings.publishOnIndividualChannels = true
+          ctx.ChannelManager.publish(
+            ctx.rclient,
+            'applied-ops',
+            '1234567890abcdef',
+            'random-message'
+          )
+        })
+
+        it('should publish on the per-id channel', function (ctx) {
+          ctx.rclient.publish
+            .calledWithExactly('applied-ops:1234567890abcdef', 'random-message')
+            .should.equal(true)
+          ctx.rclient.publish.calledOnce.should.equal(true)
+        })
+      })
+    })
+
+    describe('metrics', function () {
+      beforeEach(function (ctx) {
+        ctx.rclient.publish = sinon.stub()
+        ctx.ChannelManager.publish(
+          ctx.rclient,
+          'applied-ops',
+          'all',
+          'random-message'
+        )
+      })
+
+      it('should track the payload size', function (ctx) {
+        ctx.metrics.summary
+          .calledWithExactly(
+            'redis.publish.applied-ops',
+            'random-message'.length
+          )
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 0 - 432
services/real-time/test/unit/js/ChannelManagerTests.js

@@ -1,432 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const { expect } = require('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: {
-        '@overleaf/settings': (this.settings = {}),
-        '@overleaf/metrics': (this.metrics = {
-          inc: sinon.stub(),
-          summary: 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('failed to subscribe to channel')
-            err.cause.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)
-      })
-    })
-  })
-})

+ 688 - 0
services/real-time/test/unit/js/ConnectedUsersManager.test.js

@@ -0,0 +1,688 @@
+import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
+import path from 'node:path'
+import sinon from 'sinon'
+import tk from 'timekeeper'
+
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../app/js/ConnectedUsersManager'
+)
+
+describe('ConnectedUsersManager', function () {
+  beforeEach(async function (ctx) {
+    tk.freeze(new Date())
+    ctx.settings = {
+      redis: {
+        realtime: {
+          key_schema: {
+            clientsInProject({ project_id: projectId }) {
+              return `clients_in_project:${projectId}`
+            },
+            connectedUser({ project_id: projectId, client_id: clientId }) {
+              return `connected_user:${projectId}:${clientId}`
+            },
+            projectNotEmptySince({ projectId }) {
+              return `projectNotEmptySince:{${projectId}}`
+            },
+          },
+        },
+      },
+    }
+    ctx.rClient = {
+      auth() {},
+      getdel: sinon.stub(),
+      scard: sinon.stub(),
+      set: sinon.stub(),
+      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 ctx.rClient
+      },
+    }
+    ctx.Metrics = {
+      inc: sinon.stub(),
+      histogram: sinon.stub(),
+    }
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: ctx.Metrics,
+    }))
+
+    vi.doMock('@overleaf/redis-wrapper', () => ({
+      default: {
+        createClient: () => {
+          return ctx.rClient
+        },
+      },
+    }))
+
+    ctx.ConnectedUsersManager = (await import(modulePath)).default
+    ctx.client_id = '32132132'
+    ctx.project_id = 'dskjh2u21321'
+    ctx.user = {
+      _id: 'user-id-123',
+      first_name: 'Joe',
+      last_name: 'Bloggs',
+      email: 'joe@example.com',
+    }
+    ctx.cursorData = {
+      row: 12,
+      column: 9,
+      doc_id: '53c3b8c85fee64000023dc6e',
+    }
+  })
+
+  afterEach(function () {
+    tk.reset()
+  })
+
+  describe('updateUserPosition', function () {
+    beforeEach(function (ctx) {
+      ctx.rClient.exec.yields(null, [1, 1])
+    })
+
+    it('should set a key with the date and give it a ttl', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.updateUserPosition(
+          ctx.project_id,
+          ctx.client_id,
+          ctx.user,
+          null,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.hset
+              .calledWith(
+                `connected_user:${ctx.project_id}:${ctx.client_id}`,
+                'last_updated_at',
+                Date.now()
+              )
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should set a key with the user_id', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.updateUserPosition(
+          ctx.project_id,
+          ctx.client_id,
+          ctx.user,
+          null,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.hset
+              .calledWith(
+                `connected_user:${ctx.project_id}:${ctx.client_id}`,
+                'user_id',
+                ctx.user._id
+              )
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should set a key with the first_name', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.updateUserPosition(
+          ctx.project_id,
+          ctx.client_id,
+          ctx.user,
+          null,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.hset
+              .calledWith(
+                `connected_user:${ctx.project_id}:${ctx.client_id}`,
+                'first_name',
+                ctx.user.first_name
+              )
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should set a key with the last_name', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.updateUserPosition(
+          ctx.project_id,
+          ctx.client_id,
+          ctx.user,
+          null,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.hset
+              .calledWith(
+                `connected_user:${ctx.project_id}:${ctx.client_id}`,
+                'last_name',
+                ctx.user.last_name
+              )
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should set a key with the email', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.updateUserPosition(
+          ctx.project_id,
+          ctx.client_id,
+          ctx.user,
+          null,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.hset
+              .calledWith(
+                `connected_user:${ctx.project_id}:${ctx.client_id}`,
+                'email',
+                ctx.user.email
+              )
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should push the client_id on to the project list', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.updateUserPosition(
+          ctx.project_id,
+          ctx.client_id,
+          ctx.user,
+          null,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.sadd
+              .calledWith(`clients_in_project:${ctx.project_id}`, ctx.client_id)
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should add a ttl to the project set so it stays clean', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.updateUserPosition(
+          ctx.project_id,
+          ctx.client_id,
+          ctx.user,
+          null,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.expire
+              .calledWith(
+                `clients_in_project:${ctx.project_id}`,
+                24 * 4 * 60 * 60
+              )
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should add a ttl to the connected user so it stays clean', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.updateUserPosition(
+          ctx.project_id,
+          ctx.client_id,
+          ctx.user,
+          null,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.expire
+              .calledWith(
+                `connected_user:${ctx.project_id}:${ctx.client_id}`,
+                60 * 15
+              )
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should set the cursor position when provided', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.updateUserPosition(
+          ctx.project_id,
+          ctx.client_id,
+          ctx.user,
+          ctx.cursorData,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.hset
+              .calledWith(
+                `connected_user:${ctx.project_id}:${ctx.client_id}`,
+                'cursorData',
+                JSON.stringify(ctx.cursorData)
+              )
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    describe('editing_session_mode', function () {
+      const cases = {
+        'should bump the metric when connecting to empty room': {
+          nConnectedClients: 1,
+          cursorData: null,
+          labels: {
+            method: 'connect',
+            status: 'single',
+          },
+        },
+        'should bump the metric when connecting to non-empty room': {
+          nConnectedClients: 2,
+          cursorData: null,
+          labels: {
+            method: 'connect',
+            status: 'multi',
+          },
+        },
+        'should bump the metric when updating in empty room': {
+          nConnectedClients: 1,
+          cursorData: { row: 42 },
+          labels: {
+            method: 'update',
+            status: 'single',
+          },
+        },
+        'should bump the metric when updating in non-empty room': {
+          nConnectedClients: 2,
+          cursorData: { row: 42 },
+          labels: {
+            method: 'update',
+            status: 'multi',
+          },
+        },
+      }
+
+      for (const [
+        name,
+        { nConnectedClients, cursorData, labels },
+      ] of Object.entries(cases)) {
+        it(name, async function (ctx) {
+          await new Promise((resolve, reject) => {
+            ctx.rClient.exec.yields(null, [1, nConnectedClients])
+            ctx.ConnectedUsersManager.updateUserPosition(
+              ctx.project_id,
+              ctx.client_id,
+              ctx.user,
+              cursorData,
+              err => {
+                if (err) return reject(err)
+                expect(ctx.Metrics.inc).to.have.been.calledWith(
+                  'editing_session_mode',
+                  1,
+                  labels
+                )
+                resolve()
+              }
+            )
+          })
+        })
+      }
+    })
+  })
+
+  describe('markUserAsDisconnected', function () {
+    beforeEach(function (ctx) {
+      ctx.rClient.exec.yields(null, [1, 0])
+    })
+
+    it('should remove the user from the set', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.markUserAsDisconnected(
+          ctx.project_id,
+          ctx.client_id,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.srem
+              .calledWith(`clients_in_project:${ctx.project_id}`, ctx.client_id)
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should delete the connected_user string', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.markUserAsDisconnected(
+          ctx.project_id,
+          ctx.client_id,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.del
+              .calledWith(`connected_user:${ctx.project_id}:${ctx.client_id}`)
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should add a ttl to the connected user set so it stays clean', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.markUserAsDisconnected(
+          ctx.project_id,
+          ctx.client_id,
+          err => {
+            if (err) return reject(err)
+            ctx.rClient.expire
+              .calledWith(
+                `clients_in_project:${ctx.project_id}`,
+                24 * 4 * 60 * 60
+              )
+              .should.equal(true)
+            resolve()
+          }
+        )
+      })
+    })
+
+    describe('editing_session_mode', function () {
+      const cases = {
+        'should bump the metric when disconnecting from now empty room': {
+          nConnectedClients: 0,
+          labels: {
+            method: 'disconnect',
+            status: 'empty',
+          },
+        },
+        'should bump the metric when disconnecting from now single room': {
+          nConnectedClients: 1,
+          labels: {
+            method: 'disconnect',
+            status: 'single',
+          },
+        },
+        'should bump the metric when disconnecting from now multi room': {
+          nConnectedClients: 2,
+          labels: {
+            method: 'disconnect',
+            status: 'multi',
+          },
+        },
+      }
+
+      for (const [name, { nConnectedClients, labels }] of Object.entries(
+        cases
+      )) {
+        it(name, async function (ctx) {
+          await new Promise((resolve, reject) => {
+            ctx.rClient.exec.yields(null, [1, nConnectedClients])
+            ctx.ConnectedUsersManager.markUserAsDisconnected(
+              ctx.project_id,
+              ctx.client_id,
+              err => {
+                if (err) return reject(err)
+                expect(ctx.Metrics.inc).to.have.been.calledWith(
+                  'editing_session_mode',
+                  1,
+                  labels
+                )
+                resolve()
+              }
+            )
+          })
+        })
+      }
+    })
+
+    describe('projectNotEmptySince', function () {
+      it('should clear the projectNotEmptySince key when empty and skip metric if not set', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.rClient.exec.yields(null, [1, 0])
+          ctx.rClient.getdel.yields(null, '')
+          ctx.ConnectedUsersManager.markUserAsDisconnected(
+            ctx.project_id,
+            ctx.client_id,
+            err => {
+              if (err) return reject(err)
+              expect(ctx.rClient.getdel).to.have.been.calledWith(
+                `projectNotEmptySince:{${ctx.project_id}}`
+              )
+              expect(ctx.Metrics.histogram).to.not.have.been.called
+              resolve()
+            }
+          )
+        })
+      })
+      it('should clear the projectNotEmptySince key when empty and record metric if set', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.rClient.exec.onFirstCall().yields(null, [1, 0])
+          tk.freeze(1_234_000)
+          ctx.rClient.getdel.yields(null, '1230')
+          ctx.ConnectedUsersManager.markUserAsDisconnected(
+            ctx.project_id,
+            ctx.client_id,
+            err => {
+              if (err) return reject(err)
+              expect(ctx.rClient.getdel).to.have.been.calledWith(
+                `projectNotEmptySince:{${ctx.project_id}}`
+              )
+              expect(ctx.Metrics.histogram).to.have.been.calledWith(
+                'project_not_empty_since',
+                4,
+                sinon.match.any,
+                { status: 'empty' }
+              )
+              resolve()
+            }
+          )
+        })
+      })
+      it('should set projectNotEmptySince key when single and skip metric if not set before', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.rClient.exec.onFirstCall().yields(null, [1, 1])
+          tk.freeze(1_233_001) // should ceil up
+          ctx.rClient.exec.onSecondCall().yields(null, [''])
+          ctx.ConnectedUsersManager.markUserAsDisconnected(
+            ctx.project_id,
+            ctx.client_id,
+            err => {
+              if (err) return reject(err)
+              expect(ctx.rClient.set).to.have.been.calledWith(
+                `projectNotEmptySince:{${ctx.project_id}}`,
+                '1234',
+                'NX',
+                'EX',
+                31 * 24 * 60 * 60
+              )
+              expect(ctx.Metrics.histogram).to.not.have.been.called
+              resolve()
+            }
+          )
+        })
+      })
+      const cases = {
+        'should set projectNotEmptySince key when single and record metric if set before':
+          {
+            nConnectedClients: 1,
+            labels: {
+              status: 'single',
+            },
+          },
+        'should set projectNotEmptySince key when multi and record metric if set before':
+          {
+            nConnectedClients: 2,
+            labels: {
+              status: 'multi',
+            },
+          },
+      }
+      for (const [name, { nConnectedClients, labels }] of Object.entries(
+        cases
+      )) {
+        it(name, async function (ctx) {
+          await new Promise((resolve, reject) => {
+            ctx.rClient.exec.onFirstCall().yields(null, [1, nConnectedClients])
+            tk.freeze(1_235_000)
+            ctx.rClient.exec.onSecondCall().yields(null, ['1230'])
+            ctx.ConnectedUsersManager.markUserAsDisconnected(
+              ctx.project_id,
+              ctx.client_id,
+              err => {
+                if (err) return reject(err)
+                expect(ctx.rClient.set).to.have.been.calledWith(
+                  `projectNotEmptySince:{${ctx.project_id}}`,
+                  '1235',
+                  'NX',
+                  'EX',
+                  31 * 24 * 60 * 60
+                )
+                expect(ctx.Metrics.histogram).to.have.been.calledWith(
+                  'project_not_empty_since',
+                  5,
+                  sinon.match.any,
+                  labels
+                )
+                resolve()
+              }
+            )
+          })
+        })
+      }
+    })
+  })
+
+  describe('_getConnectedUser', function () {
+    it('should return a connected user if there is a user object', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        const cursorData = JSON.stringify({ cursorData: { row: 1 } })
+        ctx.rClient.hgetall.callsArgWith(1, null, {
+          connected_at: new Date(),
+          user_id: ctx.user._id,
+          last_updated_at: `${Date.now()}`,
+          cursorData,
+        })
+        ctx.ConnectedUsersManager._getConnectedUser(
+          ctx.project_id,
+          ctx.client_id,
+          (err, result) => {
+            if (err) return reject(err)
+            result.connected.should.equal(true)
+            result.client_id.should.equal(ctx.client_id)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should return a not connected user if there is no object', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.rClient.hgetall.callsArgWith(1, null, null)
+        ctx.ConnectedUsersManager._getConnectedUser(
+          ctx.project_id,
+          ctx.client_id,
+          (err, result) => {
+            if (err) return reject(err)
+            result.connected.should.equal(false)
+            result.client_id.should.equal(ctx.client_id)
+            resolve()
+          }
+        )
+      })
+    })
+
+    it('should return a not connected user if there is an empty object', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.rClient.hgetall.callsArgWith(1, null, {})
+        ctx.ConnectedUsersManager._getConnectedUser(
+          ctx.project_id,
+          ctx.client_id,
+          (err, result) => {
+            if (err) return reject(err)
+            result.connected.should.equal(false)
+            result.client_id.should.equal(ctx.client_id)
+            resolve()
+          }
+        )
+      })
+    })
+  })
+
+  describe('getConnectedUsers', function () {
+    beforeEach(function (ctx) {
+      ctx.users = ['1234', '5678', '9123', '8234']
+      ctx.rClient.smembers.callsArgWith(1, null, ctx.users)
+      ctx.ConnectedUsersManager._getConnectedUser = sinon.stub()
+      ctx.ConnectedUsersManager._getConnectedUser
+        .withArgs(ctx.project_id, ctx.users[0])
+        .callsArgWith(2, null, {
+          connected: true,
+          client_age: 2,
+          client_id: ctx.users[0],
+        })
+      ctx.ConnectedUsersManager._getConnectedUser
+        .withArgs(ctx.project_id, ctx.users[1])
+        .callsArgWith(2, null, {
+          connected: false,
+          client_age: 1,
+          client_id: ctx.users[1],
+        })
+      ctx.ConnectedUsersManager._getConnectedUser
+        .withArgs(ctx.project_id, ctx.users[2])
+        .callsArgWith(2, null, {
+          connected: true,
+          client_age: 3,
+          client_id: ctx.users[2],
+        })
+      ctx.ConnectedUsersManager._getConnectedUser
+        .withArgs(ctx.project_id, ctx.users[3])
+        .callsArgWith(2, null, {
+          connected: true,
+          client_age: 11,
+          client_id: ctx.users[3],
+        })
+    }) // connected but old
+
+    it('should only return the users in the list which are still in redis and recently updated', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.ConnectedUsersManager.getConnectedUsers(
+          ctx.project_id,
+          (err, users) => {
+            if (err) return reject(err)
+            users.length.should.equal(2)
+            users[0].should.deep.equal({
+              client_id: ctx.users[0],
+              client_age: 2,
+              connected: true,
+            })
+            users[1].should.deep.equal({
+              client_id: ctx.users[2],
+              client_age: 3,
+              connected: true,
+            })
+            resolve()
+          }
+        )
+      })
+    })
+  })
+})

+ 0 - 648
services/real-time/test/unit/js/ConnectedUsersManagerTests.js

@@ -1,648 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-
-const SandboxedModule = require('sandboxed-module')
-const assert = require('node:assert')
-const path = require('node: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 () {
-    tk.freeze(new Date())
-    this.settings = {
-      redis: {
-        realtime: {
-          key_schema: {
-            clientsInProject({ project_id: projectId }) {
-              return `clients_in_project:${projectId}`
-            },
-            connectedUser({ project_id: projectId, client_id: clientId }) {
-              return `connected_user:${projectId}:${clientId}`
-            },
-            projectNotEmptySince({ projectId }) {
-              return `projectNotEmptySince:{${projectId}}`
-            },
-          },
-        },
-      },
-    }
-    this.rClient = {
-      auth() {},
-      getdel: sinon.stub(),
-      scard: sinon.stub(),
-      set: sinon.stub(),
-      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
-      },
-    }
-    this.Metrics = {
-      inc: sinon.stub(),
-      histogram: sinon.stub(),
-    }
-
-    this.ConnectedUsersManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': this.settings,
-        '@overleaf/metrics': this.Metrics,
-        '@overleaf/redis-wrapper': {
-          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 () {
-      this.rClient.exec.yields(null, [1, 1])
-    })
-
-    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 => {
-          if (err) return done(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 => {
-          if (err) return done(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 => {
-          if (err) return done(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 => {
-          if (err) return done(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 => {
-          if (err) return done(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 => {
-          if (err) return done(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 => {
-          if (err) return done(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 => {
-          if (err) return done(err)
-          this.rClient.expire
-            .calledWith(
-              `connected_user:${this.project_id}:${this.client_id}`,
-              60 * 15
-            )
-            .should.equal(true)
-          return done()
-        }
-      )
-    })
-
-    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 => {
-          if (err) return done(err)
-          this.rClient.hset
-            .calledWith(
-              `connected_user:${this.project_id}:${this.client_id}`,
-              'cursorData',
-              JSON.stringify(this.cursorData)
-            )
-            .should.equal(true)
-          return done()
-        }
-      )
-    })
-
-    describe('editing_session_mode', function () {
-      const cases = {
-        'should bump the metric when connecting to empty room': {
-          nConnectedClients: 1,
-          cursorData: null,
-          labels: {
-            method: 'connect',
-            status: 'single',
-          },
-        },
-        'should bump the metric when connecting to non-empty room': {
-          nConnectedClients: 2,
-          cursorData: null,
-          labels: {
-            method: 'connect',
-            status: 'multi',
-          },
-        },
-        'should bump the metric when updating in empty room': {
-          nConnectedClients: 1,
-          cursorData: { row: 42 },
-          labels: {
-            method: 'update',
-            status: 'single',
-          },
-        },
-        'should bump the metric when updating in non-empty room': {
-          nConnectedClients: 2,
-          cursorData: { row: 42 },
-          labels: {
-            method: 'update',
-            status: 'multi',
-          },
-        },
-      }
-
-      for (const [
-        name,
-        { nConnectedClients, cursorData, labels },
-      ] of Object.entries(cases)) {
-        it(name, function (done) {
-          this.rClient.exec.yields(null, [1, nConnectedClients])
-          this.ConnectedUsersManager.updateUserPosition(
-            this.project_id,
-            this.client_id,
-            this.user,
-            cursorData,
-            err => {
-              if (err) return done(err)
-              expect(this.Metrics.inc).to.have.been.calledWith(
-                'editing_session_mode',
-                1,
-                labels
-              )
-              done()
-            }
-          )
-        })
-      }
-    })
-  })
-
-  describe('markUserAsDisconnected', function () {
-    beforeEach(function () {
-      this.rClient.exec.yields(null, [1, 0])
-    })
-
-    it('should remove the user from the set', function (done) {
-      return this.ConnectedUsersManager.markUserAsDisconnected(
-        this.project_id,
-        this.client_id,
-        err => {
-          if (err) return done(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 => {
-          if (err) return done(err)
-          this.rClient.del
-            .calledWith(`connected_user:${this.project_id}:${this.client_id}`)
-            .should.equal(true)
-          return done()
-        }
-      )
-    })
-
-    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 => {
-          if (err) return done(err)
-          this.rClient.expire
-            .calledWith(
-              `clients_in_project:${this.project_id}`,
-              24 * 4 * 60 * 60
-            )
-            .should.equal(true)
-          return done()
-        }
-      )
-    })
-
-    describe('editing_session_mode', function () {
-      const cases = {
-        'should bump the metric when disconnecting from now empty room': {
-          nConnectedClients: 0,
-          labels: {
-            method: 'disconnect',
-            status: 'empty',
-          },
-        },
-        'should bump the metric when disconnecting from now single room': {
-          nConnectedClients: 1,
-          labels: {
-            method: 'disconnect',
-            status: 'single',
-          },
-        },
-        'should bump the metric when disconnecting from now multi room': {
-          nConnectedClients: 2,
-          labels: {
-            method: 'disconnect',
-            status: 'multi',
-          },
-        },
-      }
-
-      for (const [name, { nConnectedClients, labels }] of Object.entries(
-        cases
-      )) {
-        it(name, function (done) {
-          this.rClient.exec.yields(null, [1, nConnectedClients])
-          this.ConnectedUsersManager.markUserAsDisconnected(
-            this.project_id,
-            this.client_id,
-            err => {
-              if (err) return done(err)
-              expect(this.Metrics.inc).to.have.been.calledWith(
-                'editing_session_mode',
-                1,
-                labels
-              )
-              done()
-            }
-          )
-        })
-      }
-    })
-
-    describe('projectNotEmptySince', function () {
-      it('should clear the projectNotEmptySince key when empty and skip metric if not set', function (done) {
-        this.rClient.exec.yields(null, [1, 0])
-        this.rClient.getdel.yields(null, '')
-        this.ConnectedUsersManager.markUserAsDisconnected(
-          this.project_id,
-          this.client_id,
-          err => {
-            if (err) return done(err)
-            expect(this.rClient.getdel).to.have.been.calledWith(
-              `projectNotEmptySince:{${this.project_id}}`
-            )
-            expect(this.Metrics.histogram).to.not.have.been.called
-            done()
-          }
-        )
-      })
-      it('should clear the projectNotEmptySince key when empty and record metric if set', function (done) {
-        this.rClient.exec.onFirstCall().yields(null, [1, 0])
-        tk.freeze(1_234_000)
-        this.rClient.getdel.yields(null, '1230')
-        this.ConnectedUsersManager.markUserAsDisconnected(
-          this.project_id,
-          this.client_id,
-          err => {
-            if (err) return done(err)
-            expect(this.rClient.getdel).to.have.been.calledWith(
-              `projectNotEmptySince:{${this.project_id}}`
-            )
-            expect(this.Metrics.histogram).to.have.been.calledWith(
-              'project_not_empty_since',
-              4,
-              sinon.match.any,
-              { status: 'empty' }
-            )
-            done()
-          }
-        )
-      })
-      it('should set projectNotEmptySince key when single and skip metric if not set before', function (done) {
-        this.rClient.exec.onFirstCall().yields(null, [1, 1])
-        tk.freeze(1_233_001) // should ceil up
-        this.rClient.exec.onSecondCall().yields(null, [''])
-        this.ConnectedUsersManager.markUserAsDisconnected(
-          this.project_id,
-          this.client_id,
-          err => {
-            if (err) return done(err)
-            expect(this.rClient.set).to.have.been.calledWith(
-              `projectNotEmptySince:{${this.project_id}}`,
-              '1234',
-              'NX',
-              'EX',
-              31 * 24 * 60 * 60
-            )
-            expect(this.Metrics.histogram).to.not.have.been.called
-            done()
-          }
-        )
-      })
-      const cases = {
-        'should set projectNotEmptySince key when single and record metric if set before':
-          {
-            nConnectedClients: 1,
-            labels: {
-              status: 'single',
-            },
-          },
-        'should set projectNotEmptySince key when multi and record metric if set before':
-          {
-            nConnectedClients: 2,
-            labels: {
-              status: 'multi',
-            },
-          },
-      }
-      for (const [name, { nConnectedClients, labels }] of Object.entries(
-        cases
-      )) {
-        it(name, function (done) {
-          this.rClient.exec.onFirstCall().yields(null, [1, nConnectedClients])
-          tk.freeze(1_235_000)
-          this.rClient.exec.onSecondCall().yields(null, ['1230'])
-          this.ConnectedUsersManager.markUserAsDisconnected(
-            this.project_id,
-            this.client_id,
-            err => {
-              if (err) return done(err)
-              expect(this.rClient.set).to.have.been.calledWith(
-                `projectNotEmptySince:{${this.project_id}}`,
-                '1235',
-                'NX',
-                'EX',
-                31 * 24 * 60 * 60
-              )
-              expect(this.Metrics.histogram).to.have.been.calledWith(
-                'project_not_empty_since',
-                5,
-                sinon.match.any,
-                labels
-              )
-              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) => {
-          if (err) return done(err)
-          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) => {
-          if (err) return done(err)
-          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) => {
-          if (err) return done(err)
-          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) => {
-          if (err) return done(err)
-          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()
-        }
-      )
-    })
-  })
-})

+ 271 - 0
services/real-time/test/unit/js/DocumentUpdaterController.test.js

@@ -0,0 +1,271 @@
+import { vi, describe, beforeEach, it } from 'vitest'
+
+import sinon from 'sinon'
+import MockClient from './helpers/MockClient.js'
+import path from 'node:path'
+
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../app/js/DocumentUpdaterController'
+)
+
+describe('DocumentUpdaterController', function () {
+  beforeEach(async function (ctx) {
+    ctx.project_id = 'project-id-123'
+    ctx.doc_id = 'doc-id-123'
+    ctx.callback = sinon.stub()
+    ctx.io = { mock: 'socket.io' }
+    ctx.rclient = []
+    ctx.RoomEvents = { on: sinon.stub() }
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {
+        redis: {
+          documentupdater: {
+            key_schema: {
+              pendingUpdates({ doc_id: docId }) {
+                return `PendingUpdates:${docId}`
+              },
+            },
+          },
+          pubsub: null,
+        },
+      }),
+    }))
+
+    vi.doMock('../../../app/js/RedisClientManager', () => ({
+      default: {
+        createClientList: () => {
+          ctx.redis = {
+            createClient: name => {
+              let rclientStub
+              ctx.rclient.push((rclientStub = { name }))
+              return rclientStub
+            },
+          }
+        },
+      },
+    }))
+
+    vi.doMock('../../../app/js/SafeJsonParse', () => ({
+      default: (ctx.SafeJsonParse = {
+        parse: (data, cb) => cb(null, JSON.parse(data)),
+      }),
+    }))
+
+    vi.doMock('../../../app/js/EventLogger', () => ({
+      default: (ctx.EventLogger = { checkEventOrder: sinon.stub() }),
+    }))
+
+    vi.doMock('../../../app/js/HealthCheckManager', () => ({
+      default: { check: sinon.stub() },
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: (ctx.metrics = {
+        inc: sinon.stub(),
+        histogram: sinon.stub(),
+      }),
+    }))
+
+    vi.doMock('../../../app/js/RoomManager', () => ({
+      default: (ctx.RoomManager = {
+        eventSource: sinon.stub().returns(ctx.RoomEvents),
+      }),
+    }))
+
+    vi.doMock('../../../app/js/ChannelManager', () => ({
+      default: (ctx.ChannelManager = {}),
+    }))
+
+    ctx.EditorUpdatesController = (await import(modulePath)).default
+  })
+
+  describe('listenForUpdatesFromDocumentUpdater', function () {
+    beforeEach(function (ctx) {
+      ctx.rclient.length = 0 // clear any existing clients
+      ctx.EditorUpdatesController.rclientList = [
+        ctx.redis.createClient('first'),
+        ctx.redis.createClient('second'),
+      ]
+      ctx.rclient[0].subscribe = sinon.stub()
+      ctx.rclient[0].on = sinon.stub()
+      ctx.rclient[1].subscribe = sinon.stub()
+      ctx.rclient[1].on = sinon.stub()
+      ctx.EditorUpdatesController.listenForUpdatesFromDocumentUpdater()
+    })
+
+    it('should subscribe to the doc-updater stream', function (ctx) {
+      ctx.rclient[0].subscribe.calledWith('applied-ops').should.equal(true)
+    })
+
+    it('should register a callback to handle updates', function (ctx) {
+      ctx.rclient[0].on.calledWith('message').should.equal(true)
+    })
+
+    it('should subscribe to any additional doc-updater stream', function (ctx) {
+      ctx.rclient[1].subscribe.calledWith('applied-ops').should.equal(true)
+      ctx.rclient[1].on.calledWith('message').should.equal(true)
+    })
+  })
+
+  describe('_processMessageFromDocumentUpdater', function () {
+    describe('with bad JSON', function () {
+      beforeEach(function (ctx) {
+        ctx.SafeJsonParse.parse = sinon
+          .stub()
+          .callsArgWith(1, new Error('oops'))
+        ctx.EditorUpdatesController._processMessageFromDocumentUpdater(
+          ctx.io,
+          'applied-ops',
+          'blah'
+        )
+      })
+
+      it('should log an error', function (ctx) {
+        ctx.logger.error.called.should.equal(true)
+      })
+    })
+
+    describe('with update', function () {
+      beforeEach(function (ctx) {
+        ctx.message = {
+          doc_id: ctx.doc_id,
+          op: { t: 'foo', p: 12 },
+        }
+        ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater =
+          sinon.stub()
+        ctx.EditorUpdatesController._processMessageFromDocumentUpdater(
+          ctx.io,
+          'applied-ops',
+          JSON.stringify(ctx.message)
+        )
+      })
+
+      it('should apply the update', function (ctx) {
+        ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater
+          .calledWith(ctx.io, ctx.doc_id, ctx.message.op)
+          .should.equal(true)
+      })
+    })
+
+    describe('with error', function () {
+      beforeEach(function (ctx) {
+        ctx.message = {
+          doc_id: ctx.doc_id,
+          error: 'Something went wrong',
+        }
+        ctx.EditorUpdatesController._processErrorFromDocumentUpdater =
+          sinon.stub()
+        ctx.EditorUpdatesController._processMessageFromDocumentUpdater(
+          ctx.io,
+          'applied-ops',
+          JSON.stringify(ctx.message)
+        )
+      })
+
+      it('should process the error', function (ctx) {
+        ctx.EditorUpdatesController._processErrorFromDocumentUpdater
+          .calledWith(ctx.io, ctx.doc_id, ctx.message.error)
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('_applyUpdateFromDocumentUpdater', function () {
+    beforeEach(function (ctx) {
+      ctx.sourceClient = new MockClient()
+      ctx.otherClients = [new MockClient(), new MockClient()]
+      ctx.update = {
+        op: [{ t: 'foo', p: 12 }],
+        meta: { source: ctx.sourceClient.publicId },
+        v: (ctx.version = 42),
+        doc: ctx.doc_id,
+      }
+      ctx.io.sockets = {
+        clients: sinon
+          .stub()
+          .returns([
+            ctx.sourceClient,
+            ...Array.from(ctx.otherClients),
+            ctx.sourceClient,
+          ]),
+      }
+    }) // include a duplicate client
+
+    describe('normally', function () {
+      beforeEach(function (ctx) {
+        ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater(
+          ctx.io,
+          ctx.doc_id,
+          ctx.update
+        )
+      })
+
+      it('should send a version bump to the source client', function (ctx) {
+        ctx.sourceClient.emit
+          .calledWith('otUpdateApplied', { v: ctx.version, doc: ctx.doc_id })
+          .should.equal(true)
+        ctx.sourceClient.emit.calledOnce.should.equal(true)
+      })
+
+      it('should get the clients connected to the document', function (ctx) {
+        ctx.io.sockets.clients.calledWith(ctx.doc_id).should.equal(true)
+      })
+
+      it('should send the full update to the other clients', function (ctx) {
+        Array.from(ctx.otherClients).map(client =>
+          client.emit
+            .calledWith('otUpdateApplied', ctx.update)
+            .should.equal(true)
+        )
+      })
+    })
+
+    describe('with a duplicate op', function () {
+      beforeEach(function (ctx) {
+        ctx.update.dup = true
+        ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater(
+          ctx.io,
+          ctx.doc_id,
+          ctx.update
+        )
+      })
+
+      it('should send a version bump to the source client as usual', function (ctx) {
+        ctx.sourceClient.emit
+          .calledWith('otUpdateApplied', { v: ctx.version, doc: ctx.doc_id })
+          .should.equal(true)
+      })
+
+      it("should not send anything to the other clients (they've already had the op)", function (ctx) {
+        Array.from(ctx.otherClients).map(client =>
+          client.emit.calledWith('otUpdateApplied').should.equal(false)
+        )
+      })
+    })
+  })
+
+  describe('_processErrorFromDocumentUpdater', function () {
+    beforeEach(function (ctx) {
+      ctx.clients = [new MockClient(), new MockClient()]
+      ctx.io.sockets = { clients: sinon.stub().returns(ctx.clients) }
+      ctx.EditorUpdatesController._processErrorFromDocumentUpdater(
+        ctx.io,
+        ctx.doc_id,
+        'Something went wrong'
+      )
+    })
+
+    it('should log a warning', function (ctx) {
+      ctx.logger.warn.called.should.equal(true)
+    })
+
+    it('should disconnect all clients in that document', function (ctx) {
+      ctx.io.sockets.clients.calledWith(ctx.doc_id).should.equal(true)
+      Array.from(ctx.clients).map(client =>
+        client.disconnect.called.should.equal(true)
+      )
+    })
+  })
+})

+ 0 - 259
services/real-time/test/unit/js/DocumentUpdaterControllerTests.js

@@ -1,259 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS101: Remove unnecessary use of Array.from
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const modulePath = require('node: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() }
-    this.EditorUpdatesController = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': (this.settings = {
-          redis: {
-            documentupdater: {
-              key_schema: {
-                pendingUpdates({ doc_id: docId }) {
-                  return `PendingUpdates:${docId}`
-                },
-              },
-            },
-            pubsub: null,
-          },
-        }),
-        './RedisClientManager': {
-          createClientList: () => {
-            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() },
-        '@overleaf/metrics': (this.metrics = {
-          inc: sinon.stub(),
-          histogram: 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()
-      this.EditorUpdatesController.listenForUpdatesFromDocumentUpdater()
-    })
-
-    it('should subscribe to the doc-updater stream', function () {
-      this.rclient[0].subscribe.calledWith('applied-ops').should.equal(true)
-    })
-
-    it('should register a callback to handle updates', function () {
-      this.rclient[0].on.calledWith('message').should.equal(true)
-    })
-
-    it('should subscribe to any additional doc-updater stream', function () {
-      this.rclient[1].subscribe.calledWith('applied-ops').should.equal(true)
-      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'
-        )
-      })
-
-      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)
-        )
-      })
-
-      it('should apply the update', function () {
-        return this.EditorUpdatesController._applyUpdateFromDocumentUpdater
-          .calledWith(this.io, this.doc_id, this.message.op)
-          .should.equal(true)
-      })
-    })
-
-    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)
-      )
-    })
-  })
-})

+ 422 - 0
services/real-time/test/unit/js/DocumentUpdaterManager.test.js

@@ -0,0 +1,422 @@
+// TODO: This file was created by bulk-decaffeinate.
+// Fix any style issues and re-enable lint.
+/*
+ * decaffeinate suggestions:
+ * DS102: Remove unnecessary code created because of implicit returns
+ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
+ */
+import sinon from 'sinon'
+import { vi, describe, beforeEach, it, afterEach } from 'vitest'
+import _ from 'lodash'
+const modulePath = '../../../app/js/DocumentUpdaterManager'
+
+describe('DocumentUpdaterManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.project_id = 'project-id-923'
+    ctx.doc_id = 'doc-id-394'
+    ctx.lines = ['one', 'two', 'three']
+    ctx.version = 42
+    ctx.settings = {
+      apis: { documentupdater: { url: 'http://doc-updater.example.com' } },
+      redis: {
+        documentupdater: {
+          key_schema: {
+            pendingUpdates({ doc_id: docId }) {
+              return `PendingUpdates:${docId}`
+            },
+          },
+        },
+      },
+      maxUpdateSize: 7 * 1024 * 1024,
+      pendingUpdateListShardCount: 10,
+    }
+    ctx.rclient = { auth() {} }
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('request', () => ({
+      default: (ctx.request = {}),
+    }))
+
+    vi.doMock('@overleaf/redis-wrapper', () => ({
+      default: { createClient: () => ctx.rclient },
+    }))
+
+    class Timer {
+      done() {}
+    }
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: (ctx.Metrics = {
+        summary: sinon.stub(),
+        Timer,
+      }),
+    }))
+
+    ctx.DocumentUpdaterManager = (await import(modulePath)).default
+  }) // avoid modifying JSON object directly
+
+  describe('getDocument', function () {
+    beforeEach(function (ctx) {
+      ctx.callback = sinon.stub()
+    })
+
+    describe('successfully', function () {
+      beforeEach(function (ctx) {
+        ctx.body = JSON.stringify({
+          lines: ctx.lines,
+          version: ctx.version,
+          ops: (ctx.ops = ['mock-op-1', 'mock-op-2']),
+          ranges: (ctx.ranges = { mock: 'ranges' }),
+        })
+        ctx.fromVersion = 2
+        ctx.request.get = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 200 }, ctx.body)
+        ctx.DocumentUpdaterManager.getDocument(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.fromVersion,
+          ctx.callback
+        )
+      })
+
+      it('should get the document from the document updater', function (ctx) {
+        const url = `${ctx.settings.apis.documentupdater.url}/project/${ctx.project_id}/doc/${ctx.doc_id}?fromVersion=${ctx.fromVersion}&historyOTSupport=true`
+        ctx.request.get.calledWith(url).should.equal(true)
+      })
+
+      it('should call the callback with the lines, version, ranges and ops', function (ctx) {
+        ctx.callback
+          .calledWith(null, ctx.lines, ctx.version, ctx.ranges, ctx.ops)
+          .should.equal(true)
+      })
+    })
+
+    describe('when the document updater API returns an error', function () {
+      beforeEach(function (ctx) {
+        ctx.request.get = sinon
+          .stub()
+          .callsArgWith(
+            1,
+            (ctx.error = new Error('something went wrong')),
+            null,
+            null
+          )
+        ctx.DocumentUpdaterManager.getDocument(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.fromVersion,
+          ctx.callback
+        )
+      })
+
+      it('should return an error to the callback', function (ctx) {
+        ctx.callback.calledWith(ctx.error).should.equal(true)
+      })
+    })
+    ;[404, 422].forEach(statusCode =>
+      describe(`when the document updater returns a ${statusCode} status code`, function () {
+        beforeEach(function (ctx) {
+          ctx.request.get = sinon
+            .stub()
+            .callsArgWith(1, null, { statusCode }, '')
+          ctx.DocumentUpdaterManager.getDocument(
+            ctx.project_id,
+            ctx.doc_id,
+            ctx.fromVersion,
+            ctx.callback
+          )
+        })
+
+        it('should return the callback with an error', function (ctx) {
+          ctx.callback.called.should.equal(true)
+          ctx.callback
+            .calledWith(
+              sinon.match({
+                message: 'doc updater could not load requested ops',
+                info: { statusCode },
+              })
+            )
+            .should.equal(true)
+          ctx.logger.error.called.should.equal(false)
+          ctx.logger.warn.called.should.equal(false)
+        })
+      })
+    )
+
+    describe('when the document updater returns a failure error code', function () {
+      beforeEach(function (ctx) {
+        ctx.request.get = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 500 }, '')
+        ctx.DocumentUpdaterManager.getDocument(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.fromVersion,
+          ctx.callback
+        )
+      })
+
+      it('should return the callback with an error', function (ctx) {
+        ctx.callback.called.should.equal(true)
+        ctx.callback
+          .calledWith(
+            sinon.match({
+              message: 'doc updater returned a non-success status code',
+              info: {
+                action: 'getDocument',
+                statusCode: 500,
+              },
+            })
+          )
+          .should.equal(true)
+        ctx.logger.error.called.should.equal(false)
+      })
+    })
+  })
+
+  describe('flushProjectToMongoAndDelete', function () {
+    beforeEach(function (ctx) {
+      ctx.callback = sinon.stub()
+    })
+
+    describe('successfully', function () {
+      beforeEach(function (ctx) {
+        ctx.request.del = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 204 }, '')
+        ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete(
+          ctx.project_id,
+          ctx.callback
+        )
+      })
+
+      it('should delete the project from the document updater', function (ctx) {
+        const url = `${ctx.settings.apis.documentupdater.url}/project/${ctx.project_id}?background=true`
+        ctx.request.del.calledWith(url).should.equal(true)
+      })
+
+      it('should call the callback with no error', function (ctx) {
+        ctx.callback.calledWith(null).should.equal(true)
+      })
+    })
+
+    describe('when the document updater API returns an error', function () {
+      beforeEach(function (ctx) {
+        ctx.request.del = sinon
+          .stub()
+          .callsArgWith(
+            1,
+            (ctx.error = new Error('something went wrong')),
+            null,
+            null
+          )
+        ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete(
+          ctx.project_id,
+          ctx.callback
+        )
+      })
+
+      it('should return an error to the callback', function (ctx) {
+        ctx.callback.calledWith(ctx.error).should.equal(true)
+      })
+    })
+
+    describe('when the document updater returns a failure error code', function () {
+      beforeEach(function (ctx) {
+        ctx.request.del = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 500 }, '')
+        ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete(
+          ctx.project_id,
+          ctx.callback
+        )
+      })
+
+      it('should return the callback with an error', function (ctx) {
+        ctx.callback.called.should.equal(true)
+        ctx.callback
+          .calledWith(
+            sinon.match({
+              message: 'doc updater returned a non-success status code',
+              info: {
+                action: 'flushProjectToMongoAndDelete',
+                statusCode: 500,
+              },
+            })
+          )
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('queueChange', function () {
+    beforeEach(function (ctx) {
+      ctx.change = {
+        doc: '1234567890',
+        op: [{ d: 'test', p: 345 }],
+        v: 789,
+      }
+      ctx.rclient.rpush = sinon.stub().yields()
+      ctx.callback = sinon.stub()
+    })
+
+    describe('successfully', function () {
+      beforeEach(function (ctx) {
+        ctx.pendingUpdateListKey = `pending-updates-list-key-${Math.random()}`
+
+        ctx.DocumentUpdaterManager._getPendingUpdateListKey = sinon
+          .stub()
+          .returns(ctx.pendingUpdateListKey)
+        ctx.DocumentUpdaterManager.queueChange(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.change,
+          ctx.callback
+        )
+      })
+
+      it('should push the change', function (ctx) {
+        ctx.rclient.rpush
+          .calledWith(
+            `PendingUpdates:${ctx.doc_id}`,
+            JSON.stringify(ctx.change)
+          )
+          .should.equal(true)
+      })
+
+      it('should notify the doc updater of the change via the pending-updates-list queue', function (ctx) {
+        ctx.rclient.rpush
+          .calledWith(
+            ctx.pendingUpdateListKey,
+            `${ctx.project_id}:${ctx.doc_id}`
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('with error talking to redis during rpush', function () {
+      beforeEach(function (ctx) {
+        ctx.rclient.rpush = sinon
+          .stub()
+          .yields(new Error('something went wrong'))
+        ctx.DocumentUpdaterManager.queueChange(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.change,
+          ctx.callback
+        )
+      })
+
+      it('should return an error', function (ctx) {
+        ctx.callback.calledWithExactly(sinon.match(Error)).should.equal(true)
+      })
+    })
+
+    describe('with null byte corruption', function () {
+      beforeEach(function (ctx) {
+        ctx.stringifyStub = sinon
+          .stub(JSON, 'stringify')
+          .callsFake(() => '["bad bytes! \u0000 <- here"]')
+        ctx.DocumentUpdaterManager.queueChange(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.change,
+          ctx.callback
+        )
+      })
+
+      afterEach(function (ctx) {
+        ctx.stringifyStub.restore()
+      })
+
+      it('should return an error', function (ctx) {
+        ctx.callback.calledWithExactly(sinon.match(Error)).should.equal(true)
+      })
+
+      it('should not push the change onto the pending-updates-list queue', function (ctx) {
+        ctx.rclient.rpush.called.should.equal(false)
+      })
+    })
+
+    describe('when the update is too large', function () {
+      beforeEach(function (ctx) {
+        ctx.change = {
+          op: { p: 12, t: 'update is too large'.repeat(1024 * 400) },
+        }
+        ctx.DocumentUpdaterManager.queueChange(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.change,
+          ctx.callback
+        )
+      })
+
+      it('should return an error', function (ctx) {
+        ctx.callback.calledWithExactly(sinon.match(Error)).should.equal(true)
+      })
+
+      it('should add the size to the error', function (ctx) {
+        ctx.callback.args[0][0].info.updateSize.should.equal(7782422)
+      })
+
+      it('should not push the change onto the pending-updates-list queue', function (ctx) {
+        ctx.rclient.rpush.called.should.equal(false)
+      })
+    })
+
+    describe('with invalid keys', function () {
+      beforeEach(function (ctx) {
+        ctx.change = {
+          op: [{ d: 'test', p: 345 }],
+          version: 789, // not a valid key
+        }
+        ctx.DocumentUpdaterManager.queueChange(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.change,
+          ctx.callback
+        )
+      })
+
+      it('should remove the invalid keys from the change', function (ctx) {
+        ctx.rclient.rpush
+          .calledWith(
+            `PendingUpdates:${ctx.doc_id}`,
+            JSON.stringify({ op: ctx.change.op })
+          )
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('_getPendingUpdateListKey', function () {
+    beforeEach(function (ctx) {
+      const keys = _.times(
+        10000,
+        ctx.DocumentUpdaterManager._getPendingUpdateListKey
+      )
+      ctx.keys = _.uniq(keys)
+    })
+    it('should return normal pending updates key', function (ctx) {
+      _.includes(ctx.keys, 'pending-updates-list').should.equal(true)
+    })
+
+    it('should return pending-updates-list-n keys', function (ctx) {
+      _.includes(ctx.keys, 'pending-updates-list-1').should.equal(true)
+      _.includes(ctx.keys, 'pending-updates-list-3').should.equal(true)
+      _.includes(ctx.keys, 'pending-updates-list-9').should.equal(true)
+    })
+
+    it('should not include pending-updates-list-0 key', function (ctx) {
+      _.includes(ctx.keys, 'pending-updates-list-0').should.equal(false)
+    })
+
+    it('should not include maximum as pendingUpdateListShardCount value', function (ctx) {
+      _.includes(ctx.keys, 'pending-updates-list-10').should.equal(false)
+    })
+  })
+})

+ 0 - 423
services/real-time/test/unit/js/DocumentUpdaterManagerTests.js

@@ -1,423 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const sinon = require('sinon')
-const SandboxedModule = require('sandboxed-module')
-const path = require('node:path')
-const modulePath = '../../../app/js/DocumentUpdaterManager'
-const _ = require('lodash')
-
-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: docId }) {
-              return `PendingUpdates:${docId}`
-            },
-          },
-        },
-      },
-      maxUpdateSize: 7 * 1024 * 1024,
-      pendingUpdateListShardCount: 10,
-    }
-    this.rclient = { auth() {} }
-
-    return (this.DocumentUpdaterManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': this.settings,
-        request: (this.request = {}),
-        '@overleaf/redis-wrapper': { createClient: () => this.rclient },
-        '@overleaf/metrics': (this.Metrics = {
-          summary: sinon.stub(),
-          Timer: (Timer = class Timer {
-            done() {}
-          }),
-        }),
-      },
-    }))
-  }) // 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}&historyOTSupport=true`
-        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)
-          this.callback
-            .calledWith(
-              sinon.match({
-                message: 'doc updater could not load requested ops',
-                info: { statusCode },
-              })
-            )
-            .should.equal(true)
-          this.logger.error.called.should.equal(false)
-          this.logger.warn.called.should.equal(false)
-        })
-      })
-    )
-
-    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)
-        this.callback
-          .calledWith(
-            sinon.match({
-              message: 'doc updater returned a non-success status code',
-              info: {
-                action: 'getDocument',
-                statusCode: 500,
-              },
-            })
-          )
-          .should.equal(true)
-        this.logger.error.called.should.equal(false)
-      })
-    })
-  })
-
-  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)
-        this.callback
-          .calledWith(
-            sinon.match({
-              message: 'doc updater returned a non-success status code',
-              info: {
-                action: 'flushProjectToMongoAndDelete',
-                statusCode: 500,
-              },
-            })
-          )
-          .should.equal(true)
-      })
-    })
-  })
-
-  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 () {
-        this.pendingUpdateListKey = `pending-updates-list-key-${Math.random()}`
-
-        this.DocumentUpdaterManager._getPendingUpdateListKey = sinon
-          .stub()
-          .returns(this.pendingUpdateListKey)
-        this.DocumentUpdaterManager.queueChange(
-          this.project_id,
-          this.doc_id,
-          this.change,
-          this.callback
-        )
-      })
-
-      it('should push the change', function () {
-        this.rclient.rpush
-          .calledWith(
-            `PendingUpdates:${this.doc_id}`,
-            JSON.stringify(this.change)
-          )
-          .should.equal(true)
-      })
-
-      it('should notify the doc updater of the change via the pending-updates-list queue', function () {
-        this.rclient.rpush
-          .calledWith(
-            this.pendingUpdateListKey,
-            `${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.stringifyStub = sinon
-          .stub(JSON, 'stringify')
-          .callsFake(() => '["bad bytes! \u0000 <- here"]')
-        return this.DocumentUpdaterManager.queueChange(
-          this.project_id,
-          this.doc_id,
-          this.change,
-          this.callback
-        )
-      })
-
-      afterEach(function () {
-        this.stringifyStub.restore()
-      })
-
-      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].info.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)
-      })
-    })
-
-    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
-        )
-      })
-
-      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)
-      })
-    })
-  })
-
-  describe('_getPendingUpdateListKey', function () {
-    beforeEach(function () {
-      const keys = _.times(
-        10000,
-        this.DocumentUpdaterManager._getPendingUpdateListKey
-      )
-      this.keys = _.uniq(keys)
-    })
-    it('should return normal pending updates key', function () {
-      _.includes(this.keys, 'pending-updates-list').should.equal(true)
-    })
-
-    it('should return pending-updates-list-n keys', function () {
-      _.includes(this.keys, 'pending-updates-list-1').should.equal(true)
-      _.includes(this.keys, 'pending-updates-list-3').should.equal(true)
-      _.includes(this.keys, 'pending-updates-list-9').should.equal(true)
-    })
-
-    it('should not include pending-updates-list-0 key', function () {
-      _.includes(this.keys, 'pending-updates-list-0').should.equal(false)
-    })
-
-    it('should not include maximum as pendingUpdateListShardCount value', function () {
-      _.includes(this.keys, 'pending-updates-list-10').should.equal(false)
-    })
-  })
-})

+ 126 - 0
services/real-time/test/unit/js/DrainManager.test.js

@@ -0,0 +1,126 @@
+// TODO: This file was created by bulk-decaffeinate.
+// Fix any style issues and re-enable lint.
+/*
+ * decaffeinate suggestions:
+ * DS102: Remove unnecessary code created because of implicit returns
+ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
+ */
+import sinon from 'sinon'
+import { describe, beforeEach, it } from 'vitest'
+import path from 'node:path'
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../app/js/DrainManager'
+)
+
+describe('DrainManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.DrainManager = (await import(modulePath)).default
+    ctx.io = {
+      sockets: {
+        clients: sinon.stub(),
+      },
+    }
+  })
+
+  describe('startDrainTimeWindow', function () {
+    beforeEach(function (ctx) {
+      ctx.clients = []
+      for (let i = 0; i <= 5399; i++) {
+        ctx.clients[i] = {
+          id: i,
+          emit: sinon.stub(),
+        }
+      }
+      ctx.io.sockets.clients.returns(ctx.clients)
+      ctx.DrainManager.startDrain = sinon.stub()
+    })
+
+    it('should set a drain rate fast enough', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.DrainManager.startDrainTimeWindow(ctx.io, 9)
+        ctx.DrainManager.startDrain.calledWith(ctx.io, 10).should.equal(true)
+        resolve()
+      })
+    })
+  })
+
+  describe('reconnectNClients', function () {
+    beforeEach(function (ctx) {
+      ctx.clients = []
+      for (let i = 0; i <= 9; i++) {
+        ctx.clients[i] = {
+          id: i,
+          emit: sinon.stub(),
+        }
+      }
+      ctx.io.sockets.clients.returns(ctx.clients)
+    })
+
+    describe('after first pass', function () {
+      beforeEach(function (ctx) {
+        ctx.DrainManager.reconnectNClients(ctx.io, 3)
+      })
+
+      it('should reconnect the first 3 clients', function (ctx) {
+        ;[0, 1, 2].map(i =>
+          ctx.clients[i].emit
+            .calledWith('reconnectGracefully')
+            .should.equal(true)
+        )
+      })
+
+      it('should not reconnect any more clients', function (ctx) {
+        ;[3, 4, 5, 6, 7, 8, 9].map(i =>
+          ctx.clients[i].emit
+            .calledWith('reconnectGracefully')
+            .should.equal(false)
+        )
+      })
+
+      describe('after second pass', function () {
+        beforeEach(function (ctx) {
+          ctx.DrainManager.reconnectNClients(ctx.io, 3)
+        })
+
+        it('should reconnect the next 3 clients', function (ctx) {
+          ;[3, 4, 5].map(i =>
+            ctx.clients[i].emit
+              .calledWith('reconnectGracefully')
+              .should.equal(true)
+          )
+        })
+
+        it('should not reconnect any more clients', function (ctx) {
+          ;[6, 7, 8, 9].map(i =>
+            ctx.clients[i].emit
+              .calledWith('reconnectGracefully')
+              .should.equal(false)
+          )
+        })
+
+        it('should not reconnect the first 3 clients again', function (ctx) {
+          ;[0, 1, 2].map(i => ctx.clients[i].emit.calledOnce.should.equal(true))
+        })
+
+        describe('after final pass', function () {
+          beforeEach(function (ctx) {
+            ctx.DrainManager.reconnectNClients(ctx.io, 100)
+          })
+
+          it('should not reconnect the first 6 clients again', function (ctx) {
+            ;[0, 1, 2, 3, 4, 5].map(i =>
+              ctx.clients[i].emit.calledOnce.should.equal(true)
+            )
+          })
+
+          it('should log out that it reached the end', function (ctx) {
+            ctx.logger.info
+              .calledWith('All clients have been told to reconnectGracefully')
+              .should.equal(true)
+          })
+        })
+      })
+    })
+  })
+})

+ 0 - 127
services/real-time/test/unit/js/DrainManagerTests.js

@@ -1,127 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const sinon = require('sinon')
-const SandboxedModule = require('sandboxed-module')
-const path = require('node:path')
-const modulePath = path.join(__dirname, '../../../app/js/DrainManager')
-
-describe('DrainManager', function () {
-  beforeEach(function () {
-    this.DrainManager = SandboxedModule.require(modulePath, {})
-    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())
-    })
-
-    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('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.info
-              .calledWith('All clients have been told to reconnectGracefully')
-              .should.equal(true)
-          })
-        })
-      })
-    })
-  })
-})

+ 120 - 0
services/real-time/test/unit/js/EventLogger.test.js

@@ -0,0 +1,120 @@
+import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
+
+import sinon from 'sinon'
+import tk from 'timekeeper'
+const modulePath = '../../../app/js/EventLogger'
+
+describe('EventLogger', function () {
+  beforeEach(async function (ctx) {
+    ctx.start = Date.now()
+    tk.freeze(new Date(ctx.start))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: (ctx.metrics = { inc: sinon.stub() }),
+    }))
+
+    ctx.EventLogger = (await import(modulePath)).default
+    ctx.channel = 'applied-ops'
+    ctx.id_1 = 'random-hostname:abc-1'
+    ctx.message_1 = 'message-1'
+    ctx.id_2 = 'random-hostname:abc-2'
+    ctx.message_2 = 'message-2'
+  })
+
+  afterEach(function () {
+    tk.reset()
+  })
+
+  describe('checkEventOrder', function () {
+    describe('when the events are in order', function () {
+      beforeEach(function (ctx) {
+        ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1)
+        ctx.status = ctx.EventLogger.checkEventOrder(
+          ctx.channel,
+          ctx.id_2,
+          ctx.message_2
+        )
+      })
+
+      it('should accept events in order', function (ctx) {
+        expect(ctx.status).to.be.undefined
+      })
+
+      it('should increment the valid event metric', function (ctx) {
+        ctx.metrics.inc
+          .calledWith(`event.${ctx.channel}.valid`)
+          .should.equals(true)
+      })
+    })
+
+    describe('when there is a duplicate events', function () {
+      beforeEach(function (ctx) {
+        ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1)
+        ctx.status = ctx.EventLogger.checkEventOrder(
+          ctx.channel,
+          ctx.id_1,
+          ctx.message_1
+        )
+      })
+
+      it('should return "duplicate" for the same event', function (ctx) {
+        expect(ctx.status).to.equal('duplicate')
+      })
+
+      it('should increment the duplicate event metric', function (ctx) {
+        ctx.metrics.inc
+          .calledWith(`event.${ctx.channel}.duplicate`)
+          .should.equals(true)
+      })
+    })
+
+    describe('when there are out of order events', function () {
+      beforeEach(function (ctx) {
+        ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1)
+        ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_2, ctx.message_2)
+        ctx.status = ctx.EventLogger.checkEventOrder(
+          ctx.channel,
+          ctx.id_1,
+          ctx.message_1
+        )
+      })
+
+      it('should return "out-of-order" for the event', function (ctx) {
+        expect(ctx.status).to.equal('out-of-order')
+      })
+
+      it('should increment the out-of-order event metric', function (ctx) {
+        ctx.metrics.inc
+          .calledWith(`event.${ctx.channel}.out-of-order`)
+          .should.equals(true)
+      })
+    })
+
+    describe('after MAX_STALE_TIME_IN_MS', function () {
+      it('should flush old entries', function (ctx) {
+        let status
+        ctx.EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10
+        ctx.EventLogger.checkEventOrder(ctx.channel, ctx.id_1, ctx.message_1)
+        for (let i = 1; i <= 8; i++) {
+          status = ctx.EventLogger.checkEventOrder(
+            ctx.channel,
+            ctx.id_1,
+            ctx.message_1
+          )
+          expect(status).to.equal('duplicate')
+        }
+        // the next event should flush the old entries aboce
+        ctx.EventLogger.MAX_STALE_TIME_IN_MS = 1000
+        tk.freeze(new Date(ctx.start + 5 * 1000))
+        // because we flushed the entries this should not be a duplicate
+        ctx.EventLogger.checkEventOrder(ctx.channel, 'other-1', ctx.message_2)
+        status = ctx.EventLogger.checkEventOrder(
+          ctx.channel,
+          ctx.id_1,
+          ctx.message_1
+        )
+        expect(status).to.be.undefined
+      })
+    })
+  })
+})

+ 0 - 153
services/real-time/test/unit/js/EventLoggerTests.js

@@ -1,153 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-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: {
-        '@overleaf/metrics': (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()
-  })
-
-  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
-        ))
-      })
-
-      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`)
-          .should.equals(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
-        ))
-      })
-
-      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`)
-          .should.equals(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
-        ))
-      })
-
-      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`)
-          .should.equals(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
-      })
-    })
-  })
-})

+ 403 - 0
services/real-time/test/unit/js/RoomManager.test.js

@@ -0,0 +1,403 @@
+import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
+
+import sinon from 'sinon'
+const modulePath = '../../../app/js/RoomManager.js'
+
+describe('RoomManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.project_id = 'project-id-123'
+    ctx.doc_id = 'doc-id-456'
+    ctx.other_doc_id = 'doc-id-789'
+    ctx.client = { namespace: { name: '' }, id: 'first-client' }
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {}),
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: (ctx.metrics = { gauge: sinon.stub() }),
+    }))
+
+    ctx.RoomManager = (await import(modulePath)).default
+    ctx.RoomManager._clientsInRoom = sinon.stub()
+    ctx.RoomManager._clientAlreadyInRoom = sinon.stub()
+    ctx.RoomEvents = ctx.RoomManager.eventSource()
+    sinon.spy(ctx.RoomEvents, 'emit')
+    sinon.spy(ctx.RoomEvents, 'once')
+  })
+
+  describe('emitOnCompletion', function () {
+    describe('when a subscribe errors', function () {
+      afterEach(function (ctx) {
+        process.removeListener('unhandledRejection', ctx.onUnhandled)
+      })
+
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.onUnhandled = error => {
+            ctx.unhandledError = error
+            reject(new Error(`unhandledRejection: ${error.message}`))
+          }
+          process.on('unhandledRejection', ctx.onUnhandled)
+
+          let rejectSubscribePromise
+          const subscribePromise = new Promise(
+            // eslint-disable-next-line promise/param-names
+            (_, r) => (rejectSubscribePromise = r)
+          )
+          const promises = [subscribePromise]
+          const eventName = 'project-subscribed-123'
+          ctx.RoomEvents.once(eventName, () => setTimeout(resolve, 100))
+          ctx.RoomManager.emitOnCompletion(promises, eventName)
+          setTimeout(() =>
+            rejectSubscribePromise(new Error('subscribe failed'))
+          )
+        })
+      })
+
+      it('should keep going', function (ctx) {
+        expect(ctx.unhandledError).to.not.exist
+      })
+    })
+  })
+
+  describe('joinProject', function () {
+    describe('when the project room is empty', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.RoomManager._clientsInRoom
+            .withArgs(ctx.client, ctx.project_id)
+            .onFirstCall()
+            .returns(0)
+          ctx.client.join = sinon.stub()
+          ctx.callback = sinon.stub()
+          ctx.RoomEvents.on('project-active', id => {
+            setTimeout(() => {
+              ctx.RoomEvents.emit(`project-subscribed-${id}`)
+            }, 100)
+          })
+          ctx.RoomManager.joinProject(ctx.client, ctx.project_id, err => {
+            ctx.callback(err)
+            resolve()
+          })
+        })
+      })
+
+      it("should emit a 'project-active' event with the id", function (ctx) {
+        ctx.RoomEvents.emit
+          .calledWithExactly('project-active', ctx.project_id)
+          .should.equal(true)
+      })
+
+      it("should listen for the 'project-subscribed-id' event", function (ctx) {
+        ctx.RoomEvents.once
+          .calledWith(`project-subscribed-${ctx.project_id}`)
+          .should.equal(true)
+      })
+
+      it('should join the room using the id', function (ctx) {
+        ctx.client.join.calledWithExactly(ctx.project_id).should.equal(true)
+      })
+    })
+
+    describe('when there are other clients in the project room', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.RoomManager._clientsInRoom
+            .withArgs(ctx.client, ctx.project_id)
+            .onFirstCall()
+            .returns(123)
+            .onSecondCall()
+            .returns(124)
+          ctx.client.join = sinon.stub()
+          ctx.RoomManager.joinProject(ctx.client, ctx.project_id, err => {
+            if (err) return reject(err)
+            resolve()
+          })
+        })
+      })
+
+      it('should join the room using the id', function (ctx) {
+        ctx.client.join.called.should.equal(true)
+      })
+
+      it('should not emit any events', function (ctx) {
+        ctx.RoomEvents.emit.called.should.equal(false)
+      })
+    })
+  })
+
+  describe('joinDoc', function () {
+    describe('when the doc room is empty', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.RoomManager._clientsInRoom
+            .withArgs(ctx.client, ctx.doc_id)
+            .onFirstCall()
+            .returns(0)
+          ctx.client.join = sinon.stub()
+          ctx.callback = sinon.stub()
+          ctx.RoomEvents.on('doc-active', id => {
+            setTimeout(() => {
+              ctx.RoomEvents.emit(`doc-subscribed-${id}`)
+            }, 100)
+          })
+          ctx.RoomManager.joinDoc(ctx.client, ctx.doc_id, err => {
+            ctx.callback(err)
+            resolve()
+          })
+        })
+      })
+
+      it("should emit a 'doc-active' event with the id", function (ctx) {
+        ctx.RoomEvents.emit
+          .calledWithExactly('doc-active', ctx.doc_id)
+          .should.equal(true)
+      })
+
+      it("should listen for the 'doc-subscribed-id' event", function (ctx) {
+        ctx.RoomEvents.once
+          .calledWith(`doc-subscribed-${ctx.doc_id}`)
+          .should.equal(true)
+      })
+
+      it('should join the room using the id', function (ctx) {
+        ctx.client.join.calledWithExactly(ctx.doc_id).should.equal(true)
+      })
+    })
+
+    describe('when there are other clients in the doc room', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.RoomManager._clientsInRoom
+            .withArgs(ctx.client, ctx.doc_id)
+            .onFirstCall()
+            .returns(123)
+            .onSecondCall()
+            .returns(124)
+          ctx.client.join = sinon.stub()
+          ctx.RoomManager.joinDoc(ctx.client, ctx.doc_id, err => {
+            if (err) return reject(err)
+            resolve()
+          })
+        })
+      })
+
+      it('should join the room using the id', function (ctx) {
+        ctx.client.join.called.should.equal(true)
+      })
+
+      it('should not emit any events', function (ctx) {
+        ctx.RoomEvents.emit.called.should.equal(false)
+      })
+    })
+  })
+
+  describe('leaveDoc', function () {
+    describe('when doc room will be empty after this client has left', function () {
+      beforeEach(function (ctx) {
+        ctx.RoomManager._clientAlreadyInRoom
+          .withArgs(ctx.client, ctx.doc_id)
+          .returns(true)
+        ctx.RoomManager._clientsInRoom
+          .withArgs(ctx.client, ctx.doc_id)
+          .onCall(0)
+          .returns(0)
+        ctx.client.leave = sinon.stub()
+        ctx.RoomManager.leaveDoc(ctx.client, ctx.doc_id)
+      })
+
+      it('should leave the room using the id', function (ctx) {
+        ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true)
+      })
+
+      it("should emit a 'doc-empty' event with the id", function (ctx) {
+        ctx.RoomEvents.emit
+          .calledWithExactly('doc-empty', ctx.doc_id)
+          .should.equal(true)
+      })
+    })
+
+    describe('when there are other clients in the doc room', function () {
+      beforeEach(function (ctx) {
+        ctx.RoomManager._clientAlreadyInRoom
+          .withArgs(ctx.client, ctx.doc_id)
+          .returns(true)
+        ctx.RoomManager._clientsInRoom
+          .withArgs(ctx.client, ctx.doc_id)
+          .onCall(0)
+          .returns(123)
+        ctx.client.leave = sinon.stub()
+        ctx.RoomManager.leaveDoc(ctx.client, ctx.doc_id)
+      })
+
+      it('should leave the room using the id', function (ctx) {
+        ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true)
+      })
+
+      it('should not emit any events', function (ctx) {
+        ctx.RoomEvents.emit.called.should.equal(false)
+      })
+    })
+
+    describe('when the client is not in the doc room', function () {
+      beforeEach(function (ctx) {
+        ctx.RoomManager._clientAlreadyInRoom
+          .withArgs(ctx.client, ctx.doc_id)
+          .returns(false)
+        ctx.RoomManager._clientsInRoom
+          .withArgs(ctx.client, ctx.doc_id)
+          .onCall(0)
+          .returns(0)
+        ctx.client.leave = sinon.stub()
+        ctx.RoomManager.leaveDoc(ctx.client, ctx.doc_id)
+      })
+
+      it('should not leave the room', function (ctx) {
+        ctx.client.leave.called.should.equal(false)
+      })
+
+      it('should not emit any events', function (ctx) {
+        ctx.RoomEvents.emit.called.should.equal(false)
+      })
+    })
+  })
+
+  describe('leaveProjectAndDocs', function () {
+    describe('when the client is connected to the project and multiple docs', function () {
+      beforeEach(function (ctx) {
+        ctx.RoomManager._roomsClientIsIn = sinon
+          .stub()
+          .returns([ctx.project_id, ctx.doc_id, ctx.other_doc_id])
+        ctx.client.join = sinon.stub()
+        ctx.client.leave = sinon.stub()
+      })
+
+      describe('when this is the only client connected', function () {
+        beforeEach(async function (ctx) {
+          await new Promise((resolve, reject) => {
+            // first call is for the join,
+            // second for the leave
+            ctx.RoomManager._clientsInRoom
+              .withArgs(ctx.client, ctx.doc_id)
+              .onCall(0)
+              .returns(0)
+              .onCall(1)
+              .returns(0)
+            ctx.RoomManager._clientsInRoom
+              .withArgs(ctx.client, ctx.other_doc_id)
+              .onCall(0)
+              .returns(0)
+              .onCall(1)
+              .returns(0)
+            ctx.RoomManager._clientsInRoom
+              .withArgs(ctx.client, ctx.project_id)
+              .onCall(0)
+              .returns(0)
+              .onCall(1)
+              .returns(0)
+            ctx.RoomManager._clientAlreadyInRoom
+              .withArgs(ctx.client, ctx.doc_id)
+              .returns(true)
+              .withArgs(ctx.client, ctx.other_doc_id)
+              .returns(true)
+              .withArgs(ctx.client, ctx.project_id)
+              .returns(true)
+            ctx.RoomEvents.on('project-active', id => {
+              setTimeout(() => {
+                ctx.RoomEvents.emit(`project-subscribed-${id}`)
+              }, 100)
+            })
+            ctx.RoomEvents.on('doc-active', id => {
+              setTimeout(() => {
+                ctx.RoomEvents.emit(`doc-subscribed-${id}`)
+              }, 100)
+            })
+            // put the client in the rooms
+            ctx.RoomManager.joinProject(ctx.client, ctx.project_id, () => {
+              ctx.RoomManager.joinDoc(ctx.client, ctx.doc_id, () => {
+                ctx.RoomManager.joinDoc(ctx.client, ctx.other_doc_id, () => {
+                  // now leave the project
+                  ctx.RoomManager.leaveProjectAndDocs(ctx.client)
+                  resolve()
+                })
+              })
+            })
+          })
+        })
+
+        it('should leave all the docs', function (ctx) {
+          ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true)
+          ctx.client.leave
+            .calledWithExactly(ctx.other_doc_id)
+            .should.equal(true)
+        })
+
+        it('should leave the project', function (ctx) {
+          ctx.client.leave.calledWithExactly(ctx.project_id).should.equal(true)
+        })
+
+        it("should emit a 'doc-empty' event with the id for each doc", function (ctx) {
+          ctx.RoomEvents.emit
+            .calledWithExactly('doc-empty', ctx.doc_id)
+            .should.equal(true)
+          ctx.RoomEvents.emit
+            .calledWithExactly('doc-empty', ctx.other_doc_id)
+            .should.equal(true)
+        })
+
+        it("should emit a 'project-empty' event with the id for the project", function (ctx) {
+          ctx.RoomEvents.emit
+            .calledWithExactly('project-empty', ctx.project_id)
+            .should.equal(true)
+        })
+      })
+
+      describe('when other clients are still connected', function () {
+        beforeEach(function (ctx) {
+          ctx.RoomManager._clientsInRoom
+            .withArgs(ctx.client, ctx.doc_id)
+            .onFirstCall()
+            .returns(123)
+            .onSecondCall()
+            .returns(122)
+          ctx.RoomManager._clientsInRoom
+            .withArgs(ctx.client, ctx.other_doc_id)
+            .onFirstCall()
+            .returns(123)
+            .onSecondCall()
+            .returns(122)
+          ctx.RoomManager._clientsInRoom
+            .withArgs(ctx.client, ctx.project_id)
+            .onFirstCall()
+            .returns(123)
+            .onSecondCall()
+            .returns(122)
+          ctx.RoomManager._clientAlreadyInRoom
+            .withArgs(ctx.client, ctx.doc_id)
+            .returns(true)
+            .withArgs(ctx.client, ctx.other_doc_id)
+            .returns(true)
+            .withArgs(ctx.client, ctx.project_id)
+            .returns(true)
+          ctx.RoomManager.leaveProjectAndDocs(ctx.client)
+        })
+
+        it('should leave all the docs', function (ctx) {
+          ctx.client.leave.calledWithExactly(ctx.doc_id).should.equal(true)
+          ctx.client.leave
+            .calledWithExactly(ctx.other_doc_id)
+            .should.equal(true)
+        })
+
+        it('should leave the project', function (ctx) {
+          ctx.client.leave.calledWithExactly(ctx.project_id).should.equal(true)
+        })
+
+        it('should not emit any events', function (ctx) {
+          ctx.RoomEvents.emit.called.should.equal(false)
+        })
+      })
+    })
+  })
+})

+ 0 - 412
services/real-time/test/unit/js/RoomManagerTests.js

@@ -1,412 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-    no-unused-vars,
-    promise/param-names,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const { expect } = require('chai')
-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: {
-        '@overleaf/settings': (this.settings = {}),
-        '@overleaf/metrics': (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 (done) {
-        this.RoomManager._clientsInRoom
-          .withArgs(this.client, this.project_id)
-          .onFirstCall()
-          .returns(123)
-          .onSecondCall()
-          .returns(124)
-        this.client.join = sinon.stub()
-        this.RoomManager.joinProject(this.client, this.project_id, done)
-      })
-
-      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 (done) {
-        this.RoomManager._clientsInRoom
-          .withArgs(this.client, this.doc_id)
-          .onFirstCall()
-          .returns(123)
-          .onSecondCall()
-          .returns(124)
-        this.client.join = sinon.stub()
-        this.RoomManager.joinDoc(this.client, this.doc_id, done)
-      })
-
-      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)
-        })
-      })
-    })
-  })
-})

+ 50 - 0
services/real-time/test/unit/js/SafeJsonParse.test.js

@@ -0,0 +1,50 @@
+import { vi, expect, describe, beforeEach, it } from 'vitest'
+
+const modulePath = '../../../app/js/SafeJsonParse'
+
+describe('SafeJsonParse', function () {
+  beforeEach(async function (ctx) {
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.Settings = {
+        maxUpdateSize: 16 * 1024,
+      }),
+    }))
+
+    ctx.SafeJsonParse = (await import(modulePath)).default
+  })
+
+  describe('parse', function () {
+    it('should parse documents correctly', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => {
+          if (error) return reject(error)
+          expect(parsed).to.deep.equal({ foo: 'bar' })
+          resolve()
+        })
+      })
+    })
+
+    it('should return an error on bad data', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.SafeJsonParse.parse('blah', (error, parsed) => {
+          expect(error).to.exist
+          resolve()
+        })
+      })
+    })
+
+    it('should return an error on oversized data', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        // we have a 2k overhead on top of max size
+        const bigBlob = Array(16 * 1024).join('A')
+        const data = `{"foo": "${bigBlob}"}`
+        ctx.Settings.maxUpdateSize = 2 * 1024
+        ctx.SafeJsonParse.parse(data, (error, parsed) => {
+          ctx.logger.error.called.should.equal(false)
+          expect(error).to.exist
+          resolve()
+        })
+      })
+    })
+  })
+})

+ 0 - 55
services/real-time/test/unit/js/SafeJsonParseTest.js

@@ -1,55 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-    no-useless-escape,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const modulePath = '../../../app/js/SafeJsonParse'
-
-describe('SafeJsonParse', function () {
-  beforeEach(function () {
-    return (this.SafeJsonParse = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': (this.Settings = {
-          maxUpdateSize: 16 * 1024,
-        }),
-      },
-    }))
-  })
-
-  return describe('parse', function () {
-    it('should parse documents correctly', function (done) {
-      return this.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => {
-        if (error) return done(error)
-        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 bigBlob = Array(16 * 1024).join('A')
-      const data = `{\"foo\": \"${bigBlob}\"}`
-      this.Settings.maxUpdateSize = 2 * 1024
-      return this.SafeJsonParse.parse(data, (error, parsed) => {
-        this.logger.error.called.should.equal(false)
-        expect(error).to.exist
-        return done()
-      })
-    })
-  })
-})

+ 314 - 0
services/real-time/test/unit/js/SessionSockets.test.js

@@ -0,0 +1,314 @@
+import { vi, expect, describe, beforeEach, it } from 'vitest'
+import { EventEmitter } from 'node:events'
+import sinon from 'sinon'
+const modulePath = '../../../app/js/SessionSockets'
+
+describe('SessionSockets', function () {
+  beforeEach(async function (ctx) {
+    ctx.metrics = { inc: sinon.stub() }
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: ctx.metrics,
+    }))
+
+    ctx.SessionSocketsModule = (await import(modulePath)).default
+    ctx.io = new EventEmitter()
+    ctx.id1 = Math.random().toString()
+    ctx.id2 = Math.random().toString()
+    const redisResponses = {
+      error: [new Error('Redis: something went wrong'), null],
+      unknownId: [null, null],
+    }
+    redisResponses[ctx.id1] = [null, { user: { _id: '123' } }]
+    redisResponses[ctx.id2] = [null, { user: { _id: 'abc' } }]
+
+    ctx.sessionStore = {
+      get: sinon
+        .stub()
+        .callsFake((id, fn) => fn.apply(null, redisResponses[id])),
+    }
+    ctx.cookieParser = function (req, res, next) {
+      req.signedCookies = req._signedCookies
+      return next()
+    }
+    ctx.SessionSockets = ctx.SessionSocketsModule(
+      ctx.io,
+      ctx.sessionStore,
+      ctx.cookieParser,
+      'ol.sid'
+    )
+    ctx.checkSocket = (socket, fn) => {
+      ctx.SessionSockets.once('connection', fn)
+      return ctx.io.emit('connection', socket)
+    }
+  })
+
+  describe('without cookies', function () {
+    beforeEach(function (ctx) {
+      ctx.socket = { handshake: {} }
+    })
+
+    it('should return a lookup error', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, error => {
+          expect(error).to.exist
+          expect(error.message).to.equal('could not look up session by key')
+          resolve()
+        })
+      })
+    })
+
+    it('should not query redis', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.sessionStore.get.called).to.equal(false)
+          resolve()
+        })
+      })
+    })
+
+    it('should increment the session.cookie metric with status "none"', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
+            status: 'none',
+          })
+          resolve()
+        })
+      })
+    })
+  })
+
+  describe('with a different cookie', function () {
+    beforeEach(function (ctx) {
+      ctx.socket = { handshake: { _signedCookies: { other: 1 } } }
+    })
+
+    it('should return a lookup error', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, error => {
+          expect(error).to.exist
+          expect(error.message).to.equal('could not look up session by key')
+          resolve()
+        })
+      })
+    })
+
+    it('should not query redis', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.sessionStore.get.called).to.equal(false)
+          resolve()
+        })
+      })
+    })
+  })
+
+  describe('with a cookie with an invalid signature', function () {
+    beforeEach(function (ctx) {
+      ctx.socket = {
+        handshake: { _signedCookies: { 'ol.sid': false } },
+      }
+    })
+
+    it('should return a lookup error', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, error => {
+          expect(error).to.exist
+          expect(error.message).to.equal('could not look up session by key')
+          resolve()
+        })
+      })
+    })
+
+    it('should not query redis', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.sessionStore.get.called).to.equal(false)
+          resolve()
+        })
+      })
+    })
+
+    it('should increment the session.cookie metric with status=bad-signature', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
+            status: 'bad-signature',
+          })
+          resolve()
+        })
+      })
+    })
+  })
+
+  describe('with a valid cookie and a failing session lookup', function () {
+    beforeEach(function (ctx) {
+      ctx.socket = {
+        handshake: { _signedCookies: { 'ol.sid': 'error' } },
+      }
+    })
+
+    it('should query redis', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.sessionStore.get.called).to.equal(true)
+          resolve()
+        })
+      })
+    })
+
+    it('should return a redis error', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, error => {
+          expect(error).to.exist
+          expect(error.message).to.equal('Redis: something went wrong')
+          resolve()
+        })
+      })
+    })
+
+    it('should increment the session.cookie metric with status=error', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
+            status: 'error',
+          })
+          resolve()
+        })
+      })
+    })
+  })
+
+  describe('with a valid cookie and no matching session', function () {
+    beforeEach(function (ctx) {
+      ctx.socket = {
+        handshake: { _signedCookies: { 'ol.sid': 'unknownId' } },
+      }
+    })
+
+    it('should query redis', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.sessionStore.get.called).to.equal(true)
+          resolve()
+        })
+      })
+    })
+
+    it('should return a lookup error', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, error => {
+          expect(error).to.exist
+          expect(error.message).to.equal('could not look up session by key')
+          resolve()
+        })
+      })
+    })
+
+    it('should increment the session.cookie metric with status=missing', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
+            status: 'missing',
+          })
+          resolve()
+        })
+      })
+    })
+  })
+
+  describe('with a valid cookie and a matching session', function () {
+    beforeEach(function (ctx) {
+      ctx.socket = {
+        handshake: { _signedCookies: { 'ol.sid': ctx.id1 } },
+      }
+    })
+
+    it('should query redis', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.sessionStore.get.called).to.equal(true)
+          resolve()
+        })
+      })
+    })
+
+    it('should not return an error', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, error => {
+          expect(error).to.not.exist
+          resolve()
+        })
+      })
+    })
+
+    it('should return the session', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, (error, s, session) => {
+          if (error) return reject(error)
+          expect(session).to.deep.equal({ user: { _id: '123' } })
+          resolve()
+        })
+      })
+    })
+
+    it('should increment the session.cookie metric with status=signed', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
+            status: 'signed',
+          })
+          resolve()
+        })
+      })
+    })
+  })
+
+  describe('with a different valid cookie and matching session', function () {
+    beforeEach(function (ctx) {
+      ctx.socket = {
+        handshake: { _signedCookies: { 'ol.sid': ctx.id2 } },
+      }
+    })
+
+    it('should query redis', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.sessionStore.get.called).to.equal(true)
+          resolve()
+        })
+      })
+    })
+
+    it('should not return an error', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, error => {
+          expect(error).to.not.exist
+          resolve()
+        })
+      })
+    })
+
+    it('should return the other session', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, (error, s, session) => {
+          if (error) return reject(error)
+          expect(session).to.deep.equal({ user: { _id: 'abc' } })
+          resolve()
+        })
+      })
+    })
+
+    it('should increment the session.cookie metric with status=error', async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.checkSocket(ctx.socket, () => {
+          expect(ctx.metrics.inc).to.be.calledWith('session.cookie', 1, {
+            status: 'signed',
+          })
+          resolve()
+        })
+      })
+    })
+  })
+})

+ 0 - 280
services/real-time/test/unit/js/SessionSocketsTests.js

@@ -1,280 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const { EventEmitter } = require('node:events')
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const modulePath = '../../../app/js/SessionSockets'
-const sinon = require('sinon')
-
-describe('SessionSockets', function () {
-  beforeEach(function () {
-    this.metrics = { inc: sinon.stub() }
-    this.SessionSocketsModule = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/metrics': this.metrics,
-      },
-    })
-    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 () {
-    beforeEach(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()
-      })
-    })
-
-    it('should not query redis', function (done) {
-      return this.checkSocket(this.socket, () => {
-        expect(this.sessionStore.get.called).to.equal(false)
-        return done()
-      })
-    })
-
-    it('should increment the session.cookie metric with status "none"', function (done) {
-      return this.checkSocket(this.socket, () => {
-        expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
-          status: 'none',
-        })
-        return done()
-      })
-    })
-  })
-
-  describe('with a different cookie', function () {
-    beforeEach(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()
-      })
-    })
-
-    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 cookie with an invalid signature', function () {
-    beforeEach(function () {
-      return (this.socket = {
-        handshake: { _signedCookies: { 'ol.sid': false } },
-      })
-    })
-
-    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()
-      })
-    })
-
-    it('should not query redis', function (done) {
-      return this.checkSocket(this.socket, () => {
-        expect(this.sessionStore.get.called).to.equal(false)
-        return done()
-      })
-    })
-
-    it('should increment the session.cookie metric with status=bad-signature', function (done) {
-      return this.checkSocket(this.socket, () => {
-        expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
-          status: 'bad-signature',
-        })
-        return done()
-      })
-    })
-  })
-
-  describe('with a valid cookie and a failing session lookup', function () {
-    beforeEach(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()
-      })
-    })
-
-    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()
-      })
-    })
-
-    it('should increment the session.cookie metric with status=error', function (done) {
-      return this.checkSocket(this.socket, () => {
-        expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
-          status: 'error',
-        })
-        return done()
-      })
-    })
-  })
-
-  describe('with a valid cookie and no matching session', function () {
-    beforeEach(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()
-      })
-    })
-
-    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()
-      })
-    })
-
-    it('should increment the session.cookie metric with status=missing', function (done) {
-      return this.checkSocket(this.socket, () => {
-        expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
-          status: 'missing',
-        })
-        return done()
-      })
-    })
-  })
-
-  describe('with a valid cookie and a matching session', function () {
-    beforeEach(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()
-      })
-    })
-
-    it('should return the session', function (done) {
-      return this.checkSocket(this.socket, (error, s, session) => {
-        if (error) return done(error)
-        expect(session).to.deep.equal({ user: { _id: '123' } })
-        return done()
-      })
-    })
-
-    it('should increment the session.cookie metric with status=signed', function (done) {
-      return this.checkSocket(this.socket, () => {
-        expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
-          status: 'signed',
-        })
-        return done()
-      })
-    })
-  })
-
-  describe('with a different valid cookie and matching session', function () {
-    beforeEach(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()
-      })
-    })
-
-    it('should return the other session', function (done) {
-      return this.checkSocket(this.socket, (error, s, session) => {
-        if (error) return done(error)
-        expect(session).to.deep.equal({ user: { _id: 'abc' } })
-        return done()
-      })
-    })
-
-    it('should increment the session.cookie metric with status=error', function (done) {
-      return this.checkSocket(this.socket, () => {
-        expect(this.metrics.inc).to.be.calledWith('session.cookie', 1, {
-          status: 'signed',
-        })
-        return done()
-      })
-    })
-  })
-})

+ 255 - 0
services/real-time/test/unit/js/WebApiManager.test.js

@@ -0,0 +1,255 @@
+import { vi, describe, beforeEach, it } from 'vitest'
+/* eslint-disable
+    no-return-assign,
+    no-unused-vars,
+*/
+// TODO: This file was created by bulk-decaffeinate.
+// Fix any style issues and re-enable lint.
+/*
+ * decaffeinate suggestions:
+ * DS102: Remove unnecessary code created because of implicit returns
+ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
+ */
+import sinon from 'sinon'
+
+const modulePath = '../../../app/js/WebApiManager.js'
+
+describe('WebApiManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.project_id = 'project-id-123'
+    ctx.user_id = 'user-id-123'
+    ctx.user = { _id: ctx.user_id }
+    ctx.callback = sinon.stub()
+
+    vi.doMock('request', () => ({
+      default: (ctx.request = {}),
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {
+        apis: {
+          web: {
+            url: 'http://web.example.com',
+            user: 'username',
+            pass: 'password',
+          },
+        },
+      }),
+    }))
+
+    return (ctx.WebApiManager = (await import(modulePath)).default)
+  })
+
+  return describe('joinProject', function () {
+    describe('successfully', function () {
+      beforeEach(function (ctx) {
+        ctx.response = {
+          project: { name: 'Test project' },
+          privilegeLevel: 'owner',
+          isRestrictedUser: true,
+          isTokenMember: true,
+          isInvitedMember: true,
+        }
+        ctx.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 200 }, ctx.response)
+        return ctx.WebApiManager.joinProject(
+          ctx.project_id,
+          ctx.user,
+          ctx.callback
+        )
+      })
+
+      it('should send a request to web to join the project', function (ctx) {
+        return ctx.request.post
+          .calledWith({
+            url: `${ctx.settings.apis.web.url}/project/${ctx.project_id}/join`,
+            auth: {
+              user: ctx.settings.apis.web.user,
+              pass: ctx.settings.apis.web.pass,
+              sendImmediately: true,
+            },
+            json: {
+              userId: ctx.user_id,
+              anonymousAccessToken: undefined,
+            },
+            jar: false,
+          })
+          .should.equal(true)
+      })
+
+      return it('should return the project, privilegeLevel, and restricted flag', function (ctx) {
+        return ctx.callback
+          .calledWith(null, ctx.response.project, ctx.response.privilegeLevel, {
+            isRestrictedUser: ctx.response.isRestrictedUser,
+            isTokenMember: ctx.response.isTokenMember,
+            isInvitedMember: ctx.response.isInvitedMember,
+          })
+          .should.equal(true)
+      })
+    })
+
+    describe('with anon user', function () {
+      beforeEach(function (ctx) {
+        ctx.user_id = 'anonymous-user'
+        ctx.token = 'a-ro-token'
+        ctx.user = {
+          _id: ctx.user_id,
+          anonymousAccessToken: ctx.token,
+        }
+        ctx.response = {
+          project: { name: 'Test project' },
+          privilegeLevel: 'readOnly',
+          isRestrictedUser: true,
+          isTokenMember: false,
+          isInvitedMember: false,
+        }
+        ctx.request.post = sinon
+          .stub()
+          .yields(null, { statusCode: 200 }, ctx.response)
+        ctx.WebApiManager.joinProject(ctx.project_id, ctx.user, ctx.callback)
+      })
+
+      it('should send a request to web to join the project', function (ctx) {
+        ctx.request.post.should.have.been.calledWith({
+          url: `${ctx.settings.apis.web.url}/project/${ctx.project_id}/join`,
+          auth: {
+            user: ctx.settings.apis.web.user,
+            pass: ctx.settings.apis.web.pass,
+            sendImmediately: true,
+          },
+          json: {
+            userId: ctx.user_id,
+            anonymousAccessToken: ctx.token,
+          },
+          jar: false,
+        })
+      })
+
+      it('should return the project, privilegeLevel, and restricted flag', function (ctx) {
+        ctx.callback.should.have.been.calledWith(
+          null,
+          ctx.response.project,
+          ctx.response.privilegeLevel,
+          {
+            isRestrictedUser: ctx.response.isRestrictedUser,
+            isTokenMember: ctx.response.isTokenMember,
+            isInvitedMember: ctx.response.isInvitedMember,
+          }
+        )
+      })
+    })
+
+    describe('when web replies with a 403', function () {
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 403 }, null)
+        ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback)
+      })
+
+      it('should call the callback with an error', function (ctx) {
+        ctx.callback
+          .calledWith(
+            sinon.match({
+              message: 'not authorized',
+            })
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('when web replies with a 404', function () {
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 404 }, null)
+        ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback)
+      })
+
+      it('should call the callback with an error', function (ctx) {
+        ctx.callback
+          .calledWith(
+            sinon.match({
+              message: 'project not found',
+              info: { code: 'ProjectNotFound' },
+            })
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('with an error from web', function () {
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 500 }, null)
+        return ctx.WebApiManager.joinProject(
+          ctx.project_id,
+          ctx.user_id,
+          ctx.callback
+        )
+      })
+
+      return it('should call the callback with an error', function (ctx) {
+        return ctx.callback
+          .calledWith(
+            sinon.match({
+              message: 'non-success status code from web',
+              info: { statusCode: 500 },
+            })
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('with no data from web', function () {
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 200 }, null)
+        return ctx.WebApiManager.joinProject(
+          ctx.project_id,
+          ctx.user_id,
+          ctx.callback
+        )
+      })
+
+      return it('should call the callback with an error', function (ctx) {
+        return ctx.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 (ctx) {
+        ctx.request.post = sinon
+          .stub()
+          .callsArgWith(1, null, { statusCode: 429 }, null)
+        return ctx.WebApiManager.joinProject(
+          ctx.project_id,
+          ctx.user_id,
+          ctx.callback
+        )
+      })
+
+      return it('should call the callback with a TooManyRequests error code', function (ctx) {
+        return ctx.callback
+          .calledWith(
+            sinon.match({
+              message: 'rate-limit hit when joining project',
+              info: {
+                code: 'TooManyRequests',
+              },
+            })
+          )
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 0 - 268
services/real-time/test/unit/js/WebApiManagerTests.js

@@ -1,268 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const sinon = require('sinon')
-const 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 = {}),
-        '@overleaf/settings': (this.settings = {
-          apis: {
-            web: {
-              url: 'http://web.example.com',
-              user: 'username',
-              pass: 'password',
-            },
-          },
-        }),
-      },
-    }))
-  })
-
-  return describe('joinProject', function () {
-    describe('successfully', function () {
-      beforeEach(function () {
-        this.response = {
-          project: { name: 'Test project' },
-          privilegeLevel: 'owner',
-          isRestrictedUser: true,
-          isTokenMember: true,
-          isInvitedMember: 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`,
-            auth: {
-              user: this.settings.apis.web.user,
-              pass: this.settings.apis.web.pass,
-              sendImmediately: true,
-            },
-            json: {
-              userId: this.user_id,
-              anonymousAccessToken: undefined,
-            },
-            jar: false,
-          })
-          .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,
-            {
-              isRestrictedUser: this.response.isRestrictedUser,
-              isTokenMember: this.response.isTokenMember,
-              isInvitedMember: this.response.isInvitedMember,
-            }
-          )
-          .should.equal(true)
-      })
-    })
-
-    describe('with anon user', function () {
-      beforeEach(function () {
-        this.user_id = 'anonymous-user'
-        this.token = 'a-ro-token'
-        this.user = {
-          _id: this.user_id,
-          anonymousAccessToken: this.token,
-        }
-        this.response = {
-          project: { name: 'Test project' },
-          privilegeLevel: 'readOnly',
-          isRestrictedUser: true,
-          isTokenMember: false,
-          isInvitedMember: false,
-        }
-        this.request.post = sinon
-          .stub()
-          .yields(null, { statusCode: 200 }, this.response)
-        this.WebApiManager.joinProject(
-          this.project_id,
-          this.user,
-          this.callback
-        )
-      })
-
-      it('should send a request to web to join the project', function () {
-        this.request.post.should.have.been.calledWith({
-          url: `${this.settings.apis.web.url}/project/${this.project_id}/join`,
-          auth: {
-            user: this.settings.apis.web.user,
-            pass: this.settings.apis.web.pass,
-            sendImmediately: true,
-          },
-          json: {
-            userId: this.user_id,
-            anonymousAccessToken: this.token,
-          },
-          jar: false,
-        })
-      })
-
-      it('should return the project, privilegeLevel, and restricted flag', function () {
-        this.callback.should.have.been.calledWith(
-          null,
-          this.response.project,
-          this.response.privilegeLevel,
-          {
-            isRestrictedUser: this.response.isRestrictedUser,
-            isTokenMember: this.response.isTokenMember,
-            isInvitedMember: this.response.isInvitedMember,
-          }
-        )
-      })
-    })
-
-    describe('when web replies with a 403', function () {
-      beforeEach(function () {
-        this.request.post = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 403 }, null)
-        this.WebApiManager.joinProject(
-          this.project_id,
-          this.user_id,
-          this.callback
-        )
-      })
-
-      it('should call the callback with an error', function () {
-        this.callback
-          .calledWith(
-            sinon.match({
-              message: 'not authorized',
-            })
-          )
-          .should.equal(true)
-      })
-    })
-
-    describe('when web replies with a 404', function () {
-      beforeEach(function () {
-        this.request.post = sinon
-          .stub()
-          .callsArgWith(1, null, { statusCode: 404 }, null)
-        this.WebApiManager.joinProject(
-          this.project_id,
-          this.user_id,
-          this.callback
-        )
-      })
-
-      it('should call the callback with an error', function () {
-        this.callback
-          .calledWith(
-            sinon.match({
-              message: 'project not found',
-              info: { code: 'ProjectNotFound' },
-            })
-          )
-          .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
-        )
-      })
-
-      return it('should call the callback with an error', function () {
-        return this.callback
-          .calledWith(
-            sinon.match({
-              message: 'non-success status code from web',
-              info: { statusCode: 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
-        )
-      })
-
-      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 it('should call the callback with a TooManyRequests error code', function () {
-        return this.callback
-          .calledWith(
-            sinon.match({
-              message: 'rate-limit hit when joining project',
-              info: {
-                code: 'TooManyRequests',
-              },
-            })
-          )
-          .should.equal(true)
-      })
-    })
-  })
-})

+ 27 - 28
services/real-time/test/unit/js/WebsocketAddressManagerTests.js → services/real-time/test/unit/js/WebsocketAddressManager.test.js

@@ -1,28 +1,27 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const modulePath = require('node:path').join(
-  __dirname,
+import { expect, describe, beforeEach, it } from 'vitest'
+import path from 'node:path'
+
+const modulePath = path.join(
+  import.meta.dirname,
   '../../../app/js/WebsocketAddressManager'
 )
 
 describe('WebsocketAddressManager', function () {
-  beforeEach(function () {
-    this.WebsocketAddressManager = SandboxedModule.require(modulePath, {
-      requires: {},
-    })
+  beforeEach(async function (ctx) {
+    ctx.WebsocketAddressManager = (await import(modulePath)).default
   })
 
   describe('with a proxy configuration', function () {
-    beforeEach(function () {
-      this.websocketAddressManager = new this.WebsocketAddressManager(
+    beforeEach(function (ctx) {
+      ctx.websocketAddressManager = new ctx.WebsocketAddressManager(
         true,
         '127.0.0.1'
       )
     })
 
-    it('should return the client ip address when behind a proxy', function () {
+    it('should return the client ip address when behind a proxy', function (ctx) {
       expect(
-        this.websocketAddressManager.getRemoteIp({
+        ctx.websocketAddressManager.getRemoteIp({
           headers: {
             'x-forwarded-proto': 'https',
             'x-forwarded-for': '123.45.67.89',
@@ -32,56 +31,56 @@ describe('WebsocketAddressManager', function () {
       ).to.equal('123.45.67.89')
     })
 
-    it('should return the client ip address for a direct connection', function () {
+    it('should return the client ip address for a direct connection', function (ctx) {
       expect(
-        this.websocketAddressManager.getRemoteIp({
+        ctx.websocketAddressManager.getRemoteIp({
           headers: {},
           address: { address: '123.45.67.89' },
         })
       ).to.equal('123.45.67.89')
     })
 
-    it('should return the client ip address when there are no headers in the handshake', function () {
+    it('should return the client ip address when there are no headers in the handshake', function (ctx) {
       expect(
-        this.websocketAddressManager.getRemoteIp({
+        ctx.websocketAddressManager.getRemoteIp({
           address: { address: '123.45.67.89' },
         })
       ).to.equal('123.45.67.89')
     })
 
-    it('should return a "client-handshake-missing" response when the handshake is missing', function () {
-      expect(this.websocketAddressManager.getRemoteIp()).to.equal(
+    it('should return a "client-handshake-missing" response when the handshake is missing', function (ctx) {
+      expect(ctx.websocketAddressManager.getRemoteIp()).to.equal(
         'client-handshake-missing'
       )
     })
   })
 
   describe('without a proxy configuration', function () {
-    beforeEach(function () {
-      this.websocketAddressManager = new this.WebsocketAddressManager(false)
+    beforeEach(function (ctx) {
+      ctx.websocketAddressManager = new ctx.WebsocketAddressManager(false)
     })
 
-    it('should return the client ip address for a direct connection', function () {
+    it('should return the client ip address for a direct connection', function (ctx) {
       expect(
-        this.websocketAddressManager.getRemoteIp({
+        ctx.websocketAddressManager.getRemoteIp({
           headers: {},
           address: { address: '123.45.67.89' },
         })
       ).to.equal('123.45.67.89')
     })
 
-    it('should return undefined if the client ip address is not present', function () {
+    it('should return undefined if the client ip address is not present', function (ctx) {
       expect(
-        this.websocketAddressManager.getRemoteIp({
+        ctx.websocketAddressManager.getRemoteIp({
           headers: {},
           address: { otherAddressProperty: '123.45.67.89' },
         })
       ).to.be.undefined
     })
 
-    it('should return the proxy ip address if there is actually a proxy', function () {
+    it('should return the proxy ip address if there is actually a proxy', function (ctx) {
       expect(
-        this.websocketAddressManager.getRemoteIp({
+        ctx.websocketAddressManager.getRemoteIp({
           headers: {
             'x-forwarded-proto': 'https',
             'x-forwarded-for': '123.45.67.89',
@@ -91,8 +90,8 @@ describe('WebsocketAddressManager', function () {
       ).to.equal('127.0.0.1')
     })
 
-    it('should return a "client-handshake-missing" response when the handshake is missing', function () {
-      expect(this.websocketAddressManager.getRemoteIp()).to.equal(
+    it('should return a "client-handshake-missing" response when the handshake is missing', function (ctx) {
+      expect(ctx.websocketAddressManager.getRemoteIp()).to.equal(
         'client-handshake-missing'
       )
     })

+ 1750 - 0
services/real-time/test/unit/js/WebsocketController.test.js

@@ -0,0 +1,1750 @@
+import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
+
+/* eslint-disable
+    no-return-assign,
+    no-throw-literal,
+    no-unused-vars,
+*/
+// TODO: This file was created by bulk-decaffeinate.
+// Fix any style issues and re-enable lint.
+/*
+ * decaffeinate suggestions:
+ * DS101: Remove unnecessary use of Array.from
+ * DS102: Remove unnecessary code created because of implicit returns
+ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
+ */
+import sinon from 'sinon'
+import tk from 'timekeeper'
+import Errors from '../../../app/js/Errors.js'
+const modulePath = '../../../app/js/WebsocketController.js'
+const { UpdateTooLargeError } = Errors
+
+describe('WebsocketController', function () {
+  beforeEach(async function (ctx) {
+    tk.freeze(new Date())
+    ctx.project_id = 'project-id-123'
+    ctx.user = {
+      _id: (ctx.user_id = 'user-id-123'),
+      first_name: 'James',
+      last_name: 'Allen',
+      email: 'james@example.com',
+      signUpDate: new Date('2014-01-01'),
+      loginCount: 42,
+    }
+    ctx.callback = sinon.stub()
+    ctx.client = {
+      disconnected: false,
+      id: (ctx.client_id = 'mock-client-id-123'),
+      publicId: `other-id-${Math.random()}`,
+      ol_context: {},
+      joinLeaveEpoch: 0,
+      join: sinon.stub(),
+      leave: sinon.stub(),
+    }
+
+    vi.doMock('../../../app/js/WebApiManager', () => ({
+      default: (ctx.WebApiManager = {}),
+    }))
+
+    vi.doMock('../../../app/js/AuthorizationManager', () => ({
+      default: (ctx.AuthorizationManager = {}),
+    }))
+
+    vi.doMock('../../../app/js/DocumentUpdaterManager', () => ({
+      default: (ctx.DocumentUpdaterManager = {}),
+    }))
+
+    vi.doMock('../../../app/js/ConnectedUsersManager', () => ({
+      default: (ctx.ConnectedUsersManager = {}),
+    }))
+
+    vi.doMock('../../../app/js/WebsocketLoadBalancer', () => ({
+      default: (ctx.WebsocketLoadBalancer = {}),
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: (ctx.metrics = {
+        inc: sinon.stub(),
+        set: sinon.stub(),
+      }),
+    }))
+
+    vi.doMock('../../../app/js/RoomManager', () => ({
+      default: (ctx.RoomManager = {}),
+    }))
+
+    ctx.WebsocketController = (await import(modulePath)).default
+  })
+
+  afterEach(function () {
+    return tk.reset()
+  })
+
+  describe('joinProject', function () {
+    describe('when authorised', function () {
+      beforeEach(function (ctx) {
+        ctx.client.id = 'mock-client-id'
+        ctx.project = {
+          name: 'Test Project',
+          owner: {
+            _id: (ctx.owner_id = 'mock-owner-id-123'),
+          },
+        }
+        ctx.privilegeLevel = 'owner'
+        ctx.ConnectedUsersManager.updateUserPosition = sinon
+          .stub()
+          .callsArgAsync(4)
+        ctx.isRestrictedUser = true
+        ctx.isTokenMember = true
+        ctx.isInvitedMember = true
+        ctx.WebApiManager.joinProject = sinon
+          .stub()
+          .callsArgWith(2, null, ctx.project, ctx.privilegeLevel, {
+            isRestrictedUser: ctx.isRestrictedUser,
+            isTokenMember: ctx.isTokenMember,
+            isInvitedMember: ctx.isInvitedMember,
+          })
+        ctx.RoomManager.joinProject = sinon.stub().callsArg(2)
+        return ctx.WebsocketController.joinProject(
+          ctx.client,
+          ctx.user,
+          ctx.project_id,
+          ctx.callback
+        )
+      })
+
+      it('should load the project from web', function (ctx) {
+        return ctx.WebApiManager.joinProject
+          .calledWith(ctx.project_id, ctx.user)
+          .should.equal(true)
+      })
+
+      it('should join the project room', function (ctx) {
+        return ctx.RoomManager.joinProject
+          .calledWith(ctx.client, ctx.project_id)
+          .should.equal(true)
+      })
+
+      it('should set the privilege level on the client', function (ctx) {
+        return ctx.client.ol_context.privilege_level.should.equal(
+          ctx.privilegeLevel
+        )
+      })
+      it("should set the user's id on the client", function (ctx) {
+        return ctx.client.ol_context.user_id.should.equal(ctx.user._id)
+      })
+      it("should set the user's email on the client", function (ctx) {
+        return ctx.client.ol_context.email.should.equal(ctx.user.email)
+      })
+      it("should set the user's first_name on the client", function (ctx) {
+        return ctx.client.ol_context.first_name.should.equal(
+          ctx.user.first_name
+        )
+      })
+      it("should set the user's last_name on the client", function (ctx) {
+        return ctx.client.ol_context.last_name.should.equal(ctx.user.last_name)
+      })
+      it("should set the user's sign up date on the client", function (ctx) {
+        return ctx.client.ol_context.signup_date.should.equal(
+          ctx.user.signUpDate
+        )
+      })
+      it("should set the user's login_count on the client", function (ctx) {
+        return ctx.client.ol_context.login_count.should.equal(
+          ctx.user.loginCount
+        )
+      })
+      it('should set the connected time on the client', function (ctx) {
+        return ctx.client.ol_context.connected_time.should.equal(new Date())
+      })
+      it('should set the project_id on the client', function (ctx) {
+        return ctx.client.ol_context.project_id.should.equal(ctx.project_id)
+      })
+      it('should set the project owner id on the client', function (ctx) {
+        return ctx.client.ol_context.owner_id.should.equal(ctx.owner_id)
+      })
+      it('should set the is_restricted_user flag on the client', function (ctx) {
+        return ctx.client.ol_context.is_restricted_user.should.equal(
+          ctx.isRestrictedUser
+        )
+      })
+      it('should set the is_token_member flag on the client', function (ctx) {
+        ctx.client.ol_context.is_token_member.should.equal(ctx.isTokenMember)
+      })
+      it('should set the is_invited_member flag on the client', function (ctx) {
+        ctx.client.ol_context.is_invited_member.should.equal(
+          ctx.isInvitedMember
+        )
+      })
+      it('should call the callback with the project, privilegeLevel and protocolVersion', function (ctx) {
+        return ctx.callback
+          .calledWith(
+            null,
+            ctx.project,
+            ctx.privilegeLevel,
+            ctx.WebsocketController.PROTOCOL_VERSION
+          )
+          .should.equal(true)
+      })
+
+      it('should mark the user as connected in ConnectedUsersManager', function (ctx) {
+        return ctx.ConnectedUsersManager.updateUserPosition
+          .calledWith(ctx.project_id, ctx.client.publicId, ctx.user, null)
+          .should.equal(true)
+      })
+
+      return it('should increment the join-project metric', function (ctx) {
+        return ctx.metrics.inc
+          .calledWith('editor.join-project')
+          .should.equal(true)
+      })
+    })
+
+    describe('when not authorized', function () {
+      beforeEach(function (ctx) {
+        ctx.WebApiManager.joinProject = sinon
+          .stub()
+          .callsArgWith(2, null, null, null)
+        return ctx.WebsocketController.joinProject(
+          ctx.client,
+          ctx.user,
+          ctx.project_id,
+          ctx.callback
+        )
+      })
+
+      it('should return an error', function (ctx) {
+        return ctx.callback
+          .calledWith(sinon.match({ message: 'not authorized' }))
+          .should.equal(true)
+      })
+
+      return it('should not log an error', function (ctx) {
+        return ctx.logger.error.called.should.equal(false)
+      })
+    })
+
+    describe('when the subscribe failed', function () {
+      beforeEach(function (ctx) {
+        ctx.client.id = 'mock-client-id'
+        ctx.project = {
+          name: 'Test Project',
+          owner: {
+            _id: (ctx.owner_id = 'mock-owner-id-123'),
+          },
+        }
+        ctx.privilegeLevel = 'owner'
+        ctx.ConnectedUsersManager.updateUserPosition = sinon
+          .stub()
+          .callsArgAsync(4)
+        ctx.isRestrictedUser = true
+        ctx.isTokenMember = true
+        ctx.isInvitedMember = true
+        ctx.WebApiManager.joinProject = sinon
+          .stub()
+          .callsArgWith(2, null, ctx.project, ctx.privilegeLevel, {
+            isRestrictedUser: ctx.isRestrictedUser,
+            isTokenMember: ctx.isTokenMember,
+            isInvitedMember: ctx.isInvitedMember,
+          })
+        ctx.RoomManager.joinProject = sinon
+          .stub()
+          .callsArgWith(2, new Error('subscribe failed'))
+        return ctx.WebsocketController.joinProject(
+          ctx.client,
+          ctx.user,
+          ctx.project_id,
+          ctx.callback
+        )
+      })
+
+      return it('should return an error', function (ctx) {
+        ctx.callback
+          .calledWith(sinon.match({ message: 'subscribe failed' }))
+          .should.equal(true)
+        return ctx.callback.args[0][0].message.should.equal('subscribe failed')
+      })
+    })
+
+    describe('when the client has disconnected', function () {
+      beforeEach(function (ctx) {
+        ctx.client.disconnected = true
+        ctx.WebApiManager.joinProject = sinon.stub().callsArg(2)
+        return ctx.WebsocketController.joinProject(
+          ctx.client,
+          ctx.user,
+          ctx.project_id,
+          ctx.callback
+        )
+      })
+
+      it('should not call WebApiManager.joinProject', function (ctx) {
+        return expect(ctx.WebApiManager.joinProject.called).to.equal(false)
+      })
+
+      it('should call the callback with no details', function (ctx) {
+        return expect(ctx.callback.args[0]).to.deep.equal([])
+      })
+
+      return it('should increment the editor.join-project.disconnected metric with a status', function (ctx) {
+        return expect(
+          ctx.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 (ctx) {
+        ctx.WebApiManager.joinProject = (project, user, cb) => {
+          ctx.client.disconnected = true
+          return cb(null, ctx.project, ctx.privilegeLevel, {
+            isRestrictedUser: ctx.isRestrictedUser,
+            isTokenMember: ctx.isTokenMember,
+            isInvitedMember: ctx.isInvitedMember,
+          })
+        }
+
+        return ctx.WebsocketController.joinProject(
+          ctx.client,
+          ctx.user,
+          ctx.project_id,
+          ctx.callback
+        )
+      })
+
+      it('should call the callback with no details', function (ctx) {
+        return expect(ctx.callback.args[0]).to.deep.equal([])
+      })
+
+      return it('should increment the editor.join-project.disconnected metric with a status', function (ctx) {
+        return expect(
+          ctx.metrics.inc.calledWith('editor.join-project.disconnected', 1, {
+            status: 'after-web-api-call',
+          })
+        ).to.equal(true)
+      })
+    })
+  })
+
+  describe('leaveProject', function () {
+    beforeEach(function (ctx) {
+      ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete = sinon
+        .stub()
+        .callsArg(1)
+      ctx.ConnectedUsersManager.markUserAsDisconnected = sinon
+        .stub()
+        .callsArg(2)
+      ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
+      ctx.RoomManager.leaveProjectAndDocs = sinon.stub()
+      ctx.clientsInRoom = []
+      ctx.io = {
+        sockets: {
+          clients: roomId => {
+            if (roomId !== ctx.project_id) {
+              throw 'expected room_id to be project_id'
+            }
+            return ctx.clientsInRoom
+          },
+        },
+      }
+      ctx.client.ol_context.project_id = ctx.project_id
+      ctx.client.ol_context.user_id = ctx.user_id
+      ctx.WebsocketController.FLUSH_IF_EMPTY_DELAY = 0
+      tk.reset()
+    }) // Allow setTimeout to work.
+
+    describe('when the client did not joined a project yet', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.client.ol_context = {}
+          ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => {
+            if (err) return reject(err)
+            resolve()
+          })
+        })
+      })
+
+      it('should bail out when calling leaveProject', function (ctx) {
+        ctx.WebsocketLoadBalancer.emitToRoom.called.should.equal(false)
+        ctx.RoomManager.leaveProjectAndDocs.called.should.equal(false)
+        ctx.ConnectedUsersManager.markUserAsDisconnected.called.should.equal(
+          false
+        )
+      })
+
+      it('should not inc any metric', function (ctx) {
+        ctx.metrics.inc.called.should.equal(false)
+      })
+    })
+
+    describe('when the project is empty', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.clientsInRoom = []
+          ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => {
+            if (err) return reject(err)
+            resolve()
+          })
+        })
+      })
+
+      it('should end clientTracking.clientDisconnected to the project room', function (ctx) {
+        ctx.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            ctx.project_id,
+            'clientTracking.clientDisconnected',
+            ctx.client.publicId
+          )
+          .should.equal(true)
+      })
+
+      it('should mark the user as disconnected', function (ctx) {
+        ctx.ConnectedUsersManager.markUserAsDisconnected
+          .calledWith(ctx.project_id, ctx.client.publicId)
+          .should.equal(true)
+      })
+
+      it('should flush the project in the document updater', function (ctx) {
+        ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete
+          .calledWith(ctx.project_id)
+          .should.equal(true)
+      })
+
+      it('should increment the leave-project metric', function (ctx) {
+        ctx.metrics.inc.calledWith('editor.leave-project').should.equal(true)
+      })
+
+      it('should track the disconnection in RoomManager', function (ctx) {
+        ctx.RoomManager.leaveProjectAndDocs
+          .calledWith(ctx.client)
+          .should.equal(true)
+      })
+    })
+
+    describe('when the project is not empty', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.clientsInRoom = ['mock-remaining-client']
+          ctx.io = {
+            sockets: {
+              clients: roomId => {
+                if (roomId !== ctx.project_id) {
+                  throw 'expected room_id to be project_id'
+                }
+                return ctx.clientsInRoom
+              },
+            },
+          }
+          ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => {
+            if (err) return reject(err)
+            resolve()
+          })
+        })
+      })
+
+      it('should not flush the project in the document updater', function (ctx) {
+        ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete.called.should.equal(
+          false
+        )
+      })
+    })
+
+    describe('when client has not authenticated', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.client.ol_context.user_id = null
+          ctx.client.ol_context.project_id = null
+          ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => {
+            if (err) return reject(err)
+            resolve()
+          })
+        })
+      })
+
+      it('should not end clientTracking.clientDisconnected to the project room', function (ctx) {
+        ctx.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            ctx.project_id,
+            'clientTracking.clientDisconnected',
+            ctx.client.publicId
+          )
+          .should.equal(false)
+      })
+
+      it('should not mark the user as disconnected', function (ctx) {
+        ctx.ConnectedUsersManager.markUserAsDisconnected
+          .calledWith(ctx.project_id, ctx.client.publicId)
+          .should.equal(false)
+      })
+
+      it('should not flush the project in the document updater', function (ctx) {
+        ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete
+          .calledWith(ctx.project_id)
+          .should.equal(false)
+      })
+
+      it('should not increment the leave-project metric', function (ctx) {
+        ctx.metrics.inc.calledWith('editor.leave-project').should.equal(false)
+      })
+    })
+
+    describe('when client has not joined a project', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.client.ol_context.user_id = ctx.user_id
+          ctx.client.ol_context.project_id = null
+          ctx.WebsocketController.leaveProject(ctx.io, ctx.client, err => {
+            if (err) return reject(err)
+            resolve()
+          })
+        })
+      })
+
+      it('should not end clientTracking.clientDisconnected to the project room', function (ctx) {
+        ctx.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            ctx.project_id,
+            'clientTracking.clientDisconnected',
+            ctx.client.publicId
+          )
+          .should.equal(false)
+      })
+
+      it('should not mark the user as disconnected', function (ctx) {
+        ctx.ConnectedUsersManager.markUserAsDisconnected
+          .calledWith(ctx.project_id, ctx.client.publicId)
+          .should.equal(false)
+      })
+
+      it('should not flush the project in the document updater', function (ctx) {
+        ctx.DocumentUpdaterManager.flushProjectToMongoAndDelete
+          .calledWith(ctx.project_id)
+          .should.equal(false)
+      })
+
+      it('should not increment the leave-project metric', function (ctx) {
+        ctx.metrics.inc.calledWith('editor.leave-project').should.equal(false)
+      })
+    })
+  })
+
+  describe('joinDoc', function () {
+    beforeEach(function (ctx) {
+      ctx.doc_id = 'doc-id-123'
+      ctx.doc_lines = ['doc', 'lines']
+      ctx.version = 42
+      ctx.ops = ['mock', 'ops']
+      ctx.ranges = { mock: 'ranges' }
+      ctx.options = {}
+
+      ctx.client.ol_context.project_id = ctx.project_id
+      ctx.client.ol_context.is_restricted_user = false
+      ctx.AuthorizationManager.addAccessToDoc = sinon.stub().yields()
+      ctx.AuthorizationManager.assertClientCanViewProject = sinon
+        .stub()
+        .callsArgWith(1, null)
+      ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon
+        .stub()
+        .callsArgWith(2, null)
+      ctx.DocumentUpdaterManager.getDocument = sinon
+        .stub()
+        .callsArgWith(3, null, ctx.doc_lines, ctx.version, ctx.ranges, ctx.ops)
+      ctx.RoomManager.joinDoc = sinon.stub().callsArg(2)
+    })
+
+    describe('works', function () {
+      beforeEach(function (ctx) {
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          -1,
+          ctx.options,
+          ctx.callback
+        )
+      })
+
+      it('should inc the joinLeaveEpoch', function (ctx) {
+        expect(ctx.client.joinLeaveEpoch).to.equal(1)
+      })
+
+      it('should check that the client is authorized to view the project', function (ctx) {
+        ctx.AuthorizationManager.assertClientCanViewProject
+          .calledWith(ctx.client)
+          .should.equal(true)
+      })
+
+      it('should get the document from the DocumentUpdaterManager with fromVersion', function (ctx) {
+        ctx.DocumentUpdaterManager.getDocument
+          .calledWith(ctx.project_id, ctx.doc_id, -1)
+          .should.equal(true)
+      })
+
+      it('should add permissions for the client to access the doc', function (ctx) {
+        ctx.AuthorizationManager.addAccessToDoc
+          .calledWith(ctx.client, ctx.doc_id)
+          .should.equal(true)
+      })
+
+      it('should join the client to room for the doc_id', function (ctx) {
+        ctx.RoomManager.joinDoc
+          .calledWith(ctx.client, ctx.doc_id)
+          .should.equal(true)
+      })
+
+      it('should call the callback with the lines, version, ranges and ops', function (ctx) {
+        ctx.callback
+          .calledWith(null, ctx.doc_lines, ctx.version, ctx.ops, ctx.ranges)
+          .should.equal(true)
+      })
+
+      it('should increment the join-doc metric', function (ctx) {
+        ctx.metrics.inc.calledWith('editor.join-doc').should.equal(true)
+      })
+    })
+
+    describe('with a fromVersion', function () {
+      beforeEach(function (ctx) {
+        ctx.fromVersion = 40
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          ctx.fromVersion,
+          ctx.options,
+          ctx.callback
+        )
+      })
+
+      it('should get the document from the DocumentUpdaterManager with fromVersion', function (ctx) {
+        ctx.DocumentUpdaterManager.getDocument
+          .calledWith(ctx.project_id, ctx.doc_id, ctx.fromVersion)
+          .should.equal(true)
+      })
+    })
+
+    describe('with doclines that need escaping', function () {
+      beforeEach(function (ctx) {
+        ctx.doc_lines.push(['räksmörgås'])
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          -1,
+          ctx.options,
+          ctx.callback
+        )
+      })
+
+      it('should call the callback with the escaped lines', function (ctx) {
+        const escapedLines = ctx.callback.args[0][1]
+        const escapedWord = escapedLines.pop()
+        escapedWord.should.equal('räksmörgås')
+        // Check that unescaping works
+        decodeURIComponent(escape(escapedWord)).should.equal('räksmörgås')
+      })
+    })
+
+    describe('with comments that need encoding', function () {
+      beforeEach(function (ctx) {
+        ctx.ranges.comments = [{ op: { c: 'räksmörgås' } }]
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          -1,
+          { encodeRanges: true },
+          ctx.callback
+        )
+      })
+
+      it('should call the callback with the encoded comment', function (ctx) {
+        const encodedComments = ctx.callback.args[0][4]
+        const encodedComment = encodedComments.comments.pop()
+        const encodedCommentText = encodedComment.op.c
+        encodedCommentText.should.equal('räksmörgås')
+      })
+    })
+
+    describe('with changes that need encoding', function () {
+      it('should call the callback with the encoded insert change', function (ctx) {
+        ctx.ranges.changes = [{ op: { i: 'räksmörgås' } }]
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          -1,
+          { encodeRanges: true },
+          ctx.callback
+        )
+
+        const encodedChanges = ctx.callback.args[0][4]
+        const encodedChange = encodedChanges.changes.pop()
+        const encodedChangeText = encodedChange.op.i
+        encodedChangeText.should.equal('räksmörgås')
+      })
+
+      it('should call the callback with the encoded delete change', function (ctx) {
+        ctx.ranges.changes = [{ op: { d: 'räksmörgås' } }]
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          -1,
+          { encodeRanges: true },
+          ctx.callback
+        )
+
+        const encodedChanges = ctx.callback.args[0][4]
+        const encodedChange = encodedChanges.changes.pop()
+        const encodedChangeText = encodedChange.op.d
+        encodedChangeText.should.equal('räksmörgås')
+      })
+    })
+
+    describe('when not authorized', function () {
+      beforeEach(function (ctx) {
+        ctx.AuthorizationManager.assertClientCanViewProject = sinon
+          .stub()
+          .callsArgWith(1, (ctx.err = new Error('not authorized')))
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          -1,
+          ctx.options,
+          ctx.callback
+        )
+      })
+
+      it('should call the callback with an error', function (ctx) {
+        ctx.callback
+          .calledWith(sinon.match({ message: 'not authorized' }))
+          .should.equal(true)
+      })
+
+      it('should not call the DocumentUpdaterManager', function (ctx) {
+        ctx.DocumentUpdaterManager.getDocument.called.should.equal(false)
+      })
+    })
+
+    describe('with a restricted client', function () {
+      beforeEach(function (ctx) {
+        ctx.ranges.comments = [{ op: { a: 1 } }, { op: { a: 2 } }]
+        ctx.client.ol_context.is_restricted_user = true
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          -1,
+          ctx.options,
+          ctx.callback
+        )
+      })
+
+      it('should overwrite ranges.comments with an empty list', function (ctx) {
+        const ranges = ctx.callback.args[0][4]
+        expect(ranges.comments).to.deep.equal([])
+      })
+    })
+
+    describe('when the client has disconnected', function () {
+      beforeEach(function (ctx) {
+        ctx.client.disconnected = true
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          -1,
+          ctx.options,
+          ctx.callback
+        )
+      })
+
+      it('should call the callback with no details', function (ctx) {
+        expect(ctx.callback.args[0]).to.deep.equal([])
+      })
+
+      it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
+        expect(
+          ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
+            status: 'immediately',
+          })
+        ).to.equal(true)
+      })
+
+      it('should not get the document', function (ctx) {
+        expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
+      })
+    })
+
+    describe('when the client disconnects while auth checks are running', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
+            new Error()
+          )
+          ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
+            ctx.client.disconnected = true
+            cb()
+          }
+
+          ctx.WebsocketController.joinDoc(
+            ctx.client,
+            ctx.doc_id,
+            -1,
+            ctx.options,
+            (...args) => {
+              ctx.callback(...args)
+              resolve()
+            }
+          )
+        })
+      })
+
+      it('should call the callback with no details', function (ctx) {
+        expect(ctx.callback.called).to.equal(true)
+        expect(ctx.callback.args[0]).to.deep.equal([])
+      })
+
+      it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
+        expect(
+          ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
+            status: 'after-client-auth-check',
+          })
+        ).to.equal(true)
+      })
+
+      it('should not get the document', function (ctx) {
+        expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
+      })
+    })
+
+    describe('when the client starts a parallel joinDoc request', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
+            new Error()
+          )
+          ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
+            ctx.DocumentUpdaterManager.checkDocument = sinon.stub().yields()
+            ctx.WebsocketController.joinDoc(
+              ctx.client,
+              ctx.doc_id,
+              -1,
+              {},
+              () => {}
+            )
+            cb()
+          }
+
+          ctx.WebsocketController.joinDoc(
+            ctx.client,
+            ctx.doc_id,
+            -1,
+            ctx.options,
+            (...args) => {
+              ctx.callback(...args)
+              // make sure the other joinDoc request completed
+              setTimeout(resolve, 5)
+            }
+          )
+        })
+      })
+
+      it('should call the callback with an error', function (ctx) {
+        expect(ctx.callback.called).to.equal(true)
+        expect(ctx.callback.args[0][0].message).to.equal(
+          'joinLeaveEpoch mismatch'
+        )
+      })
+
+      it('should get the document once (the parallel request wins)', function (ctx) {
+        expect(ctx.DocumentUpdaterManager.getDocument.callCount).to.equal(1)
+      })
+    })
+
+    describe('when the client starts a parallel leaveDoc request', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.RoomManager.leaveDoc = sinon.stub()
+
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
+            new Error()
+          )
+          ctx.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
+            ctx.WebsocketController.leaveDoc(ctx.client, ctx.doc_id, () => {})
+            cb()
+          }
+
+          ctx.WebsocketController.joinDoc(
+            ctx.client,
+            ctx.doc_id,
+            -1,
+            ctx.options,
+            (...args) => {
+              ctx.callback(...args)
+              resolve()
+            }
+          )
+        })
+      })
+
+      it('should call the callback with an error', function (ctx) {
+        expect(ctx.callback.called).to.equal(true)
+        expect(ctx.callback.args[0][0].message).to.equal(
+          'joinLeaveEpoch mismatch'
+        )
+      })
+
+      it('should not get the document', function (ctx) {
+        expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
+      })
+    })
+
+    describe('when the client disconnects while RoomManager.joinDoc is running', function () {
+      beforeEach(function (ctx) {
+        ctx.RoomManager.joinDoc = (client, docId, cb) => {
+          ctx.client.disconnected = true
+          cb()
+        }
+
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          -1,
+          ctx.options,
+          ctx.callback
+        )
+      })
+
+      it('should call the callback with no details', function (ctx) {
+        expect(ctx.callback.args[0]).to.deep.equal([])
+      })
+
+      it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
+        expect(
+          ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
+            status: 'after-joining-room',
+          })
+        ).to.equal(true)
+      })
+
+      it('should not get the document', function (ctx) {
+        expect(ctx.DocumentUpdaterManager.getDocument.called).to.equal(false)
+      })
+    })
+
+    describe('when the client disconnects while DocumentUpdaterManager.getDocument is running', function () {
+      beforeEach(function (ctx) {
+        ctx.DocumentUpdaterManager.getDocument = (
+          projectId,
+          docId,
+          fromVersion,
+          callback
+        ) => {
+          ctx.client.disconnected = true
+          callback(null, ctx.doc_lines, ctx.version, ctx.ranges, ctx.ops)
+        }
+
+        ctx.WebsocketController.joinDoc(
+          ctx.client,
+          ctx.doc_id,
+          -1,
+          ctx.options,
+          ctx.callback
+        )
+      })
+
+      it('should call the callback with no details', function (ctx) {
+        expect(ctx.callback.args[0]).to.deep.equal([])
+      })
+
+      it('should increment the editor.join-doc.disconnected metric with a status', function (ctx) {
+        expect(
+          ctx.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
+            status: 'after-doc-updater-call',
+          })
+        ).to.equal(true)
+      })
+    })
+  })
+
+  describe('leaveDoc', function () {
+    beforeEach(function (ctx) {
+      ctx.doc_id = 'doc-id-123'
+      ctx.client.ol_context.project_id = ctx.project_id
+      ctx.RoomManager.leaveDoc = sinon.stub()
+      ctx.WebsocketController.leaveDoc(ctx.client, ctx.doc_id, ctx.callback)
+    })
+
+    it('should inc the joinLeaveEpoch', function (ctx) {
+      expect(ctx.client.joinLeaveEpoch).to.equal(1)
+    })
+
+    it('should remove the client from the doc_id room', function (ctx) {
+      ctx.RoomManager.leaveDoc
+        .calledWith(ctx.client, ctx.doc_id)
+        .should.equal(true)
+    })
+
+    it('should call the callback', function (ctx) {
+      ctx.callback.called.should.equal(true)
+    })
+
+    it('should increment the leave-doc metric', function (ctx) {
+      ctx.metrics.inc.calledWith('editor.leave-doc').should.equal(true)
+    })
+  })
+
+  describe('getConnectedUsers', function () {
+    beforeEach(function (ctx) {
+      ctx.client.ol_context.project_id = ctx.project_id
+      ctx.users = ['mock', 'users']
+      ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
+      ctx.ConnectedUsersManager.getConnectedUsers = sinon
+        .stub()
+        .callsArgWith(1, null, ctx.users)
+    })
+
+    describe('when authorized', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.AuthorizationManager.assertClientCanViewProject = sinon
+            .stub()
+            .callsArgWith(1, null)
+          ctx.WebsocketController.getConnectedUsers(ctx.client, (...args) => {
+            ctx.callback(...Array.from(args || []))
+            resolve()
+          })
+        })
+      })
+
+      it('should check that the client is authorized to view the project', function (ctx) {
+        ctx.AuthorizationManager.assertClientCanViewProject
+          .calledWith(ctx.client)
+          .should.equal(true)
+      })
+
+      it('should broadcast a request to update the client list', function (ctx) {
+        ctx.WebsocketLoadBalancer.emitToRoom
+          .calledWith(ctx.project_id, 'clientTracking.refresh')
+          .should.equal(true)
+      })
+
+      it('should get the connected users for the project', function (ctx) {
+        ctx.ConnectedUsersManager.getConnectedUsers
+          .calledWith(ctx.project_id)
+          .should.equal(true)
+      })
+
+      it('should  the users', function (ctx) {
+        ctx.callback.calledWith(null, ctx.users).should.equal(true)
+      })
+
+      it('should increment the get-connected-users metric', function (ctx) {
+        ctx.metrics.inc
+          .calledWith('editor.get-connected-users')
+          .should.equal(true)
+      })
+    })
+
+    describe('when not authorized', function () {
+      beforeEach(function (ctx) {
+        ctx.AuthorizationManager.assertClientCanViewProject = sinon
+          .stub()
+          .callsArgWith(1, (ctx.err = new Error('not authorized')))
+        ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback)
+      })
+
+      it('should not get the connected users for the project', function (ctx) {
+        ctx.ConnectedUsersManager.getConnectedUsers.called.should.equal(false)
+      })
+
+      it('should return an error', function (ctx) {
+        ctx.callback.calledWith(ctx.err).should.equal(true)
+      })
+    })
+
+    describe('when restricted user', function () {
+      beforeEach(function (ctx) {
+        ctx.client.ol_context.is_restricted_user = true
+        ctx.AuthorizationManager.assertClientCanViewProject = sinon
+          .stub()
+          .callsArgWith(1, null)
+        ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback)
+      })
+
+      it('should return an empty array of users', function (ctx) {
+        ctx.callback.calledWith(null, []).should.equal(true)
+      })
+
+      it('should not get the connected users for the project', function (ctx) {
+        ctx.ConnectedUsersManager.getConnectedUsers.called.should.equal(false)
+      })
+    })
+
+    describe('when the client has disconnected', function () {
+      beforeEach(function (ctx) {
+        ctx.client.disconnected = true
+        ctx.AuthorizationManager.assertClientCanViewProject = sinon.stub()
+        ctx.WebsocketController.getConnectedUsers(ctx.client, ctx.callback)
+      })
+
+      it('should call the callback with no details', function (ctx) {
+        expect(ctx.callback.args[0]).to.deep.equal([])
+      })
+
+      it('should not check permissions', function (ctx) {
+        expect(
+          ctx.AuthorizationManager.assertClientCanViewProject.called
+        ).to.equal(false)
+      })
+    })
+  })
+
+  describe('updateClientPosition', function () {
+    beforeEach(function (ctx) {
+      ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
+      ctx.ConnectedUsersManager.updateUserPosition = sinon
+        .stub()
+        .callsArgAsync(4)
+      ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon
+        .stub()
+        .callsArgWith(2, null)
+      ctx.update = {
+        doc_id: (ctx.doc_id = 'doc-id-123'),
+        row: (ctx.row = 42),
+        column: (ctx.column = 37),
+      }
+    })
+
+    describe('with a logged in user', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.client.ol_context = {
+            project_id: ctx.project_id,
+            first_name: (ctx.first_name = 'Douglas'),
+            last_name: (ctx.last_name = 'Adams'),
+            email: (ctx.email = 'joe@example.com'),
+            user_id: (ctx.user_id = 'user-id-123'),
+          }
+
+          ctx.populatedCursorData = {
+            doc_id: ctx.doc_id,
+            id: ctx.client.publicId,
+            name: `${ctx.first_name} ${ctx.last_name}`,
+            row: ctx.row,
+            column: ctx.column,
+            email: ctx.email,
+            user_id: ctx.user_id,
+          }
+          ctx.WebsocketController.updateClientPosition(
+            ctx.client,
+            ctx.update,
+            err => {
+              if (err) return reject(err)
+              resolve()
+            }
+          )
+        })
+      })
+
+      it("should send the update to the project room with the user's name", function (ctx) {
+        ctx.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            ctx.project_id,
+            'clientTracking.clientUpdated',
+            ctx.populatedCursorData
+          )
+          .should.equal(true)
+      })
+
+      it('should send the  cursor data to the connected user manager', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.ConnectedUsersManager.updateUserPosition
+            .calledWith(
+              ctx.project_id,
+              ctx.client.publicId,
+              {
+                _id: ctx.user_id,
+                email: ctx.email,
+                first_name: ctx.first_name,
+                last_name: ctx.last_name,
+              },
+              {
+                row: ctx.row,
+                column: ctx.column,
+                doc_id: ctx.doc_id,
+              }
+            )
+            .should.equal(true)
+          resolve()
+        })
+      })
+
+      it('should increment the update-client-position metric at 0.1 frequency', function (ctx) {
+        ctx.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(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.client.ol_context = {
+            project_id: ctx.project_id,
+            first_name: (ctx.first_name = 'Douglas'),
+            last_name: undefined,
+            email: (ctx.email = 'joe@example.com'),
+            user_id: (ctx.user_id = 'user-id-123'),
+          }
+
+          ctx.populatedCursorData = {
+            doc_id: ctx.doc_id,
+            id: ctx.client.publicId,
+            name: `${ctx.first_name}`,
+            row: ctx.row,
+            column: ctx.column,
+            email: ctx.email,
+            user_id: ctx.user_id,
+          }
+          ctx.WebsocketController.updateClientPosition(
+            ctx.client,
+            ctx.update,
+            err => {
+              if (err) return reject(err)
+              resolve()
+            }
+          )
+        })
+      })
+
+      it("should send the update to the project room with the user's name", function (ctx) {
+        ctx.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            ctx.project_id,
+            'clientTracking.clientUpdated',
+            ctx.populatedCursorData
+          )
+          .should.equal(true)
+      })
+
+      it('should send the  cursor data to the connected user manager', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.ConnectedUsersManager.updateUserPosition
+            .calledWith(
+              ctx.project_id,
+              ctx.client.publicId,
+              {
+                _id: ctx.user_id,
+                email: ctx.email,
+                first_name: ctx.first_name,
+                last_name: undefined,
+              },
+              {
+                row: ctx.row,
+                column: ctx.column,
+                doc_id: ctx.doc_id,
+              }
+            )
+            .should.equal(true)
+          resolve()
+        })
+      })
+
+      it('should increment the update-client-position metric at 0.1 frequency', function (ctx) {
+        ctx.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(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.client.ol_context = {
+            project_id: ctx.project_id,
+            first_name: undefined,
+            last_name: (ctx.last_name = 'Adams'),
+            email: (ctx.email = 'joe@example.com'),
+            user_id: (ctx.user_id = 'user-id-123'),
+          }
+
+          ctx.populatedCursorData = {
+            doc_id: ctx.doc_id,
+            id: ctx.client.publicId,
+            name: `${ctx.last_name}`,
+            row: ctx.row,
+            column: ctx.column,
+            email: ctx.email,
+            user_id: ctx.user_id,
+          }
+          ctx.WebsocketController.updateClientPosition(
+            ctx.client,
+            ctx.update,
+            err => {
+              if (err) return reject(err)
+              resolve()
+            }
+          )
+        })
+      })
+
+      it("should send the update to the project room with the user's name", function (ctx) {
+        ctx.WebsocketLoadBalancer.emitToRoom
+          .calledWith(
+            ctx.project_id,
+            'clientTracking.clientUpdated',
+            ctx.populatedCursorData
+          )
+          .should.equal(true)
+      })
+
+      it('should send the  cursor data to the connected user manager', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.ConnectedUsersManager.updateUserPosition
+            .calledWith(
+              ctx.project_id,
+              ctx.client.publicId,
+              {
+                _id: ctx.user_id,
+                email: ctx.email,
+                first_name: undefined,
+                last_name: ctx.last_name,
+              },
+              {
+                row: ctx.row,
+                column: ctx.column,
+                doc_id: ctx.doc_id,
+              }
+            )
+            .should.equal(true)
+          resolve()
+        })
+      })
+
+      it('should increment the update-client-position metric at 0.1 frequency', function (ctx) {
+        ctx.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(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.client.ol_context = {
+            project_id: ctx.project_id,
+            first_name: undefined,
+            last_name: undefined,
+            email: (ctx.email = 'joe@example.com'),
+            user_id: (ctx.user_id = 'user-id-123'),
+          }
+          ctx.WebsocketController.updateClientPosition(
+            ctx.client,
+            ctx.update,
+            err => {
+              if (err) return reject(err)
+              resolve()
+            }
+          )
+        })
+      })
+
+      it('should send the update to the project name with no name', function (ctx) {
+        ctx.WebsocketLoadBalancer.emitToRoom
+          .calledWith(ctx.project_id, 'clientTracking.clientUpdated', {
+            doc_id: ctx.doc_id,
+            id: ctx.client.publicId,
+            user_id: ctx.user_id,
+            name: '',
+            row: ctx.row,
+            column: ctx.column,
+            email: ctx.email,
+          })
+          .should.equal(true)
+      })
+    })
+
+    describe('with an anonymous user', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.client.ol_context = {
+            project_id: ctx.project_id,
+          }
+          ctx.WebsocketController.updateClientPosition(
+            ctx.client,
+            ctx.update,
+            err => {
+              if (err) return reject(err)
+              resolve()
+            }
+          )
+        })
+      })
+
+      it('should send the update to the project room with no name', function (ctx) {
+        ctx.WebsocketLoadBalancer.emitToRoom
+          .calledWith(ctx.project_id, 'clientTracking.clientUpdated', {
+            doc_id: ctx.doc_id,
+            id: ctx.client.publicId,
+            name: '',
+            row: ctx.row,
+            column: ctx.column,
+          })
+          .should.equal(true)
+      })
+
+      it('should not send cursor data to the connected user manager', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.ConnectedUsersManager.updateUserPosition.called.should.equal(
+            false
+          )
+          resolve()
+        })
+      })
+    })
+
+    describe('when the client has disconnected', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.client.disconnected = true
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc =
+            sinon.stub()
+          ctx.WebsocketController.updateClientPosition(
+            ctx.client,
+            ctx.update,
+            (...args) => {
+              ctx.callback(...args)
+              if (args[0]) return reject(args[0])
+              resolve()
+            }
+          )
+        })
+      })
+
+      it('should call the callback with no details', function (ctx) {
+        expect(ctx.callback.args[0]).to.deep.equal([])
+      })
+
+      it('should not check permissions', function (ctx) {
+        expect(
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.called
+        ).to.equal(false)
+      })
+    })
+  })
+
+  describe('applyOtUpdate', function () {
+    beforeEach(function (ctx) {
+      ctx.update = { op: { p: 12, t: 'foo' } }
+      ctx.client.ol_context.user_id = ctx.user_id
+      ctx.client.ol_context.project_id = ctx.project_id
+      ctx.WebsocketController._assertClientCanApplyUpdate = sinon
+        .stub()
+        .yields()
+      ctx.DocumentUpdaterManager.queueChange = sinon.stub().callsArg(3)
+    })
+
+    describe('succesfully', function () {
+      beforeEach(function (ctx) {
+        ctx.WebsocketController.applyOtUpdate(
+          ctx.client,
+          ctx.doc_id,
+          ctx.update,
+          ctx.callback
+        )
+      })
+
+      it('should set the source of the update to the client id', function (ctx) {
+        ctx.update.meta.source.should.equal(ctx.client.publicId)
+      })
+
+      it('should set the user_id of the update to the user id', function (ctx) {
+        ctx.update.meta.user_id.should.equal(ctx.user_id)
+      })
+
+      it('should queue the update', function (ctx) {
+        ctx.DocumentUpdaterManager.queueChange
+          .calledWith(ctx.project_id, ctx.doc_id, ctx.update)
+          .should.equal(true)
+      })
+
+      it('should call the callback', function (ctx) {
+        ctx.callback.called.should.equal(true)
+      })
+
+      it('should increment the doc updates', function (ctx) {
+        ctx.metrics.inc.calledWith('editor.doc-update').should.equal(true)
+      })
+    })
+
+    describe('unsuccessfully', function () {
+      beforeEach(function (ctx) {
+        ctx.client.disconnect = sinon.stub()
+        ctx.DocumentUpdaterManager.queueChange = sinon
+          .stub()
+          .callsArgWith(3, (ctx.error = new Error('Something went wrong')))
+        ctx.WebsocketController.applyOtUpdate(
+          ctx.client,
+          ctx.doc_id,
+          ctx.update,
+          ctx.callback
+        )
+      })
+
+      it('should disconnect the client', function (ctx) {
+        ctx.client.disconnect.called.should.equal(true)
+      })
+
+      it('should not log an error', function (ctx) {
+        ctx.logger.error.called.should.equal(false)
+      })
+
+      it('should call the callback with the error', function (ctx) {
+        ctx.callback.calledWith(ctx.error).should.equal(true)
+      })
+    })
+
+    describe('when not authorized', function () {
+      beforeEach(function (ctx) {
+        ctx.client.disconnect = sinon.stub()
+        ctx.WebsocketController._assertClientCanApplyUpdate = sinon
+          .stub()
+          .yields((ctx.error = new Error('not authorized')))
+        ctx.WebsocketController.applyOtUpdate(
+          ctx.client,
+          ctx.doc_id,
+          ctx.update,
+          ctx.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 not log a warning', function (ctx) {
+        ctx.logger.warn.called.should.equal(false)
+      })
+
+      it('should call the callback with the error', function (ctx) {
+        ctx.callback.calledWith(ctx.error).should.equal(true)
+      })
+    })
+
+    describe('update_too_large', function () {
+      beforeEach(async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.client.disconnect = sinon.stub()
+          ctx.client.emit = sinon.stub()
+          ctx.client.ol_context.user_id = ctx.user_id
+          ctx.client.ol_context.project_id = ctx.project_id
+          const error = new UpdateTooLargeError(7372835)
+          ctx.DocumentUpdaterManager.queueChange = sinon
+            .stub()
+            .callsArgWith(3, error)
+          ctx.WebsocketController.applyOtUpdate(
+            ctx.client,
+            ctx.doc_id,
+            ctx.update,
+            ctx.callback
+          )
+          setTimeout(() => resolve(), 1)
+        })
+      })
+
+      it('should call the callback with no error', function (ctx) {
+        ctx.callback.called.should.equal(true)
+        ctx.callback.args[0].should.deep.equal([])
+      })
+
+      it('should log a warning with the size and context', function (ctx) {
+        ctx.logger.warn.called.should.equal(true)
+        ctx.logger.warn.args[0].should.deep.equal([
+          {
+            userId: ctx.user_id,
+            projectId: ctx.project_id,
+            docId: ctx.doc_id,
+            updateSize: 7372835,
+          },
+          'update is too large',
+        ])
+      })
+
+      describe('after 100ms', function () {
+        beforeEach(async function () {
+          await new Promise(resolve => {
+            setTimeout(resolve, 100)
+          })
+        })
+
+        it('should send an otUpdateError the client', function (ctx) {
+          ctx.client.emit.calledWith('otUpdateError').should.equal(true)
+        })
+
+        it('should disconnect the client', function (ctx) {
+          ctx.client.disconnect.called.should.equal(true)
+        })
+      })
+
+      describe('when the client disconnects during the next 100ms', function () {
+        beforeEach(async function (ctx) {
+          await new Promise((resolve, reject) => {
+            ctx.client.disconnected = true
+            setTimeout(resolve, 100)
+          })
+        })
+
+        it('should not send an otUpdateError the client', function (ctx) {
+          ctx.client.emit.calledWith('otUpdateError').should.equal(false)
+        })
+
+        it('should not disconnect the client', function (ctx) {
+          ctx.client.disconnect.called.should.equal(false)
+        })
+
+        it('should increment the editor.doc-update.disconnected metric with a status', function (ctx) {
+          expect(
+            ctx.metrics.inc.calledWith('editor.doc-update.disconnected', 1, {
+              status: 'at-otUpdateError',
+            })
+          ).to.equal(true)
+        })
+      })
+    })
+  })
+
+  describe('_assertClientCanApplyUpdate', function () {
+    beforeEach(function (ctx) {
+      ctx.edit_update = {
+        op: [
+          { i: 'foo', p: 42 },
+          { c: 'bar', p: 132 },
+        ],
+      } // comments may still be in an edit op
+      ctx.comment_update = { op: [{ c: 'bar', p: 132 }] }
+      ctx.AuthorizationManager.assertClientCanEditProjectAndDoc = sinon.stub()
+      ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc = sinon.stub()
+      ctx.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon.stub()
+    })
+
+    describe('with a read-write client', function () {
+      it('should return successfully', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(null)
+          ctx.WebsocketController._assertClientCanApplyUpdate(
+            ctx.client,
+            ctx.doc_id,
+            ctx.edit_update,
+            error => {
+              expect(error).to.be.null
+              resolve()
+            }
+          )
+        })
+      })
+    })
+
+    describe('with a read-only client and an edit op', function () {
+      it('should return an error', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
+            new Error('not authorized')
+          )
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
+          ctx.WebsocketController._assertClientCanApplyUpdate(
+            ctx.client,
+            ctx.doc_id,
+            ctx.edit_update,
+            error => {
+              expect(error.message).to.equal('not authorized')
+              resolve()
+            }
+          )
+        })
+      })
+    })
+
+    describe('with a read-only client and a comment op', function () {
+      it('should return successfully', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
+            new Error('not authorized')
+          )
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
+          ctx.WebsocketController._assertClientCanApplyUpdate(
+            ctx.client,
+            ctx.doc_id,
+            ctx.comment_update,
+            error => {
+              expect(error).to.be.null
+              resolve()
+            }
+          )
+        })
+      })
+    })
+
+    describe('with a totally unauthorized client', function () {
+      it('should return an error', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
+            new Error('not authorized')
+          )
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
+            new Error('not authorized')
+          )
+          ctx.WebsocketController._assertClientCanApplyUpdate(
+            ctx.client,
+            ctx.doc_id,
+            ctx.comment_update,
+            error => {
+              expect(error.message).to.equal('not authorized')
+              resolve()
+            }
+          )
+        })
+      })
+    })
+
+    describe('with a review client', function () {
+      it('op with tc should succeed', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
+            new Error('not authorized')
+          )
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
+          ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields(
+            null
+          )
+          ctx.WebsocketController._assertClientCanApplyUpdate(
+            ctx.client,
+            ctx.doc_id,
+            { op: [{ p: 10, i: 'a' }], meta: { tc: '123456' } },
+            error => {
+              expect(error).to.be.null
+              resolve()
+            }
+          )
+        })
+      })
+
+      it('op without tc should fail', async function (ctx) {
+        await new Promise((resolve, reject) => {
+          ctx.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
+            new Error('not authorized')
+          )
+          ctx.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
+          ctx.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields(
+            null
+          )
+          ctx.WebsocketController._assertClientCanApplyUpdate(
+            ctx.client,
+            ctx.doc_id,
+            { op: [{ p: 10, i: 'a' }] },
+            error => {
+              expect(error.message).to.equal('not authorized')
+              resolve()
+            }
+          )
+        })
+      })
+    })
+  })
+})

+ 0 - 1698
services/real-time/test/unit/js/WebsocketControllerTests.js

@@ -1,1698 +0,0 @@
-/* eslint-disable
-    no-return-assign,
-    no-throw-literal,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS101: Remove unnecessary use of Array.from
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../app/js/WebsocketController.js'
-const SandboxedModule = require('sandboxed-module')
-const tk = require('timekeeper')
-const { UpdateTooLargeError } = require('../../../app/js/Errors')
-
-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: {},
-      joinLeaveEpoch: 0,
-      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 = {}),
-        '@overleaf/metrics': (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()
-          .callsArgAsync(4)
-        this.isRestrictedUser = true
-        this.isTokenMember = true
-        this.isInvitedMember = true
-        this.WebApiManager.joinProject = sinon
-          .stub()
-          .callsArgWith(2, null, this.project, this.privilegeLevel, {
-            isRestrictedUser: this.isRestrictedUser,
-            isTokenMember: this.isTokenMember,
-            isInvitedMember: this.isInvitedMember,
-          })
-        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 set the is_token_member flag on the client', function () {
-        this.client.ol_context.is_token_member.should.equal(this.isTokenMember)
-      })
-      it('should set the is_invited_member flag on the client', function () {
-        this.client.ol_context.is_invited_member.should.equal(
-          this.isInvitedMember
-        )
-      })
-      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()
-          .callsArgAsync(4)
-        this.isRestrictedUser = true
-        this.isTokenMember = true
-        this.isInvitedMember = true
-        this.WebApiManager.joinProject = sinon
-          .stub()
-          .callsArgWith(2, null, this.project, this.privilegeLevel, {
-            isRestrictedUser: this.isRestrictedUser,
-            isTokenMember: this.isTokenMember,
-            isInvitedMember: this.isInvitedMember,
-          })
-        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, {
-            isRestrictedUser: this.isRestrictedUser,
-            isTokenMember: this.isTokenMember,
-            isInvitedMember: this.isInvitedMember,
-          })
-        }
-
-        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: roomId => {
-            if (roomId !== 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 (done) {
-        this.clientsInRoom = ['mock-remaining-client']
-        this.io = {
-          sockets: {
-            clients: roomId => {
-              if (roomId !== this.project_id) {
-                throw 'expected room_id to be project_id'
-              }
-              return this.clientsInRoom
-            },
-          },
-        }
-        return this.WebsocketController.leaveProject(this.io, this.client, done)
-      })
-
-      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().yields()
-      this.AuthorizationManager.assertClientCanViewProject = sinon
-        .stub()
-        .callsArgWith(1, null)
-      this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon
-        .stub()
-        .callsArgWith(2, 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 inc the joinLeaveEpoch', function () {
-        expect(this.client.joinLeaveEpoch).to.equal(1)
-      })
-
-      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 escapedLines = this.callback.args[0][1]
-        const escapedWord = escapedLines.pop()
-        escapedWord.should.equal('räksmörgås')
-        // Check that unescaping works
-        return decodeURIComponent(escape(escapedWord)).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 encodedComments = this.callback.args[0][4]
-        const encodedComment = encodedComments.comments.pop()
-        const encodedCommentText = encodedComment.op.c
-        return encodedCommentText.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 encodedChanges = this.callback.args[0][4]
-        const encodedChange = encodedChanges.changes.pop()
-        const encodedChangeText = encodedChange.op.i
-        return encodedChangeText.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 encodedChanges = this.callback.args[0][4]
-        const encodedChange = encodedChanges.changes.pop()
-        const encodedChangeText = encodedChange.op.d
-        return encodedChangeText.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 auth checks are running', function () {
-      beforeEach(function (done) {
-        this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
-          new Error()
-        )
-        this.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
-          this.client.disconnected = true
-          cb()
-        }
-
-        this.WebsocketController.joinDoc(
-          this.client,
-          this.doc_id,
-          -1,
-          this.options,
-          (...args) => {
-            this.callback(...args)
-            done()
-          }
-        )
-      })
-
-      it('should call the callback with no details', function () {
-        expect(this.callback.called).to.equal(true)
-        expect(this.callback.args[0]).to.deep.equal([])
-      })
-
-      it('should increment the editor.join-doc.disconnected metric with a status', function () {
-        expect(
-          this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, {
-            status: 'after-client-auth-check',
-          })
-        ).to.equal(true)
-      })
-
-      it('should not get the document', function () {
-        expect(this.DocumentUpdaterManager.getDocument.called).to.equal(false)
-      })
-    })
-
-    describe('when the client starts a parallel joinDoc request', function () {
-      beforeEach(function (done) {
-        this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
-          new Error()
-        )
-        this.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
-          this.DocumentUpdaterManager.checkDocument = sinon.stub().yields()
-          this.WebsocketController.joinDoc(
-            this.client,
-            this.doc_id,
-            -1,
-            {},
-            () => {}
-          )
-          cb()
-        }
-
-        this.WebsocketController.joinDoc(
-          this.client,
-          this.doc_id,
-          -1,
-          this.options,
-          (...args) => {
-            this.callback(...args)
-            // make sure the other joinDoc request completed
-            setTimeout(done, 5)
-          }
-        )
-      })
-
-      it('should call the callback with an error', function () {
-        expect(this.callback.called).to.equal(true)
-        expect(this.callback.args[0][0].message).to.equal(
-          'joinLeaveEpoch mismatch'
-        )
-      })
-
-      it('should get the document once (the parallel request wins)', function () {
-        expect(this.DocumentUpdaterManager.getDocument.callCount).to.equal(1)
-      })
-    })
-
-    describe('when the client starts a parallel leaveDoc request', function () {
-      beforeEach(function (done) {
-        this.RoomManager.leaveDoc = sinon.stub()
-
-        this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(
-          new Error()
-        )
-        this.DocumentUpdaterManager.checkDocument = (projectId, docId, cb) => {
-          this.WebsocketController.leaveDoc(this.client, this.doc_id, () => {})
-          cb()
-        }
-
-        this.WebsocketController.joinDoc(
-          this.client,
-          this.doc_id,
-          -1,
-          this.options,
-          (...args) => {
-            this.callback(...args)
-            done()
-          }
-        )
-      })
-
-      it('should call the callback with an error', function () {
-        expect(this.callback.called).to.equal(true)
-        expect(this.callback.args[0][0].message).to.equal(
-          'joinLeaveEpoch mismatch'
-        )
-      })
-
-      it('should not get the document', function () {
-        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, docId, 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 = (
-          projectId,
-          docId,
-          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 inc the joinLeaveEpoch', function () {
-      expect(this.client.joinLeaveEpoch).to.equal(1)
-    })
-
-    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()
-        .callsArgAsync(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 (done) {
-        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.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,
-        }
-        this.WebsocketController.updateClientPosition(
-          this.client,
-          this.update,
-          done
-        )
-      })
-
-      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 (done) {
-        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.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,
-        }
-        this.WebsocketController.updateClientPosition(
-          this.client,
-          this.update,
-          done
-        )
-      })
-
-      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 (done) {
-        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.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,
-        }
-        this.WebsocketController.updateClientPosition(
-          this.client,
-          this.update,
-          done
-        )
-      })
-
-      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 (done) {
-        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,
-          done
-        )
-      })
-
-      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 (done) {
-        this.client.ol_context = {
-          project_id: this.project_id,
-        }
-        return this.WebsocketController.updateClientPosition(
-          this.client,
-          this.update,
-          done
-        )
-      })
-
-      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 (done) {
-        this.client.disconnected = true
-        this.AuthorizationManager.assertClientCanViewProjectAndDoc =
-          sinon.stub()
-        return this.WebsocketController.updateClientPosition(
-          this.client,
-          this.update,
-          (...args) => {
-            this.callback(...args)
-            done(args[0])
-          }
-        )
-      })
-
-      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 not log an error', function () {
-        return this.logger.error.called.should.equal(false)
-      })
-
-      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 not log a warning', function () {
-        return this.logger.warn.called.should.equal(false)
-      })
-
-      return it('should call the callback with the error', function () {
-        return this.callback.calledWith(this.error).should.equal(true)
-      })
-    })
-
-    return describe('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 UpdateTooLargeError(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([
-          {
-            userId: this.user_id,
-            projectId: this.project_id,
-            docId: 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()
-      this.AuthorizationManager.assertClientCanReviewProjectAndDoc =
-        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()
-          }
-        )
-      })
-    })
-
-    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()
-          }
-        )
-      })
-    })
-
-    describe('with a review client', function () {
-      it('op with tc should succeed', function (done) {
-        this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
-          new Error('not authorized')
-        )
-        this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
-        this.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields(
-          null
-        )
-        return this.WebsocketController._assertClientCanApplyUpdate(
-          this.client,
-          this.doc_id,
-          { op: [{ p: 10, i: 'a' }], meta: { tc: '123456' } },
-          error => {
-            expect(error).to.be.null
-            return done()
-          }
-        )
-      })
-
-      return it('op without tc should fail', function (done) {
-        this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(
-          new Error('not authorized')
-        )
-        this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null)
-        this.AuthorizationManager.assertClientCanReviewProjectAndDoc.yields(
-          null
-        )
-        return this.WebsocketController._assertClientCanApplyUpdate(
-          this.client,
-          this.doc_id,
-          { op: [{ p: 10, i: 'a' }] },
-          error => {
-            expect(error.message).to.equal('not authorized')
-            return done()
-          }
-        )
-      })
-    })
-  })
-})

+ 181 - 168
services/real-time/test/unit/js/WebsocketLoadBalancerTests.js → services/real-time/test/unit/js/WebsocketLoadBalancer.test.js

@@ -1,62 +1,75 @@
-/* eslint-disable
-    no-return-assign,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS101: Remove unnecessary use of Array.from
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const expect = require('chai').expect
-const modulePath = require('node:path').join(
-  __dirname,
+import { vi, expect, describe, beforeEach, it } from 'vitest'
+
+import sinon from 'sinon'
+import path from 'node:path'
+
+const modulePath = path.join(
+  import.meta.dirname,
   '../../../app/js/WebsocketLoadBalancer'
 )
 
 describe('WebsocketLoadBalancer', function () {
-  beforeEach(function () {
-    this.rclient = {}
-    this.RoomEvents = { on: sinon.stub() }
-    this.WebsocketLoadBalancer = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': (this.Settings = { redis: {} }),
-        './RedisClientManager': {
-          createClientList: () => [],
-        },
-        './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(),
-        }),
+  beforeEach(async function (ctx) {
+    ctx.rclient = {}
+    ctx.RoomEvents = { on: sinon.stub() }
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.Settings = { redis: {} }),
+    }))
+
+    vi.doMock('./RedisClientManager', () => ({
+      default: {
+        createClientList: () => [],
       },
-    })
-    this.io = {}
-    this.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }]
-    this.WebsocketLoadBalancer.rclientSubList = [
+    }))
+
+    vi.doMock('../../../app/js/SafeJsonParse', () => ({
+      default: (ctx.SafeJsonParse = {
+        parse: (data, cb) => cb(null, JSON.parse(data)),
+      }),
+    }))
+
+    vi.doMock('../../../app/js/EventLogger', () => ({
+      default: { checkEventOrder: sinon.stub() },
+    }))
+
+    vi.doMock('../../../app/js/HealthCheckManager', () => ({
+      default: { check: sinon.stub() },
+    }))
+
+    vi.doMock('../../../app/js/RoomManager', () => ({
+      default: (ctx.RoomManager = {
+        eventSource: sinon.stub().returns(ctx.RoomEvents),
+      }),
+    }))
+
+    vi.doMock('../../../app/js/ChannelManager', () => ({
+      default: (ctx.ChannelManager = { publish: sinon.stub() }),
+    }))
+
+    vi.doMock('../../../app/js/ConnectedUsersManager', () => ({
+      default: (ctx.ConnectedUsersManager = {
+        refreshClient: sinon.stub(),
+      }),
+    }))
+
+    ctx.WebsocketLoadBalancer = (await import(modulePath)).default
+    ctx.io = {}
+    ctx.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }]
+    ctx.WebsocketLoadBalancer.rclientSubList = [
       {
         subscribe: sinon.stub(),
         on: sinon.stub(),
       },
     ]
 
-    this.room_id = 'room-id'
-    this.message = 'otUpdateApplied'
-    return (this.payload = ['argument one', 42])
+    ctx.room_id = 'room-id'
+    ctx.message = 'otUpdateApplied'
+    ctx.payload = ['argument one', 42]
   })
 
   describe('shouldDisconnectClient', function () {
-    it('should return false for general messages', function () {
+    it('should return false for general messages', function (ctx) {
       const client = {
         ol_context: { user_id: 'abcd' },
       }
@@ -65,7 +78,7 @@ describe('WebsocketLoadBalancer', function () {
         payload: [{ data: 'whatever' }],
       }
       expect(
-        this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
+        ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
       ).to.equal(false)
     })
 
@@ -74,7 +87,7 @@ describe('WebsocketLoadBalancer', function () {
       const client = {
         ol_context: { user_id: 'abcd' },
       }
-      it('should return true if the user id matches', function () {
+      it('should return true if the user id matches', function (ctx) {
         const message = {
           message: messageName,
           payload: [
@@ -84,10 +97,10 @@ describe('WebsocketLoadBalancer', function () {
           ],
         }
         expect(
-          this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
+          ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
         ).to.equal(true)
       })
-      it('should return false if the user id does not match', function () {
+      it('should return false if the user id does not match', function (ctx) {
         const message = {
           message: messageName,
           payload: [
@@ -97,7 +110,7 @@ describe('WebsocketLoadBalancer', function () {
           ],
         }
         expect(
-          this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
+          ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
         ).to.equal(false)
       })
     })
@@ -107,23 +120,23 @@ describe('WebsocketLoadBalancer', function () {
       const client = {
         ol_context: { user_id: 'abcd' },
       }
-      it('should return false, when the user_id does not match', function () {
+      it('should return false, when the user_id does not match', function (ctx) {
         const message = {
           message: messageName,
           payload: ['xyz'],
         }
         expect(
-          this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
+          ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
         ).to.equal(false)
       })
 
-      it('should return true, if the user_id matches', function () {
+      it('should return true, if the user_id matches', function (ctx) {
         const message = {
           message: messageName,
           payload: [`${client.ol_context.user_id}`],
         }
         expect(
-          this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
+          ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
         ).to.equal(true)
       })
     })
@@ -143,9 +156,9 @@ describe('WebsocketLoadBalancer', function () {
             },
           }
 
-          it('should return false', function () {
+          it('should return false', function (ctx) {
             expect(
-              this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
+              ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
             ).to.equal(false)
           })
         })
@@ -157,9 +170,9 @@ describe('WebsocketLoadBalancer', function () {
             },
           }
 
-          it('should return true', function () {
+          it('should return true', function (ctx) {
             expect(
-              this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
+              ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
             ).to.equal(true)
           })
         })
@@ -178,9 +191,9 @@ describe('WebsocketLoadBalancer', function () {
             },
           }
 
-          it('should return false', function () {
+          it('should return false', function (ctx) {
             expect(
-              this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
+              ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
             ).to.equal(false)
           })
         })
@@ -192,9 +205,9 @@ describe('WebsocketLoadBalancer', function () {
             },
           }
 
-          it('should return false', function () {
+          it('should return false', function (ctx) {
             expect(
-              this.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
+              ctx.WebsocketLoadBalancer.shouldDisconnectClient(client, message)
             ).to.equal(false)
           })
         })
@@ -203,24 +216,24 @@ describe('WebsocketLoadBalancer', function () {
   })
 
   describe('emitToRoom', function () {
-    beforeEach(function () {
-      return this.WebsocketLoadBalancer.emitToRoom(
-        this.room_id,
-        this.message,
-        ...Array.from(this.payload)
+    beforeEach(function (ctx) {
+      ctx.WebsocketLoadBalancer.emitToRoom(
+        ctx.room_id,
+        ctx.message,
+        ...Array.from(ctx.payload)
       )
     })
 
-    return it('should publish the message to redis', function () {
-      return this.ChannelManager.publish
+    it('should publish the message to redis', function (ctx) {
+      ctx.ChannelManager.publish
         .calledWith(
-          this.WebsocketLoadBalancer.rclientPubList[0],
+          ctx.WebsocketLoadBalancer.rclientPubList[0],
           'editor-events',
-          this.room_id,
+          ctx.room_id,
           JSON.stringify({
-            room_id: this.room_id,
-            message: this.message,
-            payload: this.payload,
+            room_id: ctx.room_id,
+            message: ctx.message,
+            payload: ctx.payload,
           })
         )
         .should.equal(true)
@@ -228,224 +241,224 @@ describe('WebsocketLoadBalancer', function () {
   })
 
   describe('emitToAll', function () {
-    beforeEach(function () {
-      this.WebsocketLoadBalancer.emitToRoom = sinon.stub()
-      return this.WebsocketLoadBalancer.emitToAll(
-        this.message,
-        ...Array.from(this.payload)
+    beforeEach(function (ctx) {
+      ctx.WebsocketLoadBalancer.emitToRoom = sinon.stub()
+      ctx.WebsocketLoadBalancer.emitToAll(
+        ctx.message,
+        ...Array.from(ctx.payload)
       )
     })
 
-    return it("should emit to the room 'all'", function () {
-      return this.WebsocketLoadBalancer.emitToRoom
-        .calledWith('all', this.message, ...Array.from(this.payload))
+    it("should emit to the room 'all'", function (ctx) {
+      ctx.WebsocketLoadBalancer.emitToRoom
+        .calledWith('all', ctx.message, ...Array.from(ctx.payload))
         .should.equal(true)
     })
   })
 
   describe('listenForEditorEvents', function () {
-    beforeEach(function () {
-      this.WebsocketLoadBalancer._processEditorEvent = sinon.stub()
-      return this.WebsocketLoadBalancer.listenForEditorEvents()
+    beforeEach(function (ctx) {
+      ctx.WebsocketLoadBalancer._processEditorEvent = sinon.stub()
+      ctx.WebsocketLoadBalancer.listenForEditorEvents()
     })
 
-    it('should subscribe to the editor-events channel', function () {
-      return this.WebsocketLoadBalancer.rclientSubList[0].subscribe
+    it('should subscribe to the editor-events channel', function (ctx) {
+      ctx.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
+    it('should process the events with _processEditorEvent', function (ctx) {
+      ctx.WebsocketLoadBalancer.rclientSubList[0].on
         .calledWith('message', sinon.match.func)
         .should.equal(true)
     })
   })
 
-  return describe('_processEditorEvent', function () {
+  describe('_processEditorEvent', function () {
     describe('with bad JSON', function () {
-      beforeEach(function () {
-        this.isRestrictedUser = false
-        this.SafeJsonParse.parse = sinon
+      beforeEach(function (ctx) {
+        ctx.isRestrictedUser = false
+        ctx.SafeJsonParse.parse = sinon
           .stub()
           .callsArgWith(1, new Error('oops'))
-        return this.WebsocketLoadBalancer._processEditorEvent(
-          this.io,
+        ctx.WebsocketLoadBalancer._processEditorEvent(
+          ctx.io,
           'editor-events',
           'blah'
         )
       })
 
-      return it('should log an error', function () {
-        return this.logger.error.called.should.equal(true)
+      it('should log an error', function (ctx) {
+        ctx.logger.error.called.should.equal(true)
       })
     })
 
     describe('with a designated room', function () {
-      beforeEach(function () {
-        this.io.sockets = {
+      beforeEach(function (ctx) {
+        ctx.io.sockets = {
           clients: sinon.stub().returns([
             {
               id: 'client-id-1',
-              emit: (this.emit1 = sinon.stub()),
+              emit: (ctx.emit1 = sinon.stub()),
               ol_context: {},
             },
             {
               id: 'client-id-2',
-              emit: (this.emit2 = sinon.stub()),
+              emit: (ctx.emit2 = sinon.stub()),
               ol_context: {},
             },
             {
               id: 'client-id-1',
-              emit: (this.emit3 = sinon.stub()),
+              emit: (ctx.emit3 = sinon.stub()),
               ol_context: {},
             }, // duplicate client
           ]),
         }
         const data = JSON.stringify({
-          room_id: this.room_id,
-          message: this.message,
-          payload: this.payload,
+          room_id: ctx.room_id,
+          message: ctx.message,
+          payload: ctx.payload,
         })
-        return this.WebsocketLoadBalancer._processEditorEvent(
-          this.io,
+        ctx.WebsocketLoadBalancer._processEditorEvent(
+          ctx.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))
+      it('should send the message to all (unique) clients in the room', function (ctx) {
+        ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
+        ctx.emit1
+          .calledWith(ctx.message, ...Array.from(ctx.payload))
           .should.equal(true)
-        this.emit2
-          .calledWith(this.message, ...Array.from(this.payload))
+        ctx.emit2
+          .calledWith(ctx.message, ...Array.from(ctx.payload))
           .should.equal(true)
-        return this.emit3.called.should.equal(false)
+        ctx.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 = {
+      beforeEach(function (ctx) {
+        ctx.io.sockets = {
           clients: sinon.stub().returns([
             {
               id: 'client-id-1',
-              emit: (this.emit1 = sinon.stub()),
+              emit: (ctx.emit1 = sinon.stub()),
               ol_context: {},
             },
             {
               id: 'client-id-2',
-              emit: (this.emit2 = sinon.stub()),
+              emit: (ctx.emit2 = sinon.stub()),
               ol_context: {},
             },
             {
               id: 'client-id-1',
-              emit: (this.emit3 = sinon.stub()),
+              emit: (ctx.emit3 = sinon.stub()),
               ol_context: {},
             }, // duplicate client
             {
               id: 'client-id-4',
-              emit: (this.emit4 = sinon.stub()),
+              emit: (ctx.emit4 = sinon.stub()),
               ol_context: { is_restricted_user: true },
             },
           ]),
         }
         const data = JSON.stringify({
-          room_id: this.room_id,
-          message: this.message,
-          payload: this.payload,
+          room_id: ctx.room_id,
+          message: ctx.message,
+          payload: ctx.payload,
         })
-        return this.WebsocketLoadBalancer._processEditorEvent(
-          this.io,
+        ctx.WebsocketLoadBalancer._processEditorEvent(
+          ctx.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))
+      it('should send the message to all (unique) clients in the room', function (ctx) {
+        ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
+        ctx.emit1
+          .calledWith(ctx.message, ...Array.from(ctx.payload))
           .should.equal(true)
-        this.emit2
-          .calledWith(this.message, ...Array.from(this.payload))
+        ctx.emit2
+          .calledWith(ctx.message, ...Array.from(ctx.payload))
           .should.equal(true)
-        this.emit3.called.should.equal(false) // duplicate client should be ignored
-        return this.emit4.called.should.equal(true)
+        ctx.emit3.called.should.equal(false) // duplicate client should be ignored
+        ctx.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 = {
+      beforeEach(function (ctx) {
+        ctx.io.sockets = {
           clients: sinon.stub().returns([
             {
               id: 'client-id-1',
-              emit: (this.emit1 = sinon.stub()),
+              emit: (ctx.emit1 = sinon.stub()),
               ol_context: {},
             },
             {
               id: 'client-id-2',
-              emit: (this.emit2 = sinon.stub()),
+              emit: (ctx.emit2 = sinon.stub()),
               ol_context: {},
             },
             {
               id: 'client-id-1',
-              emit: (this.emit3 = sinon.stub()),
+              emit: (ctx.emit3 = sinon.stub()),
               ol_context: {},
             }, // duplicate client
             {
               id: 'client-id-4',
-              emit: (this.emit4 = sinon.stub()),
+              emit: (ctx.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,
+          room_id: ctx.room_id,
+          message: (ctx.restrictedMessage = 'new-comment'),
+          payload: ctx.payload,
         })
-        return this.WebsocketLoadBalancer._processEditorEvent(
-          this.io,
+        ctx.WebsocketLoadBalancer._processEditorEvent(
+          ctx.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))
+      it('should send the message to all (unique) clients in the room, who are not restricted', function (ctx) {
+        ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
+        ctx.emit1
+          .calledWith(ctx.restrictedMessage, ...Array.from(ctx.payload))
           .should.equal(true)
-        this.emit2
-          .calledWith(this.restrictedMessage, ...Array.from(this.payload))
+        ctx.emit2
+          .calledWith(ctx.restrictedMessage, ...Array.from(ctx.payload))
           .should.equal(true)
-        this.emit3.called.should.equal(false) // duplicate client should be ignored
-        return this.emit4.called.should.equal(false)
+        ctx.emit3.called.should.equal(false) // duplicate client should be ignored
+        ctx.emit4.called.should.equal(false)
       })
     }) // restricted client, should not be called
 
     describe('when emitting to all', function () {
-      beforeEach(function () {
-        this.io.sockets = { emit: (this.emit = sinon.stub()) }
+      beforeEach(function (ctx) {
+        ctx.io.sockets = { emit: (ctx.emit = sinon.stub()) }
         const data = JSON.stringify({
           room_id: 'all',
-          message: this.message,
-          payload: this.payload,
+          message: ctx.message,
+          payload: ctx.payload,
         })
-        return this.WebsocketLoadBalancer._processEditorEvent(
-          this.io,
+        ctx.WebsocketLoadBalancer._processEditorEvent(
+          ctx.io,
           'editor-events',
           data
         )
       })
 
-      return it('should send the message to all clients', function () {
-        return this.emit
-          .calledWith(this.message, ...Array.from(this.payload))
+      it('should send the message to all clients', function (ctx) {
+        ctx.emit
+          .calledWith(ctx.message, ...Array.from(ctx.payload))
           .should.equal(true)
       })
     })
@@ -474,24 +487,24 @@ describe('WebsocketLoadBalancer', function () {
           disconnect: sinon.stub(),
         },
       ]
-      beforeEach(function () {
-        this.io.sockets = {
+      beforeEach(function (ctx) {
+        ctx.io.sockets = {
           clients: sinon.stub().returns(clients),
         }
         const data = JSON.stringify({
-          room_id: this.room_id,
+          room_id: ctx.room_id,
           message,
           payload,
         })
-        return this.WebsocketLoadBalancer._processEditorEvent(
-          this.io,
+        ctx.WebsocketLoadBalancer._processEditorEvent(
+          ctx.io,
           'editor-events',
           data
         )
       })
 
-      it('should disconnect the matching client, while sending message to other clients', function () {
-        this.io.sockets.clients.calledWith(this.room_id).should.equal(true)
+      it('should disconnect the matching client, while sending message to other clients', function (ctx) {
+        ctx.io.sockets.clients.calledWith(ctx.room_id).should.equal(true)
 
         const [client1, client2, client3] = clients
 

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

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

+ 34 - 0
services/real-time/test/unit/setup.js

@@ -0,0 +1,34 @@
+import { afterEach, beforeEach, chai, vi } from 'vitest'
+import sinon from 'sinon'
+import chaiAsPromised from 'chai-as-promised'
+import sinonChai from 'sinon-chai'
+
+// Chai configuration
+chai.should()
+chai.use(chaiAsPromised)
+chai.use(sinonChai)
+
+// Global stubs
+const sandbox = sinon.createSandbox()
+const stubs = {
+  logger: {
+    debug: sandbox.stub(),
+    log: sandbox.stub(),
+    info: sandbox.stub(),
+    warn: sandbox.stub(),
+    err: sandbox.stub(),
+    error: sandbox.stub(),
+  },
+}
+
+// Mocha hooks
+beforeEach(ctx => {
+  ctx.logger = stubs.logger
+  vi.doMock('@overleaf/logger', () => ({ default: ctx.logger }))
+})
+
+afterEach(() => {
+  sandbox.reset()
+  vi.restoreAllMocks()
+  vi.resetModules()
+})

+ 2 - 1
services/real-time/tsconfig.json

@@ -8,6 +8,7 @@
     "config/**/*",
     "scripts/**/*",
     "test/**/*",
-    "types"
+    "types",
+    "vitest.config.unit.cjs"
   ]
 }

+ 25 - 0
services/real-time/vitest.config.unit.cjs

@@ -0,0 +1,25 @@
+const { defineConfig } = require('vitest/config')
+
+let reporterOptions = {}
+if (process.env.CI) {
+  reporterOptions = {
+    reporters: [
+      'default',
+      [
+        'junit',
+        {
+          classnameTemplate: `Unit tests.{filename}`,
+        },
+      ],
+    ],
+    outputFile: 'reports/junit-vitest-unit.xml',
+  }
+}
+module.exports = defineConfig({
+  test: {
+    include: ['test/unit/js/**/*.test.{js,ts}'],
+    setupFiles: ['./test/unit/setup.js'],
+    isolate: false,
+    ...reporterOptions,
+  },
+})