Переглянути джерело

Merge pull request #29948 from overleaf/ar/docstore-conversion-to-esm

[docstore] conversion to esm

GitOrigin-RevId: 9d255047bd7ae25f2b0b38f3a721741e8a0b7ad8
Andrew Rumble 10 місяців тому
батько
коміт
a1f1ca2028
42 змінених файлів з 2735 додано та 2540 видалено
  1. 322 187
      package-lock.json
  2. 1 0
      package.json
  3. 19 17
      services/docstore/app.js
  4. 14 12
      services/docstore/app/js/DocArchiveManager.js
  5. 9 9
      services/docstore/app/js/DocManager.js
  6. 4 3
      services/docstore/app/js/Errors.js
  7. 11 7
      services/docstore/app/js/HealthChecker.js
  8. 8 8
      services/docstore/app/js/HttpController.js
  9. 6 4
      services/docstore/app/js/MongoManager.js
  10. 6 5
      services/docstore/app/js/PersistorManager.js
  11. 6 3
      services/docstore/app/js/RangeManager.js
  12. 5 9
      services/docstore/app/js/StreamToBuffer.js
  13. 8 5
      services/docstore/app/js/mongodb.js
  14. 2 0
      services/docstore/buildscript.txt
  15. 0 0
      services/docstore/config/settings.defaults.cjs
  16. 2 0
      services/docstore/docker-compose.ci.yml
  17. 1 0
      services/docstore/docker-compose.yml
  18. 5 3
      services/docstore/package.json
  19. 14 13
      services/docstore/test/acceptance/js/ArchiveDocsTests.js
  20. 9 9
      services/docstore/test/acceptance/js/DeletingDocsTests.js
  21. 6 5
      services/docstore/test/acceptance/js/GettingAllDocsTests.js
  22. 7 5
      services/docstore/test/acceptance/js/GettingDocsFromArchiveTest.js
  23. 5 4
      services/docstore/test/acceptance/js/GettingDocsTests.js
  24. 6 4
      services/docstore/test/acceptance/js/HealthCheckerTest.js
  25. 4 3
      services/docstore/test/acceptance/js/UpdatingDocsTests.js
  26. 4 4
      services/docstore/test/acceptance/js/helpers/DocstoreApp.js
  27. 7 6
      services/docstore/test/acceptance/js/helpers/DocstoreClient.js
  28. 5 46
      services/docstore/test/setup.js
  29. 108 91
      services/docstore/test/unit/js/DocArchiveManager.test.js
  30. 762 0
      services/docstore/test/unit/js/DocManager.test.js
  31. 0 777
      services/docstore/test/unit/js/DocManagerTests.js
  32. 595 0
      services/docstore/test/unit/js/HttpController.test.js
  33. 0 589
      services/docstore/test/unit/js/HttpControllerTests.js
  34. 411 0
      services/docstore/test/unit/js/MongoManager.test.js
  35. 0 403
      services/docstore/test/unit/js/MongoManagerTests.js
  36. 57 0
      services/docstore/test/unit/js/PersistorManager.test.js
  37. 0 55
      services/docstore/test/unit/js/PersistorManagerTests.js
  38. 239 0
      services/docstore/test/unit/js/RangeManager.test.js
  39. 0 253
      services/docstore/test/unit/js/RangeManagerTests.js
  40. 40 0
      services/docstore/test/unit/setup.js
  41. 2 1
      services/docstore/tsconfig.json
  42. 25 0
      services/docstore/vitest.config.unit.cjs

+ 322 - 187
package-lock.json

@@ -46,6 +46,7 @@
         "@types/mocha": "^10.0.6",
         "@typescript-eslint/eslint-plugin": "^8.30.1",
         "@typescript-eslint/parser": "^8.30.1",
+        "@vitest/eslint-plugin": "^1.5.0",
         "eslint": "^8.15.0",
         "eslint-config-prettier": "^8.5.0",
         "eslint-config-standard": "^17.0.0",
