diff --git a/package-lock.json b/package-lock.json index d0b20dd408..dbef411cbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@types/mocha": "^10.0.6", "@typescript-eslint/eslint-plugin": "^8.30.1", "@typescript-eslint/parser": "^8.30.1", + "@vitest/eslint-plugin": "^1.5.0", "eslint": "^8.15.0", "eslint-config-prettier": "^8.5.0", "eslint-config-standard": "^17.0.0", @@ -10928,6 +10929,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -10945,6 +10947,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -10962,6 +10965,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -10979,6 +10983,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -10996,6 +11001,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -11013,6 +11019,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -11030,6 +11037,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -11047,6 +11055,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -11064,6 +11073,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -11081,6 +11091,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -11098,6 +11109,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -11115,6 +11127,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -11132,6 +11145,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -11149,6 +11163,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -11166,6 +11181,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -11183,6 +11199,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -11200,6 +11217,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -11217,6 +11235,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -11234,6 +11253,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -11251,6 +11271,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -11268,6 +11289,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -11285,6 +11307,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -11302,6 +11325,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -11319,6 +11343,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -11336,6 +11361,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -16139,7 +16165,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.50.1", @@ -16153,7 +16180,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.50.1", @@ -16167,7 +16195,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.50.1", @@ -16181,7 +16210,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.50.1", @@ -16195,7 +16225,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.50.1", @@ -16209,7 +16240,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.50.1", @@ -16223,7 +16255,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.50.1", @@ -16237,7 +16270,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.50.1", @@ -16251,7 +16285,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.50.1", @@ -16265,7 +16300,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { "version": "4.50.1", @@ -16279,7 +16315,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.50.1", @@ -16293,7 +16330,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.50.1", @@ -16307,7 +16345,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.50.1", @@ -16321,7 +16360,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.50.1", @@ -16335,7 +16375,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.50.1", @@ -16349,7 +16390,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.50.1", @@ -16363,7 +16405,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.50.1", @@ -16377,7 +16420,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.50.1", @@ -16391,7 +16435,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.50.1", @@ -16405,7 +16450,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.50.1", @@ -16419,7 +16465,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -21833,6 +21880,257 @@ "node": ">= 20" } }, + "node_modules/@vitest/eslint-plugin": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.5.0.tgz", + "integrity": "sha512-j3uuIAPTYWYnSit9lspb08/EKsxEmGqjQf+Wpb1DQkxc+mMkhL58ZknDCgjYhY4Zu76oxZ0hVWTHlmRW0mJq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.46.1", + "@typescript-eslint/utils": "^8.46.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": ">=8.57.0", + "typescript": ">=5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/@vitest/expect": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", @@ -31784,6 +32082,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -53956,7 +54255,8 @@ "sandboxed-module": "~2.0.4", "sinon": "~9.0.2", "sinon-chai": "^3.7.0", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "vitest": "^3.2.4" } }, "services/docstore/node_modules/diff": { @@ -56164,143 +56464,6 @@ "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "dev": true }, - "services/web/node_modules/@typescript-eslint/scope-manager": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.1.tgz", - "integrity": "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "services/web/node_modules/@typescript-eslint/types": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.1.tgz", - "integrity": "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "services/web/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.1.tgz", - "integrity": "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/visitor-keys": "8.32.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "services/web/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "services/web/node_modules/@typescript-eslint/utils": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.1.tgz", - "integrity": "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.32.1", - "@typescript-eslint/types": "8.32.1", - "@typescript-eslint/typescript-estree": "8.32.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "services/web/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.32.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.1.tgz", - "integrity": "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "8.32.1", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "services/web/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "services/web/node_modules/@uppy/dashboard": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/@uppy/dashboard/-/dashboard-3.7.1.tgz", @@ -56962,20 +57125,6 @@ "stack-trace": "0.0.10" } }, - "services/web/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "services/web/node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -57097,20 +57246,6 @@ "node": ">= 6" } }, - "services/web/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "services/web/node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", diff --git a/package.json b/package.json index 9132b4a656..4edb5f5ce3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@types/mocha": "^10.0.6", "@typescript-eslint/eslint-plugin": "^8.30.1", "@typescript-eslint/parser": "^8.30.1", + "@vitest/eslint-plugin": "^1.5.0", "eslint": "^8.15.0", "eslint-config-prettier": "^8.5.0", "eslint-config-standard": "^17.0.0", diff --git a/services/docstore/app.js b/services/docstore/app.js index ef755c4bb1..716ad1c11e 100644 --- a/services/docstore/app.js +++ b/services/docstore/app.js @@ -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 diff --git a/services/docstore/app/js/DocArchiveManager.js b/services/docstore/app/js/DocArchiveManager.js index 58f600eb37..a058be8d61 100644 --- a/services/docstore/app/js/DocArchiveManager.js +++ b/services/docstore/app/js/DocArchiveManager.js @@ -1,14 +1,16 @@ -const MongoManager = require('./MongoManager') -const Errors = require('./Errors') -const logger = require('@overleaf/logger') -const Settings = require('@overleaf/settings') -const crypto = require('node:crypto') -const { ReadableString } = require('@overleaf/stream-utils') -const RangeManager = require('./RangeManager') -const PersistorManager = require('./PersistorManager') -const pMap = require('p-map') -const { streamToBuffer } = require('./StreamToBuffer') -const { BSON } = require('mongodb-legacy') +import MongoManager from './MongoManager.js' +import Errors from './Errors.js' +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import crypto from 'node:crypto' +import { ReadableString } from '@overleaf/stream-utils' +import RangeManager from './RangeManager.js' +import PersistorManager from './PersistorManager.js' +import pMap from 'p-map' +import { streamToBuffer } from './StreamToBuffer.js' +import mongodb from 'mongodb-legacy' + +const { BSON } = mongodb const PARALLEL_JOBS = Settings.parallelArchiveJobs const UN_ARCHIVE_BATCH_SIZE = Settings.unArchiveBatchSize @@ -220,7 +222,7 @@ function _isArchivingEnabled() { return true } -module.exports = { +export default { archiveAllDocs, archiveDoc, unArchiveAllDocs, diff --git a/services/docstore/app/js/DocManager.js b/services/docstore/app/js/DocManager.js index c9e8dadc2c..e1ca8b0579 100644 --- a/services/docstore/app/js/DocManager.js +++ b/services/docstore/app/js/DocManager.js @@ -1,11 +1,11 @@ -const MongoManager = require('./MongoManager') -const Errors = require('./Errors') -const logger = require('@overleaf/logger') -const _ = require('lodash') -const DocArchive = require('./DocArchiveManager') -const RangeManager = require('./RangeManager') -const Settings = require('@overleaf/settings') -const { setTimeout } = require('node:timers/promises') +import MongoManager from './MongoManager.js' +import Errors from './Errors.js' +import logger from '@overleaf/logger' +import _ from 'lodash' +import DocArchive from './DocArchiveManager.js' +import RangeManager from './RangeManager.js' +import Settings from '@overleaf/settings' +import { setTimeout } from 'node:timers/promises' /** * @import { Document } from 'mongodb' @@ -319,4 +319,4 @@ const DocManager = { }, } -module.exports = DocManager +export default DocManager diff --git a/services/docstore/app/js/Errors.js b/services/docstore/app/js/Errors.js index 7b150cc0db..4f3f5cca45 100644 --- a/services/docstore/app/js/Errors.js +++ b/services/docstore/app/js/Errors.js @@ -1,6 +1,7 @@ // import Errors from object-persistor to pass instanceof checks -const OError = require('@overleaf/o-error') -const { Errors } = require('@overleaf/object-persistor') +import OError from '@overleaf/o-error' + +import { Errors } from '@overleaf/object-persistor' class Md5MismatchError extends OError {} @@ -12,7 +13,7 @@ class DocVersionDecrementedError extends OError {} class DocWithoutLinesError extends OError {} -module.exports = { +export default { Md5MismatchError, DocModifiedError, DocRevValueError, diff --git a/services/docstore/app/js/HealthChecker.js b/services/docstore/app/js/HealthChecker.js index a5b7ad7e9a..f1e2768c6d 100644 --- a/services/docstore/app/js/HealthChecker.js +++ b/services/docstore/app/js/HealthChecker.js @@ -1,10 +1,13 @@ -const { db, ObjectId } = require('./mongodb') -const _ = require('lodash') -const crypto = require('node:crypto') -const settings = require('@overleaf/settings') +import mongodb from './mongodb.js' +import _ from 'lodash' +import crypto from 'node:crypto' +import settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import { fetchNothing, fetchJson } from '@overleaf/fetch-utils' + +const { db, ObjectId } = mongodb + const { port } = settings.internal.docstore -const logger = require('@overleaf/logger') -const { fetchNothing, fetchJson } = require('@overleaf/fetch-utils') async function check() { const docId = new ObjectId() @@ -30,6 +33,7 @@ async function check() { throw new Error(`health check lines not equal ${body.lines} != ${lines}`) } } -module.exports = { + +export default { check, } diff --git a/services/docstore/app/js/HttpController.js b/services/docstore/app/js/HttpController.js index 50c4302aeb..3058409934 100644 --- a/services/docstore/app/js/HttpController.js +++ b/services/docstore/app/js/HttpController.js @@ -1,10 +1,10 @@ -const DocManager = require('./DocManager') -const logger = require('@overleaf/logger') -const DocArchive = require('./DocArchiveManager') -const HealthChecker = require('./HealthChecker') -const Errors = require('./Errors') -const Settings = require('@overleaf/settings') -const { expressify } = require('@overleaf/promise-utils') +import DocManager from './DocManager.js' +import logger from '@overleaf/logger' +import DocArchive from './DocArchiveManager.js' +import HealthChecker from './HealthChecker.js' +import Errors from './Errors.js' +import Settings from '@overleaf/settings' +import { expressify } from '@overleaf/promise-utils' async function getDoc(req, res) { const { doc_id: docId, project_id: projectId } = req.params @@ -236,7 +236,7 @@ async function healthCheck(req, res) { res.sendStatus(200) } -module.exports = { +export default { getDoc: expressify(getDoc), peekDoc: expressify(peekDoc), isDocDeleted: expressify(isDocDeleted), diff --git a/services/docstore/app/js/MongoManager.js b/services/docstore/app/js/MongoManager.js index ef101f91c0..1725747b3c 100644 --- a/services/docstore/app/js/MongoManager.js +++ b/services/docstore/app/js/MongoManager.js @@ -1,6 +1,8 @@ -const { db, ObjectId } = require('./mongodb') -const Settings = require('@overleaf/settings') -const Errors = require('./Errors') +import mongodb from './mongodb.js' +import Settings from '@overleaf/settings' +import Errors from './Errors.js' + +const { db, ObjectId } = mongodb const ARCHIVING_LOCK_DURATION_MS = Settings.archivingLockDurationMs @@ -239,7 +241,7 @@ async function destroyProject(projectId) { await db.docs.deleteMany({ project_id: new ObjectId(projectId) }) } -module.exports = { +export default { findDoc, getProjectsDeletedDocs, getProjectsDocs, diff --git a/services/docstore/app/js/PersistorManager.js b/services/docstore/app/js/PersistorManager.js index 5838271c47..2980d2b6b6 100644 --- a/services/docstore/app/js/PersistorManager.js +++ b/services/docstore/app/js/PersistorManager.js @@ -1,12 +1,13 @@ -const settings = require('@overleaf/settings') +import settings from '@overleaf/settings' +import ObjectPersistor from '@overleaf/object-persistor' +import AbstractPersistor from '@overleaf/object-persistor/src/AbstractPersistor.js' +import Metrics from '@overleaf/metrics' const persistorSettings = settings.docstore -persistorSettings.Metrics = require('@overleaf/metrics') +persistorSettings.Metrics = Metrics -const ObjectPersistor = require('@overleaf/object-persistor') -const AbstractPersistor = require('@overleaf/object-persistor/src/AbstractPersistor') const persistor = settings.docstore.backend ? ObjectPersistor(persistorSettings) : new AbstractPersistor() -module.exports = persistor +export default persistor diff --git a/services/docstore/app/js/RangeManager.js b/services/docstore/app/js/RangeManager.js index 2fbadf9468..8ac254a081 100644 --- a/services/docstore/app/js/RangeManager.js +++ b/services/docstore/app/js/RangeManager.js @@ -10,11 +10,14 @@ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -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') diff --git a/services/docstore/app/js/StreamToBuffer.js b/services/docstore/app/js/StreamToBuffer.js index 09215a7367..0bc0f5ffb5 100644 --- a/services/docstore/app/js/StreamToBuffer.js +++ b/services/docstore/app/js/StreamToBuffer.js @@ -1,13 +1,9 @@ -const { LoggerStream, WritableBuffer } = require('@overleaf/stream-utils') -const Settings = require('@overleaf/settings') -const logger = require('@overleaf/logger/logging-manager') -const { pipeline } = require('node:stream/promises') +import { LoggerStream, WritableBuffer } from '@overleaf/stream-utils' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger/logging-manager.js' +import { pipeline } from 'node:stream/promises' -module.exports = { - streamToBuffer, -} - -async function streamToBuffer(projectId, docId, stream) { +export async function streamToBuffer(projectId, docId, stream) { const loggerTransform = new LoggerStream( Settings.max_doc_length, (size, isFlush) => { diff --git a/services/docstore/app/js/mongodb.js b/services/docstore/app/js/mongodb.js index 92e13c6670..6ddb183588 100644 --- a/services/docstore/app/js/mongodb.js +++ b/services/docstore/app/js/mongodb.js @@ -1,9 +1,12 @@ // @ts-check -const Metrics = require('@overleaf/metrics') -const Settings = require('@overleaf/settings') -const MongoUtils = require('@overleaf/mongo-utils') -const { MongoClient, ObjectId } = require('mongodb-legacy') +import Metrics from '@overleaf/metrics' + +import Settings from '@overleaf/settings' +import MongoUtils from '@overleaf/mongo-utils' +import mongodb from 'mongodb-legacy' + +const { MongoClient, ObjectId } = mongodb const mongoClient = new MongoClient(Settings.mongo.url, Settings.mongo.options) const mongoDb = mongoClient.db() @@ -18,7 +21,7 @@ async function cleanupTestDatabase() { await MongoUtils.cleanupTestDatabase(mongoClient) } -module.exports = { +export default { db, mongoClient, ObjectId, diff --git a/services/docstore/buildscript.txt b/services/docstore/buildscript.txt index a5aed82d3d..5ba54e77d8 100644 --- a/services/docstore/buildscript.txt +++ b/services/docstore/buildscript.txt @@ -6,3 +6,5 @@ docstore --node-version=22.18.0 --pipeline-owner=🚉 Platform --public-repo=True +--test-unit-vitest=True +--tsconfig-extra-includes=vitest.config.unit.cjs diff --git a/services/docstore/config/settings.defaults.js b/services/docstore/config/settings.defaults.cjs similarity index 100% rename from services/docstore/config/settings.defaults.js rename to services/docstore/config/settings.defaults.cjs diff --git a/services/docstore/docker-compose.ci.yml b/services/docstore/docker-compose.ci.yml index 16ce1ebfd9..0827e85eba 100644 --- a/services/docstore/docker-compose.ci.yml +++ b/services/docstore/docker-compose.ci.yml @@ -9,6 +9,7 @@ services: volumes: - ./reports:/overleaf/services/docstore/reports - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it + - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- command: npm run test:unit:_run environment: @@ -16,6 +17,7 @@ services: MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf NODE_ENV: test NODE_OPTIONS: "--unhandled-rejections=strict" + VITEST_NO_CACHE: true depends_on: mongo: condition: service_started diff --git a/services/docstore/docker-compose.yml b/services/docstore/docker-compose.yml index d0bb29d38b..8057c48714 100644 --- a/services/docstore/docker-compose.yml +++ b/services/docstore/docker-compose.yml @@ -11,6 +11,7 @@ services: - ../../libraries:/overleaf/libraries - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - ../../tools/migrations:/overleaf/tools/migrations + - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json working_dir: /overleaf/services/docstore environment: MOCHA_GREP: ${MOCHA_GREP} diff --git a/services/docstore/package.json b/services/docstore/package.json index 359d73aa24..13d4addda5 100644 --- a/services/docstore/package.json +++ b/services/docstore/package.json @@ -2,13 +2,14 @@ "name": "@overleaf/docstore", "description": "A CRUD API for handling text documents in projects", "private": true, + "type": "module", "main": "app.js", "scripts": { "start": "node app.js", "test:acceptance:_run": "mocha --recursive --timeout 15000 --exit $@ test/acceptance/js", "test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP", - "test:unit:_run": "mocha --recursive --exit $@ test/unit/js", - "test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP", + "test:unit:_run": "vitest --config ./vitest.config.unit.cjs", + "test:unit": "npm run test:unit:_run", "nodemon": "node --watch app.js", "lint": "eslint --max-warnings 0 --format unix .", "format": "prettier --list-different $PWD/'**/{*.*js,*.ts}'", @@ -46,6 +47,7 @@ "sandboxed-module": "~2.0.4", "sinon": "~9.0.2", "sinon-chai": "^3.7.0", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "vitest": "^3.2.4" } } diff --git a/services/docstore/test/acceptance/js/ArchiveDocsTests.js b/services/docstore/test/acceptance/js/ArchiveDocsTests.js index 54c915757c..5e7a465c15 100644 --- a/services/docstore/test/acceptance/js/ArchiveDocsTests.js +++ b/services/docstore/test/acceptance/js/ArchiveDocsTests.js @@ -1,13 +1,16 @@ -const Settings = require('@overleaf/settings') -const { expect } = require('chai') -const { db, ObjectId } = require('../../../app/js/mongodb') -const async = require('async') -const DocstoreApp = require('./helpers/DocstoreApp') -const DocstoreClient = require('./helpers/DocstoreClient') -const { Storage } = require('@google-cloud/storage') -const Persistor = require('../../../app/js/PersistorManager') -const { ReadableString } = require('@overleaf/stream-utils') -const { callbackify } = require('node:util') +import Settings from '@overleaf/settings' +import { expect } from 'chai' +import mongodb from '../../../app/js/mongodb.js' +import async from 'async' +import DocstoreApp from './helpers/DocstoreApp.js' +import DocstoreClient from './helpers/DocstoreClient.js' +import { Storage } from '@google-cloud/storage' +import Persistor from '../../../app/js/PersistorManager.js' +import { ReadableString } from '@overleaf/stream-utils' +import { callbackify } from 'node:util' +import Crypto from 'node:crypto' + +const { db, ObjectId } = mongodb async function uploadContent(path, json) { const stream = new ReadableString(JSON.stringify(json)) @@ -275,9 +278,7 @@ describe('Archiving', function () { this.project_id = new ObjectId() this.timeout(1000 * 30) const quarterMegInBytes = 250000 - const bigLine = require('node:crypto') - .randomBytes(quarterMegInBytes) - .toString('hex') + const bigLine = Crypto.randomBytes(quarterMegInBytes).toString('hex') this.doc = { _id: new ObjectId(), lines: [bigLine, bigLine, bigLine, bigLine], diff --git a/services/docstore/test/acceptance/js/DeletingDocsTests.js b/services/docstore/test/acceptance/js/DeletingDocsTests.js index 1c624f322a..4c3c6599c9 100644 --- a/services/docstore/test/acceptance/js/DeletingDocsTests.js +++ b/services/docstore/test/acceptance/js/DeletingDocsTests.js @@ -1,14 +1,14 @@ -const { db, ObjectId } = require('../../../app/js/mongodb') -const { expect } = require('chai') -const DocstoreApp = require('./helpers/DocstoreApp') -const Errors = require('../../../app/js/Errors') -const Settings = require('@overleaf/settings') -const { Storage } = require('@google-cloud/storage') -const { promisify } = require('node:util') +import mongodb from '../../../app/js/mongodb.js' +import { expect } from 'chai' +import DocstoreApp from './helpers/DocstoreApp.js' +import Errors from '../../../app/js/Errors.js' +import Settings from '@overleaf/settings' +import { Storage } from '@google-cloud/storage' +import { setTimeout as sleep } from 'node:timers/promises' -const sleep = promisify(setTimeout) +import DocstoreClient from './helpers/DocstoreClient.js' -const DocstoreClient = require('./helpers/DocstoreClient') +const { db, ObjectId } = mongodb function deleteTestSuite(deleteDoc) { before(async function () { diff --git a/services/docstore/test/acceptance/js/GettingAllDocsTests.js b/services/docstore/test/acceptance/js/GettingAllDocsTests.js index 8efe12a8ea..05e83f7d34 100644 --- a/services/docstore/test/acceptance/js/GettingAllDocsTests.js +++ b/services/docstore/test/acceptance/js/GettingAllDocsTests.js @@ -1,9 +1,10 @@ -const { ObjectId } = require('mongodb-legacy') -const async = require('async') -const DocstoreApp = require('./helpers/DocstoreApp') -const { callbackify } = require('node:util') +import mongodb from 'mongodb-legacy' +import async from 'async' +import DocstoreApp from './helpers/DocstoreApp.js' +import { callbackify } from 'node:util' +import DocstoreClient from './helpers/DocstoreClient.js' -const DocstoreClient = require('./helpers/DocstoreClient') +const { ObjectId } = mongodb describe('Getting all docs', function () { beforeEach(function (done) { diff --git a/services/docstore/test/acceptance/js/GettingDocsFromArchiveTest.js b/services/docstore/test/acceptance/js/GettingDocsFromArchiveTest.js index a5e78a565e..3b37d5939f 100644 --- a/services/docstore/test/acceptance/js/GettingDocsFromArchiveTest.js +++ b/services/docstore/test/acceptance/js/GettingDocsFromArchiveTest.js @@ -1,8 +1,10 @@ -const Settings = require('@overleaf/settings') -const { ObjectId } = require('../../../app/js/mongodb') -const DocstoreApp = require('./helpers/DocstoreApp') -const DocstoreClient = require('./helpers/DocstoreClient') -const { Storage } = require('@google-cloud/storage') +import Settings from '@overleaf/settings' +import mongodb from '../../../app/js/mongodb.js' +import DocstoreApp from './helpers/DocstoreApp.js' +import DocstoreClient from './helpers/DocstoreClient.js' +import { Storage } from '@google-cloud/storage' + +const { ObjectId } = mongodb describe('Getting A Doc from Archive', function () { before(async function () { diff --git a/services/docstore/test/acceptance/js/GettingDocsTests.js b/services/docstore/test/acceptance/js/GettingDocsTests.js index b7527d8350..3c7080cc0f 100644 --- a/services/docstore/test/acceptance/js/GettingDocsTests.js +++ b/services/docstore/test/acceptance/js/GettingDocsTests.js @@ -1,8 +1,9 @@ -const { ObjectId } = require('mongodb-legacy') -const { expect } = require('chai') -const DocstoreApp = require('./helpers/DocstoreApp') +import mongodb from 'mongodb-legacy' +import { expect } from 'chai' +import DocstoreApp from './helpers/DocstoreApp.js' +import DocstoreClient from './helpers/DocstoreClient.js' -const DocstoreClient = require('./helpers/DocstoreClient') +const { ObjectId } = mongodb describe('Getting a doc', function () { beforeEach(async function () { diff --git a/services/docstore/test/acceptance/js/HealthCheckerTest.js b/services/docstore/test/acceptance/js/HealthCheckerTest.js index e500a40e57..8cd4b5aedb 100644 --- a/services/docstore/test/acceptance/js/HealthCheckerTest.js +++ b/services/docstore/test/acceptance/js/HealthCheckerTest.js @@ -1,7 +1,9 @@ -const { db } = require('../../../app/js/mongodb') -const DocstoreApp = require('./helpers/DocstoreApp') -const DocstoreClient = require('./helpers/DocstoreClient') -const { expect } = require('chai') +import mongodb from '../../../app/js/mongodb.js' +import DocstoreApp from './helpers/DocstoreApp.js' +import DocstoreClient from './helpers/DocstoreClient.js' +import { expect } from 'chai' + +const { db } = mongodb describe('HealthChecker', function () { beforeEach('start', async function () { diff --git a/services/docstore/test/acceptance/js/UpdatingDocsTests.js b/services/docstore/test/acceptance/js/UpdatingDocsTests.js index a88cfe16c2..e46f346478 100644 --- a/services/docstore/test/acceptance/js/UpdatingDocsTests.js +++ b/services/docstore/test/acceptance/js/UpdatingDocsTests.js @@ -1,7 +1,8 @@ -const { ObjectId } = require('mongodb-legacy') -const DocstoreApp = require('./helpers/DocstoreApp') +import mongodb from 'mongodb-legacy' +import DocstoreApp from './helpers/DocstoreApp.js' +import DocstoreClient from './helpers/DocstoreClient.js' -const DocstoreClient = require('./helpers/DocstoreClient') +const { ObjectId } = mongodb describe('Applying updates to a doc', function () { beforeEach(async function () { diff --git a/services/docstore/test/acceptance/js/helpers/DocstoreApp.js b/services/docstore/test/acceptance/js/helpers/DocstoreApp.js index b6a7b3b8a8..be90ca48ba 100644 --- a/services/docstore/test/acceptance/js/helpers/DocstoreApp.js +++ b/services/docstore/test/acceptance/js/helpers/DocstoreApp.js @@ -1,6 +1,6 @@ -const app = require('../../../../app') -const Settings = require('@overleaf/settings') -require('./MongoHelper') +import app from '../../../../app.js' +import Settings from '@overleaf/settings' +import './MongoHelper.js' function startApp() { return new Promise((resolve, reject) => { @@ -27,6 +27,6 @@ async function ensureRunning() { await appStartedPromise } -module.exports = { +export default { ensureRunning, } diff --git a/services/docstore/test/acceptance/js/helpers/DocstoreClient.js b/services/docstore/test/acceptance/js/helpers/DocstoreClient.js index 98f354681b..de8dfa3b84 100644 --- a/services/docstore/test/acceptance/js/helpers/DocstoreClient.js +++ b/services/docstore/test/acceptance/js/helpers/DocstoreClient.js @@ -1,11 +1,12 @@ -let DocstoreClient -const { +import { fetchNothing, fetchJson, fetchJsonWithResponse, -} = require('@overleaf/fetch-utils') -const settings = require('@overleaf/settings') -const Persistor = require('../../../../app/js/PersistorManager') +} from '@overleaf/fetch-utils' +import settings from '@overleaf/settings' +import Persistor from '../../../../app/js/PersistorManager.js' + +let DocstoreClient async function streamToString(stream) { const chunks = [] @@ -22,7 +23,7 @@ async function getStringFromPersistor(persistor, bucket, key) { return await streamToString(stream) } -module.exports = DocstoreClient = { +export default DocstoreClient = { async createDoc(projectId, docId, lines, version, ranges) { return await DocstoreClient.updateDoc( projectId, diff --git a/services/docstore/test/setup.js b/services/docstore/test/setup.js index 92b86c9384..302e8bbf16 100644 --- a/services/docstore/test/setup.js +++ b/services/docstore/test/setup.js @@ -1,12 +1,10 @@ -const chai = require('chai') -const sinon = require('sinon') -const sinonChai = require('sinon-chai') -const chaiAsPromised = require('chai-as-promised') -const SandboxedModule = require('sandboxed-module') -const timersPromises = require('node:timers/promises') +import sinonChai from 'sinon-chai' +import chaiAsPromised from 'chai-as-promised' +import mongodb from 'mongodb-legacy' +import chai from 'chai' // ensure every ObjectId has the id string as a property for correct comparisons -require('mongodb-legacy').ObjectId.cacheHexString = true +mongodb.ObjectId.cacheHexString = true process.env.BACKEND = 'gcs' @@ -14,42 +12,3 @@ process.env.BACKEND = 'gcs' chai.should() chai.use(sinonChai) chai.use(chaiAsPromised) - -// Global stubs -const sandbox = sinon.createSandbox() -const stubs = { - logger: { - debug: sandbox.stub(), - log: sandbox.stub(), - info: sandbox.stub(), - warn: sandbox.stub(), - err: sandbox.stub(), - error: sandbox.stub(), - fatal: sandbox.stub(), - }, -} - -// SandboxedModule configuration -SandboxedModule.configure({ - requires: { - '@overleaf/logger': stubs.logger, - 'timers/promises': timersPromises, - 'mongodb-legacy': require('mongodb-legacy'), - }, - globals: { Buffer, JSON, Math, console, process }, - sourceTransformers: { - removeNodePrefix: function (source) { - return source.replace(/require\(['"]node:/g, "require('") - }, - }, -}) - -exports.mochaHooks = { - beforeEach() { - this.logger = stubs.logger - }, - - afterEach() { - sandbox.reset() - }, -} diff --git a/services/docstore/test/unit/js/DocArchiveManagerTests.js b/services/docstore/test/unit/js/DocArchiveManager.test.js similarity index 77% rename from services/docstore/test/unit/js/DocArchiveManagerTests.js rename to services/docstore/test/unit/js/DocArchiveManager.test.js index e7ac4f6fe8..6a558c29a3 100644 --- a/services/docstore/test/unit/js/DocArchiveManagerTests.js +++ b/services/docstore/test/unit/js/DocArchiveManager.test.js @@ -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 diff --git a/services/docstore/test/unit/js/DocManager.test.js b/services/docstore/test/unit/js/DocManager.test.js new file mode 100644 index 0000000000..fab09cb91e --- /dev/null +++ b/services/docstore/test/unit/js/DocManager.test.js @@ -0,0 +1,762 @@ +import sinon from 'sinon' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ObjectId } from 'mongodb-legacy' +import Errors from '../../../app/js/Errors.js' +import path from 'node:path' + +const modulePath = path.join(import.meta.dirname, '../../../app/js/DocManager') + +describe('DocManager', () => { + beforeEach(async ctx => { + ctx.doc_id = new ObjectId().toString() + ctx.project_id = new ObjectId().toString() + ctx.another_project_id = new ObjectId().toString() + ctx.stubbedError = new Error('blew up') + ctx.version = 42 + + ctx.MongoManager = { + findDoc: sinon.stub(), + getProjectsDocs: sinon.stub(), + patchDoc: sinon.stub().resolves(), + upsertIntoDocCollection: sinon.stub().resolves(), + } + ctx.DocArchiveManager = { + unarchiveDoc: sinon.stub(), + unArchiveAllDocs: sinon.stub(), + archiveDoc: sinon.stub().resolves(), + } + ctx.RangeManager = { + jsonRangesToMongo(r) { + return r + }, + shouldUpdateRanges: sinon.stub().returns(false), + fixCommentIds: sinon.stub(), + } + ctx.settings = { docstore: {} } + + vi.doMock('../../../app/js/MongoManager', () => ({ + default: ctx.MongoManager, + })) + + vi.doMock('../../../app/js/DocArchiveManager', () => ({ + default: ctx.DocArchiveManager, + })) + + vi.doMock('../../../app/js/RangeManager', () => ({ + default: ctx.RangeManager, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: ctx.settings, + })) + + vi.doMock('../../../app/js/Errors', () => ({ + default: Errors, + })) + + ctx.DocManager = (await import(modulePath)).default + }) + + describe('getFullDoc', () => { + beforeEach(ctx => { + ctx.DocManager._getDoc = sinon.stub() + ctx.doc = { + _id: ctx.doc_id, + lines: ['2134'], + } + }) + + it('should call get doc with a quick filter', async ctx => { + ctx.DocManager._getDoc.resolves(ctx.doc) + const doc = await ctx.DocManager.getFullDoc(ctx.project_id, ctx.doc_id) + doc.should.equal(ctx.doc) + ctx.DocManager._getDoc + .calledWith(ctx.project_id, ctx.doc_id, { + lines: true, + rev: true, + deleted: true, + version: true, + ranges: true, + inS3: true, + }) + .should.equal(true) + }) + + it('should return error when get doc errors', async ctx => { + ctx.DocManager._getDoc.rejects(ctx.stubbedError) + await expect( + ctx.DocManager.getFullDoc(ctx.project_id, ctx.doc_id) + ).to.be.rejectedWith(ctx.stubbedError) + }) + }) + + describe('getRawDoc', () => { + beforeEach(ctx => { + ctx.DocManager._getDoc = sinon.stub() + ctx.doc = { lines: ['2134'] } + }) + + it('should call get doc with a quick filter', async ctx => { + ctx.DocManager._getDoc.resolves(ctx.doc) + const content = await ctx.DocManager.getDocLines( + ctx.project_id, + ctx.doc_id + ) + content.should.equal(ctx.doc.lines.join('\n')) + ctx.DocManager._getDoc + .calledWith(ctx.project_id, ctx.doc_id, { + lines: true, + inS3: true, + }) + .should.equal(true) + }) + + it('should return error when get doc errors', async ctx => { + ctx.DocManager._getDoc.rejects(ctx.stubbedError) + await expect( + ctx.DocManager.getDocLines(ctx.project_id, ctx.doc_id) + ).to.be.rejectedWith(ctx.stubbedError) + }) + + it('should return error when get doc does not exist', async ctx => { + ctx.DocManager._getDoc.resolves(null) + await expect( + ctx.DocManager.getDocLines(ctx.project_id, ctx.doc_id) + ).to.be.rejectedWith(Errors.NotFoundError) + }) + + it('should return error when get doc has no lines', async ctx => { + ctx.DocManager._getDoc.resolves({}) + await expect( + ctx.DocManager.getDocLines(ctx.project_id, ctx.doc_id) + ).to.be.rejectedWith(Errors.DocWithoutLinesError) + }) + }) + + describe('_getDoc', () => { + it('should return error when get doc does not exist', async ctx => { + ctx.MongoManager.findDoc.resolves(null) + await expect( + ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, { inS3: true }) + ).to.be.rejectedWith(Errors.NotFoundError) + }) + + it('should fix comment ids', async ctx => { + ctx.MongoManager.findDoc.resolves({ + _id: ctx.doc_id, + ranges: {}, + }) + await ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, { + inS3: true, + ranges: true, + }) + expect(ctx.RangeManager.fixCommentIds).to.have.been.called + }) + }) + + describe('getDoc', () => { + beforeEach(ctx => { + ctx.project = { name: 'mock-project' } + ctx.doc = { + _id: ctx.doc_id, + project_id: ctx.project_id, + lines: ['mock-lines'], + version: ctx.version, + } + }) + + describe('when using a filter', () => { + beforeEach(ctx => { + ctx.MongoManager.findDoc.resolves(ctx.doc) + }) + + it('should error if inS3 is not set to true', async ctx => { + await expect( + ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, { + inS3: false, + }) + ).to.be.rejected + }) + + it('should always get inS3 even when no filter is passed', async ctx => { + await expect(ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id)).to.be + .rejected + ctx.MongoManager.findDoc.called.should.equal(false) + }) + + it('should not error if inS3 is set to true', async ctx => { + await ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, { + inS3: true, + }) + }) + }) + + describe('when the doc is in the doc collection', () => { + beforeEach(async ctx => { + ctx.MongoManager.findDoc.resolves(ctx.doc) + ctx.result = await ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, { + version: true, + inS3: true, + }) + }) + + it('should get the doc from the doc collection', ctx => { + ctx.MongoManager.findDoc + .calledWith(ctx.project_id, ctx.doc_id) + .should.equal(true) + }) + + it('should return the doc with the version', ctx => { + ctx.result.lines.should.equal(ctx.doc.lines) + ctx.result.version.should.equal(ctx.version) + }) + }) + + describe('when MongoManager.findDoc errors', () => { + it('should return the error', async ctx => { + ctx.MongoManager.findDoc.rejects(ctx.stubbedError) + await expect( + ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, { + version: true, + inS3: true, + }) + ).to.be.rejectedWith(ctx.stubbedError) + }) + }) + + describe('when the doc is archived', () => { + beforeEach(async ctx => { + ctx.doc = { + _id: ctx.doc_id, + project_id: ctx.project_id, + version: 2, + inS3: true, + } + ctx.unarchivedDoc = { + _id: ctx.doc_id, + project_id: ctx.project_id, + lines: ['mock-lines'], + version: 2, + inS3: false, + } + ctx.MongoManager.findDoc.resolves(ctx.doc) + ctx.DocArchiveManager.unarchiveDoc.callsFake( + async (projectId, docId) => { + ctx.MongoManager.findDoc.resolves({ + ...ctx.unarchivedDoc, + }) + } + ) + ctx.result = await ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, { + version: true, + inS3: true, + }) + }) + + it('should call the DocArchive to unarchive the doc', ctx => { + ctx.DocArchiveManager.unarchiveDoc + .calledWith(ctx.project_id, ctx.doc_id) + .should.equal(true) + }) + + it('should look up the doc twice', ctx => { + ctx.MongoManager.findDoc.calledTwice.should.equal(true) + }) + + it('should return the doc', ctx => { + expect(ctx.result).to.deep.equal({ + ...ctx.unarchivedDoc, + }) + }) + }) + + describe('when the doc does not exist in the docs collection', () => { + it('should return a NotFoundError', async ctx => { + ctx.MongoManager.findDoc.resolves(null) + await expect( + ctx.DocManager._getDoc(ctx.project_id, ctx.doc_id, { + version: true, + inS3: true, + }) + ).to.be.rejectedWith( + `No such doc: ${ctx.doc_id} in project ${ctx.project_id}` + ) + }) + }) + }) + + describe('getAllNonDeletedDocs', () => { + describe('when the project exists', () => { + beforeEach(async ctx => { + ctx.docs = [ + { + _id: ctx.doc_id, + project_id: ctx.project_id, + lines: ['mock-lines'], + }, + ] + ctx.MongoManager.getProjectsDocs.resolves(ctx.docs) + ctx.DocArchiveManager.unArchiveAllDocs.resolves(ctx.docs) + ctx.filter = { lines: true, ranges: true } + ctx.result = await ctx.DocManager.getAllNonDeletedDocs( + ctx.project_id, + ctx.filter + ) + }) + + it('should get the project from the database', ctx => { + ctx.MongoManager.getProjectsDocs.should.have.been.calledWith( + ctx.project_id, + { include_deleted: false }, + ctx.filter + ) + }) + + it('should fix comment ids', async ctx => { + expect(ctx.RangeManager.fixCommentIds).to.have.been.called + }) + + it('should return the docs', ctx => { + expect(ctx.result).to.deep.equal(ctx.docs) + }) + }) + + describe('when there are no docs for the project', () => { + it('should return a NotFoundError', async ctx => { + ctx.MongoManager.getProjectsDocs.resolves(null) + ctx.DocArchiveManager.unArchiveAllDocs.resolves(null) + await expect( + ctx.DocManager.getAllNonDeletedDocs(ctx.project_id, ctx.filter) + ).to.be.rejectedWith(`No docs for project ${ctx.project_id}`) + }) + }) + }) + + describe('patchDoc', () => { + describe('when the doc exists', () => { + beforeEach(ctx => { + ctx.lines = ['mock', 'doc', 'lines'] + ctx.rev = 77 + ctx.MongoManager.findDoc.resolves({ + _id: new ObjectId(ctx.doc_id), + }) + ctx.meta = {} + }) + + describe('standard path', () => { + beforeEach(async ctx => { + await ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, ctx.meta) + }) + + it('should get the doc', ctx => { + expect(ctx.MongoManager.findDoc).to.have.been.calledWith( + ctx.project_id, + ctx.doc_id + ) + }) + + it('should persist the meta', ctx => { + expect(ctx.MongoManager.patchDoc).to.have.been.calledWith( + ctx.project_id, + ctx.doc_id, + ctx.meta + ) + }) + }) + + describe('background flush disabled and deleting a doc', () => { + beforeEach(async ctx => { + ctx.settings.docstore.archiveOnSoftDelete = false + ctx.meta.deleted = true + + await ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, ctx.meta) + }) + + it('should not flush the doc out of mongo', ctx => { + expect(ctx.DocArchiveManager.archiveDoc).to.not.have.been.called + }) + }) + + describe('background flush enabled and not deleting a doc', () => { + beforeEach(async ctx => { + ctx.settings.docstore.archiveOnSoftDelete = false + ctx.meta.deleted = false + await ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, ctx.meta) + }) + + it('should not flush the doc out of mongo', ctx => { + expect(ctx.DocArchiveManager.archiveDoc).to.not.have.been.called + }) + }) + + describe('background flush enabled and deleting a doc', () => { + beforeEach(ctx => { + ctx.settings.docstore.archiveOnSoftDelete = true + ctx.meta.deleted = true + }) + + describe('when the background flush succeeds', () => { + beforeEach(async ctx => { + await ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, ctx.meta) + }) + + it('should not log a warning', ctx => { + expect(ctx.logger.warn).to.not.have.been.called + }) + + it('should flush the doc out of mongo', ctx => { + expect(ctx.DocArchiveManager.archiveDoc).to.have.been.calledWith( + ctx.project_id, + ctx.doc_id + ) + }) + }) + + describe('when the background flush fails', () => { + beforeEach(async ctx => { + ctx.err = new Error('foo') + ctx.DocArchiveManager.archiveDoc.rejects(ctx.err) + await ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, ctx.meta) + }) + + it('should log a warning', ctx => { + expect(ctx.logger.warn).to.have.been.calledWith( + sinon.match({ + projectId: ctx.project_id, + docId: ctx.doc_id, + err: ctx.err, + }), + 'archiving a single doc in the background failed' + ) + }) + }) + }) + }) + + describe('when the doc does not exist', () => { + it('should return a NotFoundError', async ctx => { + ctx.MongoManager.findDoc.resolves(null) + await expect( + ctx.DocManager.patchDoc(ctx.project_id, ctx.doc_id, {}) + ).to.be.rejectedWith( + `No such project/doc to delete: ${ctx.project_id}/${ctx.doc_id}` + ) + }) + }) + }) + + describe('updateDoc', () => { + beforeEach(ctx => { + ctx.oldDocLines = ['old', 'doc', 'lines'] + ctx.newDocLines = ['new', 'doc', 'lines'] + ctx.originalRanges = { + changes: [ + { + id: new ObjectId().toString(), + op: { i: 'foo', p: 3 }, + meta: { + user_id: new ObjectId().toString(), + ts: new Date().toString(), + }, + }, + ], + } + ctx.newRanges = { + changes: [ + { + id: new ObjectId().toString(), + op: { i: 'bar', p: 6 }, + meta: { + user_id: new ObjectId().toString(), + ts: new Date().toString(), + }, + }, + ], + } + ctx.version = 42 + ctx.doc = { + _id: ctx.doc_id, + project_id: ctx.project_id, + lines: ctx.oldDocLines, + rev: (ctx.rev = 5), + version: ctx.version, + ranges: ctx.originalRanges, + } + + ctx.DocManager._getDoc = sinon.stub() + }) + + describe('when only the doc lines have changed', () => { + beforeEach(async ctx => { + ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc) + ctx.result = await ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.newDocLines, + ctx.version, + ctx.originalRanges + ) + }) + + it('should get the existing doc', ctx => { + ctx.DocManager._getDoc + .calledWith(ctx.project_id, ctx.doc_id, { + version: true, + rev: true, + lines: true, + ranges: true, + inS3: true, + }) + .should.equal(true) + }) + + it('should upsert the document to the doc collection', ctx => { + ctx.MongoManager.upsertIntoDocCollection + .calledWith(ctx.project_id, ctx.doc_id, ctx.rev, { + lines: ctx.newDocLines, + }) + .should.equal(true) + }) + + it('should return the new rev', ctx => { + expect(ctx.result).to.deep.equal({ modified: true, rev: ctx.rev + 1 }) + }) + }) + + describe('when the doc ranges have changed', () => { + beforeEach(async ctx => { + ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc) + ctx.RangeManager.shouldUpdateRanges.returns(true) + ctx.result = await ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.oldDocLines, + ctx.version, + ctx.newRanges + ) + }) + + it('should upsert the ranges', ctx => { + ctx.MongoManager.upsertIntoDocCollection + .calledWith(ctx.project_id, ctx.doc_id, ctx.rev, { + ranges: ctx.newRanges, + }) + .should.equal(true) + }) + + it('should return the new rev', ctx => { + expect(ctx.result).to.deep.equal({ modified: true, rev: ctx.rev + 1 }) + }) + }) + + describe('when only the version has changed', () => { + beforeEach(async ctx => { + ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc) + ctx.result = await ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.oldDocLines, + ctx.version + 1, + ctx.originalRanges + ) + }) + + it('should update the version', ctx => { + ctx.MongoManager.upsertIntoDocCollection.should.have.been.calledWith( + ctx.project_id, + ctx.doc_id, + ctx.rev, + { version: ctx.version + 1 } + ) + }) + + it('should return the old rev', ctx => { + expect(ctx.result).to.deep.equal({ modified: true, rev: ctx.rev }) + }) + }) + + describe('when the doc has not changed at all', () => { + beforeEach(async ctx => { + ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc) + ctx.result = await ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.oldDocLines, + ctx.version, + ctx.originalRanges + ) + }) + + it('should not update the ranges or lines or version', ctx => { + ctx.MongoManager.upsertIntoDocCollection.called.should.equal(false) + }) + + it('should return the old rev and modified == false', ctx => { + expect(ctx.result).to.deep.equal({ modified: false, rev: ctx.rev }) + }) + }) + + describe('when the version is null', () => { + it('should return an error', async ctx => { + await expect( + ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.newDocLines, + null, + ctx.originalRanges + ) + ).to.be.rejectedWith('no lines, version or ranges provided') + }) + }) + + describe('when the lines are null', () => { + it('should return an error', async ctx => { + await expect( + ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + null, + ctx.version, + ctx.originalRanges + ) + ).to.be.rejectedWith('no lines, version or ranges provided') + }) + }) + + describe('when the ranges are null', () => { + it('should return an error', async ctx => { + await expect( + ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.newDocLines, + ctx.version, + null + ) + ).to.be.rejectedWith('no lines, version or ranges provided') + }) + }) + + describe('when there is a generic error getting the doc', () => { + beforeEach(async ctx => { + ctx.error = new Error('doc could not be found') + ctx.DocManager._getDoc = sinon.stub().rejects(ctx.error) + await expect( + ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.newDocLines, + ctx.version, + ctx.originalRanges + ) + ).to.be.rejectedWith(ctx.error) + }) + + it('should not upsert the document to the doc collection', ctx => { + ctx.MongoManager.upsertIntoDocCollection.should.not.have.been.called + }) + }) + + describe('when the version was decremented', () => { + it('should return an error', async ctx => { + ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc) + await expect( + ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.newDocLines, + ctx.version - 1, + ctx.originalRanges + ) + ).to.be.rejectedWith(Errors.DocVersionDecrementedError) + }) + }) + + describe('when the doc lines have not changed', () => { + beforeEach(async ctx => { + ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc) + ctx.result = await ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.oldDocLines.slice(), + ctx.version, + ctx.originalRanges + ) + }) + + it('should not update the doc', ctx => { + ctx.MongoManager.upsertIntoDocCollection.called.should.equal(false) + }) + + it('should return the existing rev', ctx => { + expect(ctx.result).to.deep.equal({ modified: false, rev: ctx.rev }) + }) + }) + + describe('when the doc does not exist', () => { + beforeEach(async ctx => { + ctx.DocManager._getDoc = sinon.stub().resolves(null) + ctx.result = await ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.newDocLines, + ctx.version, + ctx.originalRanges + ) + }) + + it('should upsert the document to the doc collection', ctx => { + ctx.MongoManager.upsertIntoDocCollection.should.have.been.calledWith( + ctx.project_id, + ctx.doc_id, + undefined, + { + lines: ctx.newDocLines, + ranges: ctx.originalRanges, + version: ctx.version, + } + ) + }) + + it('should return the new rev', ctx => { + expect(ctx.result).to.deep.equal({ modified: true, rev: 1 }) + }) + }) + + describe('when another update is racing', () => { + beforeEach(async ctx => { + ctx.DocManager._getDoc = sinon.stub().resolves(ctx.doc) + ctx.MongoManager.upsertIntoDocCollection + .onFirstCall() + .rejects(new Errors.DocRevValueError()) + ctx.RangeManager.shouldUpdateRanges.returns(true) + ctx.result = await ctx.DocManager.updateDoc( + ctx.project_id, + ctx.doc_id, + ctx.newDocLines, + ctx.version + 1, + ctx.newRanges + ) + }) + + it('should upsert the doc twice', ctx => { + ctx.MongoManager.upsertIntoDocCollection.should.have.been.calledWith( + ctx.project_id, + ctx.doc_id, + ctx.rev, + { + ranges: ctx.newRanges, + lines: ctx.newDocLines, + version: ctx.version + 1, + } + ) + ctx.MongoManager.upsertIntoDocCollection.should.have.been.calledTwice + }) + + it('should return the new rev', ctx => { + expect(ctx.result).to.deep.equal({ modified: true, rev: ctx.rev + 1 }) + }) + }) + }) +}) diff --git a/services/docstore/test/unit/js/DocManagerTests.js b/services/docstore/test/unit/js/DocManagerTests.js deleted file mode 100644 index 67a2f26547..0000000000 --- a/services/docstore/test/unit/js/DocManagerTests.js +++ /dev/null @@ -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 }) - }) - }) - }) -}) diff --git a/services/docstore/test/unit/js/HttpController.test.js b/services/docstore/test/unit/js/HttpController.test.js new file mode 100644 index 0000000000..e9e45c1b1a --- /dev/null +++ b/services/docstore/test/unit/js/HttpController.test.js @@ -0,0 +1,595 @@ +import sinon from 'sinon' +import { assert, beforeEach, describe, expect, it, vi } from 'vitest' +import path from 'node:path' +import { ObjectId } from 'mongodb-legacy' +import Errors from '../../../app/js/Errors.js' + +const modulePath = path.join( + import.meta.dirname, + '../../../app/js/HttpController' +) + +describe('HttpController', () => { + beforeEach(async ctx => { + const settings = { + max_doc_length: 2 * 1024 * 1024, + } + ctx.DocArchiveManager = { + unArchiveAllDocs: sinon.stub().returns(), + } + ctx.DocManager = {} + + vi.doMock('../../../app/js/DocManager', () => ({ + default: ctx.DocManager, + })) + + vi.doMock('../../../app/js/DocArchiveManager', () => ({ + default: ctx.DocArchiveManager, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: settings, + })) + + vi.doMock('../../../app/js/HealthChecker', () => ({ + default: {}, + })) + + vi.doMock('../../../app/js/Errors', () => ({ + default: Errors, + })) + + ctx.HttpController = (await import(modulePath)).default + ctx.res = { + send: sinon.stub(), + sendStatus: sinon.stub(), + json: sinon.stub(), + setHeader: sinon.stub(), + } + ctx.res.status = sinon.stub().returns(ctx.res) + ctx.req = { query: {} } + ctx.next = sinon.stub() + ctx.projectId = 'mock-project-id' + ctx.docId = 'mock-doc-id' + ctx.doc = { + _id: ctx.docId, + lines: ['mock', 'lines', ' here', '', '', ' spaces '], + version: 42, + rev: 5, + } + ctx.deletedDoc = { + deleted: true, + _id: ctx.docId, + lines: ['mock', 'lines', ' here', '', '', ' spaces '], + version: 42, + rev: 5, + } + }) + + describe('getDoc', () => { + describe('without deleted docs', () => { + beforeEach(async ctx => { + ctx.req.params = { + project_id: ctx.projectId, + doc_id: ctx.docId, + } + ctx.DocManager.getFullDoc = sinon.stub().resolves(ctx.doc) + await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next) + }) + + it('should get the document with the version (including deleted)', ctx => { + ctx.DocManager.getFullDoc + .calledWith(ctx.projectId, ctx.docId) + .should.equal(true) + }) + + it('should return the doc as JSON', ctx => { + ctx.res.json + .calledWith({ + _id: ctx.docId, + lines: ctx.doc.lines, + rev: ctx.doc.rev, + version: ctx.doc.version, + }) + .should.equal(true) + }) + }) + + describe('which is deleted', () => { + beforeEach(ctx => { + ctx.req.params = { + project_id: ctx.projectId, + doc_id: ctx.docId, + } + ctx.DocManager.getFullDoc = sinon.stub().resolves(ctx.deletedDoc) + }) + + it('should get the doc from the doc manager', async ctx => { + await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next) + ctx.DocManager.getFullDoc + .calledWith(ctx.projectId, ctx.docId) + .should.equal(true) + }) + + it('should return 404 if the query string delete is not set ', async ctx => { + await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next) + ctx.res.sendStatus.calledWith(404).should.equal(true) + }) + + it('should return the doc as JSON if include_deleted is set to true', async ctx => { + ctx.req.query.include_deleted = 'true' + await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next) + ctx.res.json + .calledWith({ + _id: ctx.docId, + lines: ctx.doc.lines, + rev: ctx.doc.rev, + deleted: true, + version: ctx.doc.version, + }) + .should.equal(true) + }) + }) + }) + + describe('getRawDoc', () => { + beforeEach(async ctx => { + ctx.req.params = { + project_id: ctx.projectId, + doc_id: ctx.docId, + } + ctx.DocManager.getDocLines = sinon + .stub() + .resolves(ctx.doc.lines.join('\n')) + await ctx.HttpController.getRawDoc(ctx.req, ctx.res, ctx.next) + }) + + it('should get the document without the version', ctx => { + ctx.DocManager.getDocLines + .calledWith(ctx.projectId, ctx.docId) + .should.equal(true) + }) + + it('should set the content type header', ctx => { + ctx.res.setHeader + .calledWith('content-type', 'text/plain') + .should.equal(true) + }) + + it('should send the raw version of the doc', ctx => { + assert.deepEqual( + ctx.res.send.args[0][0], + `${ctx.doc.lines[0]}\n${ctx.doc.lines[1]}\n${ctx.doc.lines[2]}\n${ctx.doc.lines[3]}\n${ctx.doc.lines[4]}\n${ctx.doc.lines[5]}` + ) + }) + }) + + describe('getAllDocs', () => { + describe('normally', () => { + beforeEach(async ctx => { + ctx.req.params = { project_id: ctx.projectId } + ctx.docs = [ + { + _id: new ObjectId(), + lines: ['mock', 'lines', 'one'], + rev: 2, + }, + { + _id: new ObjectId(), + lines: ['mock', 'lines', 'two'], + rev: 4, + }, + ] + ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs) + await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next) + }) + + it('should get all the (non-deleted) docs', ctx => { + ctx.DocManager.getAllNonDeletedDocs + .calledWith(ctx.projectId, { lines: true, rev: true }) + .should.equal(true) + }) + + it('should return the doc as JSON', ctx => { + ctx.res.json + .calledWith([ + { + _id: ctx.docs[0]._id.toString(), + lines: ctx.docs[0].lines, + rev: ctx.docs[0].rev, + }, + { + _id: ctx.docs[1]._id.toString(), + lines: ctx.docs[1].lines, + rev: ctx.docs[1].rev, + }, + ]) + .should.equal(true) + }) + }) + + describe('with null lines', () => { + beforeEach(async ctx => { + ctx.req.params = { project_id: ctx.projectId } + ctx.docs = [ + { + _id: new ObjectId(), + lines: null, + rev: 2, + }, + { + _id: new ObjectId(), + lines: ['mock', 'lines', 'two'], + rev: 4, + }, + ] + ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs) + await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next) + }) + + it('should return the doc with fallback lines', ctx => { + ctx.res.json + .calledWith([ + { + _id: ctx.docs[0]._id.toString(), + lines: [], + rev: ctx.docs[0].rev, + }, + { + _id: ctx.docs[1]._id.toString(), + lines: ctx.docs[1].lines, + rev: ctx.docs[1].rev, + }, + ]) + .should.equal(true) + }) + }) + + describe('with a null doc', () => { + beforeEach(async ctx => { + ctx.req.params = { project_id: ctx.projectId } + ctx.docs = [ + { + _id: new ObjectId(), + lines: ['mock', 'lines', 'one'], + rev: 2, + }, + null, + { + _id: new ObjectId(), + lines: ['mock', 'lines', 'two'], + rev: 4, + }, + ] + ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs) + await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next) + }) + + it('should return the non null docs as JSON', ctx => { + ctx.res.json + .calledWith([ + { + _id: ctx.docs[0]._id.toString(), + lines: ctx.docs[0].lines, + rev: ctx.docs[0].rev, + }, + { + _id: ctx.docs[2]._id.toString(), + lines: ctx.docs[2].lines, + rev: ctx.docs[2].rev, + }, + ]) + .should.equal(true) + }) + + it('should log out an error', ctx => { + ctx.logger.error + .calledWith( + { + err: sinon.match.has('message', 'null doc'), + projectId: ctx.projectId, + }, + 'encountered null doc' + ) + .should.equal(true) + }) + }) + }) + + describe('getAllRanges', () => { + describe('normally', () => { + beforeEach(async ctx => { + ctx.req.params = { project_id: ctx.projectId } + ctx.docs = [ + { + _id: new ObjectId(), + ranges: { mock_ranges: 'one' }, + }, + { + _id: new ObjectId(), + ranges: { mock_ranges: 'two' }, + }, + ] + ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs) + await ctx.HttpController.getAllRanges(ctx.req, ctx.res, ctx.next) + }) + + it('should get all the (non-deleted) doc ranges', ctx => { + ctx.DocManager.getAllNonDeletedDocs + .calledWith(ctx.projectId, { ranges: true }) + .should.equal(true) + }) + + it('should return the doc as JSON', ctx => { + ctx.res.json + .calledWith([ + { + _id: ctx.docs[0]._id.toString(), + ranges: ctx.docs[0].ranges, + }, + { + _id: ctx.docs[1]._id.toString(), + ranges: ctx.docs[1].ranges, + }, + ]) + .should.equal(true) + }) + }) + }) + + describe('updateDoc', () => { + beforeEach(ctx => { + ctx.req.params = { + project_id: ctx.projectId, + doc_id: ctx.docId, + } + }) + + describe('when the doc lines exist and were updated', () => { + beforeEach(async ctx => { + ctx.req.body = { + lines: (ctx.lines = ['hello', 'world']), + version: (ctx.version = 42), + ranges: (ctx.ranges = { changes: 'mock' }), + } + ctx.rev = 5 + ctx.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: true, rev: ctx.rev }) + await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next) + }) + + it('should update the document', ctx => { + ctx.DocManager.updateDoc + .calledWith( + ctx.projectId, + ctx.docId, + ctx.lines, + ctx.version, + ctx.ranges + ) + .should.equal(true) + }) + + it('should return a modified status', ctx => { + ctx.res.json + .calledWith({ modified: true, rev: ctx.rev }) + .should.equal(true) + }) + }) + + describe('when the doc lines exist and were not updated', () => { + beforeEach(async ctx => { + ctx.req.body = { + lines: (ctx.lines = ['hello', 'world']), + version: (ctx.version = 42), + ranges: {}, + } + ctx.rev = 5 + ctx.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: ctx.rev }) + await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next) + }) + + it('should return a modified status', ctx => { + ctx.res.json + .calledWith({ modified: false, rev: ctx.rev }) + .should.equal(true) + }) + }) + + describe('when the doc lines are not provided', () => { + beforeEach(async ctx => { + ctx.req.body = { version: 42, ranges: {} } + ctx.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next) + }) + + it('should not update the document', ctx => { + ctx.DocManager.updateDoc.called.should.equal(false) + }) + + it('should return a 400 (bad request) response', ctx => { + ctx.res.sendStatus.calledWith(400).should.equal(true) + }) + }) + + describe('when the doc version are not provided', () => { + beforeEach(async ctx => { + ctx.req.body = { version: 42, lines: ['hello world'] } + ctx.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next) + }) + + it('should not update the document', ctx => { + ctx.DocManager.updateDoc.called.should.equal(false) + }) + + it('should return a 400 (bad request) response', ctx => { + ctx.res.sendStatus.calledWith(400).should.equal(true) + }) + }) + + describe('when the doc ranges is not provided', () => { + beforeEach(async ctx => { + ctx.req.body = { lines: ['foo'], version: 42 } + ctx.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next) + }) + + it('should not update the document', ctx => { + ctx.DocManager.updateDoc.called.should.equal(false) + }) + + it('should return a 400 (bad request) response', ctx => { + ctx.res.sendStatus.calledWith(400).should.equal(true) + }) + }) + + describe('when the doc body is too large', () => { + beforeEach(async ctx => { + ctx.req.body = { + lines: (ctx.lines = Array(2049).fill('a'.repeat(1024))), + version: (ctx.version = 42), + ranges: (ctx.ranges = { changes: 'mock' }), + } + ctx.DocManager.updateDoc = sinon + .stub() + .resolves({ modified: false, rev: 0 }) + await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next) + }) + + it('should not update the document', ctx => { + ctx.DocManager.updateDoc.called.should.equal(false) + }) + + it('should return a 413 (too large) response', ctx => { + sinon.assert.calledWith(ctx.res.status, 413) + }) + + it('should report that the document body is too large', ctx => { + sinon.assert.calledWith(ctx.res.send, 'document body too large') + }) + }) + }) + + describe('patchDoc', () => { + beforeEach(async ctx => { + ctx.req.params = { + project_id: ctx.projectId, + doc_id: ctx.docId, + } + ctx.req.body = { name: 'foo.tex' } + ctx.DocManager.patchDoc = sinon.stub().resolves() + await ctx.HttpController.patchDoc(ctx.req, ctx.res, ctx.next) + }) + + it('should delete the document', ctx => { + expect(ctx.DocManager.patchDoc).to.have.been.calledWith( + ctx.projectId, + ctx.docId + ) + }) + + it('should return a 204 (No Content)', ctx => { + expect(ctx.res.sendStatus).to.have.been.calledWith(204) + }) + + describe('with an invalid payload', () => { + beforeEach(async ctx => { + ctx.req.body = { cannot: 'happen' } + + ctx.DocManager.patchDoc = sinon.stub().resolves() + await ctx.HttpController.patchDoc(ctx.req, ctx.res, ctx.next) + }) + + it('should log a message', ctx => { + expect(ctx.logger.fatal).to.have.been.calledWith( + { field: 'cannot' }, + 'joi validation for pathDoc is broken' + ) + }) + + it('should not pass the invalid field along', ctx => { + expect(ctx.DocManager.patchDoc).to.have.been.calledWith( + ctx.projectId, + ctx.docId, + {} + ) + }) + }) + }) + + describe('archiveAllDocs', () => { + beforeEach(async ctx => { + ctx.req.params = { project_id: ctx.projectId } + ctx.DocArchiveManager.archiveAllDocs = sinon.stub().resolves() + await ctx.HttpController.archiveAllDocs(ctx.req, ctx.res, ctx.next) + }) + + it('should archive the project', ctx => { + ctx.DocArchiveManager.archiveAllDocs + .calledWith(ctx.projectId) + .should.equal(true) + }) + + it('should return a 204 (No Content)', ctx => { + ctx.res.sendStatus.calledWith(204).should.equal(true) + }) + }) + + describe('unArchiveAllDocs', () => { + beforeEach(ctx => { + ctx.req.params = { project_id: ctx.projectId } + }) + + describe('on success', () => { + beforeEach(async ctx => { + await ctx.HttpController.unArchiveAllDocs(ctx.req, ctx.res, ctx.next) + }) + + it('returns a 200', ctx => { + expect(ctx.res.sendStatus).to.have.been.calledWith(200) + }) + }) + + describe("when the archived rev doesn't match", () => { + beforeEach(async ctx => { + ctx.DocArchiveManager.unArchiveAllDocs.rejects( + new Errors.DocRevValueError('bad rev') + ) + await ctx.HttpController.unArchiveAllDocs(ctx.req, ctx.res, ctx.next) + }) + + it('returns a 409', ctx => { + expect(ctx.res.sendStatus).to.have.been.calledWith(409) + }) + }) + }) + + describe('destroyProject', () => { + beforeEach(async ctx => { + ctx.req.params = { project_id: ctx.projectId } + ctx.DocArchiveManager.destroyProject = sinon.stub().resolves() + await ctx.HttpController.destroyProject(ctx.req, ctx.res, ctx.next) + }) + + it('should destroy the docs', ctx => { + sinon.assert.calledWith( + ctx.DocArchiveManager.destroyProject, + ctx.projectId + ) + }) + + it('should return 204', ctx => { + sinon.assert.calledWith(ctx.res.sendStatus, 204) + }) + }) +}) diff --git a/services/docstore/test/unit/js/HttpControllerTests.js b/services/docstore/test/unit/js/HttpControllerTests.js deleted file mode 100644 index ab491ec150..0000000000 --- a/services/docstore/test/unit/js/HttpControllerTests.js +++ /dev/null @@ -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) - }) - }) -}) diff --git a/services/docstore/test/unit/js/MongoManager.test.js b/services/docstore/test/unit/js/MongoManager.test.js new file mode 100644 index 0000000000..a6d2359127 --- /dev/null +++ b/services/docstore/test/unit/js/MongoManager.test.js @@ -0,0 +1,411 @@ +import sinon from 'sinon' +import { ObjectId } from 'mongodb-legacy' +import path from 'node:path' +import { assert, beforeEach, describe, expect, it, vi } from 'vitest' +import Errors from '../../../app/js/Errors.js' + +const modulePath = path.join( + import.meta.dirname, + '../../../app/js/MongoManager' +) + +describe('MongoManager', () => { + beforeEach(async ctx => { + ctx.db = { + docs: { + updateOne: sinon.stub().resolves({ matchedCount: 1 }), + insertOne: sinon.stub().resolves(), + }, + } + + vi.doMock('../../../app/js/mongodb', () => ({ + default: { + db: ctx.db, + ObjectId, + }, + })) + + vi.doMock('@overleaf/settings', () => ({ + default: { + max_deleted_docs: 42, + docstore: { archivingLockDurationMs: 5000 }, + }, + })) + + vi.doMock('../../../app/js/Errors', () => ({ + default: Errors, + })) + + ctx.MongoManager = (await import(modulePath)).default + ctx.projectId = new ObjectId().toString() + ctx.docId = new ObjectId().toString() + ctx.rev = 42 + ctx.stubbedErr = new Error('hello world') + ctx.lines = ['Three French hens', 'Two turtle doves'] + }) + + describe('findDoc', () => { + beforeEach(async ctx => { + ctx.doc = { name: 'mock-doc' } + ctx.db.docs.findOne = sinon.stub().resolves(ctx.doc) + ctx.filter = { lines: true } + ctx.result = await ctx.MongoManager.findDoc( + ctx.projectId, + ctx.docId, + ctx.filter + ) + }) + + it('should find the doc', ctx => { + ctx.db.docs.findOne + .calledWith( + { + _id: new ObjectId(ctx.docId), + project_id: new ObjectId(ctx.projectId), + }, + { + projection: ctx.filter, + } + ) + .should.equal(true) + }) + + it('should return the doc', ctx => { + expect(ctx.doc).to.deep.equal(ctx.doc) + }) + }) + + describe('patchDoc', () => { + beforeEach(async ctx => { + ctx.meta = { name: 'foo.tex' } + await ctx.MongoManager.patchDoc(ctx.projectId, ctx.docId, ctx.meta) + }) + + it('should pass the parameter along', ctx => { + ctx.db.docs.updateOne.should.have.been.calledWith( + { + _id: new ObjectId(ctx.docId), + project_id: new ObjectId(ctx.projectId), + }, + { + $set: ctx.meta, + } + ) + }) + }) + + describe('getProjectsDocs', () => { + beforeEach(ctx => { + ctx.filter = { lines: true } + ctx.doc1 = { name: 'mock-doc1' } + ctx.doc2 = { name: 'mock-doc2' } + ctx.doc3 = { name: 'mock-doc3' } + ctx.doc4 = { name: 'mock-doc4' } + ctx.db.docs.find = sinon.stub().returns({ + toArray: sinon.stub().resolves([ctx.doc, ctx.doc3, ctx.doc4]), + }) + }) + + describe('with included_deleted = false', () => { + beforeEach(async ctx => { + ctx.result = await ctx.MongoManager.getProjectsDocs( + ctx.projectId, + { include_deleted: false }, + ctx.filter + ) + }) + + it('should find the non-deleted docs via the project_id', ctx => { + ctx.db.docs.find + .calledWith( + { + project_id: new ObjectId(ctx.projectId), + deleted: { $ne: true }, + }, + { + projection: ctx.filter, + } + ) + .should.equal(true) + }) + + it('should call return the docs', ctx => { + expect(ctx.result).to.deep.equal([ctx.doc, ctx.doc3, ctx.doc4]) + }) + }) + + describe('with included_deleted = true', () => { + beforeEach(async ctx => { + ctx.result = await ctx.MongoManager.getProjectsDocs( + ctx.projectId, + { include_deleted: true }, + ctx.filter + ) + }) + + it('should find all via the project_id', ctx => { + ctx.db.docs.find + .calledWith( + { + project_id: new ObjectId(ctx.projectId), + }, + { + projection: ctx.filter, + } + ) + .should.equal(true) + }) + + it('should return the docs', ctx => { + expect(ctx.result).to.deep.equal([ctx.doc, ctx.doc3, ctx.doc4]) + }) + }) + }) + + describe('getProjectsDeletedDocs', () => { + beforeEach(async ctx => { + ctx.filter = { name: true } + ctx.doc1 = { _id: '1', name: 'mock-doc1.tex' } + ctx.doc2 = { _id: '2', name: 'mock-doc2.tex' } + ctx.doc3 = { _id: '3', name: 'mock-doc3.tex' } + ctx.db.docs.find = sinon.stub().returns({ + toArray: sinon.stub().resolves([ctx.doc1, ctx.doc2, ctx.doc3]), + }) + ctx.result = await ctx.MongoManager.getProjectsDeletedDocs( + ctx.projectId, + ctx.filter + ) + }) + + it('should find the deleted docs via the project_id', ctx => { + ctx.db.docs.find + .calledWith({ + project_id: new ObjectId(ctx.projectId), + deleted: true, + }) + .should.equal(true) + }) + + it('should filter, sort by deletedAt and limit', ctx => { + ctx.db.docs.find + .calledWith(sinon.match.any, { + projection: ctx.filter, + sort: { deletedAt: -1 }, + limit: 42, + }) + .should.equal(true) + }) + + it('should return the docs', ctx => { + expect(ctx.result).to.deep.equal([ctx.doc1, ctx.doc2, ctx.doc3]) + }) + }) + + describe('upsertIntoDocCollection', () => { + beforeEach(ctx => { + ctx.oldRev = 77 + }) + + it('should upsert the document', async ctx => { + await ctx.MongoManager.upsertIntoDocCollection( + ctx.projectId, + ctx.docId, + ctx.oldRev, + { lines: ctx.lines } + ) + + const args = ctx.db.docs.updateOne.args[0] + assert.deepEqual(args[0], { + _id: new ObjectId(ctx.docId), + project_id: new ObjectId(ctx.projectId), + rev: ctx.oldRev, + }) + assert.equal(args[1].$set.lines, ctx.lines) + assert.equal(args[1].$inc.rev, 1) + }) + + it('should handle update error', async ctx => { + ctx.db.docs.updateOne.rejects(ctx.stubbedErr) + await expect( + ctx.MongoManager.upsertIntoDocCollection( + ctx.projectId, + ctx.docId, + ctx.rev, + { + lines: ctx.lines, + } + ) + ).to.be.rejectedWith(ctx.stubbedErr) + }) + + it('should insert without a previous rev', async ctx => { + await ctx.MongoManager.upsertIntoDocCollection( + ctx.projectId, + ctx.docId, + null, + { lines: ctx.lines, ranges: ctx.ranges } + ) + + expect(ctx.db.docs.insertOne).to.have.been.calledWith({ + _id: new ObjectId(ctx.docId), + project_id: new ObjectId(ctx.projectId), + rev: 1, + lines: ctx.lines, + ranges: ctx.ranges, + }) + }) + + it('should handle generic insert error', async ctx => { + ctx.db.docs.insertOne.rejects(ctx.stubbedErr) + await expect( + ctx.MongoManager.upsertIntoDocCollection( + ctx.projectId, + ctx.docId, + null, + { lines: ctx.lines, ranges: ctx.ranges } + ) + ).to.be.rejectedWith(ctx.stubbedErr) + }) + + it('should handle duplicate insert error', async ctx => { + ctx.db.docs.insertOne.rejects({ code: 11000 }) + await expect( + ctx.MongoManager.upsertIntoDocCollection( + ctx.projectId, + ctx.docId, + null, + { lines: ctx.lines, ranges: ctx.ranges } + ) + ).to.be.rejectedWith(Errors.DocRevValueError) + }) + }) + + describe('destroyProject', () => { + beforeEach(async ctx => { + ctx.projectId = new ObjectId() + ctx.db.docs.deleteMany = sinon.stub().resolves() + await ctx.MongoManager.destroyProject(ctx.projectId) + }) + + it('should destroy all docs', ctx => { + sinon.assert.calledWith(ctx.db.docs.deleteMany, { + project_id: ctx.projectId, + }) + }) + }) + + describe('checkRevUnchanged', ctx => { + beforeEach(ctx => { + ctx.doc = { _id: new ObjectId(), name: 'mock-doc', rev: 1 } + }) + + it('should not error when the rev has not changed', async ctx => { + ctx.db.docs.findOne = sinon.stub().resolves({ rev: 1 }) + await ctx.MongoManager.checkRevUnchanged(ctx.doc) + }) + + it('should return an error when the rev has changed', async ctx => { + ctx.db.docs.findOne = sinon.stub().resolves({ rev: 2 }) + await expect( + ctx.MongoManager.checkRevUnchanged(ctx.doc) + ).to.be.rejectedWith(Errors.DocModifiedError) + }) + + it('should return a value error if incoming rev is NaN', async ctx => { + ctx.db.docs.findOne = sinon.stub().resolves({ rev: 2 }) + ctx.doc = { _id: new ObjectId(), name: 'mock-doc', rev: NaN } + await expect( + ctx.MongoManager.checkRevUnchanged(ctx.doc) + ).to.be.rejectedWith(Errors.DocRevValueError) + }) + + it('should return a value error if checked doc rev is NaN', async ctx => { + ctx.db.docs.findOne = sinon.stub().resolves({ rev: NaN }) + await expect( + ctx.MongoManager.checkRevUnchanged(ctx.doc) + ).to.be.rejectedWith(Errors.DocRevValueError) + }) + }) + + describe('restoreArchivedDoc', () => { + beforeEach(ctx => { + ctx.archivedDoc = { + lines: ['a', 'b', 'c'], + ranges: { some: 'ranges' }, + rev: 2, + } + }) + + describe('complete doc', () => { + beforeEach(async ctx => { + await ctx.MongoManager.restoreArchivedDoc( + ctx.projectId, + ctx.docId, + ctx.archivedDoc + ) + }) + + it('updates Mongo', ctx => { + expect(ctx.db.docs.updateOne).to.have.been.calledWith( + { + _id: new ObjectId(ctx.docId), + project_id: new ObjectId(ctx.projectId), + rev: ctx.archivedDoc.rev, + }, + { + $set: { + lines: ctx.archivedDoc.lines, + ranges: ctx.archivedDoc.ranges, + }, + $unset: { + inS3: true, + }, + } + ) + }) + }) + + describe('without ranges', () => { + beforeEach(async ctx => { + delete ctx.archivedDoc.ranges + await ctx.MongoManager.restoreArchivedDoc( + ctx.projectId, + ctx.docId, + ctx.archivedDoc + ) + }) + + it('sets ranges to an empty object', ctx => { + expect(ctx.db.docs.updateOne).to.have.been.calledWith( + { + _id: new ObjectId(ctx.docId), + project_id: new ObjectId(ctx.projectId), + rev: ctx.archivedDoc.rev, + }, + { + $set: { + lines: ctx.archivedDoc.lines, + ranges: {}, + }, + $unset: { + inS3: true, + }, + } + ) + }) + }) + + describe("when the update doesn't succeed", () => { + it('throws a DocRevValueError', async ctx => { + ctx.db.docs.updateOne.resolves({ matchedCount: 0 }) + await expect( + ctx.MongoManager.restoreArchivedDoc( + ctx.projectId, + ctx.docId, + ctx.archivedDoc + ) + ).to.be.rejectedWith(Errors.DocRevValueError) + }) + }) + }) +}) diff --git a/services/docstore/test/unit/js/MongoManagerTests.js b/services/docstore/test/unit/js/MongoManagerTests.js deleted file mode 100644 index b96b661df4..0000000000 --- a/services/docstore/test/unit/js/MongoManagerTests.js +++ /dev/null @@ -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) - }) - }) - }) -}) diff --git a/services/docstore/test/unit/js/PersistorManager.test.js b/services/docstore/test/unit/js/PersistorManager.test.js new file mode 100644 index 0000000000..482572624e --- /dev/null +++ b/services/docstore/test/unit/js/PersistorManager.test.js @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' + +const modulePath = '../../../app/js/PersistorManager.js' + +describe('PersistorManager', () => { + class FakePersistor { + async sendStream() { + return 'sent' + } + } + + describe('configured', () => { + it('should return fake persistor', async () => { + const Settings = { + docstore: { + backend: 'gcs', + bucket: 'wombat', + }, + } + vi.doMock('@overleaf/settings', () => ({ + default: Settings, + })) + vi.doMock('@overleaf/object-persistor', () => ({ + default: () => new FakePersistor(), + })) + vi.doMock('@overleaf/metrics', () => ({ default: {} })) + const PersistorManger = (await import(modulePath)).default + + expect(PersistorManger).to.be.instanceof(FakePersistor) + expect(PersistorManger.sendStream()).to.eventually.equal('sent') + }) + }) + + describe('not configured', () => { + it('should return abstract persistor', async () => { + const Settings = { + docstore: { + backend: undefined, + bucket: 'wombat', + }, + } + vi.doMock('@overleaf/settings', () => ({ + default: Settings, + })) + vi.doMock('@overleaf/object-persistor', () => ({ + default: () => new FakePersistor(), + })) + vi.doMock('@overleaf/metrics', () => ({ default: {} })) + const PersistorManger = (await import(modulePath)).default + + expect(PersistorManger.constructor.name).to.equal('AbstractPersistor') + expect(PersistorManger.sendStream()).to.eventually.be.rejectedWith( + /method not implemented in persistor/ + ) + }) + }) +}) diff --git a/services/docstore/test/unit/js/PersistorManagerTests.js b/services/docstore/test/unit/js/PersistorManagerTests.js deleted file mode 100644 index 8f8ddacdd8..0000000000 --- a/services/docstore/test/unit/js/PersistorManagerTests.js +++ /dev/null @@ -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/ - ) - }) - }) -}) diff --git a/services/docstore/test/unit/js/RangeManager.test.js b/services/docstore/test/unit/js/RangeManager.test.js new file mode 100644 index 0000000000..de54af351c --- /dev/null +++ b/services/docstore/test/unit/js/RangeManager.test.js @@ -0,0 +1,239 @@ +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ObjectId } from 'mongodb-legacy' + +const modulePath = path.join( + import.meta.dirname, + '../../../app/js/RangeManager' +) + +describe('RangeManager', () => { + beforeEach(async ctx => { + vi.doMock('../../../app/js/mongodb', () => ({ + default: { + ObjectId, + }, + })) + + ctx.RangeManager = (await import(modulePath)).default + }) + + describe('jsonRangesToMongo', () => { + it('should convert ObjectIds and dates to proper objects and fix comment id', ctx => { + const changeId = new ObjectId().toString() + const commentId = new ObjectId().toString() + const userId = new ObjectId().toString() + const threadId = new ObjectId().toString() + const ts = new Date().toJSON() + ctx.RangeManager.jsonRangesToMongo({ + changes: [ + { + id: changeId, + op: { i: 'foo', p: 3 }, + metadata: { + user_id: userId, + ts, + }, + }, + ], + comments: [ + { + id: commentId, + op: { c: 'foo', p: 3, t: threadId }, + }, + ], + }).should.deep.equal({ + changes: [ + { + id: new ObjectId(changeId), + op: { i: 'foo', p: 3 }, + metadata: { + user_id: new ObjectId(userId), + ts: new Date(ts), + }, + }, + ], + comments: [ + { + id: new ObjectId(threadId), + op: { c: 'foo', p: 3, t: new ObjectId(threadId) }, + }, + ], + }) + }) + + it('should leave malformed ObjectIds as they are', ctx => { + const changeId = 'foo' + const commentId = 'bar' + const userId = 'baz' + ctx.RangeManager.jsonRangesToMongo({ + changes: [ + { + id: changeId, + metadata: { + user_id: userId, + }, + }, + ], + comments: [ + { + id: commentId, + }, + ], + }).should.deep.equal({ + changes: [ + { + id: changeId, + metadata: { + user_id: userId, + }, + }, + ], + comments: [ + { + id: commentId, + }, + ], + }) + }) + + it('should be consistent when transformed through json -> mongo -> json', ctx => { + const changeId = new ObjectId().toString() + const userId = new ObjectId().toString() + const threadId = new ObjectId().toString() + const ts = new Date().toJSON() + const ranges1 = { + changes: [ + { + id: changeId, + op: { i: 'foo', p: 3 }, + metadata: { + user_id: userId, + ts, + }, + }, + ], + comments: [ + { + id: threadId, + op: { c: 'foo', p: 3, t: threadId }, + }, + ], + } + const ranges1Copy = JSON.parse(JSON.stringify(ranges1)) // jsonRangesToMongo modifies in place + const ranges2 = JSON.parse( + JSON.stringify(ctx.RangeManager.jsonRangesToMongo(ranges1Copy)) + ) + ranges1.should.deep.equal(ranges2) + }) + }) + + return describe('shouldUpdateRanges', () => { + beforeEach(ctx => { + const threadId = new ObjectId() + ctx.ranges = { + changes: [ + { + id: new ObjectId(), + op: { i: 'foo', p: 3 }, + metadata: { + user_id: new ObjectId(), + ts: new Date(), + }, + }, + ], + comments: [ + { + id: threadId, + op: { c: 'foo', p: 3, t: threadId }, + }, + ], + } + ctx.ranges_copy = ctx.RangeManager.jsonRangesToMongo( + JSON.parse(JSON.stringify(ctx.ranges)) + ) + }) + + describe('with a blank new range', () => { + it('should throw an error', ctx => { + expect(() => { + ctx.RangeManager.shouldUpdateRanges(ctx.ranges, null) + }).to.throw(Error) + }) + }) + + describe('with a blank old range', () => { + it('should treat it like {}', ctx => { + ctx.RangeManager.shouldUpdateRanges(null, {}).should.equal(false) + ctx.RangeManager.shouldUpdateRanges(null, ctx.ranges).should.equal(true) + }) + }) + + describe('with no changes', () => { + it('should return false', ctx => { + ctx.RangeManager.shouldUpdateRanges( + ctx.ranges, + ctx.ranges_copy + ).should.equal(false) + }) + }) + + describe('with changes', () => { + it('should return true when the change id changes', ctx => { + ctx.ranges_copy.changes[0].id = new ObjectId() + ctx.RangeManager.shouldUpdateRanges( + ctx.ranges, + ctx.ranges_copy + ).should.equal(true) + }) + + it('should return true when the change user id changes', ctx => { + ctx.ranges_copy.changes[0].metadata.user_id = new ObjectId() + ctx.RangeManager.shouldUpdateRanges( + ctx.ranges, + ctx.ranges_copy + ).should.equal(true) + }) + + it('should return true when the change ts changes', ctx => { + ctx.ranges_copy.changes[0].metadata.ts = new Date(Date.now() + 1000) + ctx.RangeManager.shouldUpdateRanges( + ctx.ranges, + ctx.ranges_copy + ).should.equal(true) + }) + + it('should return true when the change op changes', ctx => { + ctx.ranges_copy.changes[0].op.i = 'bar' + ctx.RangeManager.shouldUpdateRanges( + ctx.ranges, + ctx.ranges_copy + ).should.equal(true) + }) + + it('should return true when the comment id changes', ctx => { + ctx.ranges_copy.comments[0].id = new ObjectId() + ctx.RangeManager.shouldUpdateRanges( + ctx.ranges, + ctx.ranges_copy + ).should.equal(true) + }) + + it('should return true when the comment offset changes', ctx => { + ctx.ranges_copy.comments[0].op.p = 17 + ctx.RangeManager.shouldUpdateRanges( + ctx.ranges, + ctx.ranges_copy + ).should.equal(true) + }) + + it('should return true when the comment content changes', ctx => { + ctx.ranges_copy.comments[0].op.c = 'bar' + ctx.RangeManager.shouldUpdateRanges( + ctx.ranges, + ctx.ranges_copy + ).should.equal(true) + }) + }) + }) +}) diff --git a/services/docstore/test/unit/js/RangeManagerTests.js b/services/docstore/test/unit/js/RangeManagerTests.js deleted file mode 100644 index ba99280a7a..0000000000 --- a/services/docstore/test/unit/js/RangeManagerTests.js +++ /dev/null @@ -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) - }) - }) - }) -}) diff --git a/services/docstore/test/unit/setup.js b/services/docstore/test/unit/setup.js new file mode 100644 index 0000000000..6efc509470 --- /dev/null +++ b/services/docstore/test/unit/setup.js @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, chai, vi } from 'vitest' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import chaiAsPromised from 'chai-as-promised' +import mongodb from 'mongodb-legacy' + +// ensure every ObjectId has the id string as a property for correct comparisons +mongodb.ObjectId.cacheHexString = true + +process.env.BACKEND = 'gcs' + +// Chai configuration +chai.should() +chai.use(sinonChai) +chai.use(chaiAsPromised) + +// Global stubs +const sandbox = sinon.createSandbox() +const stubs = { + logger: { + debug: sandbox.stub(), + log: sandbox.stub(), + info: sandbox.stub(), + warn: sandbox.stub(), + err: sandbox.stub(), + error: sandbox.stub(), + fatal: sandbox.stub(), + }, +} + +beforeEach(ctx => { + ctx.logger = stubs.logger + vi.doMock('@overleaf/logger', () => ({ default: ctx.logger })) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + sandbox.reset() +}) diff --git a/services/docstore/tsconfig.json b/services/docstore/tsconfig.json index c018d6e682..64bc0e874a 100644 --- a/services/docstore/tsconfig.json +++ b/services/docstore/tsconfig.json @@ -8,6 +8,7 @@ "config/**/*", "scripts/**/*", "test/**/*", - "types" + "types", + "vitest.config.unit.cjs" ] } diff --git a/services/docstore/vitest.config.unit.cjs b/services/docstore/vitest.config.unit.cjs new file mode 100644 index 0000000000..9876a60525 --- /dev/null +++ b/services/docstore/vitest.config.unit.cjs @@ -0,0 +1,25 @@ +const { defineConfig } = require('vitest/config') + +let reporterOptions = {} +if (process.env.CI) { + reporterOptions = { + reporters: [ + 'default', + [ + 'junit', + { + classnameTemplate: `Unit tests.{filename}`, + }, + ], + ], + outputFile: 'reports/junit-vitest-unit.xml', + } +} +module.exports = defineConfig({ + test: { + include: ['test/unit/js/*.test.{js,ts}'], + setupFiles: ['./test/unit/setup.js'], + isolate: true, + ...reporterOptions, + }, +})