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

[docstore] conversion to esm

GitOrigin-RevId: 9d255047bd7ae25f2b0b38f3a721741e8a0b7ad8
This commit is contained in:
Andrew Rumble
2025-12-02 09:39:03 +00:00
committed by Copybot
parent f1e788d9b3
commit a1f1ca2028
42 changed files with 2736 additions and 2541 deletions

509
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -1,20 +1,22 @@
// Metrics must be initialized before importing anything else
require('@overleaf/metrics/initialize')
import '@overleaf/metrics/initialize.js'
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 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

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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"
}
}

View File

@@ -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],

View File

@@ -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 () {

View File

@@ -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) {

View File

@@ -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 () {

View File

@@ -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 () {

View File

@@ -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 () {

View File

@@ -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 () {

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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()
},
}

View File

@@ -1,12 +1,12 @@
const sinon = require('sinon')
const { expect } = require('chai')
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')
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'
describe('DocArchiveManager', function () {
const modulePath = '../../../app/js/DocArchiveManager.js'
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

View File

@@ -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 })
})
})
})
})

View File

@@ -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 })
})
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})
})

View File

@@ -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)
})
})
})
})

View File

@@ -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/
)
})
})
})

View File

@@ -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/
)
})
})
})

View File

@@ -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)
})
})
})
})

View File

@@ -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)
})
})
})
})

View File

@@ -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()
})

View File

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

View File

@@ -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,
},
})