@@ -10928,6 +10929,7 @@
       "os": [
         "aix"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -10945,6 +10947,7 @@
       "os": [
         "android"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -10962,6 +10965,7 @@
       "os": [
         "android"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -10979,6 +10983,7 @@
       "os": [
         "android"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -10996,6 +11001,7 @@
       "os": [
         "darwin"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11013,6 +11019,7 @@
       "os": [
         "darwin"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11030,6 +11037,7 @@
       "os": [
         "freebsd"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11047,6 +11055,7 @@
       "os": [
         "freebsd"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11064,6 +11073,7 @@
       "os": [
         "linux"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11081,6 +11091,7 @@
       "os": [
         "linux"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11098,6 +11109,7 @@
       "os": [
         "linux"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11115,6 +11127,7 @@
       "os": [
         "linux"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11132,6 +11145,7 @@
       "os": [
         "linux"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11149,6 +11163,7 @@
       "os": [
         "linux"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11166,6 +11181,7 @@
       "os": [
         "linux"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11183,6 +11199,7 @@
       "os": [
         "linux"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11200,6 +11217,7 @@
       "os": [
         "linux"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11217,6 +11235,7 @@
       "os": [
         "netbsd"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11234,6 +11253,7 @@
       "os": [
         "netbsd"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11251,6 +11271,7 @@
       "os": [
         "openbsd"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11268,6 +11289,7 @@
       "os": [
         "openbsd"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11285,6 +11307,7 @@
       "os": [
         "sunos"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11302,6 +11325,7 @@
       "os": [
         "win32"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11319,6 +11343,7 @@
       "os": [
         "win32"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -11336,6 +11361,7 @@
       "os": [
         "win32"
       ],
+      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -16139,7 +16165,8 @@
       "optional": true,
       "os": [
         "android"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-android-arm64": {
       "version": "4.50.1",
@@ -16153,7 +16180,8 @@
       "optional": true,
       "os": [
         "android"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-darwin-arm64": {
       "version": "4.50.1",
@@ -16167,7 +16195,8 @@
       "optional": true,
       "os": [
         "darwin"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-darwin-x64": {
       "version": "4.50.1",
@@ -16181,7 +16210,8 @@
       "optional": true,
       "os": [
         "darwin"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-freebsd-arm64": {
       "version": "4.50.1",
@@ -16195,7 +16225,8 @@
       "optional": true,
       "os": [
         "freebsd"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-freebsd-x64": {
       "version": "4.50.1",
@@ -16209,7 +16240,8 @@
       "optional": true,
       "os": [
         "freebsd"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
       "version": "4.50.1",
@@ -16223,7 +16255,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-arm-musleabihf": {
       "version": "4.50.1",
@@ -16237,7 +16270,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-arm64-gnu": {
       "version": "4.50.1",
@@ -16251,7 +16285,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-arm64-musl": {
       "version": "4.50.1",
@@ -16265,7 +16300,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
       "version": "4.50.1",
@@ -16279,7 +16315,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-ppc64-gnu": {
       "version": "4.50.1",
@@ -16293,7 +16330,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-riscv64-gnu": {
       "version": "4.50.1",
@@ -16307,7 +16345,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-riscv64-musl": {
       "version": "4.50.1",
@@ -16321,7 +16360,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-s390x-gnu": {
       "version": "4.50.1",
@@ -16335,7 +16375,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-x64-gnu": {
       "version": "4.50.1",
@@ -16349,7 +16390,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-linux-x64-musl": {
       "version": "4.50.1",
@@ -16363,7 +16405,8 @@
       "optional": true,
       "os": [
         "linux"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-openharmony-arm64": {
       "version": "4.50.1",
@@ -16377,7 +16420,8 @@
       "optional": true,
       "os": [
         "openharmony"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-win32-arm64-msvc": {
       "version": "4.50.1",
@@ -16391,7 +16435,8 @@
       "optional": true,
       "os": [
         "win32"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-win32-ia32-msvc": {
       "version": "4.50.1",
@@ -16405,7 +16450,8 @@
       "optional": true,
       "os": [
         "win32"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rollup/rollup-win32-x64-msvc": {
       "version": "4.50.1",
@@ -16419,7 +16465,8 @@
       "optional": true,
       "os": [
         "win32"
-      ]
+      ],
+      "peer": true
     },
     "node_modules/@rtsao/scc": {
       "version": "1.1.0",
@@ -21833,6 +21880,257 @@
         "node": ">= 20"
       }
     },
+    "node_modules/@vitest/eslint-plugin": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.5.0.tgz",
+      "integrity": "sha512-j3uuIAPTYWYnSit9lspb08/EKsxEmGqjQf+Wpb1DQkxc+mMkhL58ZknDCgjYhY4Zu76oxZ0hVWTHlmRW0mJq5w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "^8.46.1",
+        "@typescript-eslint/utils": "^8.46.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "eslint": ">=8.57.0",
+        "typescript": ">=5.0.0",
+        "vitest": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        },
+        "vitest": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz",
+      "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/tsconfig-utils": "^8.48.0",
+        "@typescript-eslint/types": "^8.48.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz",
+      "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.48.0",
+        "@typescript-eslint/visitor-keys": "8.48.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz",
+      "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz",
+      "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz",
+      "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/project-service": "8.48.0",
+        "@typescript-eslint/tsconfig-utils": "8.48.0",
+        "@typescript-eslint/types": "8.48.0",
+        "@typescript-eslint/visitor-keys": "8.48.0",
+        "debug": "^4.3.4",
+        "minimatch": "^9.0.4",
+        "semver": "^7.6.0",
+        "tinyglobby": "^0.2.15",
+        "ts-api-utils": "^2.1.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz",
+      "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.7.0",
+        "@typescript-eslint/scope-manager": "8.48.0",
+        "@typescript-eslint/types": "8.48.0",
+        "@typescript-eslint/typescript-estree": "8.48.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
+      "version": "8.48.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz",
+      "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.48.0",
+        "eslint-visitor-keys": "^4.2.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/eslint-visitor-keys": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/semver": {
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@vitest/eslint-plugin/node_modules/ts-api-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+      "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.12"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4"
+      }
+    },
     "node_modules/@vitest/expect": {
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz",
@@ -31784,6 +32082,7 @@
       "os": [
         "darwin"
       ],
+      "peer": true,
       "engines": {
         "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
       }
@@ -53956,7 +54255,8 @@
         "sandboxed-module": "~2.0.4",
         "sinon": "~9.0.2",
         "sinon-chai": "^3.7.0",
-        "typescript": "^5.0.4"
+        "typescript": "^5.0.4",
+        "vitest": "^3.2.4"
       }
     },
     "services/docstore/node_modules/diff": {
@@ -56164,143 +56464,6 @@
       "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==",
       "dev": true
     },
-    "services/web/node_modules/@typescript-eslint/scope-manager": {
-      "version": "8.32.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz",
-      "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@typescript-eslint/types": "8.32.1",
-        "@typescript-eslint/visitor-keys": "8.32.1"
-      },
-      "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      }
-    },
-    "services/web/node_modules/@typescript-eslint/types": {
-      "version": "8.32.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz",
-      "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      }
-    },
-    "services/web/node_modules/@typescript-eslint/typescript-estree": {
-      "version": "8.32.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz",
-      "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@typescript-eslint/types": "8.32.1",
-        "@typescript-eslint/visitor-keys": "8.32.1",
-        "debug": "^4.3.4",
-        "fast-glob": "^3.3.2",
-        "is-glob": "^4.0.3",
-        "minimatch": "^9.0.4",
-        "semver": "^7.6.0",
-        "ts-api-utils": "^2.1.0"
-      },
-      "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      },
-      "peerDependencies": {
-        "typescript": ">=4.8.4 <5.9.0"
-      }
-    },
-    "services/web/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
-      "version": "9.0.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "dependencies": {
-        "brace-expansion": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=16 || 14 >=14.17"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "services/web/node_modules/@typescript-eslint/utils": {
-      "version": "8.32.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz",
-      "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@eslint-community/eslint-utils": "^4.7.0",
-        "@typescript-eslint/scope-manager": "8.32.1",
-        "@typescript-eslint/types": "8.32.1",
-        "@typescript-eslint/typescript-estree": "8.32.1"
-      },
-      "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      },
-      "peerDependencies": {
-        "eslint": "^8.57.0 || ^9.0.0",
-        "typescript": ">=4.8.4 <5.9.0"
-      }
-    },
-    "services/web/node_modules/@typescript-eslint/visitor-keys": {
-      "version": "8.32.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz",
-      "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "dependencies": {
-        "@typescript-eslint/types": "8.32.1",
-        "eslint-visitor-keys": "^4.2.0"
-      },
-      "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/typescript-eslint"
-      }
-    },
-    "services/web/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
-      "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "peer": true,
-      "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/eslint"
-      }
-    },
     "services/web/node_modules/@uppy/dashboard": {
       "version": "3.7.1",
       "resolved": "https://registry.npmjs.org/@uppy/dashboard/-/dashboard-3.7.1.tgz",
@@ -56962,20 +57125,6 @@
         "stack-trace": "0.0.10"
       }
     },
-    "services/web/node_modules/semver": {
-      "version": "7.7.2",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
-      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
-      "dev": true,
-      "license": "ISC",
-      "peer": true,
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "services/web/node_modules/signal-exit": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -57097,20 +57246,6 @@
         "node": ">= 6"
       }
     },
-    "services/web/node_modules/ts-api-utils": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
-      "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
-      "dev": true,
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=18.12"
-      },
-      "peerDependencies": {
-        "typescript": ">=4.8.4"
-      }
-    },
     "services/web/node_modules/uuid": {
       "version": "9.0.1",
       "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",

+ 1 - 0
package.json

@@ -10,6 +10,7 @@
     "@types/mocha": "^10.0.6",
     "@typescript-eslint/eslint-plugin": "^8.30.1",
     "@typescript-eslint/parser": "^8.30.1",
+    "@vitest/eslint-plugin": "^1.5.0",
     "eslint": "^8.15.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-config-standard": "^17.0.0",

+ 19 - 17
services/docstore/app.js

@@ -1,20 +1,22 @@
 // Metrics must be initialized before importing anything else
-require('@overleaf/metrics/initialize')
-
-const Events = require('node:events')
-const Metrics = require('@overleaf/metrics')
-const Settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-const express = require('express')
-const bodyParser = require('body-parser')
-const {
-  celebrate: validate,
+import '@overleaf/metrics/initialize.js'
+
+import Events from 'node:events'
+import Metrics from '@overleaf/metrics'
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import express from 'express'
+import bodyParser from 'body-parser'
+import {
+  celebrate as validate,
   Joi,
-  errors: handleValidationErrors,
-} = require('celebrate')
-const { mongoClient } = require('./app/js/mongodb')
-const Errors = require('./app/js/Errors')
-const HttpController = require('./app/js/HttpController')
+  errors as handleValidationErrors,
+} from 'celebrate'
+import mongodb from './app/js/mongodb.js'
+import Errors from './app/js/Errors.js'
+import HttpController from './app/js/HttpController.js'
+
+const { mongoClient } = mongodb
 
 Events.setMaxListeners(20)
 
@@ -114,7 +116,7 @@ app.use(function (error, req, res, next) {
 const { port } = Settings.internal.docstore
 const { host } = Settings.internal.docstore
 
-if (!module.parent) {
+if (import.meta.main) {
   // Called directly
   mongoClient
     .connect()
@@ -137,4 +139,4 @@ if (!module.parent) {
     })
 }
 
-module.exports = app
+export default app

+ 14 - 12
services/docstore/app/js/DocArchiveManager.js

@@ -1,14 +1,16 @@
-const MongoManager = require('./MongoManager')
-const Errors = require('./Errors')
-const logger = require('@overleaf/logger')
-const Settings = require('@overleaf/settings')
-const crypto = require('node:crypto')
-const { ReadableString } = require('@overleaf/stream-utils')
-const RangeManager = require('./RangeManager')
-const PersistorManager = require('./PersistorManager')
-const pMap = require('p-map')
-const { streamToBuffer } = require('./StreamToBuffer')
-const { BSON } = require('mongodb-legacy')
+import MongoManager from './MongoManager.js'
+import Errors from './Errors.js'
+import logger from '@overleaf/logger'
+import Settings from '@overleaf/settings'
+import crypto from 'node:crypto'
+import { ReadableString } from '@overleaf/stream-utils'
+import RangeManager from './RangeManager.js'
+import PersistorManager from './PersistorManager.js'
+import pMap from 'p-map'
+import { streamToBuffer } from './StreamToBuffer.js'
+import mongodb from 'mongodb-legacy'
+
+const { BSON } = mongodb
 
 const PARALLEL_JOBS = Settings.parallelArchiveJobs
 const UN_ARCHIVE_BATCH_SIZE = Settings.unArchiveBatchSize
@@ -220,7 +222,7 @@ function _isArchivingEnabled() {
   return true
 }
 
-module.exports = {
+export default {
   archiveAllDocs,
   archiveDoc,
   unArchiveAllDocs,

+ 9 - 9
services/docstore/app/js/DocManager.js

@@ -1,11 +1,11 @@
-const MongoManager = require('./MongoManager')
-const Errors = require('./Errors')
-const logger = require('@overleaf/logger')
-const _ = require('lodash')
-const DocArchive = require('./DocArchiveManager')
-const RangeManager = require('./RangeManager')
-const Settings = require('@overleaf/settings')
-const { setTimeout } = require('node:timers/promises')
+import MongoManager from './MongoManager.js'
+import Errors from './Errors.js'
+import logger from '@overleaf/logger'
+import _ from 'lodash'
+import DocArchive from './DocArchiveManager.js'
+import RangeManager from './RangeManager.js'
+import Settings from '@overleaf/settings'
+import { setTimeout } from 'node:timers/promises'
 
 /**
  * @import { Document } from 'mongodb'
@@ -319,4 +319,4 @@ const DocManager = {
   },
 }
 
-module.exports = DocManager
+export default DocManager

+ 4 - 3
services/docstore/app/js/Errors.js

@@ -1,6 +1,7 @@
 // import Errors from object-persistor to pass instanceof checks
-const OError = require('@overleaf/o-error')
-const { Errors } = require('@overleaf/object-persistor')
+import OError from '@overleaf/o-error'
+
+import { Errors } from '@overleaf/object-persistor'
 
 class Md5MismatchError extends OError {}
 
@@ -12,7 +13,7 @@ class DocVersionDecrementedError extends OError {}
 
 class DocWithoutLinesError extends OError {}
 
-module.exports = {
+export default {
   Md5MismatchError,
   DocModifiedError,
   DocRevValueError,

+ 11 - 7
services/docstore/app/js/HealthChecker.js

@@ -1,10 +1,13 @@
-const { db, ObjectId } = require('./mongodb')
-const _ = require('lodash')
-const crypto = require('node:crypto')
-const settings = require('@overleaf/settings')
+import mongodb from './mongodb.js'
+import _ from 'lodash'
+import crypto from 'node:crypto'
+import settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import { fetchNothing, fetchJson } from '@overleaf/fetch-utils'
+
+const { db, ObjectId } = mongodb
+
 const { port } = settings.internal.docstore
-const logger = require('@overleaf/logger')
-const { fetchNothing, fetchJson } = require('@overleaf/fetch-utils')
 
 async function check() {
   const docId = new ObjectId()
@@ -30,6 +33,7 @@ async function check() {
     throw new Error(`health check lines not equal ${body.lines} != ${lines}`)
   }
 }
-module.exports = {
+
+export default {
   check,
 }

+ 8 - 8
services/docstore/app/js/HttpController.js

@@ -1,10 +1,10 @@
-const DocManager = require('./DocManager')
-const logger = require('@overleaf/logger')
-const DocArchive = require('./DocArchiveManager')
-const HealthChecker = require('./HealthChecker')
-const Errors = require('./Errors')
-const Settings = require('@overleaf/settings')
-const { expressify } = require('@overleaf/promise-utils')
+import DocManager from './DocManager.js'
+import logger from '@overleaf/logger'
+import DocArchive from './DocArchiveManager.js'
+import HealthChecker from './HealthChecker.js'
+import Errors from './Errors.js'
+import Settings from '@overleaf/settings'
+import { expressify } from '@overleaf/promise-utils'
 
 async function getDoc(req, res) {
   const { doc_id: docId, project_id: projectId } = req.params
@@ -236,7 +236,7 @@ async function healthCheck(req, res) {
   res.sendStatus(200)
 }
 
-module.exports = {
+export default {
   getDoc: expressify(getDoc),
   peekDoc: expressify(peekDoc),
   isDocDeleted: expressify(isDocDeleted),

+ 6 - 4
services/docstore/app/js/MongoManager.js

@@ -1,6 +1,8 @@
-const { db, ObjectId } = require('./mongodb')
-const Settings = require('@overleaf/settings')
-const Errors = require('./Errors')
+import mongodb from './mongodb.js'
+import Settings from '@overleaf/settings'
+import Errors from './Errors.js'
+
+const { db, ObjectId } = mongodb
 
 const ARCHIVING_LOCK_DURATION_MS = Settings.archivingLockDurationMs
 
@@ -239,7 +241,7 @@ async function destroyProject(projectId) {
   await db.docs.deleteMany({ project_id: new ObjectId(projectId) })
 }
 
-module.exports = {
+export default {
   findDoc,
   getProjectsDeletedDocs,
   getProjectsDocs,

+ 6 - 5
services/docstore/app/js/PersistorManager.js

@@ -1,12 +1,13 @@
-const settings = require('@overleaf/settings')
+import settings from '@overleaf/settings'
+import ObjectPersistor from '@overleaf/object-persistor'
+import AbstractPersistor from '@overleaf/object-persistor/src/AbstractPersistor.js'
+import Metrics from '@overleaf/metrics'
 
 const persistorSettings = settings.docstore
-persistorSettings.Metrics = require('@overleaf/metrics')
+persistorSettings.Metrics = Metrics
 
-const ObjectPersistor = require('@overleaf/object-persistor')
-const AbstractPersistor = require('@overleaf/object-persistor/src/AbstractPersistor')
 const persistor = settings.docstore.backend
   ? ObjectPersistor(persistorSettings)
   : new AbstractPersistor()
 
-module.exports = persistor
+export default persistor

+ 6 - 3
services/docstore/app/js/RangeManager.js

@@ -10,11 +10,14 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
+import _ from 'lodash'
+import mongodb from './mongodb.js'
+
+const { ObjectId } = mongodb
+
 let RangeManager
-const _ = require('lodash')
-const { ObjectId } = require('./mongodb')
 
-module.exports = RangeManager = {
+export default RangeManager = {
   shouldUpdateRanges(docRanges, incomingRanges) {
     if (incomingRanges == null) {
       throw new Error('expected incoming_ranges')

+ 5 - 9
services/docstore/app/js/StreamToBuffer.js

@@ -1,13 +1,9 @@
-const { LoggerStream, WritableBuffer } = require('@overleaf/stream-utils')
-const Settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger/logging-manager')
-const { pipeline } = require('node:stream/promises')
+import { LoggerStream, WritableBuffer } from '@overleaf/stream-utils'
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger/logging-manager.js'
+import { pipeline } from 'node:stream/promises'
 
-module.exports = {
-  streamToBuffer,
-}
-
-async function streamToBuffer(projectId, docId, stream) {
+export async function streamToBuffer(projectId, docId, stream) {
   const loggerTransform = new LoggerStream(
     Settings.max_doc_length,
     (size, isFlush) => {

+ 8 - 5
services/docstore/app/js/mongodb.js

@@ -1,9 +1,12 @@
 // @ts-check
 
-const Metrics = require('@overleaf/metrics')
-const Settings = require('@overleaf/settings')
-const MongoUtils = require('@overleaf/mongo-utils')
-const { MongoClient, ObjectId } = require('mongodb-legacy')
+import Metrics from '@overleaf/metrics'
+
+import Settings from '@overleaf/settings'
+import MongoUtils from '@overleaf/mongo-utils'
+import mongodb from 'mongodb-legacy'
+
+const { MongoClient, ObjectId } = mongodb
 
 const mongoClient = new MongoClient(Settings.mongo.url, Settings.mongo.options)
 const mongoDb = mongoClient.db()
@@ -18,7 +21,7 @@ async function cleanupTestDatabase() {
   await MongoUtils.cleanupTestDatabase(mongoClient)
 }
 
-module.exports = {
+export default {
   db,
   mongoClient,
   ObjectId,

+ 2 - 0
services/docstore/buildscript.txt

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

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


+ 2 - 0
services/docstore/docker-compose.ci.yml

@@ -9,6 +9,7 @@ services:
     volumes:
       - ./reports:/overleaf/services/docstore/reports
       - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it
+      - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json
     entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 --
     command: npm run test:unit:_run
     environment:
@@ -16,6 +17,7 @@ services:
       MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf
       NODE_ENV: test
       NODE_OPTIONS: "--unhandled-rejections=strict"
+      VITEST_NO_CACHE: true
     depends_on:
       mongo:
         condition: service_started

+ 1 - 0
services/docstore/docker-compose.yml

@@ -11,6 +11,7 @@ services:
       - ../../libraries:/overleaf/libraries
       - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it
       - ../../tools/migrations:/overleaf/tools/migrations
+      - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json
     working_dir: /overleaf/services/docstore
     environment:
       MOCHA_GREP: ${MOCHA_GREP}

+ 5 - 3
services/docstore/package.json

@@ -2,13 +2,14 @@
   "name": "@overleaf/docstore",
   "description": "A CRUD API for handling text documents in projects",
   "private": true,
+  "type": "module",
   "main": "app.js",
   "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,6 +47,7 @@
     "sandboxed-module": "~2.0.4",
     "sinon": "~9.0.2",
     "sinon-chai": "^3.7.0",
-    "typescript": "^5.0.4"
+    "typescript": "^5.0.4",
+    "vitest": "^3.2.4"
   }
 }

+ 14 - 13
services/docstore/test/acceptance/js/ArchiveDocsTests.js

@@ -1,13 +1,16 @@
-const Settings = require('@overleaf/settings')
-const { expect } = require('chai')
-const { db, ObjectId } = require('../../../app/js/mongodb')
-const async = require('async')
-const DocstoreApp = require('./helpers/DocstoreApp')
-const DocstoreClient = require('./helpers/DocstoreClient')
-const { Storage } = require('@google-cloud/storage')
-const Persistor = require('../../../app/js/PersistorManager')
-const { ReadableString } = require('@overleaf/stream-utils')
-const { callbackify } = require('node:util')
+import Settings from '@overleaf/settings'
+import { expect } from 'chai'
+import mongodb from '../../../app/js/mongodb.js'
+import async from 'async'
+import DocstoreApp from './helpers/DocstoreApp.js'
+import DocstoreClient from './helpers/DocstoreClient.js'
+import { Storage } from '@google-cloud/storage'
+import Persistor from '../../../app/js/PersistorManager.js'
+import { ReadableString } from '@overleaf/stream-utils'
+import { callbackify } from 'node:util'
+import Crypto from 'node:crypto'
+
+const { db, ObjectId } = mongodb
 
 async function uploadContent(path, json) {
   const stream = new ReadableString(JSON.stringify(json))
@@ -275,9 +278,7 @@ describe('Archiving', function () {
       this.project_id = new ObjectId()
       this.timeout(1000 * 30)
       const quarterMegInBytes = 250000
-      const bigLine = require('node:crypto')
-        .randomBytes(quarterMegInBytes)
-        .toString('hex')
+      const bigLine = Crypto.randomBytes(quarterMegInBytes).toString('hex')
       this.doc = {
         _id: new ObjectId(),
         lines: [bigLine, bigLine, bigLine, bigLine],

+ 9 - 9
services/docstore/test/acceptance/js/DeletingDocsTests.js

@@ -1,14 +1,14 @@
-const { db, ObjectId } = require('../../../app/js/mongodb')
-const { expect } = require('chai')
-const DocstoreApp = require('./helpers/DocstoreApp')
-const Errors = require('../../../app/js/Errors')
-const Settings = require('@overleaf/settings')
-const { Storage } = require('@google-cloud/storage')
-const { promisify } = require('node:util')
+import mongodb from '../../../app/js/mongodb.js'
+import { expect } from 'chai'
+import DocstoreApp from './helpers/DocstoreApp.js'
+import Errors from '../../../app/js/Errors.js'
+import Settings from '@overleaf/settings'
+import { Storage } from '@google-cloud/storage'
+import { setTimeout as sleep } from 'node:timers/promises'
 
-const sleep = promisify(setTimeout)
+import DocstoreClient from './helpers/DocstoreClient.js'
 
-const DocstoreClient = require('./helpers/DocstoreClient')
+const { db, ObjectId } = mongodb
 
 function deleteTestSuite(deleteDoc) {
   before(async function () {

+ 6 - 5
services/docstore/test/acceptance/js/GettingAllDocsTests.js

@@ -1,9 +1,10 @@
-const { ObjectId } = require('mongodb-legacy')
-const async = require('async')
-const DocstoreApp = require('./helpers/DocstoreApp')
-const { callbackify } = require('node:util')
+import mongodb from 'mongodb-legacy'
+import async from 'async'
+import DocstoreApp from './helpers/DocstoreApp.js'
+import { callbackify } from 'node:util'
+import DocstoreClient from './helpers/DocstoreClient.js'
 
-const DocstoreClient = require('./helpers/DocstoreClient')
+const { ObjectId } = mongodb
 
 describe('Getting all docs', function () {
   beforeEach(function (done) {

+ 7 - 5
services/docstore/test/acceptance/js/GettingDocsFromArchiveTest.js

@@ -1,8 +1,10 @@
-const Settings = require('@overleaf/settings')
-const { ObjectId } = require('../../../app/js/mongodb')
-const DocstoreApp = require('./helpers/DocstoreApp')
-const DocstoreClient = require('./helpers/DocstoreClient')
-const { Storage } = require('@google-cloud/storage')
+import Settings from '@overleaf/settings'
+import mongodb from '../../../app/js/mongodb.js'
+import DocstoreApp from './helpers/DocstoreApp.js'
+import DocstoreClient from './helpers/DocstoreClient.js'
+import { Storage } from '@google-cloud/storage'
+
+const { ObjectId } = mongodb
 
 describe('Getting A Doc from Archive', function () {
   before(async function () {

+ 5 - 4
services/docstore/test/acceptance/js/GettingDocsTests.js

@@ -1,8 +1,9 @@
-const { ObjectId } = require('mongodb-legacy')
-const { expect } = require('chai')
-const DocstoreApp = require('./helpers/DocstoreApp')
+import mongodb from 'mongodb-legacy'
+import { expect } from 'chai'
+import DocstoreApp from './helpers/DocstoreApp.js'
+import DocstoreClient from './helpers/DocstoreClient.js'
 
-const DocstoreClient = require('./helpers/DocstoreClient')
+const { ObjectId } = mongodb
 
 describe('Getting a doc', function () {
   beforeEach(async function () {

+ 6 - 4
services/docstore/test/acceptance/js/HealthCheckerTest.js

@@ -1,7 +1,9 @@
-const { db } = require('../../../app/js/mongodb')
-const DocstoreApp = require('./helpers/DocstoreApp')
-const DocstoreClient = require('./helpers/DocstoreClient')
-const { expect } = require('chai')
+import mongodb from '../../../app/js/mongodb.js'
+import DocstoreApp from './helpers/DocstoreApp.js'
+import DocstoreClient from './helpers/DocstoreClient.js'
+import { expect } from 'chai'
+
+const { db } = mongodb
 
 describe('HealthChecker', function () {
   beforeEach('start', async function () {

+ 4 - 3
services/docstore/test/acceptance/js/UpdatingDocsTests.js

@@ -1,7 +1,8 @@
-const { ObjectId } = require('mongodb-legacy')
-const DocstoreApp = require('./helpers/DocstoreApp')
+import mongodb from 'mongodb-legacy'
+import DocstoreApp from './helpers/DocstoreApp.js'
+import DocstoreClient from './helpers/DocstoreClient.js'
 
-const DocstoreClient = require('./helpers/DocstoreClient')
+const { ObjectId } = mongodb
 
 describe('Applying updates to a doc', function () {
   beforeEach(async function () {

+ 4 - 4
services/docstore/test/acceptance/js/helpers/DocstoreApp.js

@@ -1,6 +1,6 @@
-const app = require('../../../../app')
-const Settings = require('@overleaf/settings')
-require('./MongoHelper')
+import app from '../../../../app.js'
+import Settings from '@overleaf/settings'
+import './MongoHelper.js'
 
 function startApp() {
   return new Promise((resolve, reject) => {
@@ -27,6 +27,6 @@ async function ensureRunning() {
   await appStartedPromise
 }
 
-module.exports = {
+export default {
   ensureRunning,
 }

+ 7 - 6
services/docstore/test/acceptance/js/helpers/DocstoreClient.js

@@ -1,11 +1,12 @@
-let DocstoreClient
-const {
+import {
   fetchNothing,
   fetchJson,
   fetchJsonWithResponse,
-} = require('@overleaf/fetch-utils')
-const settings = require('@overleaf/settings')
-const Persistor = require('../../../../app/js/PersistorManager')
+} from '@overleaf/fetch-utils'
+import settings from '@overleaf/settings'
+import Persistor from '../../../../app/js/PersistorManager.js'
+
+let DocstoreClient
 
 async function streamToString(stream) {
   const chunks = []
@@ -22,7 +23,7 @@ async function getStringFromPersistor(persistor, bucket, key) {
   return await streamToString(stream)
 }
 
-module.exports = DocstoreClient = {
+export default DocstoreClient = {
   async createDoc(projectId, docId, lines, version, ranges) {
     return await DocstoreClient.updateDoc(
       projectId,

+ 5 - 46
services/docstore/test/setup.js

@@ -1,12 +1,10 @@
-const chai = require('chai')
-const sinon = require('sinon')
-const sinonChai = require('sinon-chai')
-const chaiAsPromised = require('chai-as-promised')
-const SandboxedModule = require('sandboxed-module')
-const timersPromises = require('node:timers/promises')
+import sinonChai from 'sinon-chai'
+import chaiAsPromised from 'chai-as-promised'
+import mongodb from 'mongodb-legacy'
+import chai from 'chai'
 
 // ensure every ObjectId has the id string as a property for correct comparisons
-require('mongodb-legacy').ObjectId.cacheHexString = true
+mongodb.ObjectId.cacheHexString = true
 
 process.env.BACKEND = 'gcs'
 
@@ -14,42 +12,3 @@ process.env.BACKEND = 'gcs'
 chai.should()
 chai.use(sinonChai)
 chai.use(chaiAsPromised)
-
-// 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(),
-    fatal: sandbox.stub(),
-  },
-}
-
-// SandboxedModule configuration
-SandboxedModule.configure({
-  requires: {
-    '@overleaf/logger': stubs.logger,
-    'timers/promises': timersPromises,
-    'mongodb-legacy': require('mongodb-legacy'),
-  },
-  globals: { Buffer, JSON, Math, console, process },
-  sourceTransformers: {
-    removeNodePrefix: function (source) {
-      return source.replace(/require\(['"]node:/g, "require('")
-    },
-  },
-})
-
-exports.mochaHooks = {
-  beforeEach() {
-    this.logger = stubs.logger
-  },
-
-  afterEach() {
-    sandbox.reset()
-  },
-}

+ 108 - 91
services/docstore/test/unit/js/DocArchiveManagerTests.js → services/docstore/test/unit/js/DocArchiveManager.test.js

@@ -1,12 +1,12 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
+import sinon from 'sinon'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ObjectId } from 'mongodb-legacy'
+import Errors from '../../../app/js/Errors.js'
+import * as StreamToBuffer from '../../../app/js/StreamToBuffer.js'
+
 const modulePath = '../../../app/js/DocArchiveManager.js'
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
-const Errors = require('../../../app/js/Errors')
-const StreamToBuffer = require('../../../app/js/StreamToBuffer')
 
-describe('DocArchiveManager', function () {
+describe('DocArchiveManager', () => {
   let DocArchiveManager,
     PersistorManager,
     MongoManager,
@@ -26,7 +26,7 @@ describe('DocArchiveManager', function () {
     stream,
     streamToBuffer
 
-  beforeEach(function () {
+  beforeEach(async () => {
     md5Sum = 'decafbad'
 
     RangeManager = {
@@ -173,32 +173,49 @@ describe('DocArchiveManager', function () {
       },
     }
 
-    DocArchiveManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': Settings,
-        crypto: Crypto,
-        '@overleaf/stream-utils': StreamUtils,
-        './MongoManager': MongoManager,
-        './RangeManager': RangeManager,
-        './PersistorManager': PersistorManager,
-        './Errors': Errors,
-        './StreamToBuffer': streamToBuffer,
-      },
-    })
+    vi.doMock('@overleaf/settings', () => ({
+      default: Settings,
+    }))
+
+    vi.doMock('crypto', () => ({
+      default: Crypto,
+    }))
+
+    vi.doMock('@overleaf/stream-utils', () => StreamUtils)
+
+    vi.doMock('../../../app/js/MongoManager', () => ({
+      default: MongoManager,
+    }))
+
+    vi.doMock('../../../app/js/RangeManager', () => ({
+      default: RangeManager,
+    }))
+
+    vi.doMock('../../../app/js/PersistorManager', () => ({
+      default: PersistorManager,
+    }))
+
+    vi.doMock('../../../app/js/Errors', () => ({
+      default: Errors,
+    }))
+
+    vi.doMock('../../../app/js/StreamToBuffer', () => streamToBuffer)
+
+    DocArchiveManager = (await import(modulePath)).default
   })
 
-  describe('archiveDoc', function () {
-    it('should resolve when passed a valid document', async function () {
+  describe('archiveDoc', () => {
+    it('should resolve when passed a valid document', async () => {
       await expect(DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)).to
         .eventually.be.fulfilled
     })
 
-    it('should fix comment ids', async function () {
+    it('should fix comment ids', async () => {
       await DocArchiveManager.archiveDoc(projectId, mongoDocs[1]._id)
       expect(RangeManager.fixCommentIds).to.have.been.called
     })
 
-    it('should throw an error if the doc has no lines', async function () {
+    it('should throw an error if the doc has no lines', async () => {
       const doc = mongoDocs[0]
       doc.lines = null
 
@@ -207,21 +224,21 @@ describe('DocArchiveManager', function () {
       ).to.eventually.be.rejectedWith('doc has no lines')
     })
 
-    it('should add the schema version', async function () {
+    it('should add the schema version', async () => {
       await DocArchiveManager.archiveDoc(projectId, mongoDocs[1]._id)
       expect(StreamUtils.ReadableString).to.have.been.calledWith(
         sinon.match(/"schema_v":1/)
       )
     })
 
-    it('should calculate the hex md5 sum of the content', async function () {
+    it('should calculate the hex md5 sum of the content', async () => {
       await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
       expect(Crypto.createHash).to.have.been.calledWith('md5')
       expect(HashUpdate).to.have.been.calledWith(archivedDocJson)
       expect(HashDigest).to.have.been.calledWith('hex')
     })
 
-    it('should pass the md5 hash to the object persistor for verification', async function () {
+    it('should pass the md5 hash to the object persistor for verification', async () => {
       await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
 
       expect(PersistorManager.sendStream).to.have.been.calledWith(
@@ -232,26 +249,26 @@ describe('DocArchiveManager', function () {
       )
     })
 
-    describe('with S3 persistor', function () {
-      beforeEach(async function () {
+    describe('with S3 persistor', () => {
+      beforeEach(async () => {
         Settings.docstore.backend = 's3'
         await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
       })
 
-      it('should not calculate the hex md5 sum of the content', function () {
+      it('should not calculate the hex md5 sum of the content', () => {
         expect(Crypto.createHash).not.to.have.been.called
         expect(HashUpdate).not.to.have.been.called
         expect(HashDigest).not.to.have.been.called
       })
 
-      it('should not pass an md5 hash to the object persistor for verification', function () {
+      it('should not pass an md5 hash to the object persistor for verification', () => {
         expect(PersistorManager.sendStream).not.to.have.been.calledWithMatch({
           sourceMd5: sinon.match.any,
         })
       })
     })
 
-    it('should pass the correct bucket and key to the persistor', async function () {
+    it('should pass the correct bucket and key to the persistor', async () => {
       await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
 
       expect(PersistorManager.sendStream).to.have.been.calledWith(
@@ -260,7 +277,7 @@ describe('DocArchiveManager', function () {
       )
     })
 
-    it('should create a stream from the encoded json and send it', async function () {
+    it('should create a stream from the encoded json and send it', async () => {
       await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
       expect(StreamUtils.ReadableString).to.have.been.calledWith(
         archivedDocJson
@@ -272,7 +289,7 @@ describe('DocArchiveManager', function () {
       )
     })
 
-    it('should mark the doc as archived', async function () {
+    it('should mark the doc as archived', async () => {
       await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
       expect(MongoManager.markDocAsArchived).to.have.been.calledWith(
         projectId,
@@ -281,29 +298,29 @@ describe('DocArchiveManager', function () {
       )
     })
 
-    describe('when archiving is not configured', function () {
-      beforeEach(function () {
+    describe('when archiving is not configured', () => {
+      beforeEach(() => {
         Settings.docstore.backend = undefined
       })
 
-      it('should bail out early', async function () {
+      it('should bail out early', async () => {
         await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
         expect(MongoManager.getDocForArchiving).to.not.have.been.called
       })
     })
 
-    describe('with null bytes in the result', function () {
+    describe('with null bytes in the result', () => {
       const _stringify = JSON.stringify
 
-      beforeEach(function () {
+      beforeEach(() => {
         JSON.stringify = sinon.stub().returns('{"bad": "\u0000"}')
       })
 
-      afterEach(function () {
+      afterEach(() => {
         JSON.stringify = _stringify
       })
 
-      it('should return an error', async function () {
+      it('should return an error', async () => {
         await expect(
           DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
         ).to.eventually.be.rejectedWith('null bytes detected')
@@ -311,37 +328,37 @@ describe('DocArchiveManager', function () {
     })
   })
 
-  describe('unarchiveDoc', function () {
+  describe('unarchiveDoc', () => {
     let docId, lines, rev
 
-    describe('when the doc is in S3', function () {
-      beforeEach(function () {
+    describe('when the doc is in S3', () => {
+      beforeEach(() => {
         MongoManager.findDoc = sinon.stub().resolves({ inS3: true, rev })
         docId = mongoDocs[0]._id
         lines = ['doc', 'lines']
         rev = 123
       })
 
-      it('should resolve when passed a valid document', async function () {
+      it('should resolve when passed a valid document', async () => {
         await expect(DocArchiveManager.unarchiveDoc(projectId, docId)).to
           .eventually.be.fulfilled
       })
 
-      it('should test md5 validity with the raw buffer', async function () {
+      it('should test md5 validity with the raw buffer', async () => {
         await DocArchiveManager.unarchiveDoc(projectId, docId)
         expect(HashUpdate).to.have.been.calledWith(
           sinon.match.instanceOf(Buffer)
         )
       })
 
-      it('should throw an error if the md5 does not match', async function () {
+      it('should throw an error if the md5 does not match', async () => {
         PersistorManager.getObjectMd5Hash.resolves('badf00d')
         await expect(
           DocArchiveManager.unarchiveDoc(projectId, docId)
         ).to.eventually.be.rejected.and.be.instanceof(Errors.Md5MismatchError)
       })
 
-      it('should restore the doc in Mongo', async function () {
+      it('should restore the doc in Mongo', async () => {
         await DocArchiveManager.unarchiveDoc(projectId, docId)
         expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith(
           projectId,
@@ -350,12 +367,12 @@ describe('DocArchiveManager', function () {
         )
       })
 
-      describe('when archiving is not configured', function () {
-        beforeEach(function () {
+      describe('when archiving is not configured', () => {
+        beforeEach(() => {
           Settings.docstore.backend = undefined
         })
 
-        it('should error out on archived doc', async function () {
+        it('should error out on archived doc', async () => {
           await expect(
             DocArchiveManager.unarchiveDoc(projectId, docId)
           ).to.eventually.be.rejected.and.match(
@@ -363,18 +380,18 @@ describe('DocArchiveManager', function () {
           )
         })
 
-        it('should return early on non-archived doc', async function () {
+        it('should return early on non-archived doc', async () => {
           MongoManager.findDoc = sinon.stub().resolves({ rev })
           await DocArchiveManager.unarchiveDoc(projectId, docId)
           expect(PersistorManager.getObjectMd5Hash).to.not.have.been.called
         })
       })
 
-      describe('doc contents', function () {
+      describe('doc contents', () => {
         let archivedDoc
 
-        describe('when the doc has the old schema', function () {
-          beforeEach(function () {
+        describe('when the doc has the old schema', () => {
+          beforeEach(() => {
             archivedDoc = lines
             archivedDocJson = JSON.stringify(archivedDoc)
             stream.on
@@ -382,7 +399,7 @@ describe('DocArchiveManager', function () {
               .yields(Buffer.from(archivedDocJson, 'utf8'))
           })
 
-          it('should return the docs lines', async function () {
+          it('should return the docs lines', async () => {
             await DocArchiveManager.unarchiveDoc(projectId, docId)
             expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith(
               projectId,
@@ -392,8 +409,8 @@ describe('DocArchiveManager', function () {
           })
         })
 
-        describe('with the new schema and ranges', function () {
-          beforeEach(function () {
+        describe('with the new schema and ranges', () => {
+          beforeEach(() => {
             archivedDoc = {
               lines,
               ranges: { json: 'ranges' },
@@ -406,7 +423,7 @@ describe('DocArchiveManager', function () {
               .yields(Buffer.from(archivedDocJson, 'utf8'))
           })
 
-          it('should return the doc lines and ranges', async function () {
+          it('should return the doc lines and ranges', async () => {
             await DocArchiveManager.unarchiveDoc(projectId, docId)
             expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith(
               projectId,
@@ -420,8 +437,8 @@ describe('DocArchiveManager', function () {
           })
         })
 
-        describe('with the new schema and no ranges', function () {
-          beforeEach(function () {
+        describe('with the new schema and no ranges', () => {
+          beforeEach(() => {
             archivedDoc = { lines, rev: 456, schema_v: 1 }
             archivedDocJson = JSON.stringify(archivedDoc)
             stream.on
@@ -429,7 +446,7 @@ describe('DocArchiveManager', function () {
               .yields(Buffer.from(archivedDocJson, 'utf8'))
           })
 
-          it('should return only the doc lines', async function () {
+          it('should return only the doc lines', async () => {
             await DocArchiveManager.unarchiveDoc(projectId, docId)
             expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith(
               projectId,
@@ -439,8 +456,8 @@ describe('DocArchiveManager', function () {
           })
         })
 
-        describe('with the new schema and no rev', function () {
-          beforeEach(function () {
+        describe('with the new schema and no rev', () => {
+          beforeEach(() => {
             archivedDoc = { lines, schema_v: 1 }
             archivedDocJson = JSON.stringify(archivedDoc)
             stream.on
@@ -448,7 +465,7 @@ describe('DocArchiveManager', function () {
               .yields(Buffer.from(archivedDocJson, 'utf8'))
           })
 
-          it('should use the rev obtained from Mongo', async function () {
+          it('should use the rev obtained from Mongo', async () => {
             await DocArchiveManager.unarchiveDoc(projectId, docId)
             expect(MongoManager.restoreArchivedDoc).to.have.been.calledWith(
               projectId,
@@ -458,8 +475,8 @@ describe('DocArchiveManager', function () {
           })
         })
 
-        describe('with an unrecognised schema', function () {
-          beforeEach(function () {
+        describe('with an unrecognised schema', () => {
+          beforeEach(() => {
             archivedDoc = { lines, schema_v: 2 }
             archivedDocJson = JSON.stringify(archivedDoc)
             stream.on
@@ -467,7 +484,7 @@ describe('DocArchiveManager', function () {
               .yields(Buffer.from(archivedDocJson, 'utf8'))
           })
 
-          it('should throw an error', async function () {
+          it('should throw an error', async () => {
             await expect(
               DocArchiveManager.unarchiveDoc(projectId, docId)
             ).to.eventually.be.rejectedWith(
@@ -478,13 +495,13 @@ describe('DocArchiveManager', function () {
       })
     })
 
-    it('should not do anything if the file is already unarchived', async function () {
+    it('should not do anything if the file is already unarchived', async () => {
       MongoManager.findDoc.resolves({ inS3: false })
       await DocArchiveManager.unarchiveDoc(projectId, docId)
       expect(PersistorManager.getObjectStream).not.to.have.been.called
     })
 
-    it('should throw an error if the file is not found', async function () {
+    it('should throw an error if the file is not found', async () => {
       PersistorManager.getObjectStream = sinon
         .stub()
         .rejects(new Errors.NotFoundError())
@@ -494,17 +511,17 @@ describe('DocArchiveManager', function () {
     })
   })
 
-  describe('destroyProject', function () {
-    describe('when archiving is enabled', function () {
-      beforeEach(async function () {
+  describe('destroyProject', () => {
+    describe('when archiving is enabled', () => {
+      beforeEach(async () => {
         await DocArchiveManager.destroyProject(projectId)
       })
 
-      it('should delete the project in Mongo', function () {
+      it('should delete the project in Mongo', () => {
         expect(MongoManager.destroyProject).to.have.been.calledWith(projectId)
       })
 
-      it('should delete the project in the persistor', function () {
+      it('should delete the project in the persistor', () => {
         expect(PersistorManager.deleteDirectory).to.have.been.calledWith(
           Settings.docstore.bucket,
           projectId
@@ -512,29 +529,29 @@ describe('DocArchiveManager', function () {
       })
     })
 
-    describe('when archiving is disabled', function () {
-      beforeEach(async function () {
+    describe('when archiving is disabled', () => {
+      beforeEach(async () => {
         Settings.docstore.backend = ''
         await DocArchiveManager.destroyProject(projectId)
       })
 
-      it('should delete the project in Mongo', function () {
+      it('should delete the project in Mongo', () => {
         expect(MongoManager.destroyProject).to.have.been.calledWith(projectId)
       })
 
-      it('should not delete the project in the persistor', function () {
+      it('should not delete the project in the persistor', () => {
         expect(PersistorManager.deleteDirectory).not.to.have.been.called
       })
     })
   })
 
-  describe('archiveAllDocs', function () {
-    it('should resolve with valid arguments', async function () {
+  describe('archiveAllDocs', () => {
+    it('should resolve with valid arguments', async () => {
       await expect(DocArchiveManager.archiveAllDocs(projectId)).to.eventually.be
         .fulfilled
     })
 
-    it('should archive all project docs which are not in s3', async function () {
+    it('should archive all project docs which are not in s3', async () => {
       await DocArchiveManager.archiveAllDocs(projectId)
       // not inS3
       expect(MongoManager.markDocAsArchived).to.have.been.calledWith(
@@ -561,25 +578,25 @@ describe('DocArchiveManager', function () {
       )
     })
 
-    describe('when archiving is not configured', function () {
-      beforeEach(function () {
+    describe('when archiving is not configured', () => {
+      beforeEach(() => {
         Settings.docstore.backend = undefined
       })
 
-      it('should bail out early', async function () {
+      it('should bail out early', async () => {
         await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
         expect(MongoManager.getNonArchivedProjectDocIds).to.not.have.been.called
       })
     })
   })
 
-  describe('unArchiveAllDocs', function () {
-    it('should resolve with valid arguments', async function () {
+  describe('unArchiveAllDocs', () => {
+    it('should resolve with valid arguments', async () => {
       await expect(DocArchiveManager.unArchiveAllDocs(projectId)).to.eventually
         .be.fulfilled
     })
 
-    it('should unarchive all inS3 docs', async function () {
+    it('should unarchive all inS3 docs', async () => {
       await DocArchiveManager.unArchiveAllDocs(projectId)
 
       for (const doc of archivedDocs) {
@@ -590,12 +607,12 @@ describe('DocArchiveManager', function () {
       }
     })
 
-    describe('when archiving is not configured', function () {
-      beforeEach(function () {
+    describe('when archiving is not configured', () => {
+      beforeEach(() => {
         Settings.docstore.backend = undefined
       })
 
-      it('should bail out early', async function () {
+      it('should bail out early', async () => {
         await DocArchiveManager.archiveDoc(projectId, mongoDocs[0]._id)
         expect(MongoManager.getNonDeletedArchivedProjectDocs).to.not.have.been
           .called

+ 762 - 0
services/docstore/test/unit/js/DocManager.test.js

@@ -0,0 +1,762 @@
+import sinon from 'sinon'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ObjectId } from 'mongodb-legacy'
+import Errors from '../../../app/js/Errors.js'
+import path from 'node:path'
+
+const modulePath = path.join(import.meta.dirname, '../../../app/js/DocManager')
+
+describe('DocManager', () => {
+  beforeEach(async ctx => {
+    ctx.doc_id = new ObjectId().toString()
+    ctx.project_id = new ObjectId().toString()
+    ctx.another_project_id = new ObjectId().toString()
+    ctx.stubbedError = new Error('blew up')
+    ctx.version = 42
+
+    ctx.MongoManager = {
+      findDoc: sinon.stub(),
+      getProjectsDocs: sinon.stub(),
+      patchDoc: sinon.stub().resolves(),
+      upsertIntoDocCollection: sinon.stub().resolves(),
+    }
+    ctx.DocArchiveManager = {
+      unarchiveDoc: sinon.stub(),
+      unArchiveAllDocs: sinon.stub(),
+      archiveDoc: sinon.stub().resolves(),
+    }
+    ctx.RangeManager = {
+      jsonRangesToMongo(r) {
+        return r
+      },
+      shouldUpdateRanges: sinon.stub().returns(false),
+      fixCommentIds: sinon.stub(),
+    }
+    ctx.settings = { docstore: {} }
+
+    vi.doMock('../../../app/js/MongoManager', () => ({
+      default: ctx.MongoManager,
+    }))
+
+    vi.doMock('../../../app/js/DocArchiveManager', () => ({
+      default: ctx.DocArchiveManager,
+    }))
+
+    vi.doMock('../../../app/js/RangeManager', () => ({
+      default: ctx.RangeManager,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('../../../app/js/Errors', () => ({
+      default: Errors,
+    }))
+
+    ctx.DocManager = (await import(modulePath)).default
+  })
+
+  describe('getFullDoc', () => {
+    beforeEach(ctx => {
+      ctx.DocManager._getDoc = sinon.stub()
+      ctx.doc = {
+        _id: ctx.doc_id,
+        lines: ['2134'],
+      }
+    })
+
+    it('should call get doc with a quick filter', async ctx => {
+      ctx.DocManager._getDoc.resolves(ctx.doc)
+      const doc = await ctx.DocManager.getFullDoc(ctx.project_id, ctx.doc_id)
+      doc.should.equal(ctx.doc)
+      ctx.DocManager._getDoc
+        .calledWith(ctx.project_id, ctx.doc_id, {
+          lines: true,
+          rev: true,
+          deleted: true,
+          version: true,
+          ranges: true,
+          inS3: true,
+        })
+        .should.equal(true)
+    })
+
+    it('should return error when get doc errors', async ctx => {
+      ctx.DocManager._getDoc.rejects(ctx.stubbedError)
+      await expect(
+        ctx.DocManager.getFullDoc(ctx.project_id, ctx.doc_id)
+      ).to.be.rejectedWith(ctx.stubbedError)
+    })
+  })
+
+  describe('getRawDoc', () => {
+    beforeEach(ctx => {
+      ctx.DocManager._getDoc = sinon.stub()
+      ctx.doc = { lines: ['2134'] }
+    })
+
+    it('should call get doc with a quick filter', async ctx => {
+      ctx.DocManager._getDoc.resolves(ctx.doc)
+      const content = await ctx.DocManager.getDocLines(
+        ctx.project_id,
+        ctx.doc_id
+      )
+      content.should.equal(ctx.doc.lines.join('\n'))
+      ctx.DocManager._getDoc
+        .calledWith(ctx.project_id, ctx.doc_id, {
+          lines: true,
+          inS3: true,
+        })
+        .should.equal(true)
+    })
+
+    it('should return error when get doc errors', async ctx => {
+      ctx.DocManager._getDoc.rejects(ctx.stubbedError)
+      await expect(
+        ctx.DocManager.getDocLines(ctx.project_id, ctx.doc_id)
+      ).to.be.rejectedWith(ctx.stubbedError)
+    })
+
+    it('should return error when get doc does not exist', async ctx => {
+      ctx.DocManager._getDoc.resolves(null)
+      await expect(
+        ctx.DocManager.getDocLines(ctx.project_id, ctx.doc_id)
+      ).to.be.rejectedWith(Errors.NotFoundError)
+    })
+
+    it('should return error when get doc has no lines', async ctx => {
+      ctx.DocManager._getDoc.resolves({})
+      await expect(
+        ctx.DocManager.getDocLines(ctx.project_id, ctx.doc_id)
+      ).to.be.rejectedWith(Errors.DocWithoutLinesError)
+    })
+  })
+
+  describe('_getDoc', () => {
+    it('should return error when get doc does not exist', async ctx => {
+      ctx.MongoManager.findDoc.resolves(null)
+      await expect(
+        ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, { inS3: true })
+      ).to.be.rejectedWith(Errors.NotFoundError)
+    })
+
+    it('should fix comment ids', async ctx => {
+      ctx.MongoManager.findDoc.resolves({
+        _id: ctx.doc_id,
+        ranges: {},
+      })
+      await ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, {
+        inS3: true,
+        ranges: true,
+      })
+      expect(ctx.RangeManager.fixCommentIds).to.have.been.called
+    })
+  })
+
+  describe('getDoc', () => {
+    beforeEach(ctx => {
+      ctx.project = { name: 'mock-project' }
+      ctx.doc = {
+        _id: ctx.doc_id,
+        project_id: ctx.project_id,
+        lines: ['mock-lines'],
+        version: ctx.version,
+      }
+    })
+
+    describe('when using a filter', () => {
+      beforeEach(ctx => {
+        ctx.MongoManager.findDoc.resolves(ctx.doc)
+      })
+
+      it('should error if inS3 is not set to true', async ctx => {
+        await expect(
+          ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, {
+            inS3: false,
+          })
+        ).to.be.rejected
+      })
+
+      it('should always get inS3 even when no filter is passed', async ctx => {
+        await expect(ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id)).to.be
+          .rejected
+        ctx.MongoManager.findDoc.called.should.equal(false)
+      })
+
+      it('should not error if inS3 is set to true', async ctx => {
+        await ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, {
+          inS3: true,
+        })
+      })
+    })
+
+    describe('when the doc is in the doc collection', () => {
+      beforeEach(async ctx => {
+        ctx.MongoManager.findDoc.resolves(ctx.doc)
+        ctx.result = await ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, {
+          version: true,
+          inS3: true,
+        })
+      })
+
+      it('should get the doc from the doc collection', ctx => {
+        ctx.MongoManager.findDoc
+          .calledWith(ctx.project_id, ctx.doc_id)
+          .should.equal(true)
+      })
+
+      it('should return the doc with the version', ctx => {
+        ctx.result.lines.should.equal(ctx.doc.lines)
+        ctx.result.version.should.equal(ctx.version)
+      })
+    })
+
+    describe('when MongoManager.findDoc errors', () => {
+      it('should return the error', async ctx => {
+        ctx.MongoManager.findDoc.rejects(ctx.stubbedError)
+        await expect(
+          ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, {
+            version: true,
+            inS3: true,
+          })
+        ).to.be.rejectedWith(ctx.stubbedError)
+      })
+    })
+
+    describe('when the doc is archived', () => {
+      beforeEach(async ctx => {
+        ctx.doc = {
+          _id: ctx.doc_id,
+          project_id: ctx.project_id,
+          version: 2,
+          inS3: true,
+        }
+        ctx.unarchivedDoc = {
+          _id: ctx.doc_id,
+          project_id: ctx.project_id,
+          lines: ['mock-lines'],
+          version: 2,
+          inS3: false,
+        }
+        ctx.MongoManager.findDoc.resolves(ctx.doc)
+        ctx.DocArchiveManager.unarchiveDoc.callsFake(
+          async (projectId, docId) => {
+            ctx.MongoManager.findDoc.resolves({
+              ...ctx.unarchivedDoc,
+            })
+          }
+        )
+        ctx.result = await ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, {
+          version: true,
+          inS3: true,
+        })
+      })
+
+      it('should call the DocArchive to unarchive the doc', ctx => {
+        ctx.DocArchiveManager.unarchiveDoc
+          .calledWith(ctx.project_id, ctx.doc_id)
+          .should.equal(true)
+      })
+
+      it('should look up the doc twice', ctx => {
+        ctx.MongoManager.findDoc.calledTwice.should.equal(true)
+      })
+
+      it('should return the doc', ctx => {
+        expect(ctx.result).to.deep.equal({
+          ...ctx.unarchivedDoc,
+        })
+      })
+    })
+
+    describe('when the doc does not exist in the docs collection', () => {
+      it('should return a NotFoundError', async ctx => {
+        ctx.MongoManager.findDoc.resolves(null)
+        await expect(
+          ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, {
+            version: true,
+            inS3: true,
+          })
+        ).to.be.rejectedWith(
+          `No such doc: ${ctx.doc_id} in project ${ctx.project_id}`
+        )
+      })
+    })
+  })
+
+  describe('getAllNonDeletedDocs', () => {
+    describe('when the project exists', () => {
+      beforeEach(async ctx => {
+        ctx.docs = [
+          {
+            _id: ctx.doc_id,
+            project_id: ctx.project_id,
+            lines: ['mock-lines'],
+          },
+        ]
+        ctx.MongoManager.getProjectsDocs.resolves(ctx.docs)
+        ctx.DocArchiveManager.unArchiveAllDocs.resolves(ctx.docs)
+        ctx.filter = { lines: true, ranges: true }
+        ctx.result = await ctx.DocManager.getAllNonDeletedDocs(
+          ctx.project_id,
+          ctx.filter
+        )
+      })
+
+      it('should get the project from the database', ctx => {
+        ctx.MongoManager.getProjectsDocs.should.have.been.calledWith(
+          ctx.project_id,
+          { include_deleted: false },
+          ctx.filter
+        )
+      })
+
+      it('should fix comment ids', async ctx => {
+        expect(ctx.RangeManager.fixCommentIds).to.have.been.called
+      })
+
+      it('should return the docs', ctx => {
+        expect(ctx.result).to.deep.equal(ctx.docs)
+      })
+    })
+
+    describe('when there are no docs for the project', () => {
+      it('should return a NotFoundError', async ctx => {
+        ctx.MongoManager.getProjectsDocs.resolves(null)
+        ctx.DocArchiveManager.unArchiveAllDocs.resolves(null)
+        await expect(
+          ctx.DocManager.getAllNonDeletedDocs(ctx.project_id, ctx.filter)
+        ).to.be.rejectedWith(`No docs for project ${ctx.project_id}`)
+      })
+    })
+  })
+
+  describe('patchDoc', () => {
+    describe('when the doc exists', () => {
+      beforeEach(ctx => {
+        ctx.lines = ['mock', 'doc', 'lines']
+        ctx.rev = 77
+        ctx.MongoManager.findDoc.resolves({
+          _id: new ObjectId(ctx.doc_id),
+        })
+        ctx.meta = {}
+      })
+
+      describe('standard path', () => {
+        beforeEach(async ctx => {
+          await ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, ctx.meta)
+        })
+
+        it('should get the doc', ctx => {
+          expect(ctx.MongoManager.findDoc).to.have.been.calledWith(
+            ctx.project_id,
+            ctx.doc_id
+          )
+        })
+
+        it('should persist the meta', ctx => {
+          expect(ctx.MongoManager.patchDoc).to.have.been.calledWith(
+            ctx.project_id,
+            ctx.doc_id,
+            ctx.meta
+          )
+        })
+      })
+
+      describe('background flush disabled and deleting a doc', () => {
+        beforeEach(async ctx => {
+          ctx.settings.docstore.archiveOnSoftDelete = false
+          ctx.meta.deleted = true
+
+          await ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, ctx.meta)
+        })
+
+        it('should not flush the doc out of mongo', ctx => {
+          expect(ctx.DocArchiveManager.archiveDoc).to.not.have.been.called
+        })
+      })
+
+      describe('background flush enabled and not deleting a doc', () => {
+        beforeEach(async ctx => {
+          ctx.settings.docstore.archiveOnSoftDelete = false
+          ctx.meta.deleted = false
+          await ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, ctx.meta)
+        })
+
+        it('should not flush the doc out of mongo', ctx => {
+          expect(ctx.DocArchiveManager.archiveDoc).to.not.have.been.called
+        })
+      })
+
+      describe('background flush enabled and deleting a doc', () => {
+        beforeEach(ctx => {
+          ctx.settings.docstore.archiveOnSoftDelete = true
+          ctx.meta.deleted = true
+        })
+
+        describe('when the background flush succeeds', () => {
+          beforeEach(async ctx => {
+            await ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, ctx.meta)
+          })
+
+          it('should not log a warning', ctx => {
+            expect(ctx.logger.warn).to.not.have.been.called
+          })
+
+          it('should flush the doc out of mongo', ctx => {
+            expect(ctx.DocArchiveManager.archiveDoc).to.have.been.calledWith(
+              ctx.project_id,
+              ctx.doc_id
+            )
+          })
+        })
+
+        describe('when the background flush fails', () => {
+          beforeEach(async ctx => {
+            ctx.err = new Error('foo')
+            ctx.DocArchiveManager.archiveDoc.rejects(ctx.err)
+            await ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, ctx.meta)
+          })
+
+          it('should log a warning', ctx => {
+            expect(ctx.logger.warn).to.have.been.calledWith(
+              sinon.match({
+                projectId: ctx.project_id,
+                docId: ctx.doc_id,
+                err: ctx.err,
+              }),
+              'archiving a single doc in the background failed'
+            )
+          })
+        })
+      })
+    })
+
+    describe('when the doc does not exist', () => {
+      it('should return a NotFoundError', async ctx => {
+        ctx.MongoManager.findDoc.resolves(null)
+        await expect(
+          ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, {})
+        ).to.be.rejectedWith(
+          `No such project/doc to delete: ${ctx.project_id}/${ctx.doc_id}`
+        )
+      })
+    })
+  })
+
+  describe('updateDoc', () => {
+    beforeEach(ctx => {
+      ctx.oldDocLines = ['old', 'doc', 'lines']
+      ctx.newDocLines = ['new', 'doc', 'lines']
+      ctx.originalRanges = {
+        changes: [
+          {
+            id: new ObjectId().toString(),
+            op: { i: 'foo', p: 3 },
+            meta: {
+              user_id: new ObjectId().toString(),
+              ts: new Date().toString(),
+            },
+          },
+        ],
+      }
+      ctx.newRanges = {
+        changes: [
+          {
+            id: new ObjectId().toString(),
+            op: { i: 'bar', p: 6 },
+            meta: {
+              user_id: new ObjectId().toString(),
+              ts: new Date().toString(),
+            },
+          },
+        ],
+      }
+      ctx.version = 42
+      ctx.doc = {
+        _id: ctx.doc_id,
+        project_id: ctx.project_id,
+        lines: ctx.oldDocLines,
+        rev: (ctx.rev = 5),
+        version: ctx.version,
+        ranges: ctx.originalRanges,
+      }
+
+      ctx.DocManager._getDoc = sinon.stub()
+    })
+
+    describe('when only the doc lines have changed', () => {
+      beforeEach(async ctx => {
+        ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc)
+        ctx.result = await ctx.DocManager.updateDoc(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.newDocLines,
+          ctx.version,
+          ctx.originalRanges
+        )
+      })
+
+      it('should get the existing doc', ctx => {
+        ctx.DocManager._getDoc
+          .calledWith(ctx.project_id, ctx.doc_id, {
+            version: true,
+            rev: true,
+            lines: true,
+            ranges: true,
+            inS3: true,
+          })
+          .should.equal(true)
+      })
+
+      it('should upsert the document to the doc collection', ctx => {
+        ctx.MongoManager.upsertIntoDocCollection
+          .calledWith(ctx.project_id, ctx.doc_id, ctx.rev, {
+            lines: ctx.newDocLines,
+          })
+          .should.equal(true)
+      })
+
+      it('should return the new rev', ctx => {
+        expect(ctx.result).to.deep.equal({ modified: true, rev: ctx.rev + 1 })
+      })
+    })
+
+    describe('when the doc ranges have changed', () => {
+      beforeEach(async ctx => {
+        ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc)
+        ctx.RangeManager.shouldUpdateRanges.returns(true)
+        ctx.result = await ctx.DocManager.updateDoc(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.oldDocLines,
+          ctx.version,
+          ctx.newRanges
+        )
+      })
+
+      it('should upsert the ranges', ctx => {
+        ctx.MongoManager.upsertIntoDocCollection
+          .calledWith(ctx.project_id, ctx.doc_id, ctx.rev, {
+            ranges: ctx.newRanges,
+          })
+          .should.equal(true)
+      })
+
+      it('should return the new rev', ctx => {
+        expect(ctx.result).to.deep.equal({ modified: true, rev: ctx.rev + 1 })
+      })
+    })
+
+    describe('when only the version has changed', () => {
+      beforeEach(async ctx => {
+        ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc)
+        ctx.result = await ctx.DocManager.updateDoc(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.oldDocLines,
+          ctx.version + 1,
+          ctx.originalRanges
+        )
+      })
+
+      it('should update the version', ctx => {
+        ctx.MongoManager.upsertIntoDocCollection.should.have.been.calledWith(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.rev,
+          { version: ctx.version + 1 }
+        )
+      })
+
+      it('should return the old rev', ctx => {
+        expect(ctx.result).to.deep.equal({ modified: true, rev: ctx.rev })
+      })
+    })
+
+    describe('when the doc has not changed at all', () => {
+      beforeEach(async ctx => {
+        ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc)
+        ctx.result = await ctx.DocManager.updateDoc(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.oldDocLines,
+          ctx.version,
+          ctx.originalRanges
+        )
+      })
+
+      it('should not update the ranges or lines or version', ctx => {
+        ctx.MongoManager.upsertIntoDocCollection.called.should.equal(false)
+      })
+
+      it('should return the old rev and modified == false', ctx => {
+        expect(ctx.result).to.deep.equal({ modified: false, rev: ctx.rev })
+      })
+    })
+
+    describe('when the version is null', () => {
+      it('should return an error', async ctx => {
+        await expect(
+          ctx.DocManager.updateDoc(
+            ctx.project_id,
+            ctx.doc_id,
+            ctx.newDocLines,
+            null,
+            ctx.originalRanges
+          )
+        ).to.be.rejectedWith('no lines, version or ranges provided')
+      })
+    })
+
+    describe('when the lines are null', () => {
+      it('should return an error', async ctx => {
+        await expect(
+          ctx.DocManager.updateDoc(
+            ctx.project_id,
+            ctx.doc_id,
+            null,
+            ctx.version,
+            ctx.originalRanges
+          )
+        ).to.be.rejectedWith('no lines, version or ranges provided')
+      })
+    })
+
+    describe('when the ranges are null', () => {
+      it('should return an error', async ctx => {
+        await expect(
+          ctx.DocManager.updateDoc(
+            ctx.project_id,
+            ctx.doc_id,
+            ctx.newDocLines,
+            ctx.version,
+            null
+          )
+        ).to.be.rejectedWith('no lines, version or ranges provided')
+      })
+    })
+
+    describe('when there is a generic error getting the doc', () => {
+      beforeEach(async ctx => {
+        ctx.error = new Error('doc could not be found')
+        ctx.DocManager._getDoc = sinon.stub().rejects(ctx.error)
+        await expect(
+          ctx.DocManager.updateDoc(
+            ctx.project_id,
+            ctx.doc_id,
+            ctx.newDocLines,
+            ctx.version,
+            ctx.originalRanges
+          )
+        ).to.be.rejectedWith(ctx.error)
+      })
+
+      it('should not upsert the document to the doc collection', ctx => {
+        ctx.MongoManager.upsertIntoDocCollection.should.not.have.been.called
+      })
+    })
+
+    describe('when the version was decremented', () => {
+      it('should return an error', async ctx => {
+        ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc)
+        await expect(
+          ctx.DocManager.updateDoc(
+            ctx.project_id,
+            ctx.doc_id,
+            ctx.newDocLines,
+            ctx.version - 1,
+            ctx.originalRanges
+          )
+        ).to.be.rejectedWith(Errors.DocVersionDecrementedError)
+      })
+    })
+
+    describe('when the doc lines have not changed', () => {
+      beforeEach(async ctx => {
+        ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc)
+        ctx.result = await ctx.DocManager.updateDoc(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.oldDocLines.slice(),
+          ctx.version,
+          ctx.originalRanges
+        )
+      })
+
+      it('should not update the doc', ctx => {
+        ctx.MongoManager.upsertIntoDocCollection.called.should.equal(false)
+      })
+
+      it('should return the existing rev', ctx => {
+        expect(ctx.result).to.deep.equal({ modified: false, rev: ctx.rev })
+      })
+    })
+
+    describe('when the doc does not exist', () => {
+      beforeEach(async ctx => {
+        ctx.DocManager._getDoc = sinon.stub().resolves(null)
+        ctx.result = await ctx.DocManager.updateDoc(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.newDocLines,
+          ctx.version,
+          ctx.originalRanges
+        )
+      })
+
+      it('should upsert the document to the doc collection', ctx => {
+        ctx.MongoManager.upsertIntoDocCollection.should.have.been.calledWith(
+          ctx.project_id,
+          ctx.doc_id,
+          undefined,
+          {
+            lines: ctx.newDocLines,
+            ranges: ctx.originalRanges,
+            version: ctx.version,
+          }
+        )
+      })
+
+      it('should return the new rev', ctx => {
+        expect(ctx.result).to.deep.equal({ modified: true, rev: 1 })
+      })
+    })
+
+    describe('when another update is racing', () => {
+      beforeEach(async ctx => {
+        ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc)
+        ctx.MongoManager.upsertIntoDocCollection
+          .onFirstCall()
+          .rejects(new Errors.DocRevValueError())
+        ctx.RangeManager.shouldUpdateRanges.returns(true)
+        ctx.result = await ctx.DocManager.updateDoc(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.newDocLines,
+          ctx.version + 1,
+          ctx.newRanges
+        )
+      })
+
+      it('should upsert the doc twice', ctx => {
+        ctx.MongoManager.upsertIntoDocCollection.should.have.been.calledWith(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.rev,
+          {
+            ranges: ctx.newRanges,
+            lines: ctx.newDocLines,
+            version: ctx.version + 1,
+          }
+        )
+        ctx.MongoManager.upsertIntoDocCollection.should.have.been.calledTwice
+      })
+
+      it('should return the new rev', ctx => {
+        expect(ctx.result).to.deep.equal({ modified: true, rev: ctx.rev + 1 })
+      })
+    })
+  })
+})

+ 0 - 777
services/docstore/test/unit/js/DocManagerTests.js

@@ -1,777 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = require('node:path').join(
-  __dirname,
-  '../../../app/js/DocManager'
-)
-const { ObjectId } = require('mongodb-legacy')
-const Errors = require('../../../app/js/Errors')
-
-describe('DocManager', function () {
-  beforeEach(function () {
-    this.doc_id = new ObjectId().toString()
-    this.project_id = new ObjectId().toString()
-    this.another_project_id = new ObjectId().toString()
-    this.stubbedError = new Error('blew up')
-    this.version = 42
-
-    this.MongoManager = {
-      findDoc: sinon.stub(),
-      getProjectsDocs: sinon.stub(),
-      patchDoc: sinon.stub().resolves(),
-      upsertIntoDocCollection: sinon.stub().resolves(),
-    }
-    this.DocArchiveManager = {
-      unarchiveDoc: sinon.stub(),
-      unArchiveAllDocs: sinon.stub(),
-      archiveDoc: sinon.stub().resolves(),
-    }
-    this.RangeManager = {
-      jsonRangesToMongo(r) {
-        return r
-      },
-      shouldUpdateRanges: sinon.stub().returns(false),
-      fixCommentIds: sinon.stub(),
-    }
-    this.settings = { docstore: {} }
-
-    this.DocManager = SandboxedModule.require(modulePath, {
-      requires: {
-        './MongoManager': this.MongoManager,
-        './DocArchiveManager': this.DocArchiveManager,
-        './RangeManager': this.RangeManager,
-        '@overleaf/settings': this.settings,
-        './Errors': Errors,
-      },
-    })
-  })
-
-  describe('getFullDoc', function () {
-    beforeEach(function () {
-      this.DocManager._getDoc = sinon.stub()
-      this.doc = {
-        _id: this.doc_id,
-        lines: ['2134'],
-      }
-    })
-
-    it('should call get doc with a quick filter', async function () {
-      this.DocManager._getDoc.resolves(this.doc)
-      const doc = await this.DocManager.getFullDoc(this.project_id, this.doc_id)
-      doc.should.equal(this.doc)
-      this.DocManager._getDoc
-        .calledWith(this.project_id, this.doc_id, {
-          lines: true,
-          rev: true,
-          deleted: true,
-          version: true,
-          ranges: true,
-          inS3: true,
-        })
-        .should.equal(true)
-    })
-
-    it('should return error when get doc errors', async function () {
-      this.DocManager._getDoc.rejects(this.stubbedError)
-      await expect(
-        this.DocManager.getFullDoc(this.project_id, this.doc_id)
-      ).to.be.rejectedWith(this.stubbedError)
-    })
-  })
-
-  describe('getRawDoc', function () {
-    beforeEach(function () {
-      this.DocManager._getDoc = sinon.stub()
-      this.doc = { lines: ['2134'] }
-    })
-
-    it('should call get doc with a quick filter', async function () {
-      this.DocManager._getDoc.resolves(this.doc)
-      const content = await this.DocManager.getDocLines(
-        this.project_id,
-        this.doc_id
-      )
-      content.should.equal(this.doc.lines.join('\n'))
-      this.DocManager._getDoc
-        .calledWith(this.project_id, this.doc_id, {
-          lines: true,
-          inS3: true,
-        })
-        .should.equal(true)
-    })
-
-    it('should return error when get doc errors', async function () {
-      this.DocManager._getDoc.rejects(this.stubbedError)
-      await expect(
-        this.DocManager.getDocLines(this.project_id, this.doc_id)
-      ).to.be.rejectedWith(this.stubbedError)
-    })
-
-    it('should return error when get doc does not exist', async function () {
-      this.DocManager._getDoc.resolves(null)
-      await expect(
-        this.DocManager.getDocLines(this.project_id, this.doc_id)
-      ).to.be.rejectedWith(Errors.NotFoundError)
-    })
-
-    it('should return error when get doc has no lines', async function () {
-      this.DocManager._getDoc.resolves({})
-      await expect(
-        this.DocManager.getDocLines(this.project_id, this.doc_id)
-      ).to.be.rejectedWith(Errors.DocWithoutLinesError)
-    })
-  })
-
-  describe('_getDoc', function () {
-    it('should return error when get doc does not exist', async function () {
-      this.MongoManager.findDoc.resolves(null)
-      await expect(
-        this.DocManager._getDoc(this.project_id, this.doc_id, { inS3: true })
-      ).to.be.rejectedWith(Errors.NotFoundError)
-    })
-
-    it('should fix comment ids', async function () {
-      this.MongoManager.findDoc.resolves({
-        _id: this.doc_id,
-        ranges: {},
-      })
-      await this.DocManager._getDoc(this.project_id, this.doc_id, {
-        inS3: true,
-        ranges: true,
-      })
-      expect(this.RangeManager.fixCommentIds).to.have.been.called
-    })
-  })
-
-  describe('getDoc', function () {
-    beforeEach(function () {
-      this.project = { name: 'mock-project' }
-      this.doc = {
-        _id: this.doc_id,
-        project_id: this.project_id,
-        lines: ['mock-lines'],
-        version: this.version,
-      }
-    })
-
-    describe('when using a filter', function () {
-      beforeEach(function () {
-        this.MongoManager.findDoc.resolves(this.doc)
-      })
-
-      it('should error if inS3 is not set to true', async function () {
-        await expect(
-          this.DocManager._getDoc(this.project_id, this.doc_id, {
-            inS3: false,
-          })
-        ).to.be.rejected
-      })
-
-      it('should always get inS3 even when no filter is passed', async function () {
-        await expect(this.DocManager._getDoc(this.project_id, this.doc_id)).to
-          .be.rejected
-        this.MongoManager.findDoc.called.should.equal(false)
-      })
-
-      it('should not error if inS3 is set to true', async function () {
-        await this.DocManager._getDoc(this.project_id, this.doc_id, {
-          inS3: true,
-        })
-      })
-    })
-
-    describe('when the doc is in the doc collection', function () {
-      beforeEach(async function () {
-        this.MongoManager.findDoc.resolves(this.doc)
-        this.result = await this.DocManager._getDoc(
-          this.project_id,
-          this.doc_id,
-          { version: true, inS3: true }
-        )
-      })
-
-      it('should get the doc from the doc collection', function () {
-        this.MongoManager.findDoc
-          .calledWith(this.project_id, this.doc_id)
-          .should.equal(true)
-      })
-
-      it('should return the doc with the version', function () {
-        this.result.lines.should.equal(this.doc.lines)
-        this.result.version.should.equal(this.version)
-      })
-    })
-
-    describe('when MongoManager.findDoc errors', function () {
-      it('should return the error', async function () {
-        this.MongoManager.findDoc.rejects(this.stubbedError)
-        await expect(
-          this.DocManager._getDoc(this.project_id, this.doc_id, {
-            version: true,
-            inS3: true,
-          })
-        ).to.be.rejectedWith(this.stubbedError)
-      })
-    })
-
-    describe('when the doc is archived', function () {
-      beforeEach(async function () {
-        this.doc = {
-          _id: this.doc_id,
-          project_id: this.project_id,
-          version: 2,
-          inS3: true,
-        }
-        this.unarchivedDoc = {
-          _id: this.doc_id,
-          project_id: this.project_id,
-          lines: ['mock-lines'],
-          version: 2,
-          inS3: false,
-        }
-        this.MongoManager.findDoc.resolves(this.doc)
-        this.DocArchiveManager.unarchiveDoc.callsFake(
-          async (projectId, docId) => {
-            this.MongoManager.findDoc.resolves({
-              ...this.unarchivedDoc,
-            })
-          }
-        )
-        this.result = await this.DocManager._getDoc(
-          this.project_id,
-          this.doc_id,
-          {
-            version: true,
-            inS3: true,
-          }
-        )
-      })
-
-      it('should call the DocArchive to unarchive the doc', function () {
-        this.DocArchiveManager.unarchiveDoc
-          .calledWith(this.project_id, this.doc_id)
-          .should.equal(true)
-      })
-
-      it('should look up the doc twice', function () {
-        this.MongoManager.findDoc.calledTwice.should.equal(true)
-      })
-
-      it('should return the doc', function () {
-        expect(this.result).to.deep.equal({
-          ...this.unarchivedDoc,
-        })
-      })
-    })
-
-    describe('when the doc does not exist in the docs collection', function () {
-      it('should return a NotFoundError', async function () {
-        this.MongoManager.findDoc.resolves(null)
-        await expect(
-          this.DocManager._getDoc(this.project_id, this.doc_id, {
-            version: true,
-            inS3: true,
-          })
-        ).to.be.rejectedWith(
-          `No such doc: ${this.doc_id} in project ${this.project_id}`
-        )
-      })
-    })
-  })
-
-  describe('getAllNonDeletedDocs', function () {
-    describe('when the project exists', function () {
-      beforeEach(async function () {
-        this.docs = [
-          {
-            _id: this.doc_id,
-            project_id: this.project_id,
-            lines: ['mock-lines'],
-          },
-        ]
-        this.MongoManager.getProjectsDocs.resolves(this.docs)
-        this.DocArchiveManager.unArchiveAllDocs.resolves(this.docs)
-        this.filter = { lines: true, ranges: true }
-        this.result = await this.DocManager.getAllNonDeletedDocs(
-          this.project_id,
-          this.filter
-        )
-      })
-
-      it('should get the project from the database', function () {
-        this.MongoManager.getProjectsDocs.should.have.been.calledWith(
-          this.project_id,
-          { include_deleted: false },
-          this.filter
-        )
-      })
-
-      it('should fix comment ids', async function () {
-        expect(this.RangeManager.fixCommentIds).to.have.been.called
-      })
-
-      it('should return the docs', function () {
-        expect(this.result).to.deep.equal(this.docs)
-      })
-    })
-
-    describe('when there are no docs for the project', function () {
-      it('should return a NotFoundError', async function () {
-        this.MongoManager.getProjectsDocs.resolves(null)
-        this.DocArchiveManager.unArchiveAllDocs.resolves(null)
-        await expect(
-          this.DocManager.getAllNonDeletedDocs(this.project_id, this.filter)
-        ).to.be.rejectedWith(`No docs for project ${this.project_id}`)
-      })
-    })
-  })
-
-  describe('patchDoc', function () {
-    describe('when the doc exists', function () {
-      beforeEach(function () {
-        this.lines = ['mock', 'doc', 'lines']
-        this.rev = 77
-        this.MongoManager.findDoc.resolves({
-          _id: new ObjectId(this.doc_id),
-        })
-        this.meta = {}
-      })
-
-      describe('standard path', function () {
-        beforeEach(async function () {
-          await this.DocManager.patchDoc(
-            this.project_id,
-            this.doc_id,
-            this.meta
-          )
-        })
-
-        it('should get the doc', function () {
-          expect(this.MongoManager.findDoc).to.have.been.calledWith(
-            this.project_id,
-            this.doc_id
-          )
-        })
-
-        it('should persist the meta', function () {
-          expect(this.MongoManager.patchDoc).to.have.been.calledWith(
-            this.project_id,
-            this.doc_id,
-            this.meta
-          )
-        })
-      })
-
-      describe('background flush disabled and deleting a doc', function () {
-        beforeEach(async function () {
-          this.settings.docstore.archiveOnSoftDelete = false
-          this.meta.deleted = true
-
-          await this.DocManager.patchDoc(
-            this.project_id,
-            this.doc_id,
-            this.meta
-          )
-        })
-
-        it('should not flush the doc out of mongo', function () {
-          expect(this.DocArchiveManager.archiveDoc).to.not.have.been.called
-        })
-      })
-
-      describe('background flush enabled and not deleting a doc', function () {
-        beforeEach(async function () {
-          this.settings.docstore.archiveOnSoftDelete = false
-          this.meta.deleted = false
-          await this.DocManager.patchDoc(
-            this.project_id,
-            this.doc_id,
-            this.meta
-          )
-        })
-
-        it('should not flush the doc out of mongo', function () {
-          expect(this.DocArchiveManager.archiveDoc).to.not.have.been.called
-        })
-      })
-
-      describe('background flush enabled and deleting a doc', function () {
-        beforeEach(function () {
-          this.settings.docstore.archiveOnSoftDelete = true
-          this.meta.deleted = true
-        })
-
-        describe('when the background flush succeeds', function () {
-          beforeEach(async function () {
-            await this.DocManager.patchDoc(
-              this.project_id,
-              this.doc_id,
-              this.meta
-            )
-          })
-
-          it('should not log a warning', function () {
-            expect(this.logger.warn).to.not.have.been.called
-          })
-
-          it('should flush the doc out of mongo', function () {
-            expect(this.DocArchiveManager.archiveDoc).to.have.been.calledWith(
-              this.project_id,
-              this.doc_id
-            )
-          })
-        })
-
-        describe('when the background flush fails', function () {
-          beforeEach(async function () {
-            this.err = new Error('foo')
-            this.DocArchiveManager.archiveDoc.rejects(this.err)
-            await this.DocManager.patchDoc(
-              this.project_id,
-              this.doc_id,
-              this.meta
-            )
-          })
-
-          it('should log a warning', function () {
-            expect(this.logger.warn).to.have.been.calledWith(
-              sinon.match({
-                projectId: this.project_id,
-                docId: this.doc_id,
-                err: this.err,
-              }),
-              'archiving a single doc in the background failed'
-            )
-          })
-        })
-      })
-    })
-
-    describe('when the doc does not exist', function () {
-      it('should return a NotFoundError', async function () {
-        this.MongoManager.findDoc.resolves(null)
-        await expect(
-          this.DocManager.patchDoc(this.project_id, this.doc_id, {})
-        ).to.be.rejectedWith(
-          `No such project/doc to delete: ${this.project_id}/${this.doc_id}`
-        )
-      })
-    })
-  })
-
-  describe('updateDoc', function () {
-    beforeEach(function () {
-      this.oldDocLines = ['old', 'doc', 'lines']
-      this.newDocLines = ['new', 'doc', 'lines']
-      this.originalRanges = {
-        changes: [
-          {
-            id: new ObjectId().toString(),
-            op: { i: 'foo', p: 3 },
-            meta: {
-              user_id: new ObjectId().toString(),
-              ts: new Date().toString(),
-            },
-          },
-        ],
-      }
-      this.newRanges = {
-        changes: [
-          {
-            id: new ObjectId().toString(),
-            op: { i: 'bar', p: 6 },
-            meta: {
-              user_id: new ObjectId().toString(),
-              ts: new Date().toString(),
-            },
-          },
-        ],
-      }
-      this.version = 42
-      this.doc = {
-        _id: this.doc_id,
-        project_id: this.project_id,
-        lines: this.oldDocLines,
-        rev: (this.rev = 5),
-        version: this.version,
-        ranges: this.originalRanges,
-      }
-
-      this.DocManager._getDoc = sinon.stub()
-    })
-
-    describe('when only the doc lines have changed', function () {
-      beforeEach(async function () {
-        this.DocManager._getDoc = sinon.stub().resolves(this.doc)
-        this.result = await this.DocManager.updateDoc(
-          this.project_id,
-          this.doc_id,
-          this.newDocLines,
-          this.version,
-          this.originalRanges
-        )
-      })
-
-      it('should get the existing doc', function () {
-        this.DocManager._getDoc
-          .calledWith(this.project_id, this.doc_id, {
-            version: true,
-            rev: true,
-            lines: true,
-            ranges: true,
-            inS3: true,
-          })
-          .should.equal(true)
-      })
-
-      it('should upsert the document to the doc collection', function () {
-        this.MongoManager.upsertIntoDocCollection
-          .calledWith(this.project_id, this.doc_id, this.rev, {
-            lines: this.newDocLines,
-          })
-          .should.equal(true)
-      })
-
-      it('should return the new rev', function () {
-        expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
-      })
-    })
-
-    describe('when the doc ranges have changed', function () {
-      beforeEach(async function () {
-        this.DocManager._getDoc = sinon.stub().resolves(this.doc)
-        this.RangeManager.shouldUpdateRanges.returns(true)
-        this.result = await this.DocManager.updateDoc(
-          this.project_id,
-          this.doc_id,
-          this.oldDocLines,
-          this.version,
-          this.newRanges
-        )
-      })
-
-      it('should upsert the ranges', function () {
-        this.MongoManager.upsertIntoDocCollection
-          .calledWith(this.project_id, this.doc_id, this.rev, {
-            ranges: this.newRanges,
-          })
-          .should.equal(true)
-      })
-
-      it('should return the new rev', function () {
-        expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
-      })
-    })
-
-    describe('when only the version has changed', function () {
-      beforeEach(async function () {
-        this.DocManager._getDoc = sinon.stub().resolves(this.doc)
-        this.result = await this.DocManager.updateDoc(
-          this.project_id,
-          this.doc_id,
-          this.oldDocLines,
-          this.version + 1,
-          this.originalRanges
-        )
-      })
-
-      it('should update the version', function () {
-        this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith(
-          this.project_id,
-          this.doc_id,
-          this.rev,
-          { version: this.version + 1 }
-        )
-      })
-
-      it('should return the old rev', function () {
-        expect(this.result).to.deep.equal({ modified: true, rev: this.rev })
-      })
-    })
-
-    describe('when the doc has not changed at all', function () {
-      beforeEach(async function () {
-        this.DocManager._getDoc = sinon.stub().resolves(this.doc)
-        this.result = await this.DocManager.updateDoc(
-          this.project_id,
-          this.doc_id,
-          this.oldDocLines,
-          this.version,
-          this.originalRanges
-        )
-      })
-
-      it('should not update the ranges or lines or version', function () {
-        this.MongoManager.upsertIntoDocCollection.called.should.equal(false)
-      })
-
-      it('should return the old rev and modified == false', function () {
-        expect(this.result).to.deep.equal({ modified: false, rev: this.rev })
-      })
-    })
-
-    describe('when the version is null', function () {
-      it('should return an error', async function () {
-        await expect(
-          this.DocManager.updateDoc(
-            this.project_id,
-            this.doc_id,
-            this.newDocLines,
-            null,
-            this.originalRanges
-          )
-        ).to.be.rejectedWith('no lines, version or ranges provided')
-      })
-    })
-
-    describe('when the lines are null', function () {
-      it('should return an error', async function () {
-        await expect(
-          this.DocManager.updateDoc(
-            this.project_id,
-            this.doc_id,
-            null,
-            this.version,
-            this.originalRanges
-          )
-        ).to.be.rejectedWith('no lines, version or ranges provided')
-      })
-    })
-
-    describe('when the ranges are null', function () {
-      it('should return an error', async function () {
-        await expect(
-          this.DocManager.updateDoc(
-            this.project_id,
-            this.doc_id,
-            this.newDocLines,
-            this.version,
-            null
-          )
-        ).to.be.rejectedWith('no lines, version or ranges provided')
-      })
-    })
-
-    describe('when there is a generic error getting the doc', function () {
-      beforeEach(async function () {
-        this.error = new Error('doc could not be found')
-        this.DocManager._getDoc = sinon.stub().rejects(this.error)
-        await expect(
-          this.DocManager.updateDoc(
-            this.project_id,
-            this.doc_id,
-            this.newDocLines,
-            this.version,
-            this.originalRanges
-          )
-        ).to.be.rejectedWith(this.error)
-      })
-
-      it('should not upsert the document to the doc collection', function () {
-        this.MongoManager.upsertIntoDocCollection.should.not.have.been.called
-      })
-    })
-
-    describe('when the version was decremented', function () {
-      it('should return an error', async function () {
-        this.DocManager._getDoc = sinon.stub().resolves(this.doc)
-        await expect(
-          this.DocManager.updateDoc(
-            this.project_id,
-            this.doc_id,
-            this.newDocLines,
-            this.version - 1,
-            this.originalRanges
-          )
-        ).to.be.rejectedWith(Errors.DocVersionDecrementedError)
-      })
-    })
-
-    describe('when the doc lines have not changed', function () {
-      beforeEach(async function () {
-        this.DocManager._getDoc = sinon.stub().resolves(this.doc)
-        this.result = await this.DocManager.updateDoc(
-          this.project_id,
-          this.doc_id,
-          this.oldDocLines.slice(),
-          this.version,
-          this.originalRanges
-        )
-      })
-
-      it('should not update the doc', function () {
-        this.MongoManager.upsertIntoDocCollection.called.should.equal(false)
-      })
-
-      it('should return the existing rev', function () {
-        expect(this.result).to.deep.equal({ modified: false, rev: this.rev })
-      })
-    })
-
-    describe('when the doc does not exist', function () {
-      beforeEach(async function () {
-        this.DocManager._getDoc = sinon.stub().resolves(null)
-        this.result = await this.DocManager.updateDoc(
-          this.project_id,
-          this.doc_id,
-          this.newDocLines,
-          this.version,
-          this.originalRanges
-        )
-      })
-
-      it('should upsert the document to the doc collection', function () {
-        this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith(
-          this.project_id,
-          this.doc_id,
-          undefined,
-          {
-            lines: this.newDocLines,
-            ranges: this.originalRanges,
-            version: this.version,
-          }
-        )
-      })
-
-      it('should return the new rev', function () {
-        expect(this.result).to.deep.equal({ modified: true, rev: 1 })
-      })
-    })
-
-    describe('when another update is racing', function () {
-      beforeEach(async function () {
-        this.DocManager._getDoc = sinon.stub().resolves(this.doc)
-        this.MongoManager.upsertIntoDocCollection
-          .onFirstCall()
-          .rejects(new Errors.DocRevValueError())
-        this.RangeManager.shouldUpdateRanges.returns(true)
-        this.result = await this.DocManager.updateDoc(
-          this.project_id,
-          this.doc_id,
-          this.newDocLines,
-          this.version + 1,
-          this.newRanges
-        )
-      })
-
-      it('should upsert the doc twice', function () {
-        this.MongoManager.upsertIntoDocCollection.should.have.been.calledWith(
-          this.project_id,
-          this.doc_id,
-          this.rev,
-          {
-            ranges: this.newRanges,
-            lines: this.newDocLines,
-            version: this.version + 1,
-          }
-        )
-        this.MongoManager.upsertIntoDocCollection.should.have.been.calledTwice
-      })
-
-      it('should return the new rev', function () {
-        expect(this.result).to.deep.equal({ modified: true, rev: this.rev + 1 })
-      })
-    })
-  })
-})

+ 595 - 0
services/docstore/test/unit/js/HttpController.test.js

@@ -0,0 +1,595 @@
+import sinon from 'sinon'
+import { assert, beforeEach, describe, expect, it, vi } from 'vitest'
+import path from 'node:path'
+import { ObjectId } from 'mongodb-legacy'
+import Errors from '../../../app/js/Errors.js'
+
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../app/js/HttpController'
+)
+
+describe('HttpController', () => {
+  beforeEach(async ctx => {
+    const settings = {
+      max_doc_length: 2 * 1024 * 1024,
+    }
+    ctx.DocArchiveManager = {
+      unArchiveAllDocs: sinon.stub().returns(),
+    }
+    ctx.DocManager = {}
+
+    vi.doMock('../../../app/js/DocManager', () => ({
+      default: ctx.DocManager,
+    }))
+
+    vi.doMock('../../../app/js/DocArchiveManager', () => ({
+      default: ctx.DocArchiveManager,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: settings,
+    }))
+
+    vi.doMock('../../../app/js/HealthChecker', () => ({
+      default: {},
+    }))
+
+    vi.doMock('../../../app/js/Errors', () => ({
+      default: Errors,
+    }))
+
+    ctx.HttpController = (await import(modulePath)).default
+    ctx.res = {
+      send: sinon.stub(),
+      sendStatus: sinon.stub(),
+      json: sinon.stub(),
+      setHeader: sinon.stub(),
+    }
+    ctx.res.status = sinon.stub().returns(ctx.res)
+    ctx.req = { query: {} }
+    ctx.next = sinon.stub()
+    ctx.projectId = 'mock-project-id'
+    ctx.docId = 'mock-doc-id'
+    ctx.doc = {
+      _id: ctx.docId,
+      lines: ['mock', 'lines', ' here', '', '', ' spaces '],
+      version: 42,
+      rev: 5,
+    }
+    ctx.deletedDoc = {
+      deleted: true,
+      _id: ctx.docId,
+      lines: ['mock', 'lines', ' here', '', '', ' spaces '],
+      version: 42,
+      rev: 5,
+    }
+  })
+
+  describe('getDoc', () => {
+    describe('without deleted docs', () => {
+      beforeEach(async ctx => {
+        ctx.req.params = {
+          project_id: ctx.projectId,
+          doc_id: ctx.docId,
+        }
+        ctx.DocManager.getFullDoc = sinon.stub().resolves(ctx.doc)
+        await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should get the document with the version (including deleted)', ctx => {
+        ctx.DocManager.getFullDoc
+          .calledWith(ctx.projectId, ctx.docId)
+          .should.equal(true)
+      })
+
+      it('should return the doc as JSON', ctx => {
+        ctx.res.json
+          .calledWith({
+            _id: ctx.docId,
+            lines: ctx.doc.lines,
+            rev: ctx.doc.rev,
+            version: ctx.doc.version,
+          })
+          .should.equal(true)
+      })
+    })
+
+    describe('which is deleted', () => {
+      beforeEach(ctx => {
+        ctx.req.params = {
+          project_id: ctx.projectId,
+          doc_id: ctx.docId,
+        }
+        ctx.DocManager.getFullDoc = sinon.stub().resolves(ctx.deletedDoc)
+      })
+
+      it('should get the doc from the doc manager', async ctx => {
+        await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
+        ctx.DocManager.getFullDoc
+          .calledWith(ctx.projectId, ctx.docId)
+          .should.equal(true)
+      })
+
+      it('should return 404 if the query string delete is not set ', async ctx => {
+        await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
+        ctx.res.sendStatus.calledWith(404).should.equal(true)
+      })
+
+      it('should return the doc as JSON if include_deleted is set to true', async ctx => {
+        ctx.req.query.include_deleted = 'true'
+        await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
+        ctx.res.json
+          .calledWith({
+            _id: ctx.docId,
+            lines: ctx.doc.lines,
+            rev: ctx.doc.rev,
+            deleted: true,
+            version: ctx.doc.version,
+          })
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('getRawDoc', () => {
+    beforeEach(async ctx => {
+      ctx.req.params = {
+        project_id: ctx.projectId,
+        doc_id: ctx.docId,
+      }
+      ctx.DocManager.getDocLines = sinon
+        .stub()
+        .resolves(ctx.doc.lines.join('\n'))
+      await ctx.HttpController.getRawDoc(ctx.req, ctx.res, ctx.next)
+    })
+
+    it('should get the document without the version', ctx => {
+      ctx.DocManager.getDocLines
+        .calledWith(ctx.projectId, ctx.docId)
+        .should.equal(true)
+    })
+
+    it('should set the content type header', ctx => {
+      ctx.res.setHeader
+        .calledWith('content-type', 'text/plain')
+        .should.equal(true)
+    })
+
+    it('should send the raw version of the doc', ctx => {
+      assert.deepEqual(
+        ctx.res.send.args[0][0],
+        `${ctx.doc.lines[0]}\n${ctx.doc.lines[1]}\n${ctx.doc.lines[2]}\n${ctx.doc.lines[3]}\n${ctx.doc.lines[4]}\n${ctx.doc.lines[5]}`
+      )
+    })
+  })
+
+  describe('getAllDocs', () => {
+    describe('normally', () => {
+      beforeEach(async ctx => {
+        ctx.req.params = { project_id: ctx.projectId }
+        ctx.docs = [
+          {
+            _id: new ObjectId(),
+            lines: ['mock', 'lines', 'one'],
+            rev: 2,
+          },
+          {
+            _id: new ObjectId(),
+            lines: ['mock', 'lines', 'two'],
+            rev: 4,
+          },
+        ]
+        ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
+        await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should get all the (non-deleted) docs', ctx => {
+        ctx.DocManager.getAllNonDeletedDocs
+          .calledWith(ctx.projectId, { lines: true, rev: true })
+          .should.equal(true)
+      })
+
+      it('should return the doc as JSON', ctx => {
+        ctx.res.json
+          .calledWith([
+            {
+              _id: ctx.docs[0]._id.toString(),
+              lines: ctx.docs[0].lines,
+              rev: ctx.docs[0].rev,
+            },
+            {
+              _id: ctx.docs[1]._id.toString(),
+              lines: ctx.docs[1].lines,
+              rev: ctx.docs[1].rev,
+            },
+          ])
+          .should.equal(true)
+      })
+    })
+
+    describe('with null lines', () => {
+      beforeEach(async ctx => {
+        ctx.req.params = { project_id: ctx.projectId }
+        ctx.docs = [
+          {
+            _id: new ObjectId(),
+            lines: null,
+            rev: 2,
+          },
+          {
+            _id: new ObjectId(),
+            lines: ['mock', 'lines', 'two'],
+            rev: 4,
+          },
+        ]
+        ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
+        await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should return the doc with fallback lines', ctx => {
+        ctx.res.json
+          .calledWith([
+            {
+              _id: ctx.docs[0]._id.toString(),
+              lines: [],
+              rev: ctx.docs[0].rev,
+            },
+            {
+              _id: ctx.docs[1]._id.toString(),
+              lines: ctx.docs[1].lines,
+              rev: ctx.docs[1].rev,
+            },
+          ])
+          .should.equal(true)
+      })
+    })
+
+    describe('with a null doc', () => {
+      beforeEach(async ctx => {
+        ctx.req.params = { project_id: ctx.projectId }
+        ctx.docs = [
+          {
+            _id: new ObjectId(),
+            lines: ['mock', 'lines', 'one'],
+            rev: 2,
+          },
+          null,
+          {
+            _id: new ObjectId(),
+            lines: ['mock', 'lines', 'two'],
+            rev: 4,
+          },
+        ]
+        ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
+        await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should return the non null docs as JSON', ctx => {
+        ctx.res.json
+          .calledWith([
+            {
+              _id: ctx.docs[0]._id.toString(),
+              lines: ctx.docs[0].lines,
+              rev: ctx.docs[0].rev,
+            },
+            {
+              _id: ctx.docs[2]._id.toString(),
+              lines: ctx.docs[2].lines,
+              rev: ctx.docs[2].rev,
+            },
+          ])
+          .should.equal(true)
+      })
+
+      it('should log out an error', ctx => {
+        ctx.logger.error
+          .calledWith(
+            {
+              err: sinon.match.has('message', 'null doc'),
+              projectId: ctx.projectId,
+            },
+            'encountered null doc'
+          )
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('getAllRanges', () => {
+    describe('normally', () => {
+      beforeEach(async ctx => {
+        ctx.req.params = { project_id: ctx.projectId }
+        ctx.docs = [
+          {
+            _id: new ObjectId(),
+            ranges: { mock_ranges: 'one' },
+          },
+          {
+            _id: new ObjectId(),
+            ranges: { mock_ranges: 'two' },
+          },
+        ]
+        ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
+        await ctx.HttpController.getAllRanges(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should get all the (non-deleted) doc ranges', ctx => {
+        ctx.DocManager.getAllNonDeletedDocs
+          .calledWith(ctx.projectId, { ranges: true })
+          .should.equal(true)
+      })
+
+      it('should return the doc as JSON', ctx => {
+        ctx.res.json
+          .calledWith([
+            {
+              _id: ctx.docs[0]._id.toString(),
+              ranges: ctx.docs[0].ranges,
+            },
+            {
+              _id: ctx.docs[1]._id.toString(),
+              ranges: ctx.docs[1].ranges,
+            },
+          ])
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('updateDoc', () => {
+    beforeEach(ctx => {
+      ctx.req.params = {
+        project_id: ctx.projectId,
+        doc_id: ctx.docId,
+      }
+    })
+
+    describe('when the doc lines exist and were updated', () => {
+      beforeEach(async ctx => {
+        ctx.req.body = {
+          lines: (ctx.lines = ['hello', 'world']),
+          version: (ctx.version = 42),
+          ranges: (ctx.ranges = { changes: 'mock' }),
+        }
+        ctx.rev = 5
+        ctx.DocManager.updateDoc = sinon
+          .stub()
+          .resolves({ modified: true, rev: ctx.rev })
+        await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should update the document', ctx => {
+        ctx.DocManager.updateDoc
+          .calledWith(
+            ctx.projectId,
+            ctx.docId,
+            ctx.lines,
+            ctx.version,
+            ctx.ranges
+          )
+          .should.equal(true)
+      })
+
+      it('should return a modified status', ctx => {
+        ctx.res.json
+          .calledWith({ modified: true, rev: ctx.rev })
+          .should.equal(true)
+      })
+    })
+
+    describe('when the doc lines exist and were not updated', () => {
+      beforeEach(async ctx => {
+        ctx.req.body = {
+          lines: (ctx.lines = ['hello', 'world']),
+          version: (ctx.version = 42),
+          ranges: {},
+        }
+        ctx.rev = 5
+        ctx.DocManager.updateDoc = sinon
+          .stub()
+          .resolves({ modified: false, rev: ctx.rev })
+        await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should return a modified status', ctx => {
+        ctx.res.json
+          .calledWith({ modified: false, rev: ctx.rev })
+          .should.equal(true)
+      })
+    })
+
+    describe('when the doc lines are not provided', () => {
+      beforeEach(async ctx => {
+        ctx.req.body = { version: 42, ranges: {} }
+        ctx.DocManager.updateDoc = sinon
+          .stub()
+          .resolves({ modified: false, rev: 0 })
+        await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should not update the document', ctx => {
+        ctx.DocManager.updateDoc.called.should.equal(false)
+      })
+
+      it('should return a 400 (bad request) response', ctx => {
+        ctx.res.sendStatus.calledWith(400).should.equal(true)
+      })
+    })
+
+    describe('when the doc version are not provided', () => {
+      beforeEach(async ctx => {
+        ctx.req.body = { version: 42, lines: ['hello world'] }
+        ctx.DocManager.updateDoc = sinon
+          .stub()
+          .resolves({ modified: false, rev: 0 })
+        await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should not update the document', ctx => {
+        ctx.DocManager.updateDoc.called.should.equal(false)
+      })
+
+      it('should return a 400 (bad request) response', ctx => {
+        ctx.res.sendStatus.calledWith(400).should.equal(true)
+      })
+    })
+
+    describe('when the doc ranges is not provided', () => {
+      beforeEach(async ctx => {
+        ctx.req.body = { lines: ['foo'], version: 42 }
+        ctx.DocManager.updateDoc = sinon
+          .stub()
+          .resolves({ modified: false, rev: 0 })
+        await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should not update the document', ctx => {
+        ctx.DocManager.updateDoc.called.should.equal(false)
+      })
+
+      it('should return a 400 (bad request) response', ctx => {
+        ctx.res.sendStatus.calledWith(400).should.equal(true)
+      })
+    })
+
+    describe('when the doc body is too large', () => {
+      beforeEach(async ctx => {
+        ctx.req.body = {
+          lines: (ctx.lines = Array(2049).fill('a'.repeat(1024))),
+          version: (ctx.version = 42),
+          ranges: (ctx.ranges = { changes: 'mock' }),
+        }
+        ctx.DocManager.updateDoc = sinon
+          .stub()
+          .resolves({ modified: false, rev: 0 })
+        await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should not update the document', ctx => {
+        ctx.DocManager.updateDoc.called.should.equal(false)
+      })
+
+      it('should return a 413 (too large) response', ctx => {
+        sinon.assert.calledWith(ctx.res.status, 413)
+      })
+
+      it('should report that the document body is too large', ctx => {
+        sinon.assert.calledWith(ctx.res.send, 'document body too large')
+      })
+    })
+  })
+
+  describe('patchDoc', () => {
+    beforeEach(async ctx => {
+      ctx.req.params = {
+        project_id: ctx.projectId,
+        doc_id: ctx.docId,
+      }
+      ctx.req.body = { name: 'foo.tex' }
+      ctx.DocManager.patchDoc = sinon.stub().resolves()
+      await ctx.HttpController.patchDoc(ctx.req, ctx.res, ctx.next)
+    })
+
+    it('should delete the document', ctx => {
+      expect(ctx.DocManager.patchDoc).to.have.been.calledWith(
+        ctx.projectId,
+        ctx.docId
+      )
+    })
+
+    it('should return a 204 (No Content)', ctx => {
+      expect(ctx.res.sendStatus).to.have.been.calledWith(204)
+    })
+
+    describe('with an invalid payload', () => {
+      beforeEach(async ctx => {
+        ctx.req.body = { cannot: 'happen' }
+
+        ctx.DocManager.patchDoc = sinon.stub().resolves()
+        await ctx.HttpController.patchDoc(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should log a message', ctx => {
+        expect(ctx.logger.fatal).to.have.been.calledWith(
+          { field: 'cannot' },
+          'joi validation for pathDoc is broken'
+        )
+      })
+
+      it('should not pass the invalid field along', ctx => {
+        expect(ctx.DocManager.patchDoc).to.have.been.calledWith(
+          ctx.projectId,
+          ctx.docId,
+          {}
+        )
+      })
+    })
+  })
+
+  describe('archiveAllDocs', () => {
+    beforeEach(async ctx => {
+      ctx.req.params = { project_id: ctx.projectId }
+      ctx.DocArchiveManager.archiveAllDocs = sinon.stub().resolves()
+      await ctx.HttpController.archiveAllDocs(ctx.req, ctx.res, ctx.next)
+    })
+
+    it('should archive the project', ctx => {
+      ctx.DocArchiveManager.archiveAllDocs
+        .calledWith(ctx.projectId)
+        .should.equal(true)
+    })
+
+    it('should return a 204 (No Content)', ctx => {
+      ctx.res.sendStatus.calledWith(204).should.equal(true)
+    })
+  })
+
+  describe('unArchiveAllDocs', () => {
+    beforeEach(ctx => {
+      ctx.req.params = { project_id: ctx.projectId }
+    })
+
+    describe('on success', () => {
+      beforeEach(async ctx => {
+        await ctx.HttpController.unArchiveAllDocs(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('returns a 200', ctx => {
+        expect(ctx.res.sendStatus).to.have.been.calledWith(200)
+      })
+    })
+
+    describe("when the archived rev doesn't match", () => {
+      beforeEach(async ctx => {
+        ctx.DocArchiveManager.unArchiveAllDocs.rejects(
+          new Errors.DocRevValueError('bad rev')
+        )
+        await ctx.HttpController.unArchiveAllDocs(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('returns a 409', ctx => {
+        expect(ctx.res.sendStatus).to.have.been.calledWith(409)
+      })
+    })
+  })
+
+  describe('destroyProject', () => {
+    beforeEach(async ctx => {
+      ctx.req.params = { project_id: ctx.projectId }
+      ctx.DocArchiveManager.destroyProject = sinon.stub().resolves()
+      await ctx.HttpController.destroyProject(ctx.req, ctx.res, ctx.next)
+    })
+
+    it('should destroy the docs', ctx => {
+      sinon.assert.calledWith(
+        ctx.DocArchiveManager.destroyProject,
+        ctx.projectId
+      )
+    })
+
+    it('should return 204', ctx => {
+      sinon.assert.calledWith(ctx.res.sendStatus, 204)
+    })
+  })
+})

+ 0 - 589
services/docstore/test/unit/js/HttpControllerTests.js

@@ -1,589 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { assert, expect } = require('chai')
-const modulePath = require('node:path').join(
-  __dirname,
-  '../../../app/js/HttpController'
-)
-const { ObjectId } = require('mongodb-legacy')
-const Errors = require('../../../app/js/Errors')
-
-describe('HttpController', function () {
-  beforeEach(function () {
-    const settings = {
-      max_doc_length: 2 * 1024 * 1024,
-    }
-    this.DocArchiveManager = {
-      unArchiveAllDocs: sinon.stub().returns(),
-    }
-    this.DocManager = {}
-    this.HttpController = SandboxedModule.require(modulePath, {
-      requires: {
-        './DocManager': this.DocManager,
-        './DocArchiveManager': this.DocArchiveManager,
-        '@overleaf/settings': settings,
-        './HealthChecker': {},
-        './Errors': Errors,
-      },
-    })
-    this.res = {
-      send: sinon.stub(),
-      sendStatus: sinon.stub(),
-      json: sinon.stub(),
-      setHeader: sinon.stub(),
-    }
-    this.res.status = sinon.stub().returns(this.res)
-    this.req = { query: {} }
-    this.next = sinon.stub()
-    this.projectId = 'mock-project-id'
-    this.docId = 'mock-doc-id'
-    this.doc = {
-      _id: this.docId,
-      lines: ['mock', 'lines', ' here', '', '', ' spaces '],
-      version: 42,
-      rev: 5,
-    }
-    this.deletedDoc = {
-      deleted: true,
-      _id: this.docId,
-      lines: ['mock', 'lines', ' here', '', '', ' spaces '],
-      version: 42,
-      rev: 5,
-    }
-  })
-
-  describe('getDoc', function () {
-    describe('without deleted docs', function () {
-      beforeEach(async function () {
-        this.req.params = {
-          project_id: this.projectId,
-          doc_id: this.docId,
-        }
-        this.DocManager.getFullDoc = sinon.stub().resolves(this.doc)
-        await this.HttpController.getDoc(this.req, this.res, this.next)
-      })
-
-      it('should get the document with the version (including deleted)', function () {
-        this.DocManager.getFullDoc
-          .calledWith(this.projectId, this.docId)
-          .should.equal(true)
-      })
-
-      it('should return the doc as JSON', function () {
-        this.res.json
-          .calledWith({
-            _id: this.docId,
-            lines: this.doc.lines,
-            rev: this.doc.rev,
-            version: this.doc.version,
-          })
-          .should.equal(true)
-      })
-    })
-
-    describe('which is deleted', function () {
-      beforeEach(function () {
-        this.req.params = {
-          project_id: this.projectId,
-          doc_id: this.docId,
-        }
-        this.DocManager.getFullDoc = sinon.stub().resolves(this.deletedDoc)
-      })
-
-      it('should get the doc from the doc manager', async function () {
-        await this.HttpController.getDoc(this.req, this.res, this.next)
-        this.DocManager.getFullDoc
-          .calledWith(this.projectId, this.docId)
-          .should.equal(true)
-      })
-
-      it('should return 404 if the query string delete is not set ', async function () {
-        await this.HttpController.getDoc(this.req, this.res, this.next)
-        this.res.sendStatus.calledWith(404).should.equal(true)
-      })
-
-      it('should return the doc as JSON if include_deleted is set to true', async function () {
-        this.req.query.include_deleted = 'true'
-        await this.HttpController.getDoc(this.req, this.res, this.next)
-        this.res.json
-          .calledWith({
-            _id: this.docId,
-            lines: this.doc.lines,
-            rev: this.doc.rev,
-            deleted: true,
-            version: this.doc.version,
-          })
-          .should.equal(true)
-      })
-    })
-  })
-
-  describe('getRawDoc', function () {
-    beforeEach(async function () {
-      this.req.params = {
-        project_id: this.projectId,
-        doc_id: this.docId,
-      }
-      this.DocManager.getDocLines = sinon
-        .stub()
-        .resolves(this.doc.lines.join('\n'))
-      await this.HttpController.getRawDoc(this.req, this.res, this.next)
-    })
-
-    it('should get the document without the version', function () {
-      this.DocManager.getDocLines
-        .calledWith(this.projectId, this.docId)
-        .should.equal(true)
-    })
-
-    it('should set the content type header', function () {
-      this.res.setHeader
-        .calledWith('content-type', 'text/plain')
-        .should.equal(true)
-    })
-
-    it('should send the raw version of the doc', function () {
-      assert.deepEqual(
-        this.res.send.args[0][0],
-        `${this.doc.lines[0]}\n${this.doc.lines[1]}\n${this.doc.lines[2]}\n${this.doc.lines[3]}\n${this.doc.lines[4]}\n${this.doc.lines[5]}`
-      )
-    })
-  })
-
-  describe('getAllDocs', function () {
-    describe('normally', function () {
-      beforeEach(async function () {
-        this.req.params = { project_id: this.projectId }
-        this.docs = [
-          {
-            _id: new ObjectId(),
-            lines: ['mock', 'lines', 'one'],
-            rev: 2,
-          },
-          {
-            _id: new ObjectId(),
-            lines: ['mock', 'lines', 'two'],
-            rev: 4,
-          },
-        ]
-        this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs)
-        await this.HttpController.getAllDocs(this.req, this.res, this.next)
-      })
-
-      it('should get all the (non-deleted) docs', function () {
-        this.DocManager.getAllNonDeletedDocs
-          .calledWith(this.projectId, { lines: true, rev: true })
-          .should.equal(true)
-      })
-
-      it('should return the doc as JSON', function () {
-        this.res.json
-          .calledWith([
-            {
-              _id: this.docs[0]._id.toString(),
-              lines: this.docs[0].lines,
-              rev: this.docs[0].rev,
-            },
-            {
-              _id: this.docs[1]._id.toString(),
-              lines: this.docs[1].lines,
-              rev: this.docs[1].rev,
-            },
-          ])
-          .should.equal(true)
-      })
-    })
-
-    describe('with null lines', function () {
-      beforeEach(async function () {
-        this.req.params = { project_id: this.projectId }
-        this.docs = [
-          {
-            _id: new ObjectId(),
-            lines: null,
-            rev: 2,
-          },
-          {
-            _id: new ObjectId(),
-            lines: ['mock', 'lines', 'two'],
-            rev: 4,
-          },
-        ]
-        this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs)
-        await this.HttpController.getAllDocs(this.req, this.res, this.next)
-      })
-
-      it('should return the doc with fallback lines', function () {
-        this.res.json
-          .calledWith([
-            {
-              _id: this.docs[0]._id.toString(),
-              lines: [],
-              rev: this.docs[0].rev,
-            },
-            {
-              _id: this.docs[1]._id.toString(),
-              lines: this.docs[1].lines,
-              rev: this.docs[1].rev,
-            },
-          ])
-          .should.equal(true)
-      })
-    })
-
-    describe('with a null doc', function () {
-      beforeEach(async function () {
-        this.req.params = { project_id: this.projectId }
-        this.docs = [
-          {
-            _id: new ObjectId(),
-            lines: ['mock', 'lines', 'one'],
-            rev: 2,
-          },
-          null,
-          {
-            _id: new ObjectId(),
-            lines: ['mock', 'lines', 'two'],
-            rev: 4,
-          },
-        ]
-        this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs)
-        await this.HttpController.getAllDocs(this.req, this.res, this.next)
-      })
-
-      it('should return the non null docs as JSON', function () {
-        this.res.json
-          .calledWith([
-            {
-              _id: this.docs[0]._id.toString(),
-              lines: this.docs[0].lines,
-              rev: this.docs[0].rev,
-            },
-            {
-              _id: this.docs[2]._id.toString(),
-              lines: this.docs[2].lines,
-              rev: this.docs[2].rev,
-            },
-          ])
-          .should.equal(true)
-      })
-
-      it('should log out an error', function () {
-        this.logger.error
-          .calledWith(
-            {
-              err: sinon.match.has('message', 'null doc'),
-              projectId: this.projectId,
-            },
-            'encountered null doc'
-          )
-          .should.equal(true)
-      })
-    })
-  })
-
-  describe('getAllRanges', function () {
-    describe('normally', function () {
-      beforeEach(async function () {
-        this.req.params = { project_id: this.projectId }
-        this.docs = [
-          {
-            _id: new ObjectId(),
-            ranges: { mock_ranges: 'one' },
-          },
-          {
-            _id: new ObjectId(),
-            ranges: { mock_ranges: 'two' },
-          },
-        ]
-        this.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(this.docs)
-        await this.HttpController.getAllRanges(this.req, this.res, this.next)
-      })
-
-      it('should get all the (non-deleted) doc ranges', function () {
-        this.DocManager.getAllNonDeletedDocs
-          .calledWith(this.projectId, { ranges: true })
-          .should.equal(true)
-      })
-
-      it('should return the doc as JSON', function () {
-        this.res.json
-          .calledWith([
-            {
-              _id: this.docs[0]._id.toString(),
-              ranges: this.docs[0].ranges,
-            },
-            {
-              _id: this.docs[1]._id.toString(),
-              ranges: this.docs[1].ranges,
-            },
-          ])
-          .should.equal(true)
-      })
-    })
-  })
-
-  describe('updateDoc', function () {
-    beforeEach(function () {
-      this.req.params = {
-        project_id: this.projectId,
-        doc_id: this.docId,
-      }
-    })
-
-    describe('when the doc lines exist and were updated', function () {
-      beforeEach(async function () {
-        this.req.body = {
-          lines: (this.lines = ['hello', 'world']),
-          version: (this.version = 42),
-          ranges: (this.ranges = { changes: 'mock' }),
-        }
-        this.rev = 5
-        this.DocManager.updateDoc = sinon
-          .stub()
-          .resolves({ modified: true, rev: this.rev })
-        await this.HttpController.updateDoc(this.req, this.res, this.next)
-      })
-
-      it('should update the document', function () {
-        this.DocManager.updateDoc
-          .calledWith(
-            this.projectId,
-            this.docId,
-            this.lines,
-            this.version,
-            this.ranges
-          )
-          .should.equal(true)
-      })
-
-      it('should return a modified status', function () {
-        this.res.json
-          .calledWith({ modified: true, rev: this.rev })
-          .should.equal(true)
-      })
-    })
-
-    describe('when the doc lines exist and were not updated', function () {
-      beforeEach(async function () {
-        this.req.body = {
-          lines: (this.lines = ['hello', 'world']),
-          version: (this.version = 42),
-          ranges: {},
-        }
-        this.rev = 5
-        this.DocManager.updateDoc = sinon
-          .stub()
-          .resolves({ modified: false, rev: this.rev })
-        await this.HttpController.updateDoc(this.req, this.res, this.next)
-      })
-
-      it('should return a modified status', function () {
-        this.res.json
-          .calledWith({ modified: false, rev: this.rev })
-          .should.equal(true)
-      })
-    })
-
-    describe('when the doc lines are not provided', function () {
-      beforeEach(async function () {
-        this.req.body = { version: 42, ranges: {} }
-        this.DocManager.updateDoc = sinon
-          .stub()
-          .resolves({ modified: false, rev: 0 })
-        await this.HttpController.updateDoc(this.req, this.res, this.next)
-      })
-
-      it('should not update the document', function () {
-        this.DocManager.updateDoc.called.should.equal(false)
-      })
-
-      it('should return a 400 (bad request) response', function () {
-        this.res.sendStatus.calledWith(400).should.equal(true)
-      })
-    })
-
-    describe('when the doc version are not provided', function () {
-      beforeEach(async function () {
-        this.req.body = { version: 42, lines: ['hello world'] }
-        this.DocManager.updateDoc = sinon
-          .stub()
-          .resolves({ modified: false, rev: 0 })
-        await this.HttpController.updateDoc(this.req, this.res, this.next)
-      })
-
-      it('should not update the document', function () {
-        this.DocManager.updateDoc.called.should.equal(false)
-      })
-
-      it('should return a 400 (bad request) response', function () {
-        this.res.sendStatus.calledWith(400).should.equal(true)
-      })
-    })
-
-    describe('when the doc ranges is not provided', function () {
-      beforeEach(async function () {
-        this.req.body = { lines: ['foo'], version: 42 }
-        this.DocManager.updateDoc = sinon
-          .stub()
-          .resolves({ modified: false, rev: 0 })
-        await this.HttpController.updateDoc(this.req, this.res, this.next)
-      })
-
-      it('should not update the document', function () {
-        this.DocManager.updateDoc.called.should.equal(false)
-      })
-
-      it('should return a 400 (bad request) response', function () {
-        this.res.sendStatus.calledWith(400).should.equal(true)
-      })
-    })
-
-    describe('when the doc body is too large', function () {
-      beforeEach(async function () {
-        this.req.body = {
-          lines: (this.lines = Array(2049).fill('a'.repeat(1024))),
-          version: (this.version = 42),
-          ranges: (this.ranges = { changes: 'mock' }),
-        }
-        this.DocManager.updateDoc = sinon
-          .stub()
-          .resolves({ modified: false, rev: 0 })
-        await this.HttpController.updateDoc(this.req, this.res, this.next)
-      })
-
-      it('should not update the document', function () {
-        this.DocManager.updateDoc.called.should.equal(false)
-      })
-
-      it('should return a 413 (too large) response', function () {
-        sinon.assert.calledWith(this.res.status, 413)
-      })
-
-      it('should report that the document body is too large', function () {
-        sinon.assert.calledWith(this.res.send, 'document body too large')
-      })
-    })
-  })
-
-  describe('patchDoc', function () {
-    beforeEach(async function () {
-      this.req.params = {
-        project_id: this.projectId,
-        doc_id: this.docId,
-      }
-      this.req.body = { name: 'foo.tex' }
-      this.DocManager.patchDoc = sinon.stub().resolves()
-      await this.HttpController.patchDoc(this.req, this.res, this.next)
-    })
-
-    it('should delete the document', function () {
-      expect(this.DocManager.patchDoc).to.have.been.calledWith(
-        this.projectId,
-        this.docId
-      )
-    })
-
-    it('should return a 204 (No Content)', function () {
-      expect(this.res.sendStatus).to.have.been.calledWith(204)
-    })
-
-    describe('with an invalid payload', function () {
-      beforeEach(async function () {
-        this.req.body = { cannot: 'happen' }
-
-        this.DocManager.patchDoc = sinon.stub().resolves()
-        await this.HttpController.patchDoc(this.req, this.res, this.next)
-      })
-
-      it('should log a message', function () {
-        expect(this.logger.fatal).to.have.been.calledWith(
-          { field: 'cannot' },
-          'joi validation for pathDoc is broken'
-        )
-      })
-
-      it('should not pass the invalid field along', function () {
-        expect(this.DocManager.patchDoc).to.have.been.calledWith(
-          this.projectId,
-          this.docId,
-          {}
-        )
-      })
-    })
-  })
-
-  describe('archiveAllDocs', function () {
-    beforeEach(async function () {
-      this.req.params = { project_id: this.projectId }
-      this.DocArchiveManager.archiveAllDocs = sinon.stub().resolves()
-      await this.HttpController.archiveAllDocs(this.req, this.res, this.next)
-    })
-
-    it('should archive the project', function () {
-      this.DocArchiveManager.archiveAllDocs
-        .calledWith(this.projectId)
-        .should.equal(true)
-    })
-
-    it('should return a 204 (No Content)', function () {
-      this.res.sendStatus.calledWith(204).should.equal(true)
-    })
-  })
-
-  describe('unArchiveAllDocs', function () {
-    beforeEach(function () {
-      this.req.params = { project_id: this.projectId }
-    })
-
-    describe('on success', function () {
-      beforeEach(async function () {
-        await this.HttpController.unArchiveAllDocs(
-          this.req,
-          this.res,
-          this.next
-        )
-      })
-
-      it('returns a 200', function () {
-        expect(this.res.sendStatus).to.have.been.calledWith(200)
-      })
-    })
-
-    describe("when the archived rev doesn't match", function () {
-      beforeEach(async function () {
-        this.DocArchiveManager.unArchiveAllDocs.rejects(
-          new Errors.DocRevValueError('bad rev')
-        )
-        await this.HttpController.unArchiveAllDocs(
-          this.req,
-          this.res,
-          this.next
-        )
-      })
-
-      it('returns a 409', function () {
-        expect(this.res.sendStatus).to.have.been.calledWith(409)
-      })
-    })
-  })
-
-  describe('destroyProject', function () {
-    beforeEach(async function () {
-      this.req.params = { project_id: this.projectId }
-      this.DocArchiveManager.destroyProject = sinon.stub().resolves()
-      await this.HttpController.destroyProject(this.req, this.res, this.next)
-    })
-
-    it('should destroy the docs', function () {
-      sinon.assert.calledWith(
-        this.DocArchiveManager.destroyProject,
-        this.projectId
-      )
-    })
-
-    it('should return 204', function () {
-      sinon.assert.calledWith(this.res.sendStatus, 204)
-    })
-  })
-})

+ 411 - 0
services/docstore/test/unit/js/MongoManager.test.js

@@ -0,0 +1,411 @@
+import sinon from 'sinon'
+import { ObjectId } from 'mongodb-legacy'
+import path from 'node:path'
+import { assert, beforeEach, describe, expect, it, vi } from 'vitest'
+import Errors from '../../../app/js/Errors.js'
+
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../app/js/MongoManager'
+)
+
+describe('MongoManager', () => {
+  beforeEach(async ctx => {
+    ctx.db = {
+      docs: {
+        updateOne: sinon.stub().resolves({ matchedCount: 1 }),
+        insertOne: sinon.stub().resolves(),
+      },
+    }
+
+    vi.doMock('../../../app/js/mongodb', () => ({
+      default: {
+        db: ctx.db,
+        ObjectId,
+      },
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
+        max_deleted_docs: 42,
+        docstore: { archivingLockDurationMs: 5000 },
+      },
+    }))
+
+    vi.doMock('../../../app/js/Errors', () => ({
+      default: Errors,
+    }))
+
+    ctx.MongoManager = (await import(modulePath)).default
+    ctx.projectId = new ObjectId().toString()
+    ctx.docId = new ObjectId().toString()
+    ctx.rev = 42
+    ctx.stubbedErr = new Error('hello world')
+    ctx.lines = ['Three French hens', 'Two turtle doves']
+  })
+
+  describe('findDoc', () => {
+    beforeEach(async ctx => {
+      ctx.doc = { name: 'mock-doc' }
+      ctx.db.docs.findOne = sinon.stub().resolves(ctx.doc)
+      ctx.filter = { lines: true }
+      ctx.result = await ctx.MongoManager.findDoc(
+        ctx.projectId,
+        ctx.docId,
+        ctx.filter
+      )
+    })
+
+    it('should find the doc', ctx => {
+      ctx.db.docs.findOne
+        .calledWith(
+          {
+            _id: new ObjectId(ctx.docId),
+            project_id: new ObjectId(ctx.projectId),
+          },
+          {
+            projection: ctx.filter,
+          }
+        )
+        .should.equal(true)
+    })
+
+    it('should return the doc', ctx => {
+      expect(ctx.doc).to.deep.equal(ctx.doc)
+    })
+  })
+
+  describe('patchDoc', () => {
+    beforeEach(async ctx => {
+      ctx.meta = { name: 'foo.tex' }
+      await ctx.MongoManager.patchDoc(ctx.projectId, ctx.docId, ctx.meta)
+    })
+
+    it('should pass the parameter along', ctx => {
+      ctx.db.docs.updateOne.should.have.been.calledWith(
+        {
+          _id: new ObjectId(ctx.docId),
+          project_id: new ObjectId(ctx.projectId),
+        },
+        {
+          $set: ctx.meta,
+        }
+      )
+    })
+  })
+
+  describe('getProjectsDocs', () => {
+    beforeEach(ctx => {
+      ctx.filter = { lines: true }
+      ctx.doc1 = { name: 'mock-doc1' }
+      ctx.doc2 = { name: 'mock-doc2' }
+      ctx.doc3 = { name: 'mock-doc3' }
+      ctx.doc4 = { name: 'mock-doc4' }
+      ctx.db.docs.find = sinon.stub().returns({
+        toArray: sinon.stub().resolves([ctx.doc, ctx.doc3, ctx.doc4]),
+      })
+    })
+
+    describe('with included_deleted = false', () => {
+      beforeEach(async ctx => {
+        ctx.result = await ctx.MongoManager.getProjectsDocs(
+          ctx.projectId,
+          { include_deleted: false },
+          ctx.filter
+        )
+      })
+
+      it('should find the non-deleted docs via the project_id', ctx => {
+        ctx.db.docs.find
+          .calledWith(
+            {
+              project_id: new ObjectId(ctx.projectId),
+              deleted: { $ne: true },
+            },
+            {
+              projection: ctx.filter,
+            }
+          )
+          .should.equal(true)
+      })
+
+      it('should call return the docs', ctx => {
+        expect(ctx.result).to.deep.equal([ctx.doc, ctx.doc3, ctx.doc4])
+      })
+    })
+
+    describe('with included_deleted = true', () => {
+      beforeEach(async ctx => {
+        ctx.result = await ctx.MongoManager.getProjectsDocs(
+          ctx.projectId,
+          { include_deleted: true },
+          ctx.filter
+        )
+      })
+
+      it('should find all via the project_id', ctx => {
+        ctx.db.docs.find
+          .calledWith(
+            {
+              project_id: new ObjectId(ctx.projectId),
+            },
+            {
+              projection: ctx.filter,
+            }
+          )
+          .should.equal(true)
+      })
+
+      it('should return the docs', ctx => {
+        expect(ctx.result).to.deep.equal([ctx.doc, ctx.doc3, ctx.doc4])
+      })
+    })
+  })
+
+  describe('getProjectsDeletedDocs', () => {
+    beforeEach(async ctx => {
+      ctx.filter = { name: true }
+      ctx.doc1 = { _id: '1', name: 'mock-doc1.tex' }
+      ctx.doc2 = { _id: '2', name: 'mock-doc2.tex' }
+      ctx.doc3 = { _id: '3', name: 'mock-doc3.tex' }
+      ctx.db.docs.find = sinon.stub().returns({
+        toArray: sinon.stub().resolves([ctx.doc1, ctx.doc2, ctx.doc3]),
+      })
+      ctx.result = await ctx.MongoManager.getProjectsDeletedDocs(
+        ctx.projectId,
+        ctx.filter
+      )
+    })
+
+    it('should find the deleted docs via the project_id', ctx => {
+      ctx.db.docs.find
+        .calledWith({
+          project_id: new ObjectId(ctx.projectId),
+          deleted: true,
+        })
+        .should.equal(true)
+    })
+
+    it('should filter, sort by deletedAt and limit', ctx => {
+      ctx.db.docs.find
+        .calledWith(sinon.match.any, {
+          projection: ctx.filter,
+          sort: { deletedAt: -1 },
+          limit: 42,
+        })
+        .should.equal(true)
+    })
+
+    it('should return the docs', ctx => {
+      expect(ctx.result).to.deep.equal([ctx.doc1, ctx.doc2, ctx.doc3])
+    })
+  })
+
+  describe('upsertIntoDocCollection', () => {
+    beforeEach(ctx => {
+      ctx.oldRev = 77
+    })
+
+    it('should upsert the document', async ctx => {
+      await ctx.MongoManager.upsertIntoDocCollection(
+        ctx.projectId,
+        ctx.docId,
+        ctx.oldRev,
+        { lines: ctx.lines }
+      )
+
+      const args = ctx.db.docs.updateOne.args[0]
+      assert.deepEqual(args[0], {
+        _id: new ObjectId(ctx.docId),
+        project_id: new ObjectId(ctx.projectId),
+        rev: ctx.oldRev,
+      })
+      assert.equal(args[1].$set.lines, ctx.lines)
+      assert.equal(args[1].$inc.rev, 1)
+    })
+
+    it('should handle update error', async ctx => {
+      ctx.db.docs.updateOne.rejects(ctx.stubbedErr)
+      await expect(
+        ctx.MongoManager.upsertIntoDocCollection(
+          ctx.projectId,
+          ctx.docId,
+          ctx.rev,
+          {
+            lines: ctx.lines,
+          }
+        )
+      ).to.be.rejectedWith(ctx.stubbedErr)
+    })
+
+    it('should insert without a previous rev', async ctx => {
+      await ctx.MongoManager.upsertIntoDocCollection(
+        ctx.projectId,
+        ctx.docId,
+        null,
+        { lines: ctx.lines, ranges: ctx.ranges }
+      )
+
+      expect(ctx.db.docs.insertOne).to.have.been.calledWith({
+        _id: new ObjectId(ctx.docId),
+        project_id: new ObjectId(ctx.projectId),
+        rev: 1,
+        lines: ctx.lines,
+        ranges: ctx.ranges,
+      })
+    })
+
+    it('should handle generic insert error', async ctx => {
+      ctx.db.docs.insertOne.rejects(ctx.stubbedErr)
+      await expect(
+        ctx.MongoManager.upsertIntoDocCollection(
+          ctx.projectId,
+          ctx.docId,
+          null,
+          { lines: ctx.lines, ranges: ctx.ranges }
+        )
+      ).to.be.rejectedWith(ctx.stubbedErr)
+    })
+
+    it('should handle duplicate insert error', async ctx => {
+      ctx.db.docs.insertOne.rejects({ code: 11000 })
+      await expect(
+        ctx.MongoManager.upsertIntoDocCollection(
+          ctx.projectId,
+          ctx.docId,
+          null,
+          { lines: ctx.lines, ranges: ctx.ranges }
+        )
+      ).to.be.rejectedWith(Errors.DocRevValueError)
+    })
+  })
+
+  describe('destroyProject', () => {
+    beforeEach(async ctx => {
+      ctx.projectId = new ObjectId()
+      ctx.db.docs.deleteMany = sinon.stub().resolves()
+      await ctx.MongoManager.destroyProject(ctx.projectId)
+    })
+
+    it('should destroy all docs', ctx => {
+      sinon.assert.calledWith(ctx.db.docs.deleteMany, {
+        project_id: ctx.projectId,
+      })
+    })
+  })
+
+  describe('checkRevUnchanged', ctx => {
+    beforeEach(ctx => {
+      ctx.doc = { _id: new ObjectId(), name: 'mock-doc', rev: 1 }
+    })
+
+    it('should not error when the rev has not changed', async ctx => {
+      ctx.db.docs.findOne = sinon.stub().resolves({ rev: 1 })
+      await ctx.MongoManager.checkRevUnchanged(ctx.doc)
+    })
+
+    it('should return an error when the rev has changed', async ctx => {
+      ctx.db.docs.findOne = sinon.stub().resolves({ rev: 2 })
+      await expect(
+        ctx.MongoManager.checkRevUnchanged(ctx.doc)
+      ).to.be.rejectedWith(Errors.DocModifiedError)
+    })
+
+    it('should return a value error if incoming rev is NaN', async ctx => {
+      ctx.db.docs.findOne = sinon.stub().resolves({ rev: 2 })
+      ctx.doc = { _id: new ObjectId(), name: 'mock-doc', rev: NaN }
+      await expect(
+        ctx.MongoManager.checkRevUnchanged(ctx.doc)
+      ).to.be.rejectedWith(Errors.DocRevValueError)
+    })
+
+    it('should return a value error if checked doc rev is NaN', async ctx => {
+      ctx.db.docs.findOne = sinon.stub().resolves({ rev: NaN })
+      await expect(
+        ctx.MongoManager.checkRevUnchanged(ctx.doc)
+      ).to.be.rejectedWith(Errors.DocRevValueError)
+    })
+  })
+
+  describe('restoreArchivedDoc', () => {
+    beforeEach(ctx => {
+      ctx.archivedDoc = {
+        lines: ['a', 'b', 'c'],
+        ranges: { some: 'ranges' },
+        rev: 2,
+      }
+    })
+
+    describe('complete doc', () => {
+      beforeEach(async ctx => {
+        await ctx.MongoManager.restoreArchivedDoc(
+          ctx.projectId,
+          ctx.docId,
+          ctx.archivedDoc
+        )
+      })
+
+      it('updates Mongo', ctx => {
+        expect(ctx.db.docs.updateOne).to.have.been.calledWith(
+          {
+            _id: new ObjectId(ctx.docId),
+            project_id: new ObjectId(ctx.projectId),
+            rev: ctx.archivedDoc.rev,
+          },
+          {
+            $set: {
+              lines: ctx.archivedDoc.lines,
+              ranges: ctx.archivedDoc.ranges,
+            },
+            $unset: {
+              inS3: true,
+            },
+          }
+        )
+      })
+    })
+
+    describe('without ranges', () => {
+      beforeEach(async ctx => {
+        delete ctx.archivedDoc.ranges
+        await ctx.MongoManager.restoreArchivedDoc(
+          ctx.projectId,
+          ctx.docId,
+          ctx.archivedDoc
+        )
+      })
+
+      it('sets ranges to an empty object', ctx => {
+        expect(ctx.db.docs.updateOne).to.have.been.calledWith(
+          {
+            _id: new ObjectId(ctx.docId),
+            project_id: new ObjectId(ctx.projectId),
+            rev: ctx.archivedDoc.rev,
+          },
+          {
+            $set: {
+              lines: ctx.archivedDoc.lines,
+              ranges: {},
+            },
+            $unset: {
+              inS3: true,
+            },
+          }
+        )
+      })
+    })
+
+    describe("when the update doesn't succeed", () => {
+      it('throws a DocRevValueError', async ctx => {
+        ctx.db.docs.updateOne.resolves({ matchedCount: 0 })
+        await expect(
+          ctx.MongoManager.restoreArchivedDoc(
+            ctx.projectId,
+            ctx.docId,
+            ctx.archivedDoc
+          )
+        ).to.be.rejectedWith(Errors.DocRevValueError)
+      })
+    })
+  })
+})

+ 0 - 403
services/docstore/test/unit/js/MongoManagerTests.js

@@ -1,403 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const modulePath = require('node:path').join(
-  __dirname,
-  '../../../app/js/MongoManager'
-)
-const { ObjectId } = require('mongodb-legacy')
-const { assert, expect } = require('chai')
-const Errors = require('../../../app/js/Errors')
-
-describe('MongoManager', function () {
-  beforeEach(function () {
-    this.db = {
-      docs: {
-        updateOne: sinon.stub().resolves({ matchedCount: 1 }),
-        insertOne: sinon.stub().resolves(),
-      },
-    }
-    this.MongoManager = SandboxedModule.require(modulePath, {
-      requires: {
-        './mongodb': {
-          db: this.db,
-          ObjectId,
-        },
-        '@overleaf/settings': {
-          max_deleted_docs: 42,
-          docstore: { archivingLockDurationMs: 5000 },
-        },
-        './Errors': Errors,
-      },
-    })
-    this.projectId = new ObjectId().toString()
-    this.docId = new ObjectId().toString()
-    this.rev = 42
-    this.stubbedErr = new Error('hello world')
-    this.lines = ['Three French hens', 'Two turtle doves']
-  })
-
-  describe('findDoc', function () {
-    beforeEach(async function () {
-      this.doc = { name: 'mock-doc' }
-      this.db.docs.findOne = sinon.stub().resolves(this.doc)
-      this.filter = { lines: true }
-      this.result = await this.MongoManager.findDoc(
-        this.projectId,
-        this.docId,
-        this.filter
-      )
-    })
-
-    it('should find the doc', function () {
-      this.db.docs.findOne
-        .calledWith(
-          {
-            _id: new ObjectId(this.docId),
-            project_id: new ObjectId(this.projectId),
-          },
-          {
-            projection: this.filter,
-          }
-        )
-        .should.equal(true)
-    })
-
-    it('should return the doc', function () {
-      expect(this.doc).to.deep.equal(this.doc)
-    })
-  })
-
-  describe('patchDoc', function () {
-    beforeEach(async function () {
-      this.meta = { name: 'foo.tex' }
-      await this.MongoManager.patchDoc(this.projectId, this.docId, this.meta)
-    })
-
-    it('should pass the parameter along', function () {
-      this.db.docs.updateOne.should.have.been.calledWith(
-        {
-          _id: new ObjectId(this.docId),
-          project_id: new ObjectId(this.projectId),
-        },
-        {
-          $set: this.meta,
-        }
-      )
-    })
-  })
-
-  describe('getProjectsDocs', function () {
-    beforeEach(function () {
-      this.filter = { lines: true }
-      this.doc1 = { name: 'mock-doc1' }
-      this.doc2 = { name: 'mock-doc2' }
-      this.doc3 = { name: 'mock-doc3' }
-      this.doc4 = { name: 'mock-doc4' }
-      this.db.docs.find = sinon.stub().returns({
-        toArray: sinon.stub().resolves([this.doc, this.doc3, this.doc4]),
-      })
-    })
-
-    describe('with included_deleted = false', function () {
-      beforeEach(async function () {
-        this.result = await this.MongoManager.getProjectsDocs(
-          this.projectId,
-          { include_deleted: false },
-          this.filter
-        )
-      })
-
-      it('should find the non-deleted docs via the project_id', function () {
-        this.db.docs.find
-          .calledWith(
-            {
-              project_id: new ObjectId(this.projectId),
-              deleted: { $ne: true },
-            },
-            {
-              projection: this.filter,
-            }
-          )
-          .should.equal(true)
-      })
-
-      it('should call return the docs', function () {
-        expect(this.result).to.deep.equal([this.doc, this.doc3, this.doc4])
-      })
-    })
-
-    describe('with included_deleted = true', function () {
-      beforeEach(async function () {
-        this.result = await this.MongoManager.getProjectsDocs(
-          this.projectId,
-          { include_deleted: true },
-          this.filter
-        )
-      })
-
-      it('should find all via the project_id', function () {
-        this.db.docs.find
-          .calledWith(
-            {
-              project_id: new ObjectId(this.projectId),
-            },
-            {
-              projection: this.filter,
-            }
-          )
-          .should.equal(true)
-      })
-
-      it('should return the docs', function () {
-        expect(this.result).to.deep.equal([this.doc, this.doc3, this.doc4])
-      })
-    })
-  })
-
-  describe('getProjectsDeletedDocs', function () {
-    beforeEach(async function () {
-      this.filter = { name: true }
-      this.doc1 = { _id: '1', name: 'mock-doc1.tex' }
-      this.doc2 = { _id: '2', name: 'mock-doc2.tex' }
-      this.doc3 = { _id: '3', name: 'mock-doc3.tex' }
-      this.db.docs.find = sinon.stub().returns({
-        toArray: sinon.stub().resolves([this.doc1, this.doc2, this.doc3]),
-      })
-      this.result = await this.MongoManager.getProjectsDeletedDocs(
-        this.projectId,
-        this.filter
-      )
-    })
-
-    it('should find the deleted docs via the project_id', function () {
-      this.db.docs.find
-        .calledWith({
-          project_id: new ObjectId(this.projectId),
-          deleted: true,
-        })
-        .should.equal(true)
-    })
-
-    it('should filter, sort by deletedAt and limit', function () {
-      this.db.docs.find
-        .calledWith(sinon.match.any, {
-          projection: this.filter,
-          sort: { deletedAt: -1 },
-          limit: 42,
-        })
-        .should.equal(true)
-    })
-
-    it('should return the docs', function () {
-      expect(this.result).to.deep.equal([this.doc1, this.doc2, this.doc3])
-    })
-  })
-
-  describe('upsertIntoDocCollection', function () {
-    beforeEach(function () {
-      this.oldRev = 77
-    })
-
-    it('should upsert the document', async function () {
-      await this.MongoManager.upsertIntoDocCollection(
-        this.projectId,
-        this.docId,
-        this.oldRev,
-        { lines: this.lines }
-      )
-
-      const args = this.db.docs.updateOne.args[0]
-      assert.deepEqual(args[0], {
-        _id: new ObjectId(this.docId),
-        project_id: new ObjectId(this.projectId),
-        rev: this.oldRev,
-      })
-      assert.equal(args[1].$set.lines, this.lines)
-      assert.equal(args[1].$inc.rev, 1)
-    })
-
-    it('should handle update error', async function () {
-      this.db.docs.updateOne.rejects(this.stubbedErr)
-      await expect(
-        this.MongoManager.upsertIntoDocCollection(
-          this.projectId,
-          this.docId,
-          this.rev,
-          {
-            lines: this.lines,
-          }
-        )
-      ).to.be.rejectedWith(this.stubbedErr)
-    })
-
-    it('should insert without a previous rev', async function () {
-      await this.MongoManager.upsertIntoDocCollection(
-        this.projectId,
-        this.docId,
-        null,
-        { lines: this.lines, ranges: this.ranges }
-      )
-
-      expect(this.db.docs.insertOne).to.have.been.calledWith({
-        _id: new ObjectId(this.docId),
-        project_id: new ObjectId(this.projectId),
-        rev: 1,
-        lines: this.lines,
-        ranges: this.ranges,
-      })
-    })
-
-    it('should handle generic insert error', async function () {
-      this.db.docs.insertOne.rejects(this.stubbedErr)
-      await expect(
-        this.MongoManager.upsertIntoDocCollection(
-          this.projectId,
-          this.docId,
-          null,
-          { lines: this.lines, ranges: this.ranges }
-        )
-      ).to.be.rejectedWith(this.stubbedErr)
-    })
-
-    it('should handle duplicate insert error', async function () {
-      this.db.docs.insertOne.rejects({ code: 11000 })
-      await expect(
-        this.MongoManager.upsertIntoDocCollection(
-          this.projectId,
-          this.docId,
-          null,
-          { lines: this.lines, ranges: this.ranges }
-        )
-      ).to.be.rejectedWith(Errors.DocRevValueError)
-    })
-  })
-
-  describe('destroyProject', function () {
-    beforeEach(async function () {
-      this.projectId = new ObjectId()
-      this.db.docs.deleteMany = sinon.stub().resolves()
-      await this.MongoManager.destroyProject(this.projectId)
-    })
-
-    it('should destroy all docs', function () {
-      sinon.assert.calledWith(this.db.docs.deleteMany, {
-        project_id: this.projectId,
-      })
-    })
-  })
-
-  describe('checkRevUnchanged', function () {
-    this.beforeEach(function () {
-      this.doc = { _id: new ObjectId(), name: 'mock-doc', rev: 1 }
-    })
-
-    it('should not error when the rev has not changed', async function () {
-      this.db.docs.findOne = sinon.stub().resolves({ rev: 1 })
-      await this.MongoManager.checkRevUnchanged(this.doc)
-    })
-
-    it('should return an error when the rev has changed', async function () {
-      this.db.docs.findOne = sinon.stub().resolves({ rev: 2 })
-      await expect(
-        this.MongoManager.checkRevUnchanged(this.doc)
-      ).to.be.rejectedWith(Errors.DocModifiedError)
-    })
-
-    it('should return a value error if incoming rev is NaN', async function () {
-      this.db.docs.findOne = sinon.stub().resolves({ rev: 2 })
-      this.doc = { _id: new ObjectId(), name: 'mock-doc', rev: NaN }
-      await expect(
-        this.MongoManager.checkRevUnchanged(this.doc)
-      ).to.be.rejectedWith(Errors.DocRevValueError)
-    })
-
-    it('should return a value error if checked doc rev is NaN', async function () {
-      this.db.docs.findOne = sinon.stub().resolves({ rev: NaN })
-      await expect(
-        this.MongoManager.checkRevUnchanged(this.doc)
-      ).to.be.rejectedWith(Errors.DocRevValueError)
-    })
-  })
-
-  describe('restoreArchivedDoc', function () {
-    beforeEach(function () {
-      this.archivedDoc = {
-        lines: ['a', 'b', 'c'],
-        ranges: { some: 'ranges' },
-        rev: 2,
-      }
-    })
-
-    describe('complete doc', function () {
-      beforeEach(async function () {
-        await this.MongoManager.restoreArchivedDoc(
-          this.projectId,
-          this.docId,
-          this.archivedDoc
-        )
-      })
-
-      it('updates Mongo', function () {
-        expect(this.db.docs.updateOne).to.have.been.calledWith(
-          {
-            _id: new ObjectId(this.docId),
-            project_id: new ObjectId(this.projectId),
-            rev: this.archivedDoc.rev,
-          },
-          {
-            $set: {
-              lines: this.archivedDoc.lines,
-              ranges: this.archivedDoc.ranges,
-            },
-            $unset: {
-              inS3: true,
-            },
-          }
-        )
-      })
-    })
-
-    describe('without ranges', function () {
-      beforeEach(async function () {
-        delete this.archivedDoc.ranges
-        await this.MongoManager.restoreArchivedDoc(
-          this.projectId,
-          this.docId,
-          this.archivedDoc
-        )
-      })
-
-      it('sets ranges to an empty object', function () {
-        expect(this.db.docs.updateOne).to.have.been.calledWith(
-          {
-            _id: new ObjectId(this.docId),
-            project_id: new ObjectId(this.projectId),
-            rev: this.archivedDoc.rev,
-          },
-          {
-            $set: {
-              lines: this.archivedDoc.lines,
-              ranges: {},
-            },
-            $unset: {
-              inS3: true,
-            },
-          }
-        )
-      })
-    })
-
-    describe("when the update doesn't succeed", function () {
-      it('throws a DocRevValueError', async function () {
-        this.db.docs.updateOne.resolves({ matchedCount: 0 })
-        await expect(
-          this.MongoManager.restoreArchivedDoc(
-            this.projectId,
-            this.docId,
-            this.archivedDoc
-          )
-        ).to.be.rejectedWith(Errors.DocRevValueError)
-      })
-    })
-  })
-})

+ 57 - 0
services/docstore/test/unit/js/PersistorManager.test.js

@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from 'vitest'
+
+const modulePath = '../../../app/js/PersistorManager.js'
+
+describe('PersistorManager', () => {
+  class FakePersistor {
+    async sendStream() {
+      return 'sent'
+    }
+  }
+
+  describe('configured', () => {
+    it('should return fake persistor', async () => {
+      const Settings = {
+        docstore: {
+          backend: 'gcs',
+          bucket: 'wombat',
+        },
+      }
+      vi.doMock('@overleaf/settings', () => ({
+        default: Settings,
+      }))
+      vi.doMock('@overleaf/object-persistor', () => ({
+        default: () => new FakePersistor(),
+      }))
+      vi.doMock('@overleaf/metrics', () => ({ default: {} }))
+      const PersistorManger = (await import(modulePath)).default
+
+      expect(PersistorManger).to.be.instanceof(FakePersistor)
+      expect(PersistorManger.sendStream()).to.eventually.equal('sent')
+    })
+  })
+
+  describe('not configured', () => {
+    it('should return abstract persistor', async () => {
+      const Settings = {
+        docstore: {
+          backend: undefined,
+          bucket: 'wombat',
+        },
+      }
+      vi.doMock('@overleaf/settings', () => ({
+        default: Settings,
+      }))
+      vi.doMock('@overleaf/object-persistor', () => ({
+        default: () => new FakePersistor(),
+      }))
+      vi.doMock('@overleaf/metrics', () => ({ default: {} }))
+      const PersistorManger = (await import(modulePath)).default
+
+      expect(PersistorManger.constructor.name).to.equal('AbstractPersistor')
+      expect(PersistorManger.sendStream()).to.eventually.be.rejectedWith(
+        /method not implemented in persistor/
+      )
+    })
+  })
+})

+ 0 - 55
services/docstore/test/unit/js/PersistorManagerTests.js

@@ -1,55 +0,0 @@
-const { expect } = require('chai')
-const modulePath = '../../../app/js/PersistorManager.js'
-const SandboxedModule = require('sandboxed-module')
-
-describe('PersistorManager', function () {
-  class FakePersistor {
-    async sendStream() {
-      return 'sent'
-    }
-  }
-
-  describe('configured', function () {
-    it('should return fake persistor', function () {
-      const Settings = {
-        docstore: {
-          backend: 'gcs',
-          bucket: 'wombat',
-        },
-      }
-      const PersistorManger = SandboxedModule.require(modulePath, {
-        requires: {
-          '@overleaf/settings': Settings,
-          '@overleaf/object-persistor': () => new FakePersistor(),
-          '@overleaf/metrics': {},
-        },
-      })
-
-      expect(PersistorManger).to.be.instanceof(FakePersistor)
-      expect(PersistorManger.sendStream()).to.eventually.equal('sent')
-    })
-  })
-
-  describe('not configured', function () {
-    it('should return abstract persistor', async function () {
-      const Settings = {
-        docstore: {
-          backend: undefined,
-          bucket: 'wombat',
-        },
-      }
-      const PersistorManger = SandboxedModule.require(modulePath, {
-        requires: {
-          '@overleaf/settings': Settings,
-          '@overleaf/object-persistor': () => new FakePersistor(),
-          '@overleaf/metrics': {},
-        },
-      })
-
-      expect(PersistorManger.constructor.name).to.equal('AbstractPersistor')
-      expect(PersistorManger.sendStream()).to.eventually.be.rejectedWith(
-        /method not implemented in persistor/
-      )
-    })
-  })
-})

+ 239 - 0
services/docstore/test/unit/js/RangeManager.test.js

@@ -0,0 +1,239 @@
+import path from 'node:path'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ObjectId } from 'mongodb-legacy'
+
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../app/js/RangeManager'
+)
+
+describe('RangeManager', () => {
+  beforeEach(async ctx => {
+    vi.doMock('../../../app/js/mongodb', () => ({
+      default: {
+        ObjectId,
+      },
+    }))
+
+    ctx.RangeManager = (await import(modulePath)).default
+  })
+
+  describe('jsonRangesToMongo', () => {
+    it('should convert ObjectIds and dates to proper objects and fix comment id', ctx => {
+      const changeId = new ObjectId().toString()
+      const commentId = new ObjectId().toString()
+      const userId = new ObjectId().toString()
+      const threadId = new ObjectId().toString()
+      const ts = new Date().toJSON()
+      ctx.RangeManager.jsonRangesToMongo({
+        changes: [
+          {
+            id: changeId,
+            op: { i: 'foo', p: 3 },
+            metadata: {
+              user_id: userId,
+              ts,
+            },
+          },
+        ],
+        comments: [
+          {
+            id: commentId,
+            op: { c: 'foo', p: 3, t: threadId },
+          },
+        ],
+      }).should.deep.equal({
+        changes: [
+          {
+            id: new ObjectId(changeId),
+            op: { i: 'foo', p: 3 },
+            metadata: {
+              user_id: new ObjectId(userId),
+              ts: new Date(ts),
+            },
+          },
+        ],
+        comments: [
+          {
+            id: new ObjectId(threadId),
+            op: { c: 'foo', p: 3, t: new ObjectId(threadId) },
+          },
+        ],
+      })
+    })
+
+    it('should leave malformed ObjectIds as they are', ctx => {
+      const changeId = 'foo'
+      const commentId = 'bar'
+      const userId = 'baz'
+      ctx.RangeManager.jsonRangesToMongo({
+        changes: [
+          {
+            id: changeId,
+            metadata: {
+              user_id: userId,
+            },
+          },
+        ],
+        comments: [
+          {
+            id: commentId,
+          },
+        ],
+      }).should.deep.equal({
+        changes: [
+          {
+            id: changeId,
+            metadata: {
+              user_id: userId,
+            },
+          },
+        ],
+        comments: [
+          {
+            id: commentId,
+          },
+        ],
+      })
+    })
+
+    it('should be consistent when transformed through json -> mongo -> json', ctx => {
+      const changeId = new ObjectId().toString()
+      const userId = new ObjectId().toString()
+      const threadId = new ObjectId().toString()
+      const ts = new Date().toJSON()
+      const ranges1 = {
+        changes: [
+          {
+            id: changeId,
+            op: { i: 'foo', p: 3 },
+            metadata: {
+              user_id: userId,
+              ts,
+            },
+          },
+        ],
+        comments: [
+          {
+            id: threadId,
+            op: { c: 'foo', p: 3, t: threadId },
+          },
+        ],
+      }
+      const ranges1Copy = JSON.parse(JSON.stringify(ranges1)) // jsonRangesToMongo modifies in place
+      const ranges2 = JSON.parse(
+        JSON.stringify(ctx.RangeManager.jsonRangesToMongo(ranges1Copy))
+      )
+      ranges1.should.deep.equal(ranges2)
+    })
+  })
+
+  return describe('shouldUpdateRanges', () => {
+    beforeEach(ctx => {
+      const threadId = new ObjectId()
+      ctx.ranges = {
+        changes: [
+          {
+            id: new ObjectId(),
+            op: { i: 'foo', p: 3 },
+            metadata: {
+              user_id: new ObjectId(),
+              ts: new Date(),
+            },
+          },
+        ],
+        comments: [
+          {
+            id: threadId,
+            op: { c: 'foo', p: 3, t: threadId },
+          },
+        ],
+      }
+      ctx.ranges_copy = ctx.RangeManager.jsonRangesToMongo(
+        JSON.parse(JSON.stringify(ctx.ranges))
+      )
+    })
+
+    describe('with a blank new range', () => {
+      it('should throw an error', ctx => {
+        expect(() => {
+          ctx.RangeManager.shouldUpdateRanges(ctx.ranges, null)
+        }).to.throw(Error)
+      })
+    })
+
+    describe('with a blank old range', () => {
+      it('should treat it like {}', ctx => {
+        ctx.RangeManager.shouldUpdateRanges(null, {}).should.equal(false)
+        ctx.RangeManager.shouldUpdateRanges(null, ctx.ranges).should.equal(true)
+      })
+    })
+
+    describe('with no changes', () => {
+      it('should return false', ctx => {
+        ctx.RangeManager.shouldUpdateRanges(
+          ctx.ranges,
+          ctx.ranges_copy
+        ).should.equal(false)
+      })
+    })
+
+    describe('with changes', () => {
+      it('should return true when the change id changes', ctx => {
+        ctx.ranges_copy.changes[0].id = new ObjectId()
+        ctx.RangeManager.shouldUpdateRanges(
+          ctx.ranges,
+          ctx.ranges_copy
+        ).should.equal(true)
+      })
+
+      it('should return true when the change user id changes', ctx => {
+        ctx.ranges_copy.changes[0].metadata.user_id = new ObjectId()
+        ctx.RangeManager.shouldUpdateRanges(
+          ctx.ranges,
+          ctx.ranges_copy
+        ).should.equal(true)
+      })
+
+      it('should return true when the change ts changes', ctx => {
+        ctx.ranges_copy.changes[0].metadata.ts = new Date(Date.now() + 1000)
+        ctx.RangeManager.shouldUpdateRanges(
+          ctx.ranges,
+          ctx.ranges_copy
+        ).should.equal(true)
+      })
+
+      it('should return true when the change op changes', ctx => {
+        ctx.ranges_copy.changes[0].op.i = 'bar'
+        ctx.RangeManager.shouldUpdateRanges(
+          ctx.ranges,
+          ctx.ranges_copy
+        ).should.equal(true)
+      })
+
+      it('should return true when the comment id changes', ctx => {
+        ctx.ranges_copy.comments[0].id = new ObjectId()
+        ctx.RangeManager.shouldUpdateRanges(
+          ctx.ranges,
+          ctx.ranges_copy
+        ).should.equal(true)
+      })
+
+      it('should return true when the comment offset changes', ctx => {
+        ctx.ranges_copy.comments[0].op.p = 17
+        ctx.RangeManager.shouldUpdateRanges(
+          ctx.ranges,
+          ctx.ranges_copy
+        ).should.equal(true)
+      })
+
+      it('should return true when the comment content changes', ctx => {
+        ctx.ranges_copy.comments[0].op.c = 'bar'
+        ctx.RangeManager.shouldUpdateRanges(
+          ctx.ranges,
+          ctx.ranges_copy
+        ).should.equal(true)
+      })
+    })
+  })
+})

+ 0 - 253
services/docstore/test/unit/js/RangeManagerTests.js

@@ -1,253 +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 sinon = require('sinon')
-const { assert, expect } = require('chai')
-const modulePath = require('node:path').join(
-  __dirname,
-  '../../../app/js/RangeManager'
-)
-const { ObjectId } = require('mongodb-legacy')
-
-describe('RangeManager', function () {
-  beforeEach(function () {
-    return (this.RangeManager = SandboxedModule.require(modulePath, {
-      requires: {
-        './mongodb': {
-          ObjectId,
-        },
-      },
-    }))
-  })
-
-  describe('jsonRangesToMongo', function () {
-    it('should convert ObjectIds and dates to proper objects and fix comment id', function () {
-      const changeId = new ObjectId().toString()
-      const commentId = new ObjectId().toString()
-      const userId = new ObjectId().toString()
-      const threadId = new ObjectId().toString()
-      const ts = new Date().toJSON()
-      return this.RangeManager.jsonRangesToMongo({
-        changes: [
-          {
-            id: changeId,
-            op: { i: 'foo', p: 3 },
-            metadata: {
-              user_id: userId,
-              ts,
-            },
-          },
-        ],
-        comments: [
-          {
-            id: commentId,
-            op: { c: 'foo', p: 3, t: threadId },
-          },
-        ],
-      }).should.deep.equal({
-        changes: [
-          {
-            id: new ObjectId(changeId),
-            op: { i: 'foo', p: 3 },
-            metadata: {
-              user_id: new ObjectId(userId),
-              ts: new Date(ts),
-            },
-          },
-        ],
-        comments: [
-          {
-            id: new ObjectId(threadId),
-            op: { c: 'foo', p: 3, t: new ObjectId(threadId) },
-          },
-        ],
-      })
-    })
-
-    it('should leave malformed ObjectIds as they are', function () {
-      const changeId = 'foo'
-      const commentId = 'bar'
-      const userId = 'baz'
-      return this.RangeManager.jsonRangesToMongo({
-        changes: [
-          {
-            id: changeId,
-            metadata: {
-              user_id: userId,
-            },
-          },
-        ],
-        comments: [
-          {
-            id: commentId,
-          },
-        ],
-      }).should.deep.equal({
-        changes: [
-          {
-            id: changeId,
-            metadata: {
-              user_id: userId,
-            },
-          },
-        ],
-        comments: [
-          {
-            id: commentId,
-          },
-        ],
-      })
-    })
-
-    return it('should be consistent when transformed through json -> mongo -> json', function () {
-      const changeId = new ObjectId().toString()
-      const userId = new ObjectId().toString()
-      const threadId = new ObjectId().toString()
-      const ts = new Date().toJSON()
-      const ranges1 = {
-        changes: [
-          {
-            id: changeId,
-            op: { i: 'foo', p: 3 },
-            metadata: {
-              user_id: userId,
-              ts,
-            },
-          },
-        ],
-        comments: [
-          {
-            id: threadId,
-            op: { c: 'foo', p: 3, t: threadId },
-          },
-        ],
-      }
-      const ranges1Copy = JSON.parse(JSON.stringify(ranges1)) // jsonRangesToMongo modifies in place
-      const ranges2 = JSON.parse(
-        JSON.stringify(this.RangeManager.jsonRangesToMongo(ranges1Copy))
-      )
-      return ranges1.should.deep.equal(ranges2)
-    })
-  })
-
-  return describe('shouldUpdateRanges', function () {
-    beforeEach(function () {
-      const threadId = new ObjectId()
-      this.ranges = {
-        changes: [
-          {
-            id: new ObjectId(),
-            op: { i: 'foo', p: 3 },
-            metadata: {
-              user_id: new ObjectId(),
-              ts: new Date(),
-            },
-          },
-        ],
-        comments: [
-          {
-            id: threadId,
-            op: { c: 'foo', p: 3, t: threadId },
-          },
-        ],
-      }
-      return (this.ranges_copy = this.RangeManager.jsonRangesToMongo(
-        JSON.parse(JSON.stringify(this.ranges))
-      ))
-    })
-
-    describe('with a blank new range', function () {
-      return it('should throw an error', function () {
-        return expect(() => {
-          return this.RangeManager.shouldUpdateRanges(this.ranges, null)
-        }).to.throw(Error)
-      })
-    })
-
-    describe('with a blank old range', function () {
-      return it('should treat it like {}', function () {
-        this.RangeManager.shouldUpdateRanges(null, {}).should.equal(false)
-        return this.RangeManager.shouldUpdateRanges(
-          null,
-          this.ranges
-        ).should.equal(true)
-      })
-    })
-
-    describe('with no changes', function () {
-      return it('should return false', function () {
-        return this.RangeManager.shouldUpdateRanges(
-          this.ranges,
-          this.ranges_copy
-        ).should.equal(false)
-      })
-    })
-
-    return describe('with changes', function () {
-      it('should return true when the change id changes', function () {
-        this.ranges_copy.changes[0].id = new ObjectId()
-        return this.RangeManager.shouldUpdateRanges(
-          this.ranges,
-          this.ranges_copy
-        ).should.equal(true)
-      })
-
-      it('should return true when the change user id changes', function () {
-        this.ranges_copy.changes[0].metadata.user_id = new ObjectId()
-        return this.RangeManager.shouldUpdateRanges(
-          this.ranges,
-          this.ranges_copy
-        ).should.equal(true)
-      })
-
-      it('should return true when the change ts changes', function () {
-        this.ranges_copy.changes[0].metadata.ts = new Date(Date.now() + 1000)
-        return this.RangeManager.shouldUpdateRanges(
-          this.ranges,
-          this.ranges_copy
-        ).should.equal(true)
-      })
-
-      it('should return true when the change op changes', function () {
-        this.ranges_copy.changes[0].op.i = 'bar'
-        return this.RangeManager.shouldUpdateRanges(
-          this.ranges,
-          this.ranges_copy
-        ).should.equal(true)
-      })
-
-      it('should return true when the comment id changes', function () {
-        this.ranges_copy.comments[0].id = new ObjectId()
-        return this.RangeManager.shouldUpdateRanges(
-          this.ranges,
-          this.ranges_copy
-        ).should.equal(true)
-      })
-
-      it('should return true when the comment offset changes', function () {
-        this.ranges_copy.comments[0].op.p = 17
-        return this.RangeManager.shouldUpdateRanges(
-          this.ranges,
-          this.ranges_copy
-        ).should.equal(true)
-      })
-
-      return it('should return true when the comment content changes', function () {
-        this.ranges_copy.comments[0].op.c = 'bar'
-        return this.RangeManager.shouldUpdateRanges(
-          this.ranges,
-          this.ranges_copy
-        ).should.equal(true)
-      })
-    })
-  })
-})

+ 40 - 0
services/docstore/test/unit/setup.js

@@ -0,0 +1,40 @@
+import { afterEach, beforeEach, chai, vi } from 'vitest'
+import sinon from 'sinon'
+import sinonChai from 'sinon-chai'
+import chaiAsPromised from 'chai-as-promised'
+import mongodb from 'mongodb-legacy'
+
+// ensure every ObjectId has the id string as a property for correct comparisons
+mongodb.ObjectId.cacheHexString = true
+
+process.env.BACKEND = 'gcs'
+
+// Chai configuration
+chai.should()
+chai.use(sinonChai)
+chai.use(chaiAsPromised)
+
+// 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(),
+    fatal: sandbox.stub(),
+  },
+}
+
+beforeEach(ctx => {
+  ctx.logger = stubs.logger
+  vi.doMock('@overleaf/logger', () => ({ default: ctx.logger }))
+})
+
+afterEach(() => {
+  vi.restoreAllMocks()
+  vi.resetModules()
+  sandbox.reset()
+})

+ 2 - 1
services/docstore/tsconfig.json

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

+ 25 - 0
services/docstore/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: true,
+    ...reporterOptions,
+  },
+})