Эх сурвалжийг харах

[web] Convert some Features files to ES modules (part 3) (#28494)

* Rename files to mjs

* Rename test files to mjs

* Update CODEOWNERS

* Update files to ESM

* Update test files to ESM

* Update RestoreManager.test.mjs

* Remove unused `AdminAuthorizationHelper` mock and stub

* Remove unnecessary return

GitOrigin-RevId: 2b9ef126de1d8964afbc6e5641cca36712655866
Antoine Clausse 1 жил өмнө
parent
commit
71f0b28a84
54 өөрчлөгдсөн 3274 нэмэгдсэн , 3075 устгасан
  1. 21 16
      services/web/app/src/Features/Authorization/AuthorizationMiddleware.mjs
  2. 14 12
      services/web/app/src/Features/Authorization/PermissionsController.mjs
  3. 8 8
      services/web/app/src/Features/BrandVariations/BrandVariationsHandler.mjs
  4. 1 1
      services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs
  5. 5 5
      services/web/app/src/Features/Captcha/DeviceHistory.mjs
  6. 1 1
      services/web/app/src/Features/Collaborators/CollaboratorsController.mjs
  7. 1 1
      services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
  8. 1 1
      services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs
  9. 1 1
      services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs
  10. 1 1
      services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs
  11. 1 1
      services/web/app/src/Features/Compile/ClsiCacheController.mjs
  12. 27 24
      services/web/app/src/Features/Compile/CompileController.mjs
  13. 1 1
      services/web/app/src/Features/Editor/EditorRouter.mjs
  14. 2 2
      services/web/app/src/Features/Helpers/AuthorizationHelper.mjs
  15. 1 1
      services/web/app/src/Features/History/HistoryController.mjs
  16. 1 1
      services/web/app/src/Features/History/HistoryRouter.mjs
  17. 21 21
      services/web/app/src/Features/History/RestoreManager.mjs
  18. 1 1
      services/web/app/src/Features/InactiveData/InactiveProjectController.mjs
  19. 12 12
      services/web/app/src/Features/InactiveData/InactiveProjectManager.mjs
  20. 1 1
      services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
  21. 9 9
      services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs
  22. 1 1
      services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs
  23. 1 1
      services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs
  24. 1 1
      services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs
  25. 1 1
      services/web/app/src/Features/LinkedFiles/UrlAgent.mjs
  26. 4 4
      services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs
  27. 4 4
      services/web/app/src/Features/Project/ProjectController.mjs
  28. 23 23
      services/web/app/src/Features/Project/ProjectDuplicator.mjs
  29. 1 1
      services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
  30. 1 1
      services/web/app/src/Features/TokenAccess/TokenAccessController.mjs
  31. 1 1
      services/web/app/src/Features/TokenAccess/TokenAccessRouter.mjs
  32. 1 1
      services/web/app/src/Features/Uploads/UploadsRouter.mjs
  33. 3 3
      services/web/app/src/router.mjs
  34. 1 1
      services/web/modules/launchpad/app/src/LaunchpadRouter.mjs
  35. 1 1
      services/web/modules/user-activate/app/src/UserActivateRouter.mjs
  36. 1 1
      services/web/scripts/check_project_files.js
  37. 1 1
      services/web/scripts/deactivate_projects.mjs
  38. 160 115
      services/web/test/unit/src/Authorization/AuthorizationMiddleware.test.mjs
  39. 46 59
      services/web/test/unit/src/BrandVariations/BrandVariationsHandler.test.mjs
  40. 1 1
      services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs
  41. 1 1
      services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs
  42. 1 1
      services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs
  43. 1 1
      services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs
  44. 1003 0
      services/web/test/unit/src/Compile/CompileController.test.mjs
  45. 0 972
      services/web/test/unit/src/Compile/CompileControllerTests.js
  46. 67 0
      services/web/test/unit/src/HelperFiles/AuthorizationHelper.test.mjs
  47. 0 66
      services/web/test/unit/src/HelperFiles/AuthorizationHelperTests.js
  48. 6 3
      services/web/test/unit/src/History/HistoryController.test.mjs
  49. 1169 0
      services/web/test/unit/src/History/RestoreManager.test.mjs
  50. 0 1130
      services/web/test/unit/src/History/RestoreManagerTests.js
  51. 181 0
      services/web/test/unit/src/InactiveData/InactiveProjectManager.test.mjs
  52. 0 151
      services/web/test/unit/src/InactiveData/InactiveProjectManagerTests.js
  53. 461 0
      services/web/test/unit/src/Project/ProjectDuplicator.test.mjs
  54. 0 408
      services/web/test/unit/src/Project/ProjectDuplicatorTests.js

+ 21 - 16
services/web/app/src/Features/Authorization/AuthorizationMiddleware.js → services/web/app/src/Features/Authorization/AuthorizationMiddleware.mjs

@@ -1,21 +1,26 @@
-const AuthorizationManager = require('./AuthorizationManager')
-const logger = require('@overleaf/logger')
-const { ObjectId } = require('mongodb-legacy')
-const Errors = require('../Errors/Errors')
-const HttpErrorHandler = require('../Errors/HttpErrorHandler')
-const AuthenticationController = require('../Authentication/AuthenticationController')
-const SessionManager = require('../Authentication/SessionManager')
-const TokenAccessHandler = require('../TokenAccess/TokenAccessHandler')
-const { expressify } = require('@overleaf/promise-utils')
-const {
-  canRedirectToAdminDomain,
-} = require('../Helpers/AdminAuthorizationHelper')
-const { getSafeAdminDomainRedirect } = require('../Helpers/UrlHelper')
+import AuthorizationManager from './AuthorizationManager.js'
+import logger from '@overleaf/logger'
+import mongodb from 'mongodb-legacy'
+
+import Errors from '../Errors/Errors.js'
+import HttpErrorHandler from '../Errors/HttpErrorHandler.js'
+import AuthenticationController from '../Authentication/AuthenticationController.js'
+import SessionManager from '../Authentication/SessionManager.js'
+import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.js'
+import { expressify } from '@overleaf/promise-utils'
+import AdminAuthorizationHelper from '../Helpers/AdminAuthorizationHelper.js'
+import UrlHelper from '../Helpers/UrlHelper.js'
+
+const { ObjectId } = mongodb
 
 function _handleAdminDomainRedirect(req, res) {
-  if (canRedirectToAdminDomain(SessionManager.getSessionUser(req.session))) {
+  if (
+    AdminAuthorizationHelper.canRedirectToAdminDomain(
+      SessionManager.getSessionUser(req.session)
+    )
+  ) {
     logger.warn({ req }, 'redirecting admin user to admin domain')
-    res.redirect(getSafeAdminDomainRedirect(req.originalUrl))
+    res.redirect(UrlHelper.getSafeAdminDomainRedirect(req.originalUrl))
     return true
   }
   return false
@@ -255,7 +260,7 @@ function restricted(req, res, next) {
   res.redirect('/login')
 }
 
-module.exports = {
+export default {
   ensureUserCanReadMultipleProjects: expressify(
     ensureUserCanReadMultipleProjects
   ),

+ 14 - 12
services/web/app/src/Features/Authorization/PermissionsController.js → services/web/app/src/Features/Authorization/PermissionsController.mjs

@@ -1,15 +1,9 @@
 // @ts-check
-const { ForbiddenError, UserNotFoundError } = require('../Errors/Errors')
-const {
-  getUserCapabilities,
-  getUserRestrictions,
-  combineGroupPolicies,
-  combineAllowedProperties,
-} = require('./PermissionsManager')
-const { assertUserPermissions } = require('./PermissionsManager').promises
-const Modules = require('../../infrastructure/Modules')
-const { expressify } = require('@overleaf/promise-utils')
-const Features = require('../../infrastructure/Features')
+import { ForbiddenError, UserNotFoundError } from '../Errors/Errors.js'
+import PermissionsManager from './PermissionsManager.js'
+import Modules from '../../infrastructure/Modules.js'
+import { expressify } from '@overleaf/promise-utils'
+import Features from '../../infrastructure/Features.js'
 
 /**
  * @typedef {(import('express').Request)} Request
@@ -18,6 +12,14 @@ const Features = require('../../infrastructure/Features')
  * @typedef {import('./PermissionsManager').Capability} Capability
  */
 
+const {
+  getUserCapabilities,
+  getUserRestrictions,
+  combineGroupPolicies,
+  combineAllowedProperties,
+  promises: { assertUserPermissions },
+} = PermissionsManager
+
 /**
  * Function that returns middleware to add an `assertPermission` function to the request object to check if the user has a specific capability.
  * @returns {() => (req: Request, res: Response, next: NextFunction) => void} The middleware function that adds the `assertPermission` function to the request object.
@@ -116,7 +118,7 @@ function requirePermission(...requiredCapabilities) {
   return doRequest
 }
 
-module.exports = {
+export default {
   requirePermission,
   useCapabilities,
 }

+ 8 - 8
services/web/app/src/Features/BrandVariations/BrandVariationsHandler.js → services/web/app/src/Features/BrandVariations/BrandVariationsHandler.mjs

@@ -1,12 +1,12 @@
-const OError = require('@overleaf/o-error')
-const { URL } = require('url')
-const settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-const V1Api = require('../V1/V1Api')
-const sanitizeHtml = require('sanitize-html')
-const { promisify } = require('@overleaf/promise-utils')
+import OError from '@overleaf/o-error'
+import { URL } from 'node:url'
+import settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import V1Api from '../V1/V1Api.js'
+import sanitizeHtml from 'sanitize-html'
+import { promisify } from '@overleaf/promise-utils'
 
-module.exports = {
+export default {
   getBrandVariationById,
   promises: {
     getBrandVariationById: promisify(getBrandVariationById),

+ 1 - 1
services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs

@@ -3,7 +3,7 @@ import logger from '@overleaf/logger'
 import Settings from '@overleaf/settings'
 import Metrics from '@overleaf/metrics'
 import OError from '@overleaf/o-error'
-import DeviceHistory from './DeviceHistory.js'
+import DeviceHistory from './DeviceHistory.mjs'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
 import { expressify } from '@overleaf/promise-utils'
 import EmailsHelper from '../Helpers/EmailHelper.js'

+ 5 - 5
services/web/app/src/Features/Captcha/DeviceHistory.js → services/web/app/src/Features/Captcha/DeviceHistory.mjs

@@ -1,7 +1,7 @@
-const crypto = require('crypto')
-const jose = require('jose')
-const Metrics = require('@overleaf/metrics')
-const Settings = require('@overleaf/settings')
+import crypto from 'node:crypto'
+import * as jose from 'jose'
+import Metrics from '@overleaf/metrics'
+import Settings from '@overleaf/settings'
 
 const COOKIE_NAME = Settings.deviceHistory.cookieName
 const ENTRY_EXPIRY = Settings.deviceHistory.entryExpiry
@@ -100,4 +100,4 @@ class DeviceHistory {
   }
 }
 
-module.exports = DeviceHistory
+export default DeviceHistory

+ 1 - 1
services/web/app/src/Features/Collaborators/CollaboratorsController.mjs

@@ -12,7 +12,7 @@ import logger from '@overleaf/logger'
 import { expressify } from '@overleaf/promise-utils'
 import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js'
 import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.js'
-import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
 import LimitationsManager from '../Subscription/LimitationsManager.js'
 import Features from '../../infrastructure/Features.js'
 

+ 1 - 1
services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs

@@ -12,7 +12,7 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.js'
 import SessionManager from '../Authentication/SessionManager.js'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
 import { expressify } from '@overleaf/promise-utils'
-import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
 import Errors from '../Errors/Errors.js'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
 import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'

+ 1 - 1
services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs

@@ -10,7 +10,7 @@ import ProjectGetter from '../Project/ProjectGetter.js'
 import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
 import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
 import LimitationsManager from '../Subscription/LimitationsManager.js'
-import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
 import _ from 'lodash'
 
 const CollaboratorsInviteHandler = {

+ 1 - 1
services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs

@@ -1,6 +1,6 @@
 import CollaboratorsController from './CollaboratorsController.mjs'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
 import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
 import CollaboratorsInviteController from './CollaboratorsInviteController.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'

+ 1 - 1
services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs

@@ -7,7 +7,7 @@ import EmailHandler from '../Email/EmailHandler.js'
 import Errors from '../Errors/Errors.js'
 import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
 import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.js'
-import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
 import AnalyticsManager from '../Analytics/AnalyticsManager.js'
 import OError from '@overleaf/o-error'
 import TagsHandler from '../Tags/TagsHandler.js'

+ 1 - 1
services/web/app/src/Features/Compile/ClsiCacheController.mjs

@@ -7,7 +7,7 @@ import Path from 'node:path'
 import { pipeline } from 'node:stream/promises'
 import logger from '@overleaf/logger'
 import ClsiCacheManager from './ClsiCacheManager.js'
-import CompileController from './CompileController.js'
+import CompileController from './CompileController.mjs'
 import { expressify } from '@overleaf/promise-utils'
 import ClsiCacheHandler from './ClsiCacheHandler.js'
 import ProjectGetter from '../Project/ProjectGetter.js'

+ 27 - 24
services/web/app/src/Features/Compile/CompileController.js → services/web/app/src/Features/Compile/CompileController.mjs

@@ -1,28 +1,31 @@
-const { URL } = require('url')
-const { pipeline } = require('stream/promises')
-const { Cookie } = require('tough-cookie')
-const OError = require('@overleaf/o-error')
-const Metrics = require('@overleaf/metrics')
-const ProjectGetter = require('../Project/ProjectGetter')
-const CompileManager = require('./CompileManager')
-const ClsiManager = require('./ClsiManager')
-const logger = require('@overleaf/logger')
-const Settings = require('@overleaf/settings')
-const Errors = require('../Errors/Errors')
-const SessionManager = require('../Authentication/SessionManager')
-const { RateLimiter } = require('../../infrastructure/RateLimiter')
-const { z, zz, validateReq } = require('../../infrastructure/Validation')
-const ClsiCookieManager = require('./ClsiCookieManager')(
-  Settings.apis.clsi?.backendGroupName
-)
-const Path = require('path')
-const AnalyticsManager = require('../Analytics/AnalyticsManager')
-const SplitTestHandler = require('../SplitTests/SplitTestHandler')
-const { expressify } = require('@overleaf/promise-utils')
-const {
+import { URL } from 'node:url'
+import { pipeline } from 'node:stream/promises'
+import { Cookie } from 'tough-cookie'
+import OError from '@overleaf/o-error'
+import Metrics from '@overleaf/metrics'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import CompileManager from './CompileManager.js'
+import ClsiManager from './ClsiManager.js'
+import logger from '@overleaf/logger'
+import Settings from '@overleaf/settings'
+import Errors from '../Errors/Errors.js'
+import SessionManager from '../Authentication/SessionManager.js'
+import { RateLimiter } from '../../infrastructure/RateLimiter.js'
+import Validation from '../../infrastructure/Validation.js'
+import ClsiCookieManagerFactory from './ClsiCookieManager.js'
+import Path from 'node:path'
+import AnalyticsManager from '../Analytics/AnalyticsManager.js'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
+import { expressify } from '@overleaf/promise-utils'
+import {
   fetchStreamWithResponse,
   RequestFailedError,
-} = require('@overleaf/fetch-utils')
+} from '@overleaf/fetch-utils'
+
+const { z, zz, validateReq } = Validation
+const ClsiCookieManager = ClsiCookieManagerFactory(
+  Settings.apis.clsi?.backendGroupName
+)
 
 const COMPILE_TIMEOUT_MS = 10 * 60 * 1000
 
@@ -744,4 +747,4 @@ const CompileController = {
   _proxyToClsiWithLimits: _CompileController._proxyToClsiWithLimits,
 }
 
-module.exports = CompileController
+export default CompileController

+ 1 - 1
services/web/app/src/Features/Editor/EditorRouter.mjs

@@ -1,6 +1,6 @@
 import EditorHttpController from './EditorHttpController.mjs'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
 import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
 import { validate, Joi } from '../../infrastructure/Validation.js'

+ 2 - 2
services/web/app/src/Features/Helpers/AuthorizationHelper.js → services/web/app/src/Features/Helpers/AuthorizationHelper.mjs

@@ -1,6 +1,6 @@
-const { UserSchema } = require('../../models/User')
+import { UserSchema } from '../../models/User.js'
 
-module.exports = {
+export default {
   hasAnyStaffAccess,
 }
 

+ 1 - 1
services/web/app/src/Features/History/HistoryController.mjs

@@ -22,7 +22,7 @@ import Errors from '../Errors/Errors.js'
 import HistoryManager from './HistoryManager.js'
 import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.js'
 import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.js'
-import RestoreManager from './RestoreManager.js'
+import RestoreManager from './RestoreManager.mjs'
 import { prepareZipAttachment } from '../../infrastructure/Response.js'
 import Features from '../../infrastructure/Features.js'
 

+ 1 - 1
services/web/app/src/Features/History/HistoryRouter.mjs

@@ -4,7 +4,7 @@ import Settings from '@overleaf/settings'
 import { Joi, validate } from '../../infrastructure/Validation.js'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
 import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
 import HistoryController from './HistoryController.mjs'
 

+ 21 - 21
services/web/app/src/Features/History/RestoreManager.js → services/web/app/src/Features/History/RestoreManager.mjs

@@ -1,23 +1,23 @@
-const Settings = require('@overleaf/settings')
-const Path = require('path')
-const FileWriter = require('../../infrastructure/FileWriter')
-const Metrics = require('../../infrastructure/Metrics')
-const FileSystemImportManager = require('../Uploads/FileSystemImportManager')
-const EditorController = require('../Editor/EditorController')
-const Errors = require('../Errors/Errors')
-const moment = require('moment')
-const { callbackifyAll } = require('@overleaf/promise-utils')
-const { fetchJson } = require('@overleaf/fetch-utils')
-const ProjectLocator = require('../Project/ProjectLocator')
-const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-const ChatApiHandler = require('../Chat/ChatApiHandler')
-const DocstoreManager = require('../Docstore/DocstoreManager')
-const logger = require('@overleaf/logger')
-const EditorRealTimeController = require('../Editor/EditorRealTimeController')
-const ChatManager = require('../Chat/ChatManager')
-const OError = require('@overleaf/o-error')
-const ProjectGetter = require('../Project/ProjectGetter')
-const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
+import Settings from '@overleaf/settings'
+import Path from 'node:path'
+import FileWriter from '../../infrastructure/FileWriter.js'
+import Metrics from '../../infrastructure/Metrics.js'
+import FileSystemImportManager from '../Uploads/FileSystemImportManager.js'
+import EditorController from '../Editor/EditorController.js'
+import Errors from '../Errors/Errors.js'
+import moment from 'moment'
+import { callbackifyAll } from '@overleaf/promise-utils'
+import { fetchJson } from '@overleaf/fetch-utils'
+import ProjectLocator from '../Project/ProjectLocator.js'
+import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
+import ChatApiHandler from '../Chat/ChatApiHandler.js'
+import DocstoreManager from '../Docstore/DocstoreManager.js'
+import logger from '@overleaf/logger'
+import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
+import ChatManager from '../Chat/ChatManager.js'
+import OError from '@overleaf/o-error'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
 
 async function getCommentThreadIds(projectId) {
   await DocumentUpdaterHandler.promises.flushProjectToMongo(projectId)
@@ -441,4 +441,4 @@ const RestoreManager = {
   },
 }
 
-module.exports = { ...callbackifyAll(RestoreManager), promises: RestoreManager }
+export default { ...callbackifyAll(RestoreManager), promises: RestoreManager }

+ 1 - 1
services/web/app/src/Features/InactiveData/InactiveProjectController.mjs

@@ -9,7 +9,7 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-import InactiveProjectManager from './InactiveProjectManager.js'
+import InactiveProjectManager from './InactiveProjectManager.mjs'
 
 export default {
   deactivateOldProjects(req, res) {

+ 12 - 12
services/web/app/src/Features/InactiveData/InactiveProjectManager.js → services/web/app/src/Features/InactiveData/InactiveProjectManager.mjs

@@ -1,14 +1,14 @@
-const OError = require('@overleaf/o-error')
-const logger = require('@overleaf/logger')
-const DocstoreManager = require('../Docstore/DocstoreManager')
-const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-const ProjectGetter = require('../Project/ProjectGetter')
-const ProjectUpdateHandler = require('../Project/ProjectUpdateHandler')
-const { Project } = require('../../models/Project')
-const Modules = require('../../infrastructure/Modules')
-const { READ_PREFERENCE_SECONDARY } = require('../../infrastructure/mongodb')
-const { callbackifyAll } = require('@overleaf/promise-utils')
-const Metrics = require('@overleaf/metrics')
+import OError from '@overleaf/o-error'
+import logger from '@overleaf/logger'
+import DocstoreManager from '../Docstore/DocstoreManager.js'
+import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import ProjectUpdateHandler from '../Project/ProjectUpdateHandler.js'
+import { Project } from '../../models/Project.js'
+import Modules from '../../infrastructure/Modules.js'
+import { READ_PREFERENCE_SECONDARY } from '../../infrastructure/mongodb.js'
+import { callbackifyAll } from '@overleaf/promise-utils'
+import Metrics from '@overleaf/metrics'
 
 const MILISECONDS_IN_DAY = 86400000
 
@@ -132,7 +132,7 @@ const InactiveProjectManager = {
   },
 }
 
-module.exports = {
+export default {
   ...callbackifyAll(InactiveProjectManager),
   promises: InactiveProjectManager,
   findInactiveProjects,

+ 1 - 1
services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs

@@ -14,7 +14,7 @@ import SessionManager from '../Authentication/SessionManager.js'
 import Settings from '@overleaf/settings'
 import _ from 'lodash'
 import AnalyticsManager from '../../../../app/src/Features/Analytics/AnalyticsManager.js'
-import LinkedFilesHandler from './LinkedFilesHandler.js'
+import LinkedFilesHandler from './LinkedFilesHandler.mjs'
 import {
   CompileFailedError,
   UrlFetchFailedError,

+ 9 - 9
services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.js → services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs

@@ -1,14 +1,14 @@
-const FileWriter = require('../../infrastructure/FileWriter')
-const EditorController = require('../Editor/EditorController')
-const ProjectLocator = require('../Project/ProjectLocator')
-const { Project } = require('../../models/Project')
-const ProjectGetter = require('../Project/ProjectGetter')
-const {
+import FileWriter from '../../infrastructure/FileWriter.js'
+import EditorController from '../Editor/EditorController.js'
+import ProjectLocator from '../Project/ProjectLocator.js'
+import { Project } from '../../models/Project.js'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import {
   ProjectNotFoundError,
   V1ProjectNotFoundError,
   BadDataError,
-} = require('./LinkedFilesErrors')
-const { callbackifyAll } = require('@overleaf/promise-utils')
+} from './LinkedFilesErrors.js'
+import { callbackifyAll } from '@overleaf/promise-utils'
 
 const LinkedFilesHandler = {
   async getFileById(projectId, fileId) {
@@ -100,7 +100,7 @@ const LinkedFilesHandler = {
   },
 }
 
-module.exports = {
+export default {
   promises: LinkedFilesHandler,
   ...callbackifyAll(LinkedFilesHandler, {
     multiResult: { getFileById: ['file', 'path', 'parentFolder'] },

+ 1 - 1
services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs

@@ -1,4 +1,4 @@
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
 import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'

+ 1 - 1
services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs

@@ -15,7 +15,7 @@ import ProjectLocator from '../Project/ProjectLocator.js'
 import DocstoreManager from '../Docstore/DocstoreManager.js'
 import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
 import _ from 'lodash'
-import LinkedFilesHandler from './LinkedFilesHandler.js'
+import LinkedFilesHandler from './LinkedFilesHandler.mjs'
 
 import {
   BadDataError,

+ 1 - 1
services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs

@@ -9,7 +9,7 @@ import {
   AccessDeniedError,
 } from './LinkedFilesErrors.js'
 import { OutputFileFetchFailedError } from '../Errors/Errors.js'
-import LinkedFilesHandler from './LinkedFilesHandler.js'
+import LinkedFilesHandler from './LinkedFilesHandler.mjs'
 import { promisify } from '@overleaf/promise-utils'
 
 function _prepare(projectId, linkedFileData, userId, callback) {

+ 1 - 1
services/web/app/src/Features/LinkedFiles/UrlAgent.mjs

@@ -1,7 +1,7 @@
 import logger from '@overleaf/logger'
 import urlValidator from 'valid-url'
 import { InvalidUrlError, UrlFetchFailedError } from './LinkedFilesErrors.js'
-import LinkedFilesHandler from './LinkedFilesHandler.js'
+import LinkedFilesHandler from './LinkedFilesHandler.mjs'
 import UrlHelper from '../Helpers/UrlHelper.js'
 import { fetchStream, RequestFailedError } from '@overleaf/fetch-utils'
 import { callbackify } from '@overleaf/promise-utils'

+ 4 - 4
services/web/app/src/Features/Project/ProjectAuditLogHandler.js → services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs

@@ -1,8 +1,8 @@
-const logger = require('@overleaf/logger')
-const { ProjectAuditLogEntry } = require('../../models/ProjectAuditLogEntry')
-const { callbackify } = require('@overleaf/promise-utils')
+import logger from '@overleaf/logger'
+import { ProjectAuditLogEntry } from '../../models/ProjectAuditLogEntry.js'
+import { callbackify } from '@overleaf/promise-utils'
 
-module.exports = {
+export default {
   promises: {
     addEntry,
   },

+ 4 - 4
services/web/app/src/Features/Project/ProjectController.mjs

@@ -7,7 +7,7 @@ import logger from '@overleaf/logger'
 import { expressify } from '@overleaf/promise-utils'
 import mongodb from 'mongodb-legacy'
 import ProjectDeleter from './ProjectDeleter.js'
-import ProjectDuplicator from './ProjectDuplicator.js'
+import ProjectDuplicator from './ProjectDuplicator.mjs'
 import ProjectCreationHandler from './ProjectCreationHandler.js'
 import EditorController from '../Editor/EditorController.js'
 import ProjectHelper from './ProjectHelper.js'
@@ -18,7 +18,7 @@ import { isPaidSubscription } from '../Subscription/SubscriptionHelper.js'
 import LimitationsManager from '../Subscription/LimitationsManager.js'
 import Settings from '@overleaf/settings'
 import AuthorizationManager from '../Authorization/AuthorizationManager.js'
-import InactiveProjectManager from '../InactiveData/InactiveProjectManager.js'
+import InactiveProjectManager from '../InactiveData/InactiveProjectManager.mjs'
 import ProjectUpdateHandler from './ProjectUpdateHandler.js'
 import ProjectGetter from './ProjectGetter.js'
 import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
@@ -29,7 +29,7 @@ import CollaboratorsGetter from '../Collaborators/CollaboratorsGetter.js'
 import ProjectEntityHandler from './ProjectEntityHandler.js'
 import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.js'
 import Features from '../../infrastructure/Features.js'
-import BrandVariationsHandler from '../BrandVariations/BrandVariationsHandler.js'
+import BrandVariationsHandler from '../BrandVariations/BrandVariationsHandler.mjs'
 import UserController from '../User/UserController.mjs'
 import AnalyticsManager from '../Analytics/AnalyticsManager.js'
 import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
@@ -39,7 +39,7 @@ import SpellingHandler from '../Spelling/SpellingHandler.js'
 import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js'
 import InstitutionsFeatures from '../Institutions/InstitutionsFeatures.js'
 import InstitutionsGetter from '../Institutions/InstitutionsGetter.js'
-import ProjectAuditLogHandler from './ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from './ProjectAuditLogHandler.mjs'
 import PublicAccessLevels from '../Authorization/PublicAccessLevels.js'
 import TagsHandler from '../Tags/TagsHandler.js'
 import TutorialHandler from '../Tutorial/TutorialHandler.js'

+ 23 - 23
services/web/app/src/Features/Project/ProjectDuplicator.js → services/web/app/src/Features/Project/ProjectDuplicator.mjs

@@ -1,27 +1,27 @@
-const { callbackify } = require('util')
-const Path = require('path')
-const logger = require('@overleaf/logger')
-const OError = require('@overleaf/o-error')
-const { promiseMapWithLimit } = require('@overleaf/promise-utils')
-const { Doc } = require('../../models/Doc')
-const { File } = require('../../models/File')
-const DocstoreManager = require('../Docstore/DocstoreManager')
-const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-const HistoryManager = require('../History/HistoryManager')
-const ProjectCreationHandler = require('./ProjectCreationHandler')
-const ProjectDeleter = require('./ProjectDeleter')
-const ProjectEntityMongoUpdateHandler = require('./ProjectEntityMongoUpdateHandler')
-const ProjectEntityUpdateHandler = require('./ProjectEntityUpdateHandler')
-const ProjectGetter = require('./ProjectGetter')
-const ProjectLocator = require('./ProjectLocator')
-const ProjectOptionsHandler = require('./ProjectOptionsHandler')
-const SafePath = require('./SafePath')
-const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher')
-const _ = require('lodash')
-const TagsHandler = require('../Tags/TagsHandler')
-const ClsiCacheManager = require('../Compile/ClsiCacheManager')
+import { callbackify } from 'node:util'
+import Path from 'node:path'
+import logger from '@overleaf/logger'
+import OError from '@overleaf/o-error'
+import { promiseMapWithLimit } from '@overleaf/promise-utils'
+import { Doc } from '../../models/Doc.js'
+import { File } from '../../models/File.js'
+import DocstoreManager from '../Docstore/DocstoreManager.js'
+import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
+import HistoryManager from '../History/HistoryManager.js'
+import ProjectCreationHandler from './ProjectCreationHandler.js'
+import ProjectDeleter from './ProjectDeleter.js'
+import ProjectEntityMongoUpdateHandler from './ProjectEntityMongoUpdateHandler.js'
+import ProjectEntityUpdateHandler from './ProjectEntityUpdateHandler.js'
+import ProjectGetter from './ProjectGetter.js'
+import ProjectLocator from './ProjectLocator.js'
+import ProjectOptionsHandler from './ProjectOptionsHandler.js'
+import SafePath from './SafePath.js'
+import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.js'
+import _ from 'lodash'
+import TagsHandler from '../Tags/TagsHandler.js'
+import ClsiCacheManager from '../Compile/ClsiCacheManager.js'
 
-module.exports = {
+export default {
   duplicate: callbackify(duplicate),
   promises: {
     duplicate,

+ 1 - 1
services/web/app/src/Features/Subscription/SubscriptionRouter.mjs

@@ -1,5 +1,5 @@
 import AuthenticationController from '../Authentication/AuthenticationController.js'
-import PermissionsController from '../Authorization/PermissionsController.js'
+import PermissionsController from '../Authorization/PermissionsController.mjs'
 import SubscriptionController from './SubscriptionController.js'
 import SubscriptionGroupController from './SubscriptionGroupController.mjs'
 import TeamInvitesController from './TeamInvitesController.mjs'

+ 1 - 1
services/web/app/src/Features/TokenAccess/TokenAccessController.mjs

@@ -7,7 +7,7 @@ import OError from '@overleaf/o-error'
 import { expressify } from '@overleaf/promise-utils'
 import AuthorizationManager from '../Authorization/AuthorizationManager.js'
 import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
-import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
+import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
 import CollaboratorsInviteHandler from '../Collaborators/CollaboratorsInviteHandler.mjs'
 import CollaboratorsHandler from '../Collaborators/CollaboratorsHandler.js'
 import EditorRealTimeController from '../Editor/EditorRealTimeController.js'

+ 1 - 1
services/web/app/src/Features/TokenAccess/TokenAccessRouter.mjs

@@ -1,5 +1,5 @@
 import AuthenticationController from '../Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
 import TokenAccessController from './TokenAccessController.mjs'
 
 export default {

+ 1 - 1
services/web/app/src/Features/Uploads/UploadsRouter.mjs

@@ -1,4 +1,4 @@
-import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
 import ProjectUploadController from './ProjectUploadController.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'

+ 3 - 3
services/web/app/src/router.mjs

@@ -13,7 +13,7 @@ import UploadsRouter from './Features/Uploads/UploadsRouter.mjs'
 import metrics from '@overleaf/metrics'
 import ReferalController from './Features/Referal/ReferalController.mjs'
 import AuthenticationController from './Features/Authentication/AuthenticationController.js'
-import PermissionsController from './Features/Authorization/PermissionsController.js'
+import PermissionsController from './Features/Authorization/PermissionsController.mjs'
 import SessionManager from './Features/Authentication/SessionManager.js'
 import TagsController from './Features/Tags/TagsController.mjs'
 import NotificationsController from './Features/Notifications/NotificationsController.mjs'
@@ -25,7 +25,7 @@ import UserPagesController from './Features/User/UserPagesController.mjs'
 import TutorialController from './Features/Tutorial/TutorialController.mjs'
 import DocumentController from './Features/Documents/DocumentController.mjs'
 import CompileManager from './Features/Compile/CompileManager.js'
-import CompileController from './Features/Compile/CompileController.js'
+import CompileController from './Features/Compile/CompileController.mjs'
 import HealthCheckController from './Features/HealthCheck/HealthCheckController.mjs'
 import ProjectDownloadsController from './Features/Downloads/ProjectDownloadsController.mjs'
 import FileStoreController from './Features/FileStore/FileStoreController.mjs'
@@ -45,7 +45,7 @@ import RateLimiterMiddleware from './Features/Security/RateLimiterMiddleware.js'
 import InactiveProjectController from './Features/InactiveData/InactiveProjectController.mjs'
 import ContactRouter from './Features/Contacts/ContactRouter.mjs'
 import ReferencesController from './Features/References/ReferencesController.mjs'
-import AuthorizationMiddleware from './Features/Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from './Features/Authorization/AuthorizationMiddleware.mjs'
 import BetaProgramController from './Features/BetaProgram/BetaProgramController.mjs'
 import AnalyticsRouter from './Features/Analytics/AnalyticsRouter.mjs'
 import MetaController from './Features/Metadata/MetaController.mjs'

+ 1 - 1
services/web/modules/launchpad/app/src/LaunchpadRouter.mjs

@@ -2,7 +2,7 @@ import logger from '@overleaf/logger'
 
 import LaunchpadController from './LaunchpadController.mjs'
 import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
 
 export default {
   apply(webRouter) {

+ 1 - 1
services/web/modules/user-activate/app/src/UserActivateRouter.mjs

@@ -1,7 +1,7 @@
 import logger from '@overleaf/logger'
 import UserActivateController from './UserActivateController.mjs'
 import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
-import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.js'
+import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
 
 export default {
   apply(webRouter) {

+ 1 - 1
services/web/scripts/check_project_files.js

@@ -70,7 +70,7 @@ function findPathCounts(projectId, docEntries, fileEntries) {
   return pathCounts
 }
 
-// copied from services/web/app/src/Features/Project/ProjectDuplicator.js
+// copied from services/web/app/src/Features/Project/ProjectDuplicator.mjs
 function _getFolderEntries(folder, folderPath = '/') {
   const docEntries = []
   const fileEntries = []

+ 1 - 1
services/web/scripts/deactivate_projects.mjs

@@ -1,7 +1,7 @@
 #!/usr/bin/env node
 import minimist from 'minimist'
 import PQueue from 'p-queue'
-import InactiveProjectManager from '../app/src/Features/InactiveData/InactiveProjectManager.js'
+import InactiveProjectManager from '../app/src/Features/InactiveData/InactiveProjectManager.mjs'
 import { gracefulShutdown } from '../app/src/infrastructure/GracefulShutdown.js'
 import logger from '@overleaf/logger'
 

+ 160 - 115
services/web/test/unit/src/Authorization/AuthorizationMiddlewareTests.js → services/web/test/unit/src/Authorization/AuthorizationMiddleware.test.mjs

@@ -1,26 +1,27 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
-const Errors = require('../../../../app/src/Features/Errors/Errors')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+
+const { ObjectId } = mongodb
 
 const MODULE_PATH =
-  '../../../../app/src/Features/Authorization/AuthorizationMiddleware.js'
+  '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
 
 describe('AuthorizationMiddleware', function () {
-  beforeEach(function () {
-    this.userId = new ObjectId().toString()
-    this.project_id = new ObjectId().toString()
-    this.doc_id = new ObjectId().toString()
-    this.thread_id = new ObjectId().toString()
-    this.token = 'some-token'
-    this.AuthenticationController = {}
-    this.SessionManager = {
+  beforeEach(async function (ctx) {
+    ctx.userId = new ObjectId().toString()
+    ctx.project_id = new ObjectId().toString()
+    ctx.doc_id = new ObjectId().toString()
+    ctx.thread_id = new ObjectId().toString()
+    ctx.token = 'some-token'
+    ctx.AuthenticationController = {}
+    ctx.SessionManager = {
       getSessionUser: sinon.stub().returns(null),
-      getLoggedInUserId: sinon.stub().returns(this.userId),
+      getLoggedInUserId: sinon.stub().returns(ctx.userId),
       isUserLoggedIn: sinon.stub().returns(true),
     }
-    this.AuthorizationManager = {
+    ctx.AuthorizationManager = {
       promises: {
         canUserReadProject: sinon.stub(),
         canUserWriteProjectSettings: sinon.stub(),
@@ -33,46 +34,88 @@ describe('AuthorizationMiddleware', function () {
         isRestrictedUserForProject: sinon.stub(),
       },
     }
-    this.HttpErrorHandler = {
+    ctx.HttpErrorHandler = {
       forbidden: sinon.stub(),
     }
-    this.TokenAccessHandler = {
-      getRequestToken: sinon.stub().returns(this.token),
+    ctx.TokenAccessHandler = {
+      getRequestToken: sinon.stub().returns(ctx.token),
     }
-    this.DocumentUpdaterHandler = {
+    ctx.DocumentUpdaterHandler = {
       promises: {
         getComment: sinon.stub().resolves(),
       },
     }
-    this.AuthorizationMiddleware = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        './AuthorizationManager': this.AuthorizationManager,
-        '../Errors/HttpErrorHandler': this.HttpErrorHandler,
-        'mongodb-legacy': { ObjectId },
-        '../Authentication/AuthenticationController':
-          this.AuthenticationController,
-        '../Authentication/SessionManager': this.SessionManager,
-        '../TokenAccess/TokenAccessHandler': this.TokenAccessHandler,
-        '../Helpers/AdminAuthorizationHelper': {
+
+    vi.doMock('../../../../app/src/Features/Errors/Errors.js', () => ({
+      default: Errors,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authorization/AuthorizationManager',
+      () => ({
+        default: ctx.AuthorizationManager,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
+      default: ctx.HttpErrorHandler,
+    }))
+
+    vi.doMock('mongodb-legacy', () => ({
+      default: { ObjectId },
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/AuthenticationController',
+      () => ({
+        default: ctx.AuthenticationController,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/TokenAccess/TokenAccessHandler',
+      () => ({
+        default: ctx.TokenAccessHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Helpers/AdminAuthorizationHelper',
+      () => ({
+        default: {
           canRedirectToAdminDomain: sinon.stub().returns(false),
         },
-        '../DocumentUpdater/DocumentUpdaterHandler':
-          this.DocumentUpdaterHandler,
-      },
-    })
-    this.req = {
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
+      () => ({
+        default: ctx.DocumentUpdaterHandler,
+      })
+    )
+
+    ctx.AuthorizationMiddleware = (await import(MODULE_PATH)).default
+    ctx.req = {
       params: {
-        project_id: this.project_id,
+        project_id: ctx.project_id,
       },
       body: {},
     }
-    this.res = {
+    ctx.res = {
       redirect: sinon.stub(),
       locals: {
         currentUrl: '/current/url',
       },
     }
-    this.next = sinon.stub()
+    ctx.next = sinon.stub()
   })
 
   describe('ensureCanReadProject', function () {
@@ -94,13 +137,13 @@ describe('AuthorizationMiddleware', function () {
   })
 
   describe('ensureUserCanDeleteOrResolveThread', function () {
-    beforeEach(function () {
-      this.req.params.thread_id = this.thread_id
+    beforeEach(function (ctx) {
+      ctx.req.params.thread_id = ctx.thread_id
     })
     describe('when user has permission', function () {
-      beforeEach(function () {
-        this.AuthorizationManager.promises.canUserDeleteOrResolveThread
-          .withArgs(this.userId, this.project_id, this.thread_id, this.token)
+      beforeEach(function (ctx) {
+        ctx.AuthorizationManager.promises.canUserDeleteOrResolveThread
+          .withArgs(ctx.userId, ctx.project_id, ctx.thread_id, ctx.token)
           .resolves(true)
       })
 
@@ -109,9 +152,9 @@ describe('AuthorizationMiddleware', function () {
     })
 
     describe("when user doesn't have permission", function () {
-      beforeEach(function () {
-        this.AuthorizationManager.promises.canUserDeleteOrResolveThread
-          .withArgs(this.userId, this.project_id, this.thread_id, this.token)
+      beforeEach(function (ctx) {
+        ctx.AuthorizationManager.promises.canUserDeleteOrResolveThread
+          .withArgs(ctx.userId, ctx.project_id, ctx.thread_id, ctx.token)
           .resolves(false)
       })
 
@@ -122,8 +165,8 @@ describe('AuthorizationMiddleware', function () {
 
   describe('ensureUserCanWriteProjectSettings', function () {
     describe('when renaming a project', function () {
-      beforeEach(function () {
-        this.req.body.name = 'new project name'
+      beforeEach(function (ctx) {
+        ctx.req.body.name = 'new project name'
       })
 
       testMiddleware(
@@ -133,8 +176,8 @@ describe('AuthorizationMiddleware', function () {
     })
 
     describe('when setting another parameter', function () {
-      beforeEach(function () {
-        this.req.body.compiler = 'texlive-2017'
+      beforeEach(function (ctx) {
+        ctx.req.body.compiler = 'texlive-2017'
       })
 
       testMiddleware(
@@ -201,18 +244,18 @@ describe('AuthorizationMiddleware', function () {
   })
 
   describe('ensureUserCanReadMultipleProjects', function () {
-    beforeEach(function () {
-      this.req.query = { project_ids: 'project1,project2' }
+    beforeEach(function (ctx) {
+      ctx.req.query = { project_ids: 'project1,project2' }
     })
 
     describe('with logged in user', function () {
       describe('when user has permission to access all projects', function () {
-        beforeEach(function () {
-          this.AuthorizationManager.promises.canUserReadProject
-            .withArgs(this.userId, 'project1', this.token)
+        beforeEach(function (ctx) {
+          ctx.AuthorizationManager.promises.canUserReadProject
+            .withArgs(ctx.userId, 'project1', ctx.token)
             .resolves(true)
-          this.AuthorizationManager.promises.canUserReadProject
-            .withArgs(this.userId, 'project2', this.token)
+          ctx.AuthorizationManager.promises.canUserReadProject
+            .withArgs(ctx.userId, 'project2', ctx.token)
             .resolves(true)
         })
 
@@ -221,12 +264,12 @@ describe('AuthorizationMiddleware', function () {
       })
 
       describe("when user doesn't have permission to access one of the projects", function () {
-        beforeEach(function () {
-          this.AuthorizationManager.promises.canUserReadProject
-            .withArgs(this.userId, 'project1', this.token)
+        beforeEach(function (ctx) {
+          ctx.AuthorizationManager.promises.canUserReadProject
+            .withArgs(ctx.userId, 'project1', ctx.token)
             .resolves(true)
-          this.AuthorizationManager.promises.canUserReadProject
-            .withArgs(this.userId, 'project2', this.token)
+          ctx.AuthorizationManager.promises.canUserReadProject
+            .withArgs(ctx.userId, 'project2', ctx.token)
             .resolves(false)
         })
 
@@ -238,12 +281,12 @@ describe('AuthorizationMiddleware', function () {
     describe('with oauth user', function () {
       setupOAuthUser()
 
-      beforeEach(function () {
-        this.AuthorizationManager.promises.canUserReadProject
-          .withArgs(this.userId, 'project1', this.token)
+      beforeEach(function (ctx) {
+        ctx.AuthorizationManager.promises.canUserReadProject
+          .withArgs(ctx.userId, 'project1', ctx.token)
           .resolves(true)
-        this.AuthorizationManager.promises.canUserReadProject
-          .withArgs(this.userId, 'project2', this.token)
+        ctx.AuthorizationManager.promises.canUserReadProject
+          .withArgs(ctx.userId, 'project2', ctx.token)
           .resolves(true)
       })
 
@@ -256,12 +299,12 @@ describe('AuthorizationMiddleware', function () {
 
       describe('when user has permission', function () {
         describe('when user has permission to access all projects', function () {
-          beforeEach(function () {
-            this.AuthorizationManager.promises.canUserReadProject
-              .withArgs(null, 'project1', this.token)
+          beforeEach(function (ctx) {
+            ctx.AuthorizationManager.promises.canUserReadProject
+              .withArgs(null, 'project1', ctx.token)
               .resolves(true)
-            this.AuthorizationManager.promises.canUserReadProject
-              .withArgs(null, 'project2', this.token)
+            ctx.AuthorizationManager.promises.canUserReadProject
+              .withArgs(null, 'project2', ctx.token)
               .resolves(true)
           })
 
@@ -270,12 +313,12 @@ describe('AuthorizationMiddleware', function () {
         })
 
         describe("when user doesn't have permission to access one of the projects", function () {
-          beforeEach(function () {
-            this.AuthorizationManager.promises.canUserReadProject
-              .withArgs(null, 'project1', this.token)
+          beforeEach(function (ctx) {
+            ctx.AuthorizationManager.promises.canUserReadProject
+              .withArgs(null, 'project1', ctx.token)
               .resolves(true)
-            this.AuthorizationManager.promises.canUserReadProject
-              .withArgs(null, 'project2', this.token)
+            ctx.AuthorizationManager.promises.canUserReadProject
+              .withArgs(null, 'project2', ctx.token)
               .resolves(false)
           })
 
@@ -350,99 +393,101 @@ function testMiddleware(middleware, permission) {
 }
 
 function setupAnonymousUser() {
-  beforeEach('set up anonymous user', function () {
-    this.SessionManager.getLoggedInUserId.returns(null)
-    this.SessionManager.isUserLoggedIn.returns(false)
+  beforeEach(function (ctx) {
+    ctx.SessionManager.getLoggedInUserId.returns(null)
+    ctx.SessionManager.isUserLoggedIn.returns(false)
   })
 }
 
 function setupOAuthUser() {
-  beforeEach('set up oauth user', function () {
-    this.SessionManager.getLoggedInUserId.returns(null)
-    this.req.oauth_user = { _id: this.userId }
+  beforeEach(function (ctx) {
+    ctx.SessionManager.getLoggedInUserId.returns(null)
+    ctx.req.oauth_user = { _id: ctx.userId }
   })
 }
 
 function setupPermission(permission, value) {
-  beforeEach(`set permission ${permission} to ${value}`, function () {
-    this.AuthorizationManager.promises[permission]
-      .withArgs(this.userId, this.project_id, this.token)
+  beforeEach(function (ctx) {
+    ctx.AuthorizationManager.promises[permission]
+      .withArgs(ctx.userId, ctx.project_id, ctx.token)
       .resolves(value)
   })
 }
 
 function setupAnonymousPermission(permission, value) {
-  beforeEach(`set anonymous permission ${permission} to ${value}`, function () {
-    this.AuthorizationManager.promises[permission]
-      .withArgs(null, this.project_id, this.token)
+  beforeEach(function (ctx) {
+    ctx.AuthorizationManager.promises[permission]
+      .withArgs(null, ctx.project_id, ctx.token)
       .resolves(value)
   })
 }
 
 function setupSiteAdmin(value) {
-  beforeEach(`set site admin to ${value}`, function () {
-    this.AuthorizationManager.promises.isUserSiteAdmin
-      .withArgs(this.userId)
+  beforeEach(function (ctx) {
+    ctx.AuthorizationManager.promises.isUserSiteAdmin
+      .withArgs(ctx.userId)
       .resolves(value)
   })
 }
 
 function setupMissingProjectId() {
-  beforeEach('set up missing project id', function () {
-    delete this.req.params.project_id
+  beforeEach(function (ctx) {
+    delete ctx.req.params.project_id
   })
 }
 
 function setupMalformedProjectId() {
-  beforeEach('set up malformed project id', function () {
-    this.req.params = { project_id: 'bad-project-id' }
+  beforeEach(function (ctx) {
+    ctx.req.params = { project_id: 'bad-project-id' }
   })
 }
 
 function invokeMiddleware(method) {
-  beforeEach(`invoke ${method}`, function (done) {
-    this.next.callsFake(() => done())
-    this.HttpErrorHandler.forbidden.callsFake(() => done())
-    this.res.redirect.callsFake(() => done())
-    this.AuthorizationMiddleware[method](this.req, this.res, this.next)
+  beforeEach(async function (ctx) {
+    await new Promise(resolve => {
+      ctx.next.callsFake(() => resolve())
+      ctx.HttpErrorHandler.forbidden.callsFake(() => resolve())
+      ctx.res.redirect.callsFake(() => resolve())
+      ctx.AuthorizationMiddleware[method](ctx.req, ctx.res, ctx.next)
+    })
   })
 }
 
 function expectNext() {
-  it('calls the next middleware', function () {
-    expect(this.next).to.have.been.calledWithExactly()
+  it('calls the next middleware', function (ctx) {
+    expect(ctx.next).to.have.been.calledWithExactly()
   })
 }
 
 function expectError() {
-  it('calls the error middleware', function () {
-    expect(this.next).to.have.been.calledWith(sinon.match.instanceOf(Error))
+  it('calls the error middleware', function (ctx) {
+    expect(ctx.next).to.have.been.calledWith(sinon.match.instanceOf(Error))
   })
 }
 
 function expectNotFound() {
-  it('raises a 404', function () {
-    expect(this.next).to.have.been.calledWith(
+  it('raises a 404', function (ctx) {
+    expect(ctx.next).to.have.been.calledWith(
       sinon.match.instanceOf(Errors.NotFoundError)
     )
   })
 }
 
 function expectForbidden() {
-  it('raises a 403', function () {
-    expect(this.HttpErrorHandler.forbidden).to.have.been.calledWith(
-      this.req,
-      this.res
+  it('raises a 403', function (ctx) {
+    expect(ctx.HttpErrorHandler.forbidden).to.have.been.calledWith(
+      ctx.req,
+      ctx.res
     )
-    expect(this.next).not.to.have.been.called
+    expect(ctx.next).not.to.have.been.called
   })
 }
 
 function expectRedirectToRestricted() {
-  it('redirects to restricted', function () {
-    expect(this.res.redirect).to.have.been.calledWith(
+  it('redirects to restricted', function (ctx) {
+    expect(ctx.res.redirect).to.have.been.calledWith(
       '/restricted?from=%2Fcurrent%2Furl'
     )
-    expect(this.next).not.to.have.been.called
+    expect(ctx.next).not.to.have.been.called
   })
 }

+ 46 - 59
services/web/test/unit/src/BrandVariations/BrandVariationsHandlerTests.js → services/web/test/unit/src/BrandVariations/BrandVariationsHandler.test.mjs

@@ -1,29 +1,12 @@
-/* eslint-disable
-    n/handle-callback-err,
-    max-len,
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const assert = require('assert')
-const path = require('path')
-const sinon = require('sinon')
-const modulePath = path.join(
-  __dirname,
-  '../../../../app/src/Features/BrandVariations/BrandVariationsHandler'
-)
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
+const modulePath =
+  '../../../../app/src/Features/BrandVariations/BrandVariationsHandler.mjs'
 
 describe('BrandVariationsHandler', function () {
-  beforeEach(function () {
-    this.settings = {
+  beforeEach(async function (ctx) {
+    ctx.settings = {
       apis: {
         v1: {
           publicUrl: 'http://overleaf.example.com',
@@ -40,14 +23,18 @@ describe('BrandVariationsHandler', function () {
         },
       },
     }
-    this.V1Api = { request: sinon.stub() }
-    this.BrandVariationsHandler = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': this.settings,
-        '../V1/V1Api': this.V1Api,
-      },
-    })
-    return (this.mockedBrandVariationDetails = {
+    ctx.V1Api = { request: sinon.stub() }
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('../../../../app/src/Features/V1/V1Api', () => ({
+      default: ctx.V1Api,
+    }))
+
+    ctx.BrandVariationsHandler = (await import(modulePath)).default
+    ctx.mockedBrandVariationDetails = {
       id: '12',
       active: true,
       brand_name: 'The journal',
@@ -55,81 +42,81 @@ describe('BrandVariationsHandler', function () {
       journal_cover_url: 'http://my.cdn.tld/journal-cover.jpg',
       home_url: 'http://www.thejournal.com/',
       publish_menu_link_html: 'Submit your paper to the <em>The Journal</em>',
-    })
+    }
   })
 
   describe('getBrandVariationById', function () {
-    it('should reject with an error when the branding variation id is not provided', async function () {
+    it('should reject with an error when the branding variation id is not provided', async function (ctx) {
       await expect(
-        this.BrandVariationsHandler.promises.getBrandVariationById(null)
+        ctx.BrandVariationsHandler.promises.getBrandVariationById(null)
       ).to.be.rejected
     })
 
-    it('should reject with an error when the request errors', async function () {
-      this.V1Api.request.callsArgWith(1, new Error())
+    it('should reject with an error when the request errors', async function (ctx) {
+      ctx.V1Api.request.callsArgWith(1, new Error())
       await expect(
-        this.BrandVariationsHandler.promises.getBrandVariationById('12')
+        ctx.BrandVariationsHandler.promises.getBrandVariationById('12')
       ).to.be.rejected
     })
 
-    it('should return branding details when request succeeds', async function () {
-      this.V1Api.request.callsArgWith(
+    it('should return branding details when request succeeds', async function (ctx) {
+      ctx.V1Api.request.callsArgWith(
         1,
         null,
         { statusCode: 200 },
-        this.mockedBrandVariationDetails
+        ctx.mockedBrandVariationDetails
       )
       const brandVariationDetails =
-        await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+        await ctx.BrandVariationsHandler.promises.getBrandVariationById('12')
       expect(brandVariationDetails).to.deep.equal(
-        this.mockedBrandVariationDetails
+        ctx.mockedBrandVariationDetails
       )
     })
 
-    it('should transform relative URLs in v1 absolute ones', async function () {
-      this.mockedBrandVariationDetails.logo_url = '/journal-logo.png'
-      this.V1Api.request.callsArgWith(
+    it('should transform relative URLs in v1 absolute ones', async function (ctx) {
+      ctx.mockedBrandVariationDetails.logo_url = '/journal-logo.png'
+      ctx.V1Api.request.callsArgWith(
         1,
         null,
         { statusCode: 200 },
-        this.mockedBrandVariationDetails
+        ctx.mockedBrandVariationDetails
       )
       const brandVariationDetails =
-        await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+        await ctx.BrandVariationsHandler.promises.getBrandVariationById('12')
       expect(
         brandVariationDetails.logo_url.startsWith(
-          this.settings.apis.v1.publicUrl
+          ctx.settings.apis.v1.publicUrl
         )
       ).to.be.true
     })
 
-    it("should sanitize 'submit_button_html'", async function () {
-      this.mockedBrandVariationDetails.submit_button_html =
+    it("should sanitize 'submit_button_html'", async function (ctx) {
+      ctx.mockedBrandVariationDetails.submit_button_html =
         '<br class="break"/><strong style="color:#B39500">AGU Journal</strong><iframe>hello</iframe>'
-      this.V1Api.request.callsArgWith(
+      ctx.V1Api.request.callsArgWith(
         1,
         null,
         { statusCode: 200 },
-        this.mockedBrandVariationDetails
+        ctx.mockedBrandVariationDetails
       )
       const brandVariationDetails =
-        await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+        await ctx.BrandVariationsHandler.promises.getBrandVariationById('12')
       expect(brandVariationDetails.submit_button_html).to.equal(
         '<br /><strong style="color:#B39500">AGU Journal</strong>hello'
       )
     })
 
-    it("should sanitize and remove breaks in 'submit_button_html_no_br'", async function () {
-      this.mockedBrandVariationDetails.submit_button_html =
+    it("should sanitize and remove breaks in 'submit_button_html_no_br'", async function (ctx) {
+      ctx.mockedBrandVariationDetails.submit_button_html =
         'Submit to<br class="break"/><strong style="color:#B39500">AGU Journal</strong><iframe>hello</iframe>'
-      this.V1Api.request.callsArgWith(
+      ctx.V1Api.request.callsArgWith(
         1,
         null,
         { statusCode: 200 },
-        this.mockedBrandVariationDetails
+        ctx.mockedBrandVariationDetails
       )
       const brandVariationDetails =
-        await this.BrandVariationsHandler.promises.getBrandVariationById('12')
+        await ctx.BrandVariationsHandler.promises.getBrandVariationById('12')
       expect(brandVariationDetails.submit_button_html_no_br).to.equal(
         'Submit to <strong style="color:#B39500">AGU Journal</strong>hello'
       )

+ 1 - 1
services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs

@@ -141,7 +141,7 @@ describe('CollaboratorsController', function () {
     )
 
     vi.doMock(
-      '../../../../app/src/Features/Project/ProjectAuditLogHandler.js',
+      '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
       () => ({
         default: ctx.ProjectAuditLogHandler,
       })

+ 1 - 1
services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs

@@ -130,7 +130,7 @@ describe('CollaboratorsInviteController', function () {
     }))
 
     vi.doMock(
-      '../../../../app/src/Features/Project/ProjectAuditLogHandler.js',
+      '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
       () => ({
         default: ctx.ProjectAuditLogHandler,
       })

+ 1 - 1
services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs

@@ -148,7 +148,7 @@ describe('CollaboratorsInviteHandler', function () {
     )
 
     vi.doMock(
-      '../../../../app/src/Features/Project/ProjectAuditLogHandler.js',
+      '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
       () => ({
         default: ctx.ProjectAuditLogHandler,
       })

+ 1 - 1
services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs

@@ -109,7 +109,7 @@ describe('OwnershipTransferHandler', function () {
     )
 
     vi.doMock(
-      '../../../../app/src/Features/Project/ProjectAuditLogHandler.js',
+      '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
       () => ({
         default: ctx.ProjectAuditLogHandler,
       })

+ 1003 - 0
services/web/test/unit/src/Compile/CompileController.test.mjs

@@ -0,0 +1,1003 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import MockRequest from '../helpers/MockRequest.js'
+import MockResponse from '../helpers/MockResponse.js'
+import { Headers } from 'node-fetch'
+import { ReadableString } from '@overleaf/stream-utils'
+import { RequestFailedError } from '@overleaf/fetch-utils'
+
+const modulePath = '../../../../app/src/Features/Compile/CompileController.mjs'
+
+describe('CompileController', function () {
+  beforeEach(async function (ctx) {
+    ctx.user_id = 'wat'
+    ctx.user = {
+      _id: ctx.user_id,
+      email: 'user@example.com',
+      features: {
+        compileGroup: 'premium',
+        compileTimeout: 100,
+      },
+    }
+    ctx.CompileManager = {
+      promises: {
+        compile: sinon.stub(),
+        getProjectCompileLimits: sinon.stub(),
+        syncTeX: sinon.stub(),
+      },
+    }
+    ctx.ClsiManager = {
+      promises: {},
+    }
+    ctx.UserGetter = { getUser: sinon.stub() }
+    ctx.rateLimiter = {
+      consume: sinon.stub().resolves(),
+    }
+    ctx.RateLimiter = {
+      RateLimiter: sinon.stub().returns(ctx.rateLimiter),
+    }
+    ctx.settings = {
+      apis: {
+        clsi: {
+          url: 'http://clsi.example.com',
+          submissionBackendClass: 'n2d',
+        },
+        clsi_priority: {
+          url: 'http://clsi-priority.example.com',
+        },
+      },
+      defaultFeatures: {
+        compileGroup: 'standard',
+        compileTimeout: 60,
+      },
+      clsiCookie: {
+        key: 'cookie-key',
+      },
+    }
+    ctx.ClsiCookieManager = {
+      promises: {
+        getServerId: sinon.stub().resolves('clsi-server-id-from-redis'),
+      },
+    }
+    ctx.SessionManager = {
+      getLoggedInUserId: sinon.stub().returns(ctx.user_id),
+      getSessionUser: sinon.stub().returns(ctx.user),
+      isUserLoggedIn: sinon.stub().returns(true),
+    }
+    ctx.pipeline = sinon.stub().callsFake(async (stream, res) => {
+      if (res.callback) res.callback()
+    })
+    ctx.clsiStream = new ReadableString('{}')
+    ctx.clsiResponse = {
+      headers: new Headers({
+        'Content-Length': '2',
+        'Content-Type': 'application/json',
+      }),
+    }
+    ctx.fetchUtils = {
+      fetchStreamWithResponse: sinon.stub().resolves({
+        stream: ctx.clsiStream,
+        response: ctx.clsiResponse,
+      }),
+      RequestFailedError,
+    }
+
+    vi.doMock('stream/promises', () => ({
+      pipeline: ctx.pipeline,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('@overleaf/fetch-utils', () => ctx.fetchUtils)
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: (ctx.ProjectGetter = {
+        promises: {},
+      }),
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: (ctx.Metrics = {
+        inc: sinon.stub(),
+        Timer: class {
+          constructor() {
+            this.labels = {}
+          }
+
+          done() {}
+        },
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Compile/CompileManager', () => ({
+      default: ctx.CompileManager,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Compile/ClsiManager', () => ({
+      default: ctx.ClsiManager,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/infrastructure/RateLimiter.js',
+      () => ctx.RateLimiter
+    )
+
+    vi.doMock('../../../../app/src/Features/Compile/ClsiCookieManager', () => ({
+      default: () => ctx.ClsiCookieManager,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: {
+          getAssignment: (ctx.getAssignment = sinon.stub().yields(null, {
+            variant: 'default',
+          })),
+          promises: {
+            getAssignment: sinon.stub().resolves({
+              variant: 'default',
+            }),
+          },
+        },
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager',
+      () => ({
+        default: {
+          recordEventForSession: sinon.stub(),
+        },
+      })
+    )
+
+    ctx.CompileController = (await import(modulePath)).default
+    ctx.projectId = 'abc123def456abc123def456'
+    ctx.build_id = '18fbe9e7564-30dcb2f71250c690'
+    ctx.next = sinon.stub()
+    ctx.req = new MockRequest()
+    ctx.res = new MockResponse()
+    ctx.res = new MockResponse()
+  })
+
+  describe('compile', function () {
+    beforeEach(function (ctx) {
+      ctx.req.params = { Project_id: ctx.projectId }
+      ctx.req.session = {}
+      ctx.CompileManager.promises.compile = sinon.stub().resolves({
+        status: (ctx.status = 'success'),
+        outputFiles: (ctx.outputFiles = [
+          {
+            path: 'output.pdf',
+            url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
+            type: 'pdf',
+          },
+        ]),
+        clsiServerId: undefined,
+        limits: undefined,
+        validationProblems: undefined,
+        stats: undefined,
+        timings: undefined,
+        outputUrlPrefix: undefined,
+        buildId: ctx.build_id,
+      })
+    })
+
+    describe('pdfDownloadDomain', function () {
+      beforeEach(function (ctx) {
+        ctx.settings.pdfDownloadDomain = 'https://compiles.overleaf.test'
+      })
+
+      describe('when clsi does not emit zone prefix', function () {
+        beforeEach(async function (ctx) {
+          await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+        })
+
+        it('should add domain verbatim', function (ctx) {
+          ctx.res.statusCode.should.equal(200)
+          ctx.res.body.should.equal(
+            JSON.stringify({
+              status: ctx.status,
+              outputFiles: [
+                {
+                  path: 'output.pdf',
+                  url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
+                  type: 'pdf',
+                },
+              ],
+              outputFilesArchive: {
+                path: 'output.zip',
+                url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
+                type: 'zip',
+              },
+              pdfDownloadDomain: 'https://compiles.overleaf.test',
+            })
+          )
+        })
+      })
+
+      describe('when clsi emits a zone prefix', function () {
+        beforeEach(async function (ctx) {
+          ctx.CompileManager.promises.compile = sinon.stub().resolves({
+            status: (ctx.status = 'success'),
+            outputFiles: (ctx.outputFiles = [
+              {
+                path: 'output.pdf',
+                url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
+                type: 'pdf',
+              },
+            ]),
+            clsiServerId: undefined,
+            limits: undefined,
+            validationProblems: undefined,
+            stats: undefined,
+            timings: undefined,
+            outputUrlPrefix: '/zone/b',
+            buildId: ctx.build_id,
+          })
+          await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+        })
+
+        it('should add the zone prefix', function (ctx) {
+          ctx.res.statusCode.should.equal(200)
+          ctx.res.body.should.equal(
+            JSON.stringify({
+              status: ctx.status,
+              outputFiles: [
+                {
+                  path: 'output.pdf',
+                  url: `/project/${ctx.projectId}/user/${ctx.user_id}/build/id/output.pdf`,
+                  type: 'pdf',
+                },
+              ],
+              outputFilesArchive: {
+                path: 'output.zip',
+                url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
+                type: 'zip',
+              },
+              outputUrlPrefix: '/zone/b',
+              pdfDownloadDomain: 'https://compiles.overleaf.test/zone/b',
+            })
+          )
+        })
+      })
+    })
+
+    describe('when not an auto compile', function () {
+      beforeEach(async function (ctx) {
+        await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should look up the user id', function (ctx) {
+        ctx.SessionManager.getLoggedInUserId
+          .calledWith(ctx.req.session)
+          .should.equal(true)
+      })
+
+      it('should do the compile without the auto compile flag', function (ctx) {
+        ctx.CompileManager.promises.compile.should.have.been.calledWith(
+          ctx.projectId,
+          ctx.user_id,
+          {
+            isAutoCompile: false,
+            compileFromClsiCache: false,
+            populateClsiCache: false,
+            enablePdfCaching: false,
+            fileLineErrors: false,
+            stopOnFirstError: false,
+            editorId: undefined,
+          }
+        )
+      })
+
+      it('should set the content-type of the response to application/json', function (ctx) {
+        ctx.res.type.should.equal('application/json')
+      })
+
+      it('should send a successful response reporting the status and files', function (ctx) {
+        ctx.res.statusCode.should.equal(200)
+        ctx.res.body.should.equal(
+          JSON.stringify({
+            status: ctx.status,
+            outputFiles: ctx.outputFiles,
+            outputFilesArchive: {
+              path: 'output.zip',
+              url: `/project/${ctx.projectId}/user/wat/build/${ctx.build_id}/output/output.zip`,
+              type: 'zip',
+            },
+          })
+        )
+      })
+    })
+
+    describe('when an auto compile', function () {
+      beforeEach(async function (ctx) {
+        ctx.req.query = { auto_compile: 'true' }
+        await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should do the compile with the auto compile flag', function (ctx) {
+        ctx.CompileManager.promises.compile.should.have.been.calledWith(
+          ctx.projectId,
+          ctx.user_id,
+          {
+            isAutoCompile: true,
+            compileFromClsiCache: false,
+            populateClsiCache: false,
+            enablePdfCaching: false,
+            fileLineErrors: false,
+            stopOnFirstError: false,
+            editorId: undefined,
+          }
+        )
+      })
+    })
+
+    describe('with the draft attribute', function () {
+      beforeEach(async function (ctx) {
+        ctx.req.body = { draft: true }
+        await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should do the compile without the draft compile flag', function (ctx) {
+        ctx.CompileManager.promises.compile.should.have.been.calledWith(
+          ctx.projectId,
+          ctx.user_id,
+          {
+            isAutoCompile: false,
+            compileFromClsiCache: false,
+            populateClsiCache: false,
+            enablePdfCaching: false,
+            draft: true,
+            fileLineErrors: false,
+            stopOnFirstError: false,
+            editorId: undefined,
+          }
+        )
+      })
+    })
+
+    describe('with an editor id', function () {
+      beforeEach(async function (ctx) {
+        ctx.req.body = { editorId: 'the-editor-id' }
+        await ctx.CompileController.compile(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should pass the editor id to the compiler', function (ctx) {
+        ctx.CompileManager.promises.compile.should.have.been.calledWith(
+          ctx.projectId,
+          ctx.user_id,
+          {
+            isAutoCompile: false,
+            compileFromClsiCache: false,
+            populateClsiCache: false,
+            enablePdfCaching: false,
+            fileLineErrors: false,
+            stopOnFirstError: false,
+            editorId: 'the-editor-id',
+          }
+        )
+      })
+    })
+  })
+
+  describe('compileSubmission', function () {
+    beforeEach(function (ctx) {
+      ctx.submission_id = 'sub-1234'
+      ctx.req.params = { submission_id: ctx.submission_id }
+      ctx.req.body = {}
+      ctx.ClsiManager.promises.sendExternalRequest = sinon.stub().resolves({
+        status: (ctx.status = 'success'),
+        outputFiles: (ctx.outputFiles = ['mock-output-files']),
+        clsiServerId: 'mock-server-id',
+        validationProblems: null,
+      })
+    })
+
+    it('should set the content-type of the response to application/json', async function (ctx) {
+      await ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
+      ctx.res.contentType.calledWith('application/json').should.equal(true)
+    })
+
+    it('should send a successful response reporting the status and files', async function (ctx) {
+      await ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
+      ctx.res.statusCode.should.equal(200)
+      ctx.res.body.should.equal(
+        JSON.stringify({
+          status: ctx.status,
+          outputFiles: ctx.outputFiles,
+          clsiServerId: 'mock-server-id',
+          validationProblems: null,
+        })
+      )
+    })
+
+    describe('with compileGroup and timeout', function () {
+      beforeEach(function (ctx) {
+        ctx.req.body = {
+          compileGroup: 'special',
+          timeout: 600,
+        }
+        ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should use the supplied values', function (ctx) {
+        ctx.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith(
+          ctx.submission_id,
+          { compileGroup: 'special', timeout: 600 },
+          { compileGroup: 'special', compileBackendClass: 'n2d', timeout: 600 }
+        )
+      })
+    })
+
+    describe('with other supported options but not compileGroup and timeout', function () {
+      beforeEach(function (ctx) {
+        ctx.req.body = {
+          rootResourcePath: 'main.tex',
+          compiler: 'lualatex',
+          draft: true,
+          check: 'validate',
+        }
+        ctx.CompileController.compileSubmission(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should use the other options but default values for compileGroup and timeout', function (ctx) {
+        ctx.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith(
+          ctx.submission_id,
+          {
+            rootResourcePath: 'main.tex',
+            compiler: 'lualatex',
+            draft: true,
+            check: 'validate',
+          },
+          {
+            rootResourcePath: 'main.tex',
+            compiler: 'lualatex',
+            draft: true,
+            check: 'validate',
+            compileGroup: 'standard',
+            compileBackendClass: 'n2d',
+            timeout: 60,
+          }
+        )
+      })
+    })
+  })
+
+  describe('downloadPdf', function () {
+    beforeEach(function (ctx) {
+      ctx.CompileController._proxyToClsi = sinon.stub().resolves()
+      ctx.req.params = { Project_id: ctx.projectId }
+      ctx.project = { name: 'test namè; 1' }
+      ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(ctx.project)
+    })
+
+    describe('when downloading for embedding', function () {
+      beforeEach(async function (ctx) {
+        await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should look up the project', function (ctx) {
+        ctx.ProjectGetter.promises.getProject
+          .calledWith(ctx.projectId, { name: 1 })
+          .should.equal(true)
+      })
+
+      it('should set the content-type of the response to application/pdf', function (ctx) {
+        ctx.res.contentType.calledWith('application/pdf').should.equal(true)
+      })
+
+      it('should set the content-disposition header with a safe version of the project name', function (ctx) {
+        ctx.res.setContentDisposition.should.be.calledWith('inline', {
+          filename: 'test_namè__1.pdf',
+        })
+      })
+
+      it('should increment the pdf-downloads metric', function (ctx) {
+        ctx.Metrics.inc.calledWith('pdf-downloads').should.equal(true)
+      })
+
+      it('should proxy the PDF from the CLSI', function (ctx) {
+        ctx.CompileController._proxyToClsi
+          .calledWith(
+            ctx.projectId,
+            'output-file',
+            `/project/${ctx.projectId}/user/${ctx.user_id}/output/output.pdf`,
+            {},
+            ctx.req,
+            ctx.res
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('when a build-id is provided', function () {
+      beforeEach(async function (ctx) {
+        ctx.req.params.build_id = ctx.build_id
+        await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
+      })
+
+      it('should proxy the PDF from the CLSI, with a build-id', function (ctx) {
+        ctx.CompileController._proxyToClsi
+          .calledWith(
+            ctx.projectId,
+            'output-file',
+            `/project/${ctx.projectId}/user/${ctx.user_id}/build/${ctx.build_id}/output/output.pdf`,
+            {},
+            ctx.req,
+            ctx.res
+          )
+          .should.equal(true)
+      })
+    })
+
+    describe('when rate-limited', function () {
+      beforeEach(async function (ctx) {
+        ctx.rateLimiter.consume.rejects({
+          msBeforeNext: 250,
+          remainingPoints: 0,
+          consumedPoints: 5,
+          isFirstInDuration: false,
+        })
+      })
+      it('should return 500', async function (ctx) {
+        await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
+        // should it be 429 instead?
+        ctx.res.sendStatus.calledWith(500).should.equal(true)
+        ctx.CompileController._proxyToClsi.should.not.have.been.called
+      })
+    })
+
+    describe('when rate-limit errors', function () {
+      beforeEach(async function (ctx) {
+        ctx.rateLimiter.consume.rejects(new Error('uh oh'))
+      })
+      it('should return 500', async function (ctx) {
+        await ctx.CompileController.downloadPdf(ctx.req, ctx.res, ctx.next)
+        ctx.res.sendStatus.calledWith(500).should.equal(true)
+        ctx.CompileController._proxyToClsi.should.not.have.been.called
+      })
+    })
+  })
+
+  describe('getFileFromClsiWithoutUser', function () {
+    beforeEach(function (ctx) {
+      ctx.submission_id = 'sub-1234'
+      ctx.file = 'output.pdf'
+      ctx.req.params = {
+        submission_id: ctx.submission_id,
+        build_id: ctx.build_id,
+        file: ctx.file,
+      }
+      ctx.req.body = {}
+      ctx.expected_url = `/project/${ctx.submission_id}/build/${ctx.build_id}/output/${ctx.file}`
+      ctx.CompileController._proxyToClsiWithLimits = sinon.stub()
+    })
+
+    describe('without limits specified', function () {
+      beforeEach(async function (ctx) {
+        await ctx.CompileController.getFileFromClsiWithoutUser(
+          ctx.req,
+          ctx.res,
+          ctx.next
+        )
+      })
+
+      it('should proxy to CLSI with correct URL and default limits', function (ctx) {
+        ctx.CompileController._proxyToClsiWithLimits.should.have.been.calledWith(
+          ctx.submission_id,
+          'output-file',
+          ctx.expected_url,
+          {},
+          { compileGroup: 'standard', compileBackendClass: 'n2d' }
+        )
+      })
+    })
+
+    describe('with limits specified', function () {
+      beforeEach(function (ctx) {
+        ctx.req.body = { compileTimeout: 600, compileGroup: 'special' }
+        ctx.CompileController.getFileFromClsiWithoutUser(
+          ctx.req,
+          ctx.res,
+          ctx.next
+        )
+      })
+
+      it('should proxy to CLSI with correct URL and specified limits', function (ctx) {
+        ctx.CompileController._proxyToClsiWithLimits.should.have.been.calledWith(
+          ctx.submission_id,
+          'output-file',
+          ctx.expected_url,
+          {},
+          {
+            compileGroup: 'special',
+            compileBackendClass: 'n2d',
+          }
+        )
+      })
+    })
+  })
+  describe('proxySyncCode', function () {
+    let file, line, column, imageName, editorId, buildId, clsiServerId
+
+    beforeEach(async function (ctx) {
+      ctx.req.params = { Project_id: ctx.projectId }
+      clsiServerId = 'clsi-1'
+      file = 'main.tex'
+      line = String(Date.now())
+      column = String(Date.now() + 1)
+      editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
+      buildId = '195b4a3f9e7-03e5be430a9e7796'
+      ctx.req.query = {
+        file,
+        line,
+        column,
+        editorId,
+        buildId,
+        clsiserverid: clsiServerId,
+      }
+
+      imageName = 'foo/bar:tag-0'
+      ctx.ProjectGetter.promises.getProject = sinon
+        .stub()
+        .resolves({ imageName })
+
+      ctx.CompileController._proxyToClsi = sinon.stub().resolves()
+
+      await ctx.CompileController.proxySyncCode(ctx.req, ctx.res, ctx.next)
+    })
+
+    it('should parse the parameters', function (ctx) {
+      expect(ctx.CompileManager.promises.syncTeX).to.have.been.calledWith(
+        ctx.projectId,
+        ctx.user_id,
+        {
+          direction: 'code',
+          compileFromClsiCache: false,
+          validatedOptions: {
+            file,
+            line,
+            column,
+            editorId,
+            buildId,
+          },
+          clsiServerId,
+        }
+      )
+    })
+  })
+
+  describe('proxySyncPdf', function () {
+    let page, h, v, imageName, editorId, buildId, clsiServerId
+
+    beforeEach(async function (ctx) {
+      ctx.req.params = { Project_id: ctx.projectId }
+      clsiServerId = 'clsi-1'
+      page = String(Date.now())
+      h = String(Math.random())
+      v = String(Math.random())
+      editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
+      buildId = '195b4a3f9e7-03e5be430a9e7796'
+      ctx.req.query = {
+        page,
+        h,
+        v,
+        editorId,
+        buildId,
+        clsiserverid: clsiServerId,
+      }
+
+      imageName = 'foo/bar:tag-1'
+      ctx.ProjectGetter.promises.getProject = sinon
+        .stub()
+        .resolves({ imageName })
+
+      ctx.CompileController._proxyToClsi = sinon.stub()
+
+      await ctx.CompileController.proxySyncPdf(ctx.req, ctx.res, ctx.next)
+    })
+
+    it('should parse the parameters', function (ctx) {
+      expect(ctx.CompileManager.promises.syncTeX).to.have.been.calledWith(
+        ctx.projectId,
+        ctx.user_id,
+        {
+          direction: 'pdf',
+          compileFromClsiCache: false,
+          validatedOptions: {
+            page,
+            h,
+            v,
+            editorId,
+            buildId,
+          },
+          clsiServerId,
+        }
+      )
+    })
+  })
+
+  describe('_proxyToClsi', function () {
+    beforeEach(function (ctx) {
+      ctx.req.method = 'mock-method'
+      ctx.req.headers = {
+        Mock: 'Headers',
+        Range: '123-456',
+        'If-Range': 'abcdef',
+        'If-Modified-Since': 'Mon, 15 Dec 2014 15:23:56 GMT',
+      }
+    })
+
+    describe('old pdf viewer', function () {
+      describe('user with standard priority', function () {
+        beforeEach(async function (ctx) {
+          ctx.CompileManager.promises.getProjectCompileLimits = sinon
+            .stub()
+            .resolves({
+              compileGroup: 'standard',
+              compileBackendClass: 'n2d',
+            })
+          await ctx.CompileController._proxyToClsi(
+            ctx.projectId,
+            'output-file',
+            (ctx.url = '/test'),
+            { query: 'foo' },
+            ctx.req,
+            ctx.res,
+            ctx.next
+          )
+        })
+
+        it('should open a request to the CLSI', function (ctx) {
+          ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
+            `${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=standard&compileBackendClass=n2d&query=foo`
+          )
+        })
+
+        it('should pass the request on to the client', function (ctx) {
+          ctx.pipeline.should.have.been.calledWith(ctx.clsiStream, ctx.res)
+        })
+      })
+
+      describe('user with priority compile', function () {
+        beforeEach(async function (ctx) {
+          ctx.CompileManager.promises.getProjectCompileLimits = sinon
+            .stub()
+            .resolves({
+              compileGroup: 'priority',
+              compileBackendClass: 'c2d',
+            })
+          await ctx.CompileController._proxyToClsi(
+            ctx.projectId,
+            'output-file',
+            (ctx.url = '/test'),
+            {},
+            ctx.req,
+            ctx.res,
+            ctx.next
+          )
+        })
+
+        it('should open a request to the CLSI', function (ctx) {
+          ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
+            `${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=priority&compileBackendClass=c2d`
+          )
+        })
+      })
+
+      describe('user with standard priority via query string', function () {
+        beforeEach(async function (ctx) {
+          ctx.req.query = { compileGroup: 'standard' }
+          ctx.CompileManager.promises.getProjectCompileLimits = sinon
+            .stub()
+            .resolves({
+              compileGroup: 'standard',
+              compileBackendClass: 'n2d',
+            })
+          await ctx.CompileController._proxyToClsi(
+            ctx.projectId,
+            'output-file',
+            (ctx.url = '/test'),
+            {},
+            ctx.req,
+            ctx.res,
+            ctx.next
+          )
+        })
+
+        it('should open a request to the CLSI', function (ctx) {
+          ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
+            `${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=standard&compileBackendClass=n2d`
+          )
+        })
+
+        it('should pass the request on to the client', function (ctx) {
+          ctx.pipeline.should.have.been.calledWith(ctx.clsiStream, ctx.res)
+        })
+      })
+
+      describe('user with non-existent priority via query string', function () {
+        beforeEach(async function (ctx) {
+          ctx.req.query = { compileGroup: 'foobar' }
+          ctx.CompileManager.promises.getProjectCompileLimits = sinon
+            .stub()
+            .resolves({
+              compileGroup: 'standard',
+              compileBackendClass: 'n2d',
+            })
+          await ctx.CompileController._proxyToClsi(
+            ctx.projectId,
+            'output-file',
+            (ctx.url = '/test'),
+            {},
+            ctx.req,
+            ctx.res,
+            ctx.next
+          )
+        })
+
+        it('should proxy to the standard url', function (ctx) {
+          ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
+            `${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=standard&compileBackendClass=n2d`
+          )
+        })
+      })
+
+      describe('user with build parameter via query string', function () {
+        beforeEach(async function (ctx) {
+          ctx.CompileManager.promises.getProjectCompileLimits = sinon
+            .stub()
+            .resolves({
+              compileGroup: 'standard',
+              compileBackendClass: 'n2d',
+            })
+          ctx.req.query = { build: 1234 }
+          await ctx.CompileController._proxyToClsi(
+            ctx.projectId,
+            'output-file',
+            (ctx.url = '/test'),
+            {},
+            ctx.req,
+            ctx.res,
+            ctx.next
+          )
+        })
+
+        it('should proxy to the standard url without the build parameter', function (ctx) {
+          ctx.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
+            `${ctx.settings.apis.clsi.url}${ctx.url}?compileGroup=standard&compileBackendClass=n2d`
+          )
+        })
+      })
+    })
+  })
+
+  describe('deleteAuxFiles', function () {
+    beforeEach(async function (ctx) {
+      ctx.CompileManager.promises.deleteAuxFiles = sinon.stub().resolves()
+      ctx.req.params = { Project_id: ctx.projectId }
+      ctx.req.query = { clsiserverid: 'node-1' }
+      ctx.res.sendStatus = sinon.stub()
+      await ctx.CompileController.deleteAuxFiles(ctx.req, ctx.res, ctx.next)
+    })
+
+    it('should proxy to the CLSI', function (ctx) {
+      ctx.CompileManager.promises.deleteAuxFiles
+        .calledWith(ctx.projectId, ctx.user_id, 'node-1')
+        .should.equal(true)
+    })
+
+    it('should return a 200', function (ctx) {
+      ctx.res.sendStatus.calledWith(200).should.equal(true)
+    })
+  })
+
+  describe('compileAndDownloadPdf', function () {
+    beforeEach(function (ctx) {
+      ctx.req = {
+        params: {
+          project_id: ctx.projectId,
+        },
+      }
+      ctx.downloadPath = `/project/${ctx.projectId}/build/123/output/output.pdf`
+      ctx.CompileManager.promises.compile.resolves({
+        status: 'success',
+        outputFiles: [{ path: 'output.pdf', url: ctx.downloadPath }],
+      })
+      ctx.CompileController._proxyToClsi = sinon.stub()
+      ctx.res = {
+        send: () => {},
+        sendStatus: sinon.stub(),
+      }
+    })
+
+    it('should call compile in the compile manager', async function (ctx) {
+      await ctx.CompileController.compileAndDownloadPdf(ctx.req, ctx.res)
+      ctx.CompileManager.promises.compile
+        .calledWith(ctx.projectId)
+        .should.equal(true)
+    })
+
+    it('should proxy the res to the clsi with correct url', async function (ctx) {
+      await ctx.CompileController.compileAndDownloadPdf(ctx.req, ctx.res)
+      sinon.assert.calledWith(
+        ctx.CompileController._proxyToClsi,
+        ctx.projectId,
+        'output-file',
+        ctx.downloadPath,
+        {},
+        ctx.req,
+        ctx.res
+      )
+
+      ctx.CompileController._proxyToClsi
+        .calledWith(
+          ctx.projectId,
+          'output-file',
+          ctx.downloadPath,
+          {},
+          ctx.req,
+          ctx.res
+        )
+        .should.equal(true)
+    })
+
+    it('should not download anything on compilation failures', async function (ctx) {
+      ctx.CompileManager.promises.compile.rejects(new Error('failed'))
+      await ctx.CompileController.compileAndDownloadPdf(
+        ctx.req,
+        ctx.res,
+        ctx.next
+      )
+      ctx.res.sendStatus.should.have.been.calledWith(500)
+      ctx.CompileController._proxyToClsi.should.not.have.been.called
+    })
+
+    it('should not download anything on missing pdf', async function (ctx) {
+      ctx.CompileManager.promises.compile.resolves({
+        status: 'success',
+        outputFiles: [],
+      })
+      await ctx.CompileController.compileAndDownloadPdf(ctx.req, ctx.res)
+      ctx.res.sendStatus.should.have.been.calledWith(500)
+      ctx.CompileController._proxyToClsi.should.not.have.been.called
+    })
+  })
+
+  describe('wordCount', function () {
+    beforeEach(async function (ctx) {
+      ctx.CompileManager.promises.wordCount = sinon
+        .stub()
+        .resolves({ content: 'body' })
+      ctx.req.params = { Project_id: ctx.projectId }
+      ctx.req.query = { clsiserverid: 'node-42' }
+      ctx.res.json = sinon.stub()
+      ctx.res.contentType = sinon.stub()
+      await ctx.CompileController.wordCount(ctx.req, ctx.res, ctx.next)
+    })
+
+    it('should proxy to the CLSI', function (ctx) {
+      ctx.CompileManager.promises.wordCount
+        .calledWith(ctx.projectId, ctx.user_id, false, 'node-42')
+        .should.equal(true)
+    })
+
+    it('should return a 200 and body', function (ctx) {
+      ctx.res.json.calledWith({ content: 'body' }).should.equal(true)
+    })
+  })
+})

+ 0 - 972
services/web/test/unit/src/Compile/CompileControllerTests.js

@@ -1,972 +0,0 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../../app/src/Features/Compile/CompileController.js'
-const SandboxedModule = require('sandboxed-module')
-const MockRequest = require('../helpers/MockRequest')
-const MockResponse = require('../helpers/MockResponse')
-const { Headers } = require('node-fetch')
-const { ReadableString } = require('@overleaf/stream-utils')
-
-describe('CompileController', function () {
-  beforeEach(function () {
-    this.user_id = 'wat'
-    this.user = {
-      _id: this.user_id,
-      email: 'user@example.com',
-      features: {
-        compileGroup: 'premium',
-        compileTimeout: 100,
-      },
-    }
-    this.CompileManager = {
-      promises: {
-        compile: sinon.stub(),
-        getProjectCompileLimits: sinon.stub(),
-        syncTeX: sinon.stub(),
-      },
-    }
-    this.ClsiManager = {
-      promises: {},
-    }
-    this.UserGetter = { getUser: sinon.stub() }
-    this.rateLimiter = {
-      consume: sinon.stub().resolves(),
-    }
-    this.RateLimiter = {
-      RateLimiter: sinon.stub().returns(this.rateLimiter),
-    }
-    this.settings = {
-      apis: {
-        clsi: {
-          url: 'http://clsi.example.com',
-          submissionBackendClass: 'n2d',
-        },
-        clsi_priority: {
-          url: 'http://clsi-priority.example.com',
-        },
-      },
-      defaultFeatures: {
-        compileGroup: 'standard',
-        compileTimeout: 60,
-      },
-      clsiCookie: {
-        key: 'cookie-key',
-      },
-    }
-    this.ClsiCookieManager = {
-      promises: {
-        getServerId: sinon.stub().resolves('clsi-server-id-from-redis'),
-      },
-    }
-    this.SessionManager = {
-      getLoggedInUserId: sinon.stub().returns(this.user_id),
-      getSessionUser: sinon.stub().returns(this.user),
-      isUserLoggedIn: sinon.stub().returns(true),
-    }
-    this.pipeline = sinon.stub().callsFake(async (stream, res) => {
-      if (res.callback) res.callback()
-    })
-    this.clsiStream = new ReadableString('{}')
-    this.clsiResponse = {
-      headers: new Headers({
-        'Content-Length': '2',
-        'Content-Type': 'application/json',
-      }),
-    }
-    this.fetchUtils = {
-      fetchStreamWithResponse: sinon.stub().resolves({
-        stream: this.clsiStream,
-        response: this.clsiResponse,
-      }),
-    }
-    this.CompileController = SandboxedModule.require(modulePath, {
-      requires: {
-        'stream/promises': { pipeline: this.pipeline },
-        '@overleaf/settings': this.settings,
-        '@overleaf/fetch-utils': this.fetchUtils,
-        '../Project/ProjectGetter': (this.ProjectGetter = {
-          promises: {},
-        }),
-        '@overleaf/metrics': (this.Metrics = {
-          inc: sinon.stub(),
-          Timer: class {
-            constructor() {
-              this.labels = {}
-            }
-
-            done() {}
-          },
-        }),
-        // TODO: remove this once we remove Joi/Celebrate
-        celebrate: (this.celebrate = {
-          celebrate: sinon.stub(),
-          errors: sinon.stub(),
-          Joi: {
-            any: sinon.stub(),
-            extend: sinon.stub(),
-          },
-        }),
-        './CompileManager': this.CompileManager,
-        '../User/UserGetter': this.UserGetter,
-        './ClsiManager': this.ClsiManager,
-        '../Authentication/SessionManager': this.SessionManager,
-        '../../infrastructure/RateLimiter': this.RateLimiter,
-        './ClsiCookieManager': () => this.ClsiCookieManager,
-        '../SplitTests/SplitTestHandler': {
-          getAssignment: (this.getAssignment = sinon.stub().yields(null, {
-            variant: 'default',
-          })),
-          promises: {
-            getAssignment: sinon.stub().resolves({
-              variant: 'default',
-            }),
-          },
-        },
-        '../Analytics/AnalyticsManager': {
-          recordEventForSession: sinon.stub(),
-        },
-      },
-    })
-    this.projectId = 'abc123def456abc123def456'
-    this.build_id = '18fbe9e7564-30dcb2f71250c690'
-    this.next = sinon.stub()
-    this.req = new MockRequest()
-    this.res = new MockResponse()
-    this.res = new MockResponse()
-  })
-
-  describe('compile', function () {
-    beforeEach(function () {
-      this.req.params = { Project_id: this.projectId }
-      this.req.session = {}
-      this.CompileManager.promises.compile = sinon.stub().resolves({
-        status: (this.status = 'success'),
-        outputFiles: (this.outputFiles = [
-          {
-            path: 'output.pdf',
-            url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
-            type: 'pdf',
-          },
-        ]),
-        clsiServerId: undefined,
-        limits: undefined,
-        validationProblems: undefined,
-        stats: undefined,
-        timings: undefined,
-        outputUrlPrefix: undefined,
-        buildId: this.build_id,
-      })
-    })
-
-    describe('pdfDownloadDomain', function () {
-      beforeEach(function () {
-        this.settings.pdfDownloadDomain = 'https://compiles.overleaf.test'
-      })
-
-      describe('when clsi does not emit zone prefix', function () {
-        beforeEach(async function () {
-          await this.CompileController.compile(this.req, this.res, this.next)
-        })
-
-        it('should add domain verbatim', function () {
-          this.res.statusCode.should.equal(200)
-          this.res.body.should.equal(
-            JSON.stringify({
-              status: this.status,
-              outputFiles: [
-                {
-                  path: 'output.pdf',
-                  url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
-                  type: 'pdf',
-                },
-              ],
-              outputFilesArchive: {
-                path: 'output.zip',
-                url: `/project/${this.projectId}/user/wat/build/${this.build_id}/output/output.zip`,
-                type: 'zip',
-              },
-              pdfDownloadDomain: 'https://compiles.overleaf.test',
-            })
-          )
-        })
-      })
-
-      describe('when clsi emits a zone prefix', function () {
-        beforeEach(async function () {
-          this.CompileManager.promises.compile = sinon.stub().resolves({
-            status: (this.status = 'success'),
-            outputFiles: (this.outputFiles = [
-              {
-                path: 'output.pdf',
-                url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
-                type: 'pdf',
-              },
-            ]),
-            clsiServerId: undefined,
-            limits: undefined,
-            validationProblems: undefined,
-            stats: undefined,
-            timings: undefined,
-            outputUrlPrefix: '/zone/b',
-            buildId: this.build_id,
-          })
-          await this.CompileController.compile(this.req, this.res, this.next)
-        })
-
-        it('should add the zone prefix', function () {
-          this.res.statusCode.should.equal(200)
-          this.res.body.should.equal(
-            JSON.stringify({
-              status: this.status,
-              outputFiles: [
-                {
-                  path: 'output.pdf',
-                  url: `/project/${this.projectId}/user/${this.user_id}/build/id/output.pdf`,
-                  type: 'pdf',
-                },
-              ],
-              outputFilesArchive: {
-                path: 'output.zip',
-                url: `/project/${this.projectId}/user/wat/build/${this.build_id}/output/output.zip`,
-                type: 'zip',
-              },
-              outputUrlPrefix: '/zone/b',
-              pdfDownloadDomain: 'https://compiles.overleaf.test/zone/b',
-            })
-          )
-        })
-      })
-    })
-
-    describe('when not an auto compile', function () {
-      beforeEach(async function () {
-        await this.CompileController.compile(this.req, this.res, this.next)
-      })
-
-      it('should look up the user id', function () {
-        this.SessionManager.getLoggedInUserId
-          .calledWith(this.req.session)
-          .should.equal(true)
-      })
-
-      it('should do the compile without the auto compile flag', function () {
-        this.CompileManager.promises.compile.should.have.been.calledWith(
-          this.projectId,
-          this.user_id,
-          {
-            isAutoCompile: false,
-            compileFromClsiCache: false,
-            populateClsiCache: false,
-            enablePdfCaching: false,
-            fileLineErrors: false,
-            stopOnFirstError: false,
-            editorId: undefined,
-          }
-        )
-      })
-
-      it('should set the content-type of the response to application/json', function () {
-        this.res.type.should.equal('application/json')
-      })
-
-      it('should send a successful response reporting the status and files', function () {
-        this.res.statusCode.should.equal(200)
-        this.res.body.should.equal(
-          JSON.stringify({
-            status: this.status,
-            outputFiles: this.outputFiles,
-            outputFilesArchive: {
-              path: 'output.zip',
-              url: `/project/${this.projectId}/user/wat/build/${this.build_id}/output/output.zip`,
-              type: 'zip',
-            },
-          })
-        )
-      })
-    })
-
-    describe('when an auto compile', function () {
-      beforeEach(async function () {
-        this.req.query = { auto_compile: 'true' }
-        await this.CompileController.compile(this.req, this.res, this.next)
-      })
-
-      it('should do the compile with the auto compile flag', function () {
-        this.CompileManager.promises.compile.should.have.been.calledWith(
-          this.projectId,
-          this.user_id,
-          {
-            isAutoCompile: true,
-            compileFromClsiCache: false,
-            populateClsiCache: false,
-            enablePdfCaching: false,
-            fileLineErrors: false,
-            stopOnFirstError: false,
-            editorId: undefined,
-          }
-        )
-      })
-    })
-
-    describe('with the draft attribute', function () {
-      beforeEach(async function () {
-        this.req.body = { draft: true }
-        await this.CompileController.compile(this.req, this.res, this.next)
-      })
-
-      it('should do the compile without the draft compile flag', function () {
-        this.CompileManager.promises.compile.should.have.been.calledWith(
-          this.projectId,
-          this.user_id,
-          {
-            isAutoCompile: false,
-            compileFromClsiCache: false,
-            populateClsiCache: false,
-            enablePdfCaching: false,
-            draft: true,
-            fileLineErrors: false,
-            stopOnFirstError: false,
-            editorId: undefined,
-          }
-        )
-      })
-    })
-
-    describe('with an editor id', function () {
-      beforeEach(async function () {
-        this.req.body = { editorId: 'the-editor-id' }
-        await this.CompileController.compile(this.req, this.res, this.next)
-      })
-
-      it('should pass the editor id to the compiler', function () {
-        this.CompileManager.promises.compile.should.have.been.calledWith(
-          this.projectId,
-          this.user_id,
-          {
-            isAutoCompile: false,
-            compileFromClsiCache: false,
-            populateClsiCache: false,
-            enablePdfCaching: false,
-            fileLineErrors: false,
-            stopOnFirstError: false,
-            editorId: 'the-editor-id',
-          }
-        )
-      })
-    })
-  })
-
-  describe('compileSubmission', function () {
-    beforeEach(function () {
-      this.submission_id = 'sub-1234'
-      this.req.params = { submission_id: this.submission_id }
-      this.req.body = {}
-      this.ClsiManager.promises.sendExternalRequest = sinon.stub().resolves({
-        status: (this.status = 'success'),
-        outputFiles: (this.outputFiles = ['mock-output-files']),
-        clsiServerId: 'mock-server-id',
-        validationProblems: null,
-      })
-    })
-
-    it('should set the content-type of the response to application/json', async function () {
-      await this.CompileController.compileSubmission(
-        this.req,
-        this.res,
-        this.next
-      )
-      this.res.contentType.calledWith('application/json').should.equal(true)
-    })
-
-    it('should send a successful response reporting the status and files', async function () {
-      await this.CompileController.compileSubmission(
-        this.req,
-        this.res,
-        this.next
-      )
-      this.res.statusCode.should.equal(200)
-      this.res.body.should.equal(
-        JSON.stringify({
-          status: this.status,
-          outputFiles: this.outputFiles,
-          clsiServerId: 'mock-server-id',
-          validationProblems: null,
-        })
-      )
-    })
-
-    describe('with compileGroup and timeout', function () {
-      beforeEach(function () {
-        this.req.body = {
-          compileGroup: 'special',
-          timeout: 600,
-        }
-        this.CompileController.compileSubmission(this.req, this.res, this.next)
-      })
-
-      it('should use the supplied values', function () {
-        this.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith(
-          this.submission_id,
-          { compileGroup: 'special', timeout: 600 },
-          { compileGroup: 'special', compileBackendClass: 'n2d', timeout: 600 }
-        )
-      })
-    })
-
-    describe('with other supported options but not compileGroup and timeout', function () {
-      beforeEach(function () {
-        this.req.body = {
-          rootResourcePath: 'main.tex',
-          compiler: 'lualatex',
-          draft: true,
-          check: 'validate',
-        }
-        this.CompileController.compileSubmission(this.req, this.res, this.next)
-      })
-
-      it('should use the other options but default values for compileGroup and timeout', function () {
-        this.ClsiManager.promises.sendExternalRequest.should.have.been.calledWith(
-          this.submission_id,
-          {
-            rootResourcePath: 'main.tex',
-            compiler: 'lualatex',
-            draft: true,
-            check: 'validate',
-          },
-          {
-            rootResourcePath: 'main.tex',
-            compiler: 'lualatex',
-            draft: true,
-            check: 'validate',
-            compileGroup: 'standard',
-            compileBackendClass: 'n2d',
-            timeout: 60,
-          }
-        )
-      })
-    })
-  })
-
-  describe('downloadPdf', function () {
-    beforeEach(function () {
-      this.CompileController._proxyToClsi = sinon.stub().resolves()
-      this.req.params = { Project_id: this.projectId }
-      this.project = { name: 'test namè; 1' }
-      this.ProjectGetter.promises.getProject = sinon
-        .stub()
-        .resolves(this.project)
-    })
-
-    describe('when downloading for embedding', function () {
-      beforeEach(async function () {
-        await this.CompileController.downloadPdf(this.req, this.res, this.next)
-      })
-
-      it('should look up the project', function () {
-        this.ProjectGetter.promises.getProject
-          .calledWith(this.projectId, { name: 1 })
-          .should.equal(true)
-      })
-
-      it('should set the content-type of the response to application/pdf', function () {
-        this.res.contentType.calledWith('application/pdf').should.equal(true)
-      })
-
-      it('should set the content-disposition header with a safe version of the project name', function () {
-        this.res.setContentDisposition.should.be.calledWith('inline', {
-          filename: 'test_namè__1.pdf',
-        })
-      })
-
-      it('should increment the pdf-downloads metric', function () {
-        this.Metrics.inc.calledWith('pdf-downloads').should.equal(true)
-      })
-
-      it('should proxy the PDF from the CLSI', function () {
-        this.CompileController._proxyToClsi
-          .calledWith(
-            this.projectId,
-            'output-file',
-            `/project/${this.projectId}/user/${this.user_id}/output/output.pdf`,
-            {},
-            this.req,
-            this.res
-          )
-          .should.equal(true)
-      })
-    })
-
-    describe('when a build-id is provided', function () {
-      beforeEach(async function () {
-        this.req.params.build_id = this.build_id
-        await this.CompileController.downloadPdf(this.req, this.res, this.next)
-      })
-
-      it('should proxy the PDF from the CLSI, with a build-id', function () {
-        this.CompileController._proxyToClsi
-          .calledWith(
-            this.projectId,
-            'output-file',
-            `/project/${this.projectId}/user/${this.user_id}/build/${this.build_id}/output/output.pdf`,
-            {},
-            this.req,
-            this.res
-          )
-          .should.equal(true)
-      })
-    })
-
-    describe('when rate-limited', function () {
-      beforeEach(async function () {
-        this.rateLimiter.consume.rejects({
-          msBeforeNext: 250,
-          remainingPoints: 0,
-          consumedPoints: 5,
-          isFirstInDuration: false,
-        })
-      })
-      it('should return 500', async function () {
-        await this.CompileController.downloadPdf(this.req, this.res, this.next)
-        // should it be 429 instead?
-        this.res.sendStatus.calledWith(500).should.equal(true)
-        this.CompileController._proxyToClsi.should.not.have.been.called
-      })
-    })
-
-    describe('when rate-limit errors', function () {
-      beforeEach(async function () {
-        this.rateLimiter.consume.rejects(new Error('uh oh'))
-      })
-      it('should return 500', async function () {
-        await this.CompileController.downloadPdf(this.req, this.res, this.next)
-        this.res.sendStatus.calledWith(500).should.equal(true)
-        this.CompileController._proxyToClsi.should.not.have.been.called
-      })
-    })
-  })
-
-  describe('getFileFromClsiWithoutUser', function () {
-    beforeEach(function () {
-      this.submission_id = 'sub-1234'
-      this.file = 'output.pdf'
-      this.req.params = {
-        submission_id: this.submission_id,
-        build_id: this.build_id,
-        file: this.file,
-      }
-      this.req.body = {}
-      this.expected_url = `/project/${this.submission_id}/build/${this.build_id}/output/${this.file}`
-      this.CompileController._proxyToClsiWithLimits = sinon.stub()
-    })
-
-    describe('without limits specified', function () {
-      beforeEach(async function () {
-        await this.CompileController.getFileFromClsiWithoutUser(
-          this.req,
-          this.res,
-          this.next
-        )
-      })
-
-      it('should proxy to CLSI with correct URL and default limits', function () {
-        this.CompileController._proxyToClsiWithLimits.should.have.been.calledWith(
-          this.submission_id,
-          'output-file',
-          this.expected_url,
-          {},
-          { compileGroup: 'standard', compileBackendClass: 'n2d' }
-        )
-      })
-    })
-
-    describe('with limits specified', function () {
-      beforeEach(function () {
-        this.req.body = { compileTimeout: 600, compileGroup: 'special' }
-        this.CompileController.getFileFromClsiWithoutUser(
-          this.req,
-          this.res,
-          this.next
-        )
-      })
-
-      it('should proxy to CLSI with correct URL and specified limits', function () {
-        this.CompileController._proxyToClsiWithLimits.should.have.been.calledWith(
-          this.submission_id,
-          'output-file',
-          this.expected_url,
-          {},
-          {
-            compileGroup: 'special',
-            compileBackendClass: 'n2d',
-          }
-        )
-      })
-    })
-  })
-  describe('proxySyncCode', function () {
-    let file, line, column, imageName, editorId, buildId, clsiServerId
-
-    beforeEach(async function () {
-      this.req.params = { Project_id: this.projectId }
-      clsiServerId = 'clsi-1'
-      file = 'main.tex'
-      line = String(Date.now())
-      column = String(Date.now() + 1)
-      editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
-      buildId = '195b4a3f9e7-03e5be430a9e7796'
-      this.req.query = {
-        file,
-        line,
-        column,
-        editorId,
-        buildId,
-        clsiserverid: clsiServerId,
-      }
-
-      imageName = 'foo/bar:tag-0'
-      this.ProjectGetter.promises.getProject = sinon
-        .stub()
-        .resolves({ imageName })
-
-      this.CompileController._proxyToClsi = sinon.stub().resolves()
-
-      await this.CompileController.proxySyncCode(this.req, this.res, this.next)
-    })
-
-    it('should parse the parameters', function () {
-      expect(this.CompileManager.promises.syncTeX).to.have.been.calledWith(
-        this.projectId,
-        this.user_id,
-        {
-          direction: 'code',
-          compileFromClsiCache: false,
-          validatedOptions: {
-            file,
-            line,
-            column,
-            editorId,
-            buildId,
-          },
-          clsiServerId,
-        }
-      )
-    })
-  })
-
-  describe('proxySyncPdf', function () {
-    let page, h, v, imageName, editorId, buildId, clsiServerId
-
-    beforeEach(async function () {
-      this.req.params = { Project_id: this.projectId }
-      clsiServerId = 'clsi-1'
-      page = String(Date.now())
-      h = String(Math.random())
-      v = String(Math.random())
-      editorId = '172977cb-361e-4854-a4dc-a71cf11512e5'
-      buildId = '195b4a3f9e7-03e5be430a9e7796'
-      this.req.query = {
-        page,
-        h,
-        v,
-        editorId,
-        buildId,
-        clsiserverid: clsiServerId,
-      }
-
-      imageName = 'foo/bar:tag-1'
-      this.ProjectGetter.promises.getProject = sinon
-        .stub()
-        .resolves({ imageName })
-
-      this.CompileController._proxyToClsi = sinon.stub()
-
-      await this.CompileController.proxySyncPdf(this.req, this.res, this.next)
-    })
-
-    it('should parse the parameters', function () {
-      expect(this.CompileManager.promises.syncTeX).to.have.been.calledWith(
-        this.projectId,
-        this.user_id,
-        {
-          direction: 'pdf',
-          compileFromClsiCache: false,
-          validatedOptions: {
-            page,
-            h,
-            v,
-            editorId,
-            buildId,
-          },
-          clsiServerId,
-        }
-      )
-    })
-  })
-
-  describe('_proxyToClsi', function () {
-    beforeEach(function () {
-      this.req.method = 'mock-method'
-      this.req.headers = {
-        Mock: 'Headers',
-        Range: '123-456',
-        'If-Range': 'abcdef',
-        'If-Modified-Since': 'Mon, 15 Dec 2014 15:23:56 GMT',
-      }
-    })
-
-    describe('old pdf viewer', function () {
-      describe('user with standard priority', function () {
-        beforeEach(async function () {
-          this.CompileManager.promises.getProjectCompileLimits = sinon
-            .stub()
-            .resolves({
-              compileGroup: 'standard',
-              compileBackendClass: 'n2d',
-            })
-          await this.CompileController._proxyToClsi(
-            this.projectId,
-            'output-file',
-            (this.url = '/test'),
-            { query: 'foo' },
-            this.req,
-            this.res,
-            this.next
-          )
-        })
-
-        it('should open a request to the CLSI', function () {
-          this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
-            `${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=n2d&query=foo`
-          )
-        })
-
-        it('should pass the request on to the client', function () {
-          this.pipeline.should.have.been.calledWith(this.clsiStream, this.res)
-        })
-      })
-
-      describe('user with priority compile', function () {
-        beforeEach(async function () {
-          this.CompileManager.promises.getProjectCompileLimits = sinon
-            .stub()
-            .resolves({
-              compileGroup: 'priority',
-              compileBackendClass: 'c2d',
-            })
-          await this.CompileController._proxyToClsi(
-            this.projectId,
-            'output-file',
-            (this.url = '/test'),
-            {},
-            this.req,
-            this.res,
-            this.next
-          )
-        })
-
-        it('should open a request to the CLSI', function () {
-          this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
-            `${this.settings.apis.clsi.url}${this.url}?compileGroup=priority&compileBackendClass=c2d`
-          )
-        })
-      })
-
-      describe('user with standard priority via query string', function () {
-        beforeEach(async function () {
-          this.req.query = { compileGroup: 'standard' }
-          this.CompileManager.promises.getProjectCompileLimits = sinon
-            .stub()
-            .resolves({
-              compileGroup: 'standard',
-              compileBackendClass: 'n2d',
-            })
-          await this.CompileController._proxyToClsi(
-            this.projectId,
-            'output-file',
-            (this.url = '/test'),
-            {},
-            this.req,
-            this.res,
-            this.next
-          )
-        })
-
-        it('should open a request to the CLSI', function () {
-          this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
-            `${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=n2d`
-          )
-        })
-
-        it('should pass the request on to the client', function () {
-          this.pipeline.should.have.been.calledWith(this.clsiStream, this.res)
-        })
-      })
-
-      describe('user with non-existent priority via query string', function () {
-        beforeEach(async function () {
-          this.req.query = { compileGroup: 'foobar' }
-          this.CompileManager.promises.getProjectCompileLimits = sinon
-            .stub()
-            .resolves({
-              compileGroup: 'standard',
-              compileBackendClass: 'n2d',
-            })
-          await this.CompileController._proxyToClsi(
-            this.projectId,
-            'output-file',
-            (this.url = '/test'),
-            {},
-            this.req,
-            this.res,
-            this.next
-          )
-        })
-
-        it('should proxy to the standard url', function () {
-          this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
-            `${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=n2d`
-          )
-        })
-      })
-
-      describe('user with build parameter via query string', function () {
-        beforeEach(async function () {
-          this.CompileManager.promises.getProjectCompileLimits = sinon
-            .stub()
-            .resolves({
-              compileGroup: 'standard',
-              compileBackendClass: 'n2d',
-            })
-          this.req.query = { build: 1234 }
-          await this.CompileController._proxyToClsi(
-            this.projectId,
-            'output-file',
-            (this.url = '/test'),
-            {},
-            this.req,
-            this.res,
-            this.next
-          )
-        })
-
-        it('should proxy to the standard url without the build parameter', function () {
-          this.fetchUtils.fetchStreamWithResponse.should.have.been.calledWith(
-            `${this.settings.apis.clsi.url}${this.url}?compileGroup=standard&compileBackendClass=n2d`
-          )
-        })
-      })
-    })
-  })
-
-  describe('deleteAuxFiles', function () {
-    beforeEach(async function () {
-      this.CompileManager.promises.deleteAuxFiles = sinon.stub().resolves()
-      this.req.params = { Project_id: this.projectId }
-      this.req.query = { clsiserverid: 'node-1' }
-      this.res.sendStatus = sinon.stub()
-      await this.CompileController.deleteAuxFiles(this.req, this.res, this.next)
-    })
-
-    it('should proxy to the CLSI', function () {
-      this.CompileManager.promises.deleteAuxFiles
-        .calledWith(this.projectId, this.user_id, 'node-1')
-        .should.equal(true)
-    })
-
-    it('should return a 200', function () {
-      this.res.sendStatus.calledWith(200).should.equal(true)
-    })
-  })
-
-  describe('compileAndDownloadPdf', function () {
-    beforeEach(function () {
-      this.req = {
-        params: {
-          project_id: this.projectId,
-        },
-      }
-      this.downloadPath = `/project/${this.projectId}/build/123/output/output.pdf`
-      this.CompileManager.promises.compile.resolves({
-        status: 'success',
-        outputFiles: [{ path: 'output.pdf', url: this.downloadPath }],
-      })
-      this.CompileController._proxyToClsi = sinon.stub()
-      this.res = { send: () => {}, sendStatus: sinon.stub() }
-    })
-
-    it('should call compile in the compile manager', async function () {
-      await this.CompileController.compileAndDownloadPdf(this.req, this.res)
-      this.CompileManager.promises.compile
-        .calledWith(this.projectId)
-        .should.equal(true)
-    })
-
-    it('should proxy the res to the clsi with correct url', async function () {
-      await this.CompileController.compileAndDownloadPdf(this.req, this.res)
-      sinon.assert.calledWith(
-        this.CompileController._proxyToClsi,
-        this.projectId,
-        'output-file',
-        this.downloadPath,
-        {},
-        this.req,
-        this.res
-      )
-
-      this.CompileController._proxyToClsi
-        .calledWith(
-          this.projectId,
-          'output-file',
-          this.downloadPath,
-          {},
-          this.req,
-          this.res
-        )
-        .should.equal(true)
-    })
-
-    it('should not download anything on compilation failures', async function () {
-      this.CompileManager.promises.compile.rejects(new Error('failed'))
-      await this.CompileController.compileAndDownloadPdf(
-        this.req,
-        this.res,
-        this.next
-      )
-      this.res.sendStatus.should.have.been.calledWith(500)
-      this.CompileController._proxyToClsi.should.not.have.been.called
-    })
-
-    it('should not download anything on missing pdf', async function () {
-      this.CompileManager.promises.compile.resolves({
-        status: 'success',
-        outputFiles: [],
-      })
-      await this.CompileController.compileAndDownloadPdf(this.req, this.res)
-      this.res.sendStatus.should.have.been.calledWith(500)
-      this.CompileController._proxyToClsi.should.not.have.been.called
-    })
-  })
-
-  describe('wordCount', function () {
-    beforeEach(async function () {
-      this.CompileManager.promises.wordCount = sinon
-        .stub()
-        .resolves({ content: 'body' })
-      this.req.params = { Project_id: this.projectId }
-      this.req.query = { clsiserverid: 'node-42' }
-      this.res.json = sinon.stub()
-      this.res.contentType = sinon.stub()
-      await this.CompileController.wordCount(this.req, this.res, this.next)
-    })
-
-    it('should proxy to the CLSI', function () {
-      this.CompileManager.promises.wordCount
-        .calledWith(this.projectId, this.user_id, false, 'node-42')
-        .should.equal(true)
-    })
-
-    it('should return a 200 and body', function () {
-      this.res.json.calledWith({ content: 'body' }).should.equal(true)
-    })
-  })
-})

+ 67 - 0
services/web/test/unit/src/HelperFiles/AuthorizationHelper.test.mjs

@@ -0,0 +1,67 @@
+import { vi, expect } from 'vitest'
+
+const modulePath = '../../../../app/src/Features/Helpers/AuthorizationHelper'
+
+describe('AuthorizationHelper', function () {
+  beforeEach(async function (ctx) {
+    vi.doMock('../../../../app/src/models/User', () => ({
+      UserSchema: {
+        obj: {
+          staffAccess: {
+            publisherMetrics: {},
+            publisherManagement: {},
+            institutionMetrics: {},
+            institutionManagement: {},
+            groupMetrics: {},
+            groupManagement: {},
+            adminMetrics: {},
+          },
+        },
+      },
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: (ctx.ProjectGetter = { promises: {} }),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: (ctx.SplitTestHandler = {
+          promises: {},
+        }),
+      })
+    )
+
+    ctx.AuthorizationHelper = (await import(modulePath)).default
+  })
+
+  describe('hasAnyStaffAccess', function () {
+    it('with empty user', function (ctx) {
+      const user = {}
+      expect(ctx.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
+    })
+
+    it('with no access user', function (ctx) {
+      const user = { isAdmin: false, staffAccess: { adminMetrics: false } }
+      expect(ctx.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
+    })
+
+    it('with admin user', function (ctx) {
+      const user = { isAdmin: true }
+      expect(ctx.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
+    })
+
+    it('with staff user', function (ctx) {
+      const user = { staffAccess: { adminMetrics: true, somethingElse: false } }
+      expect(ctx.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.true
+    })
+
+    it('with non-staff user with extra attributes', function (ctx) {
+      // make sure that staffAccess attributes not declared on the model don't
+      // give user access
+      const user = { staffAccess: { adminMetrics: false, somethingElse: true } }
+      expect(ctx.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
+    })
+  })
+})

+ 0 - 66
services/web/test/unit/src/HelperFiles/AuthorizationHelperTests.js

@@ -1,66 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../../app/src/Features/Helpers/AuthorizationHelper'
-
-describe('AuthorizationHelper', function () {
-  beforeEach(function () {
-    this.AuthorizationHelper = SandboxedModule.require(modulePath, {
-      requires: {
-        './AdminAuthorizationHelper': (this.AdminAuthorizationHelper = {
-          hasAdminAccess: sinon.stub().returns(false),
-        }),
-        '../../models/User': {
-          UserSchema: {
-            obj: {
-              staffAccess: {
-                publisherMetrics: {},
-                publisherManagement: {},
-                institutionMetrics: {},
-                institutionManagement: {},
-                groupMetrics: {},
-                groupManagement: {},
-                adminMetrics: {},
-              },
-            },
-          },
-        },
-        '../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
-        '../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
-          promises: {},
-        }),
-      },
-    })
-  })
-
-  describe('hasAnyStaffAccess', function () {
-    it('with empty user', function () {
-      const user = {}
-      expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
-    })
-
-    it('with no access user', function () {
-      const user = { isAdmin: false, staffAccess: { adminMetrics: false } }
-      expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
-    })
-
-    it('with admin user', function () {
-      const user = { isAdmin: true }
-      this.AdminAuthorizationHelper.hasAdminAccess.returns(true)
-      expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
-    })
-
-    it('with staff user', function () {
-      const user = { staffAccess: { adminMetrics: true, somethingElse: false } }
-      this.AdminAuthorizationHelper.hasAdminAccess.returns(true)
-      expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.true
-    })
-
-    it('with non-staff user with extra attributes', function () {
-      // make sure that staffAccess attributes not declared on the model don't
-      // give user access
-      const user = { staffAccess: { adminMetrics: false, somethingElse: true } }
-      expect(this.AuthorizationHelper.hasAnyStaffAccess(user)).to.be.false
-    })
-  })
-})

+ 6 - 3
services/web/test/unit/src/History/HistoryController.test.mjs

@@ -106,9 +106,12 @@ describe('HistoryController', function () {
       default: (ctx.ProjectGetter = {}),
     }))
 
-    vi.doMock('../../../../app/src/Features/History/RestoreManager.js', () => ({
-      default: (ctx.RestoreManager = {}),
-    }))
+    vi.doMock(
+      '../../../../app/src/Features/History/RestoreManager.mjs',
+      () => ({
+        default: (ctx.RestoreManager = {}),
+      })
+    )
 
     vi.doMock('../../../../app/src/infrastructure/Features.js', () => ({
       default: (ctx.Features = sinon.stub().withArgs('saas').returns(true)),

+ 1169 - 0
services/web/test/unit/src/History/RestoreManager.test.mjs

@@ -0,0 +1,1169 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+import tk from 'timekeeper'
+import moment from 'moment'
+
+const modulePath = '../../../../app/src/Features/History/RestoreManager'
+
+function nestedMapWithSetToObject(m) {
+  return Object.fromEntries(
+    Array.from(m.entries()).map(([key, set]) => [key, Array.from(set)])
+  )
+}
+
+describe('RestoreManager', function () {
+  beforeEach(async function (ctx) {
+    tk.freeze(Date.now()) // freeze the time for these tests
+
+    vi.doMock('../../../../app/src/Features/Errors/Errors.js', () => ({
+      default: Errors,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Metrics.js', () => ({
+      default: {
+        revertFileDurationSeconds: {
+          startTimer: sinon.stub().returns(sinon.stub()),
+        },
+        revertProjectDurationSeconds: {
+          startTimer: sinon.stub().returns(sinon.stub()),
+        },
+      },
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {},
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/FileWriter', () => ({
+      default: (ctx.FileWriter = { promises: {} }),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Uploads/FileSystemImportManager',
+      () => ({
+        default: (ctx.FileSystemImportManager = {
+          promises: {},
+        }),
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({
+      default: (ctx.EditorController = {
+        promises: {},
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
+      default: (ctx.ProjectLocator = { promises: {} }),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
+      () => ({
+        default: (ctx.DocumentUpdaterHandler = {
+          promises: { flushProjectToMongo: sinon.stub().resolves() },
+        }),
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({
+      default: (ctx.DocstoreManager = {
+        promises: { getCommentThreadIds: sinon.stub().resolves({}) },
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Chat/ChatApiHandler', () => ({
+      default: (ctx.ChatApiHandler = { promises: {} }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Chat/ChatManager', () => ({
+      default: (ctx.ChatManager = { promises: {} }),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Editor/EditorRealTimeController',
+      () => ({
+        default: (ctx.EditorRealTimeController = {}),
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: (ctx.ProjectGetter = { promises: {} }),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityHandler',
+      () => ({
+        default: (ctx.ProjectEntityHandler = {
+          promises: {},
+        }),
+      })
+    )
+
+    ctx.RestoreManager = (await import(modulePath)).default
+    ctx.user_id = 'mock-user-id'
+    ctx.project_id = 'mock-project-id'
+    ctx.version = 42
+  })
+
+  afterEach(function () {
+    tk.reset()
+  })
+
+  describe('restoreFileFromV2', function () {
+    beforeEach(function (ctx) {
+      ctx.RestoreManager.promises._writeFileVersionToDisk = sinon
+        .stub()
+        .resolves((ctx.fsPath = '/tmp/path/on/disk'))
+      ctx.RestoreManager.promises._findOrCreateFolder = sinon
+        .stub()
+        .resolves((ctx.folder_id = 'mock-folder-id'))
+      ctx.FileSystemImportManager.promises.addEntity = sinon
+        .stub()
+        .resolves((ctx.entity = 'mock-entity'))
+    })
+
+    describe('with a file not in a folder', function () {
+      beforeEach(async function (ctx) {
+        ctx.pathname = 'foo.tex'
+        ctx.result = await ctx.RestoreManager.promises.restoreFileFromV2(
+          ctx.user_id,
+          ctx.project_id,
+          ctx.version,
+          ctx.pathname
+        )
+      })
+
+      it('should write the file version to disk', function (ctx) {
+        ctx.RestoreManager.promises._writeFileVersionToDisk
+          .calledWith(ctx.project_id, ctx.version, ctx.pathname)
+          .should.equal(true)
+      })
+
+      it('should find the root folder', function (ctx) {
+        ctx.RestoreManager.promises._findOrCreateFolder
+          .calledWith(ctx.project_id, '', ctx.user_id)
+          .should.equal(true)
+      })
+
+      it('should add the entity', function (ctx) {
+        ctx.FileSystemImportManager.promises.addEntity
+          .calledWith(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.folder_id,
+            'foo.tex',
+            ctx.fsPath,
+            false
+          )
+          .should.equal(true)
+      })
+
+      it('should return the entity', function (ctx) {
+        expect(ctx.result).to.equal(ctx.entity)
+      })
+    })
+
+    describe('with a file in a folder', function () {
+      beforeEach(async function (ctx) {
+        ctx.pathname = 'foo/bar.tex'
+        await ctx.RestoreManager.promises.restoreFileFromV2(
+          ctx.user_id,
+          ctx.project_id,
+          ctx.version,
+          ctx.pathname
+        )
+      })
+
+      it('should find the folder', function (ctx) {
+        ctx.RestoreManager.promises._findOrCreateFolder
+          .calledWith(ctx.project_id, 'foo', ctx.user_id)
+          .should.equal(true)
+      })
+
+      it('should add the entity by its basename', function (ctx) {
+        ctx.FileSystemImportManager.promises.addEntity
+          .calledWith(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.folder_id,
+            'bar.tex',
+            ctx.fsPath,
+            false
+          )
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('_findOrCreateFolder', function () {
+    beforeEach(async function (ctx) {
+      ctx.EditorController.promises.mkdirp = sinon.stub().resolves({
+        newFolders: [],
+        lastFolder: { _id: (ctx.folder_id = 'mock-folder-id') },
+      })
+      ctx.result = await ctx.RestoreManager.promises._findOrCreateFolder(
+        ctx.project_id,
+        'folder/name',
+        ctx.user_id
+      )
+    })
+
+    it('should look up or create the folder', function (ctx) {
+      ctx.EditorController.promises.mkdirp
+        .calledWith(ctx.project_id, 'folder/name', ctx.user_id)
+        .should.equal(true)
+    })
+
+    it('should return the folder_id', function (ctx) {
+      expect(ctx.result).to.equal(ctx.folder_id)
+    })
+  })
+
+  describe('_addEntityWithUniqueName', function () {
+    beforeEach(function (ctx) {
+      ctx.addEntityWithName = sinon.stub()
+      ctx.filename = 'foo.tex'
+    })
+
+    describe('with a valid name', function () {
+      beforeEach(async function (ctx) {
+        ctx.addEntityWithName.resolves((ctx.entity = 'mock-entity'))
+        ctx.result = await ctx.RestoreManager.promises._addEntityWithUniqueName(
+          ctx.addEntityWithName,
+          ctx.filename
+        )
+      })
+
+      it('should add the entity', function (ctx) {
+        ctx.addEntityWithName.calledWith(ctx.filename).should.equal(true)
+      })
+
+      it('should return the entity', function (ctx) {
+        expect(ctx.result).to.equal(ctx.entity)
+      })
+    })
+
+    describe('with a duplicate name', function () {
+      beforeEach(async function (ctx) {
+        ctx.addEntityWithName.rejects(new Errors.DuplicateNameError())
+        ctx.addEntityWithName
+          .onSecondCall()
+          .resolves((ctx.entity = 'mock-entity'))
+        ctx.result = await ctx.RestoreManager.promises._addEntityWithUniqueName(
+          ctx.addEntityWithName,
+          ctx.filename
+        )
+      })
+
+      it('should try to add the entity with its original name', function (ctx) {
+        ctx.addEntityWithName.calledWith('foo.tex').should.equal(true)
+      })
+
+      it('should try to add the entity with a unique name', function (ctx) {
+        const date = moment(new Date()).format('Do MMM YY H:mm:ss')
+        ctx.addEntityWithName
+          .calledWith(`foo (Restored on ${date}).tex`)
+          .should.equal(true)
+      })
+
+      it('should return the entity', function (ctx) {
+        expect(ctx.result).to.equal(ctx.entity)
+      })
+    })
+  })
+
+  describe('revertFile', function () {
+    beforeEach(function (ctx) {
+      ctx.ProjectGetter.promises.getProject = sinon.stub()
+      ctx.ProjectGetter.promises.getProject.withArgs(ctx.project_id).resolves({
+        overleaf: { history: { rangesSupportEnabled: true } },
+        rootDoc_id: 'root-doc-id',
+      })
+      ctx.RestoreManager.promises._writeFileVersionToDisk = sinon
+        .stub()
+        .resolves((ctx.fsPath = '/tmp/path/on/disk'))
+      ctx.RestoreManager.promises._findOrCreateFolder = sinon
+        .stub()
+        .resolves((ctx.folder_id = 'mock-folder-id'))
+      ctx.FileSystemImportManager.promises.addEntity = sinon
+        .stub()
+        .resolves((ctx.entity = 'mock-entity'))
+      ctx.RestoreManager.promises._getRangesFromHistory = sinon.stub().rejects()
+      ctx.RestoreManager.promises._getMetadataFromHistory = sinon
+        .stub()
+        .resolves({ metadata: undefined })
+    })
+
+    describe('reverting a project without ranges support', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectGetter.promises.getProject = sinon.stub().resolves({
+          overleaf: { history: { rangesSupportEnabled: false } },
+        })
+      })
+
+      it('should throw an error', async function (ctx) {
+        await expect(
+          ctx.RestoreManager.promises.revertFile(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version,
+            ctx.pathname
+          )
+        ).to.eventually.be.rejectedWith('project does not have ranges support')
+      })
+    })
+
+    describe('reverting a document with ranges', function () {
+      beforeEach(function (ctx) {
+        ctx.pathname = 'foo.tex'
+        ctx.comments = [
+          {
+            id: 'comment-in-other-doc',
+            op: { t: 'comment-in-other-doc', p: 0, c: 'foo' },
+          },
+          {
+            id: 'single-comment',
+            op: { t: 'single-comment', p: 10, c: 'bar' },
+          },
+          {
+            id: 'deleted-comment',
+            op: { t: 'deleted-comment', p: 20, c: 'baz' },
+          },
+        ]
+        ctx.remappedComments = [
+          {
+            id: 'duplicate-comment',
+            op: { t: 'duplicate-comment', p: 0, c: 'foo' },
+          },
+          {
+            id: 'single-comment',
+            op: { t: 'single-comment', p: 10, c: 'bar' },
+          },
+        ]
+        ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
+        ctx.DocstoreManager.promises.getCommentThreadIds = sinon
+          .stub()
+          .resolves({ 'other-doc': [ctx.comments[0].op.t] })
+        ctx.ChatApiHandler.promises.duplicateCommentThreads = sinon
+          .stub()
+          .resolves({
+            newThreads: {
+              'comment-in-other-doc': {
+                duplicateId: 'duplicate-comment',
+              },
+            },
+          })
+        ctx.ChatApiHandler.promises.generateThreadData = sinon.stub().resolves(
+          (ctx.threadData = {
+            'single-comment': {
+              messages: [
+                {
+                  content: 'message-content',
+                  timestamp: '2024-01-01T00:00:00.000Z',
+                  user_id: 'user-1',
+                },
+              ],
+            },
+            'duplicate-comment': {
+              messages: [
+                {
+                  content: 'another message',
+                  timestamp: '2024-01-01T00:00:00.000Z',
+                  user_id: 'user-1',
+                },
+              ],
+            },
+          })
+        )
+        ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon
+          .stub()
+          .resolves(ctx.threadData)
+
+        ctx.EditorRealTimeController.emitToRoom = sinon.stub()
+        ctx.tracked_changes = [
+          {
+            op: { pos: 4, i: 'bar' },
+            metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
+          },
+          {
+            op: { pos: 8, d: 'qux' },
+            metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-2' },
+          },
+        ]
+        ctx.FileSystemImportManager.promises.importFile = sinon
+          .stub()
+          .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
+        ctx.RestoreManager.promises._getRangesFromHistory = sinon
+          .stub()
+          .resolves({
+            changes: ctx.tracked_changes,
+            comments: ctx.comments,
+          })
+        ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
+          .stub()
+          .resolves([
+            { toV: ctx.version, meta: { end_ts: (ctx.endTs = new Date()) } },
+          ])
+        ctx.EditorController.promises.addDocWithRanges = sinon
+          .stub()
+          .resolves((ctx.addedFile = { _id: 'mock-doc', type: 'doc' }))
+      })
+
+      describe("when reverting a file that doesn't current exist", function () {
+        beforeEach(async function (ctx) {
+          ctx.data = await ctx.RestoreManager.promises.revertFile(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version,
+            ctx.pathname
+          )
+        })
+
+        it('should flush the document before fetching ranges', function (ctx) {
+          expect(
+            ctx.DocumentUpdaterHandler.promises.flushProjectToMongo
+          ).to.have.been.calledBefore(
+            ctx.DocstoreManager.promises.getCommentThreadIds
+          )
+        })
+
+        it('should import the file', function (ctx) {
+          expect(
+            ctx.EditorController.promises.addDocWithRanges
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            ctx.folder_id,
+            'foo.tex',
+            ['foo', 'bar', 'baz'],
+            { changes: ctx.tracked_changes, comments: ctx.remappedComments }
+          )
+        })
+
+        it('should return the created entity', function (ctx) {
+          expect(ctx.data).to.deep.equal(ctx.addedFile)
+        })
+
+        it('should look up ranges', function (ctx) {
+          expect(
+            ctx.RestoreManager.promises._getRangesFromHistory
+          ).to.have.been.calledWith(ctx.project_id, ctx.version, ctx.pathname)
+        })
+      })
+
+      describe('with an existing file in the current project', function () {
+        beforeEach(async function (ctx) {
+          ctx.ProjectGetter.promises.getProject = sinon.stub()
+          ctx.ProjectGetter.promises.getProject
+            .withArgs(ctx.project_id)
+            .resolves({
+              overleaf: { history: { rangesSupportEnabled: true } },
+              rootDoc_id: 'root-doc-id',
+            })
+          ctx.ProjectLocator.promises.findElementByPath = sinon
+            .stub()
+            .resolves({ type: 'file', element: { _id: 'mock-file-id' } })
+          ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+
+          ctx.data = await ctx.RestoreManager.promises.revertFile(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version,
+            ctx.pathname
+          )
+        })
+
+        it('should delete the existing file', async function (ctx) {
+          expect(
+            ctx.EditorController.promises.deleteEntity
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            'mock-file-id',
+            'file',
+            {
+              kind: 'file-restore',
+              path: ctx.pathname,
+              version: ctx.version,
+              timestamp: new Date(ctx.endTs).toISOString(),
+            },
+            ctx.user_id
+          )
+        })
+      })
+
+      describe('with an existing document in the current project', function () {
+        beforeEach(async function (ctx) {
+          ctx.ProjectGetter.promises.getProject = sinon.stub()
+          ctx.ProjectGetter.promises.getProject
+            .withArgs(ctx.project_id)
+            .resolves({
+              overleaf: { history: { rangesSupportEnabled: true } },
+              rootDoc_id: 'root-doc-id',
+            })
+          ctx.ProjectLocator.promises.findElementByPath = sinon
+            .stub()
+            .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
+          ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+
+          ctx.data = await ctx.RestoreManager.promises.revertFile(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version,
+            ctx.pathname
+          )
+        })
+
+        it('should delete the existing document', async function (ctx) {
+          expect(
+            ctx.EditorController.promises.deleteEntity
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            'mock-file-id',
+            'doc',
+            {
+              kind: 'file-restore',
+              path: ctx.pathname,
+              version: ctx.version,
+              timestamp: new Date(ctx.endTs).toISOString(),
+            },
+            ctx.user_id
+          )
+        })
+
+        it('should flush the document before fetching ranges', function (ctx) {
+          expect(
+            ctx.DocumentUpdaterHandler.promises.flushProjectToMongo
+          ).to.have.been.calledBefore(
+            ctx.DocstoreManager.promises.getCommentThreadIds
+          )
+        })
+
+        it('should import the file', function (ctx) {
+          expect(
+            ctx.EditorController.promises.addDocWithRanges
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            ctx.folder_id,
+            'foo.tex',
+            ['foo', 'bar', 'baz'],
+            { changes: ctx.tracked_changes, comments: ctx.remappedComments },
+            {
+              kind: 'file-restore',
+              path: ctx.pathname,
+              version: ctx.version,
+              timestamp: new Date(ctx.endTs).toISOString(),
+            }
+          )
+        })
+
+        it('should return the created entity', function (ctx) {
+          expect(ctx.data).to.deep.equal(ctx.addedFile)
+        })
+
+        it('should look up ranges', function (ctx) {
+          expect(
+            ctx.RestoreManager.promises._getRangesFromHistory
+          ).to.have.been.calledWith(ctx.project_id, ctx.version, ctx.pathname)
+        })
+      })
+
+      describe('with comments in same doc', function () {
+        // copy of the above, addition: inject and later inspect threadIds set
+        beforeEach(async function (ctx) {
+          ctx.ProjectGetter.promises.getProject = sinon.stub()
+          ctx.ProjectGetter.promises.getProject
+            .withArgs(ctx.project_id)
+            .resolves({
+              overleaf: { history: { rangesSupportEnabled: true } },
+              rootDoc_id: 'root-doc-id',
+            })
+          ctx.ProjectLocator.promises.findElementByPath = sinon
+            .stub()
+            .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
+          ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+          ctx.ChatApiHandler.promises.generateThreadData = sinon
+            .stub()
+            .resolves(
+              (ctx.threadData = {
+                [ctx.comments[0].op.t]: {
+                  messages: [
+                    {
+                      content: 'message',
+                      timestamp: '2024-01-01T00:00:00.000Z',
+                      user_id: 'user-1',
+                    },
+                  ],
+                },
+                [ctx.comments[1].op.t]: {
+                  messages: [
+                    {
+                      content: 'other message',
+                      timestamp: '2024-01-01T00:00:00.000Z',
+                      user_id: 'user-1',
+                    },
+                  ],
+                },
+              })
+            )
+
+          ctx.threadIds = new Map([
+            [
+              'mock-file-id',
+              new Set([ctx.comments[0].op.t, ctx.comments[1].op.t]),
+            ],
+          ])
+          // Comments are updated in-place. Look up threads before reverting.
+          ctx.afterThreadIds = {
+            // mock-file-id removed
+            [ctx.addedFile._id]: [ctx.comments[0].op.t, ctx.comments[1].op.t],
+          }
+          ctx.data = await ctx.RestoreManager.promises._revertSingleFile(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version,
+            ctx.pathname,
+            ctx.threadIds
+          )
+        })
+
+        it('should import the file with original comments minus the deleted one', function (ctx) {
+          expect(
+            ctx.EditorController.promises.addDocWithRanges
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            ctx.folder_id,
+            'foo.tex',
+            ['foo', 'bar', 'baz'],
+            {
+              changes: ctx.tracked_changes,
+              comments: ctx.comments.slice(0, 2),
+            },
+            {
+              kind: 'file-restore',
+              path: ctx.pathname,
+              version: ctx.version,
+              timestamp: new Date(ctx.endTs).toISOString(),
+            }
+          )
+        })
+
+        it('should add the seen thread ids to the map', function (ctx) {
+          expect(nestedMapWithSetToObject(ctx.threadIds)).to.deep.equal(
+            ctx.afterThreadIds
+          )
+        })
+      })
+
+      describe('with remapped comments during revertProject', function () {
+        // copy of the above, addition: inject and later inspect threadIds set
+        beforeEach(async function (ctx) {
+          ctx.ProjectGetter.promises.getProject = sinon.stub()
+          ctx.ProjectGetter.promises.getProject
+            .withArgs(ctx.project_id)
+            .resolves({
+              overleaf: { history: { rangesSupportEnabled: true } },
+              rootDoc_id: 'root-doc-id',
+            })
+          ctx.ProjectLocator.promises.findElementByPath = sinon
+            .stub()
+            .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
+          ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+
+          ctx.threadIds = new Map([
+            ['other-doc', new Set([ctx.comments[0].op.t])],
+          ])
+          // Comments are updated in-place. Look up threads before reverting.
+          ctx.afterThreadIds = {
+            // mock-file-id removed
+            'other-doc': [ctx.comments[0].op.t],
+            [ctx.addedFile._id]: [
+              ctx.remappedComments[0].op.t,
+              ctx.remappedComments[1].op.t,
+            ],
+          }
+          ctx.data = await ctx.RestoreManager.promises._revertSingleFile(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version,
+            ctx.pathname,
+            ctx.threadIds
+          )
+        })
+
+        it('should import the file', function (ctx) {
+          expect(
+            ctx.EditorController.promises.addDocWithRanges
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            ctx.folder_id,
+            'foo.tex',
+            ['foo', 'bar', 'baz'],
+            { changes: ctx.tracked_changes, comments: ctx.remappedComments },
+            {
+              kind: 'file-restore',
+              path: ctx.pathname,
+              version: ctx.version,
+              timestamp: new Date(ctx.endTs).toISOString(),
+            }
+          )
+        })
+
+        it('should add the seen thread ids to the map', function (ctx) {
+          expect(nestedMapWithSetToObject(ctx.threadIds)).to.deep.equal(
+            ctx.afterThreadIds
+          )
+        })
+      })
+
+      describe('when restored file has the same id as root doc', function () {
+        beforeEach(async function (ctx) {
+          ctx.ProjectGetter.promises.getProject = sinon.stub()
+          ctx.ProjectGetter.promises.getProject
+            .withArgs(ctx.project_id)
+            .resolves({
+              overleaf: { history: { rangesSupportEnabled: true } },
+              rootDoc_id: 'root-doc-id',
+            })
+          ctx.ProjectLocator.promises.findElementByPath = sinon
+            .stub()
+            .resolves({ type: 'doc', element: { _id: 'root-doc-id' } })
+          ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+          ctx.EditorController.promises.addDocWithRanges = sinon
+            .stub()
+            .resolves((ctx.addedFile = { _id: 'new-doc-id', type: 'doc' }))
+          ctx.EditorController.promises.setRootDoc = sinon.stub().resolves()
+
+          ctx.data = await ctx.RestoreManager.promises.revertFile(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version,
+            ctx.pathname
+          )
+        })
+
+        it('should delete the existing root document', async function (ctx) {
+          expect(
+            ctx.EditorController.promises.deleteEntity
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            'root-doc-id',
+            'doc',
+            {
+              kind: 'file-restore',
+              path: ctx.pathname,
+              version: ctx.version,
+              timestamp: new Date(ctx.endTs).toISOString(),
+            },
+            ctx.user_id
+          )
+        })
+
+        it('should import the file', function (ctx) {
+          expect(
+            ctx.EditorController.promises.addDocWithRanges
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            ctx.folder_id,
+            'foo.tex',
+            ['foo', 'bar', 'baz'],
+            { changes: ctx.tracked_changes, comments: ctx.remappedComments },
+            {
+              kind: 'file-restore',
+              path: ctx.pathname,
+              version: ctx.version,
+              timestamp: new Date(ctx.endTs).toISOString(),
+            }
+          )
+        })
+
+        it('should return the created entity with root doc id', function (ctx) {
+          expect(ctx.data).to.deep.equal(ctx.addedFile)
+          expect(ctx.data._id).to.equal('new-doc-id')
+        })
+
+        it('should set the restored document as the new root doc', function (ctx) {
+          expect(
+            ctx.EditorController.promises.setRootDoc
+          ).to.have.been.calledWith(ctx.project_id, ctx.addedFile._id)
+        })
+      })
+    })
+
+    describe('reverting a file or document with metadata', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
+        ctx.EditorController.promises.addDocWithRanges = sinon.stub()
+        ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
+          .stub()
+          .resolves([
+            { toV: ctx.version, meta: { end_ts: (ctx.endTs = new Date()) } },
+          ])
+
+        ctx.EditorController.promises.upsertFile = sinon
+          .stub()
+          .resolves({ _id: 'mock-file-id', type: 'file' })
+        ctx.RestoreManager.promises._getRangesFromHistory = sinon
+          .stub()
+          .resolves({
+            changes: [],
+            comments: [],
+          })
+        ctx.EditorController.promises.addDocWithRanges = sinon
+          .stub()
+          .resolves((ctx.addedFile = { _id: 'mock-doc-id', type: 'doc' }))
+
+        ctx.DocstoreManager.promises.getCommentThreadIds = sinon
+          .stub()
+          .resolves({})
+        ctx.ChatApiHandler.promises.generateThreadData = sinon
+          .stub()
+          .resolves({})
+        ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon
+          .stub()
+          .resolves({})
+        ctx.EditorRealTimeController.emitToRoom = sinon.stub()
+      })
+
+      describe('when reverting a linked file', function () {
+        beforeEach(async function (ctx) {
+          ctx.pathname = 'foo.png'
+          ctx.FileSystemImportManager.promises.importFile = sinon
+            .stub()
+            .resolves({ type: 'file' })
+          ctx.RestoreManager.promises._getMetadataFromHistory = sinon
+            .stub()
+            .resolves({ metadata: { provider: 'bar' } })
+          ctx.result = await ctx.RestoreManager.promises.revertFile(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version,
+            ctx.pathname
+          )
+        })
+
+        it('should revert it as a file', function (ctx) {
+          expect(ctx.result).to.deep.equal({
+            _id: 'mock-file-id',
+            type: 'file',
+          })
+        })
+
+        it('should upload to the project as a file', function (ctx) {
+          expect(
+            ctx.EditorController.promises.upsertFile
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            'mock-folder-id',
+            'foo.png',
+            ctx.fsPath,
+            { provider: 'bar' },
+            {
+              kind: 'file-restore',
+              path: ctx.pathname,
+              version: ctx.version,
+              timestamp: new Date(ctx.endTs).toISOString(),
+            },
+            ctx.user_id
+          )
+        })
+
+        it('should not look up ranges', function (ctx) {
+          expect(ctx.RestoreManager.promises._getRangesFromHistory).to.not.have
+            .been.called
+        })
+
+        it('should not try to add a document', function (ctx) {
+          expect(ctx.EditorController.promises.addDocWithRanges).to.not.have
+            .been.called
+        })
+      })
+
+      describe('when reverting a linked document with provider', function () {
+        beforeEach(async function (ctx) {
+          ctx.pathname = 'foo.tex'
+          ctx.FileSystemImportManager.promises.importFile = sinon
+            .stub()
+            .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
+          ctx.RestoreManager.promises._getMetadataFromHistory = sinon
+            .stub()
+            .resolves({ metadata: { provider: 'bar' } })
+          ctx.result = await ctx.RestoreManager.promises.revertFile(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version,
+            ctx.pathname
+          )
+        })
+
+        it('should revert it as a file', function (ctx) {
+          expect(ctx.result).to.deep.equal({
+            _id: 'mock-file-id',
+            type: 'file',
+          })
+        })
+
+        it('should upload to the project as a file', function (ctx) {
+          expect(
+            ctx.EditorController.promises.upsertFile
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            'mock-folder-id',
+            'foo.tex',
+            ctx.fsPath,
+            { provider: 'bar' },
+            {
+              kind: 'file-restore',
+              path: ctx.pathname,
+              version: ctx.version,
+              timestamp: new Date(ctx.endTs).toISOString(),
+            },
+            ctx.user_id
+          )
+        })
+
+        it('should not look up ranges', function (ctx) {
+          expect(ctx.RestoreManager.promises._getRangesFromHistory).to.not.have
+            .been.called
+        })
+
+        it('should not try to add a document', function (ctx) {
+          expect(ctx.EditorController.promises.addDocWithRanges).to.not.have
+            .been.called
+        })
+      })
+
+      describe('when reverting a linked document with { main: true }', function () {
+        beforeEach(async function (ctx) {
+          ctx.pathname = 'foo.tex'
+          ctx.FileSystemImportManager.promises.importFile = sinon
+            .stub()
+            .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
+          ctx.RestoreManager.promises._getMetadataFromHistory = sinon
+            .stub()
+            .resolves({ metadata: { main: true } })
+          ctx.result = await ctx.RestoreManager.promises.revertFile(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version,
+            ctx.pathname
+          )
+        })
+
+        it('should revert it as a document', function (ctx) {
+          expect(ctx.result).to.deep.equal({
+            _id: 'mock-doc-id',
+            type: 'doc',
+          })
+        })
+
+        it('should not upload to the project as a file', function (ctx) {
+          expect(ctx.EditorController.promises.upsertFile).to.not.have.been
+            .called
+        })
+
+        it('should look up ranges', function (ctx) {
+          expect(ctx.RestoreManager.promises._getRangesFromHistory).to.have.been
+            .called
+        })
+
+        it('should add the document', function (ctx) {
+          expect(
+            ctx.EditorController.promises.addDocWithRanges
+          ).to.have.been.calledWith(
+            ctx.project_id,
+            ctx.folder_id,
+            'foo.tex',
+            ['foo', 'bar', 'baz'],
+            { changes: [], comments: [] }
+          )
+        })
+      })
+    })
+
+    describe('when reverting a binary file', function () {
+      beforeEach(async function (ctx) {
+        ctx.pathname = 'foo.png'
+        ctx.FileSystemImportManager.promises.importFile = sinon
+          .stub()
+          .resolves({ type: 'file' })
+        ctx.EditorController.promises.upsertFile = sinon
+          .stub()
+          .resolves({ _id: 'mock-file-id', type: 'file' })
+        ctx.EditorController.promises.deleteEntity = sinon.stub().resolves()
+        ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
+          .stub()
+          .resolves([{ toV: ctx.version, meta: { end_ts: Date.now() } }])
+      })
+
+      it('should return the created entity if file exists', async function (ctx) {
+        ctx.ProjectLocator.promises.findElementByPath = sinon
+          .stub()
+          .resolves({ type: 'file', element: { _id: 'existing-file-id' } })
+
+        const revertRes = await ctx.RestoreManager.promises.revertFile(
+          ctx.user_id,
+          ctx.project_id,
+          ctx.version,
+          ctx.pathname
+        )
+
+        expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
+      })
+
+      it('should return the created entity if file does not exists', async function (ctx) {
+        ctx.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
+
+        const revertRes = await ctx.RestoreManager.promises.revertFile(
+          ctx.user_id,
+          ctx.project_id,
+          ctx.version,
+          ctx.pathname
+        )
+
+        expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
+      })
+    })
+  })
+
+  describe('revertProject', function () {
+    beforeEach(function (ctx) {
+      ctx.ProjectGetter.promises.getProject = sinon.stub()
+      ctx.ProjectGetter.promises.getProject
+        .withArgs(ctx.project_id)
+        .resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
+      ctx.RestoreManager.promises._revertSingleFile = sinon.stub().resolves({
+        _id: 'mock-doc-id',
+        type: 'doc',
+      })
+      ctx.RestoreManager.promises._getProjectPathsAtVersion = sinon
+        .stub()
+        .resolves([])
+      ctx.ProjectEntityHandler.promises.getAllEntities = sinon
+        .stub()
+        .resolves({ docs: [], files: [] })
+      ctx.EditorController.promises.deleteEntityWithPath = sinon
+        .stub()
+        .resolves()
+      ctx.RestoreManager.promises._getUpdatesFromHistory = sinon
+        .stub()
+        .resolves([
+          { toV: ctx.version, meta: { end_ts: (ctx.end_ts = Date.now()) } },
+        ])
+    })
+
+    describe('reverting a project without ranges support', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectGetter.promises.getProject = sinon.stub().resolves({
+          overleaf: { history: { rangesSupportEnabled: false } },
+        })
+      })
+
+      it('should throw an error', async function (ctx) {
+        await expect(
+          ctx.RestoreManager.promises.revertProject(
+            ctx.user_id,
+            ctx.project_id,
+            ctx.version
+          )
+        ).to.eventually.be.rejectedWith('project does not have ranges support')
+      })
+    })
+
+    describe('for a project with overlap in current files and old files', function () {
+      beforeEach(async function (ctx) {
+        ctx.ProjectEntityHandler.promises.getAllEntities = sinon
+          .stub()
+          .resolves({
+            docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
+            files: [{ path: '/figures/image.png' }],
+          })
+        ctx.RestoreManager.promises._getProjectPathsAtVersion = sinon
+          .stub()
+          .resolves(['main.tex', 'figures/image.png', 'since-deleted.tex'])
+
+        await ctx.RestoreManager.promises.revertProject(
+          ctx.user_id,
+          ctx.project_id,
+          ctx.version
+        )
+        ctx.origin = {
+          kind: 'project-restore',
+          version: ctx.version,
+          timestamp: new Date(ctx.end_ts).toISOString(),
+        }
+      })
+
+      it('should delete the old files', function (ctx) {
+        expect(
+          ctx.EditorController.promises.deleteEntityWithPath
+        ).to.have.been.calledWith(
+          ctx.project_id,
+          'new-file.tex',
+          ctx.origin,
+          ctx.user_id
+        )
+      })
+
+      it('should not delete the current files', function (ctx) {
+        expect(
+          ctx.EditorController.promises.deleteEntityWithPath
+        ).to.not.have.been.calledWith(
+          ctx.project_id,
+          'main.tex',
+          ctx.origin,
+          ctx.user_id
+        )
+
+        expect(
+          ctx.EditorController.promises.deleteEntityWithPath
+        ).to.not.have.been.calledWith(
+          ctx.project_id,
+          'figures/image.png',
+          ctx.origin,
+          ctx.user_id
+        )
+      })
+
+      it('should revert the old files', function (ctx) {
+        expect(
+          ctx.RestoreManager.promises._revertSingleFile
+        ).to.have.been.calledWith(
+          ctx.user_id,
+          ctx.project_id,
+          ctx.version,
+          'main.tex'
+        )
+
+        expect(
+          ctx.RestoreManager.promises._revertSingleFile
+        ).to.have.been.calledWith(
+          ctx.user_id,
+          ctx.project_id,
+          ctx.version,
+          'figures/image.png'
+        )
+
+        expect(
+          ctx.RestoreManager.promises._revertSingleFile
+        ).to.have.been.calledWith(
+          ctx.user_id,
+          ctx.project_id,
+          ctx.version,
+          'since-deleted.tex'
+        )
+      })
+
+      it('should not revert the current files', function (ctx) {
+        expect(
+          ctx.RestoreManager.promises._revertSingleFile
+        ).to.not.have.been.calledWith(
+          ctx.user_id,
+          ctx.project_id,
+          ctx.version,
+          'new-file.tex'
+        )
+      })
+    })
+  })
+})

+ 0 - 1130
services/web/test/unit/src/History/RestoreManagerTests.js

@@ -1,1130 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/History/RestoreManager'
-)
-const Errors = require('../../../../app/src/Features/Errors/Errors')
-const tk = require('timekeeper')
-const moment = require('moment')
-const { expect } = require('chai')
-
-function nestedMapWithSetToObject(m) {
-  return Object.fromEntries(
-    Array.from(m.entries()).map(([key, set]) => [key, Array.from(set)])
-  )
-}
-
-describe('RestoreManager', function () {
-  beforeEach(function () {
-    tk.freeze(Date.now()) // freeze the time for these tests
-    this.RestoreManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': {},
-        '../../infrastructure/FileWriter': (this.FileWriter = { promises: {} }),
-        '../Uploads/FileSystemImportManager': (this.FileSystemImportManager = {
-          promises: {},
-        }),
-        '../Editor/EditorController': (this.EditorController = {
-          promises: {},
-        }),
-        '../Project/ProjectLocator': (this.ProjectLocator = { promises: {} }),
-        '../DocumentUpdater/DocumentUpdaterHandler':
-          (this.DocumentUpdaterHandler = {
-            promises: { flushProjectToMongo: sinon.stub().resolves() },
-          }),
-        '../Docstore/DocstoreManager': (this.DocstoreManager = {
-          promises: { getCommentThreadIds: sinon.stub().resolves({}) },
-        }),
-        '../Chat/ChatApiHandler': (this.ChatApiHandler = { promises: {} }),
-        '../Chat/ChatManager': (this.ChatManager = { promises: {} }),
-        '../Editor/EditorRealTimeController': (this.EditorRealTimeController =
-          {}),
-        '../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
-        '../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {
-          promises: {},
-        }),
-      },
-    })
-    this.user_id = 'mock-user-id'
-    this.project_id = 'mock-project-id'
-    this.version = 42
-  })
-
-  afterEach(function () {
-    tk.reset()
-  })
-
-  describe('restoreFileFromV2', function () {
-    beforeEach(function () {
-      this.RestoreManager.promises._writeFileVersionToDisk = sinon
-        .stub()
-        .resolves((this.fsPath = '/tmp/path/on/disk'))
-      this.RestoreManager.promises._findOrCreateFolder = sinon
-        .stub()
-        .resolves((this.folder_id = 'mock-folder-id'))
-      this.FileSystemImportManager.promises.addEntity = sinon
-        .stub()
-        .resolves((this.entity = 'mock-entity'))
-    })
-
-    describe('with a file not in a folder', function () {
-      beforeEach(async function () {
-        this.pathname = 'foo.tex'
-        this.result = await this.RestoreManager.promises.restoreFileFromV2(
-          this.user_id,
-          this.project_id,
-          this.version,
-          this.pathname
-        )
-      })
-
-      it('should write the file version to disk', function () {
-        this.RestoreManager.promises._writeFileVersionToDisk
-          .calledWith(this.project_id, this.version, this.pathname)
-          .should.equal(true)
-      })
-
-      it('should find the root folder', function () {
-        this.RestoreManager.promises._findOrCreateFolder
-          .calledWith(this.project_id, '', this.user_id)
-          .should.equal(true)
-      })
-
-      it('should add the entity', function () {
-        this.FileSystemImportManager.promises.addEntity
-          .calledWith(
-            this.user_id,
-            this.project_id,
-            this.folder_id,
-            'foo.tex',
-            this.fsPath,
-            false
-          )
-          .should.equal(true)
-      })
-
-      it('should return the entity', function () {
-        expect(this.result).to.equal(this.entity)
-      })
-    })
-
-    describe('with a file in a folder', function () {
-      beforeEach(async function () {
-        this.pathname = 'foo/bar.tex'
-        await this.RestoreManager.promises.restoreFileFromV2(
-          this.user_id,
-          this.project_id,
-          this.version,
-          this.pathname
-        )
-      })
-
-      it('should find the folder', function () {
-        this.RestoreManager.promises._findOrCreateFolder
-          .calledWith(this.project_id, 'foo', this.user_id)
-          .should.equal(true)
-      })
-
-      it('should add the entity by its basename', function () {
-        this.FileSystemImportManager.promises.addEntity
-          .calledWith(
-            this.user_id,
-            this.project_id,
-            this.folder_id,
-            'bar.tex',
-            this.fsPath,
-            false
-          )
-          .should.equal(true)
-      })
-    })
-  })
-
-  describe('_findOrCreateFolder', function () {
-    beforeEach(async function () {
-      this.EditorController.promises.mkdirp = sinon.stub().resolves({
-        newFolders: [],
-        lastFolder: { _id: (this.folder_id = 'mock-folder-id') },
-      })
-      this.result = await this.RestoreManager.promises._findOrCreateFolder(
-        this.project_id,
-        'folder/name',
-        this.user_id
-      )
-    })
-
-    it('should look up or create the folder', function () {
-      this.EditorController.promises.mkdirp
-        .calledWith(this.project_id, 'folder/name', this.user_id)
-        .should.equal(true)
-    })
-
-    it('should return the folder_id', function () {
-      expect(this.result).to.equal(this.folder_id)
-    })
-  })
-
-  describe('_addEntityWithUniqueName', function () {
-    beforeEach(function () {
-      this.addEntityWithName = sinon.stub()
-      this.name = 'foo.tex'
-    })
-
-    describe('with a valid name', function () {
-      beforeEach(async function () {
-        this.addEntityWithName.resolves((this.entity = 'mock-entity'))
-        this.result =
-          await this.RestoreManager.promises._addEntityWithUniqueName(
-            this.addEntityWithName,
-            this.name
-          )
-      })
-
-      it('should add the entity', function () {
-        this.addEntityWithName.calledWith(this.name).should.equal(true)
-      })
-
-      it('should return the entity', function () {
-        expect(this.result).to.equal(this.entity)
-      })
-    })
-
-    describe('with a duplicate name', function () {
-      beforeEach(async function () {
-        this.addEntityWithName.rejects(new Errors.DuplicateNameError())
-        this.addEntityWithName
-          .onSecondCall()
-          .resolves((this.entity = 'mock-entity'))
-        this.result =
-          await this.RestoreManager.promises._addEntityWithUniqueName(
-            this.addEntityWithName,
-            this.name
-          )
-      })
-
-      it('should try to add the entity with its original name', function () {
-        this.addEntityWithName.calledWith('foo.tex').should.equal(true)
-      })
-
-      it('should try to add the entity with a unique name', function () {
-        const date = moment(new Date()).format('Do MMM YY H:mm:ss')
-        this.addEntityWithName
-          .calledWith(`foo (Restored on ${date}).tex`)
-          .should.equal(true)
-      })
-
-      it('should return the entity', function () {
-        expect(this.result).to.equal(this.entity)
-      })
-    })
-  })
-
-  describe('revertFile', function () {
-    beforeEach(function () {
-      this.ProjectGetter.promises.getProject = sinon.stub()
-      this.ProjectGetter.promises.getProject
-        .withArgs(this.project_id)
-        .resolves({
-          overleaf: { history: { rangesSupportEnabled: true } },
-          rootDoc_id: 'root-doc-id',
-        })
-      this.RestoreManager.promises._writeFileVersionToDisk = sinon
-        .stub()
-        .resolves((this.fsPath = '/tmp/path/on/disk'))
-      this.RestoreManager.promises._findOrCreateFolder = sinon
-        .stub()
-        .resolves((this.folder_id = 'mock-folder-id'))
-      this.FileSystemImportManager.promises.addEntity = sinon
-        .stub()
-        .resolves((this.entity = 'mock-entity'))
-      this.RestoreManager.promises._getRangesFromHistory = sinon
-        .stub()
-        .rejects()
-      this.RestoreManager.promises._getMetadataFromHistory = sinon
-        .stub()
-        .resolves({ metadata: undefined })
-    })
-
-    describe('reverting a project without ranges support', function () {
-      beforeEach(function () {
-        this.ProjectGetter.promises.getProject = sinon.stub().resolves({
-          overleaf: { history: { rangesSupportEnabled: false } },
-        })
-      })
-
-      it('should throw an error', async function () {
-        await expect(
-          this.RestoreManager.promises.revertFile(
-            this.user_id,
-            this.project_id,
-            this.version,
-            this.pathname
-          )
-        ).to.eventually.be.rejectedWith('project does not have ranges support')
-      })
-    })
-
-    describe('reverting a document with ranges', function () {
-      beforeEach(function () {
-        this.pathname = 'foo.tex'
-        this.comments = [
-          {
-            id: 'comment-in-other-doc',
-            op: { t: 'comment-in-other-doc', p: 0, c: 'foo' },
-          },
-          {
-            id: 'single-comment',
-            op: { t: 'single-comment', p: 10, c: 'bar' },
-          },
-          {
-            id: 'deleted-comment',
-            op: { t: 'deleted-comment', p: 20, c: 'baz' },
-          },
-        ]
-        this.remappedComments = [
-          {
-            id: 'duplicate-comment',
-            op: { t: 'duplicate-comment', p: 0, c: 'foo' },
-          },
-          {
-            id: 'single-comment',
-            op: { t: 'single-comment', p: 10, c: 'bar' },
-          },
-        ]
-        this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
-        this.DocstoreManager.promises.getCommentThreadIds = sinon
-          .stub()
-          .resolves({ 'other-doc': [this.comments[0].op.t] })
-        this.ChatApiHandler.promises.duplicateCommentThreads = sinon
-          .stub()
-          .resolves({
-            newThreads: {
-              'comment-in-other-doc': {
-                duplicateId: 'duplicate-comment',
-              },
-            },
-          })
-        this.ChatApiHandler.promises.generateThreadData = sinon.stub().resolves(
-          (this.threadData = {
-            'single-comment': {
-              messages: [
-                {
-                  content: 'message-content',
-                  timestamp: '2024-01-01T00:00:00.000Z',
-                  user_id: 'user-1',
-                },
-              ],
-            },
-            'duplicate-comment': {
-              messages: [
-                {
-                  content: 'another message',
-                  timestamp: '2024-01-01T00:00:00.000Z',
-                  user_id: 'user-1',
-                },
-              ],
-            },
-          })
-        )
-        this.ChatManager.promises.injectUserInfoIntoThreads = sinon
-          .stub()
-          .resolves(this.threadData)
-
-        this.EditorRealTimeController.emitToRoom = sinon.stub()
-        this.tracked_changes = [
-          {
-            op: { pos: 4, i: 'bar' },
-            metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-1' },
-          },
-          {
-            op: { pos: 8, d: 'qux' },
-            metadata: { ts: '2024-01-01T00:00:00.000Z', user_id: 'user-2' },
-          },
-        ]
-        this.FileSystemImportManager.promises.importFile = sinon
-          .stub()
-          .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
-        this.RestoreManager.promises._getRangesFromHistory = sinon
-          .stub()
-          .resolves({
-            changes: this.tracked_changes,
-            comments: this.comments,
-          })
-        this.RestoreManager.promises._getUpdatesFromHistory = sinon
-          .stub()
-          .resolves([
-            { toV: this.version, meta: { end_ts: (this.endTs = new Date()) } },
-          ])
-        this.EditorController.promises.addDocWithRanges = sinon
-          .stub()
-          .resolves((this.addedFile = { _id: 'mock-doc', type: 'doc' }))
-      })
-
-      describe("when reverting a file that doesn't current exist", function () {
-        beforeEach(async function () {
-          this.data = await this.RestoreManager.promises.revertFile(
-            this.user_id,
-            this.project_id,
-            this.version,
-            this.pathname
-          )
-        })
-
-        it('should flush the document before fetching ranges', function () {
-          expect(
-            this.DocumentUpdaterHandler.promises.flushProjectToMongo
-          ).to.have.been.calledBefore(
-            this.DocstoreManager.promises.getCommentThreadIds
-          )
-        })
-
-        it('should import the file', function () {
-          expect(
-            this.EditorController.promises.addDocWithRanges
-          ).to.have.been.calledWith(
-            this.project_id,
-            this.folder_id,
-            'foo.tex',
-            ['foo', 'bar', 'baz'],
-            { changes: this.tracked_changes, comments: this.remappedComments }
-          )
-        })
-
-        it('should return the created entity', function () {
-          expect(this.data).to.deep.equal(this.addedFile)
-        })
-
-        it('should look up ranges', function () {
-          expect(
-            this.RestoreManager.promises._getRangesFromHistory
-          ).to.have.been.calledWith(
-            this.project_id,
-            this.version,
-            this.pathname
-          )
-        })
-      })
-
-      describe('with an existing file in the current project', function () {
-        beforeEach(async function () {
-          this.ProjectGetter.promises.getProject = sinon.stub()
-          this.ProjectGetter.promises.getProject
-            .withArgs(this.project_id)
-            .resolves({
-              overleaf: { history: { rangesSupportEnabled: true } },
-              rootDoc_id: 'root-doc-id',
-            })
-          this.ProjectLocator.promises.findElementByPath = sinon
-            .stub()
-            .resolves({ type: 'file', element: { _id: 'mock-file-id' } })
-          this.EditorController.promises.deleteEntity = sinon.stub().resolves()
-
-          this.data = await this.RestoreManager.promises.revertFile(
-            this.user_id,
-            this.project_id,
-            this.version,
-            this.pathname
-          )
-        })
-
-        it('should delete the existing file', async function () {
-          expect(
-            this.EditorController.promises.deleteEntity
-          ).to.have.been.calledWith(
-            this.project_id,
-            'mock-file-id',
-            'file',
-            {
-              kind: 'file-restore',
-              path: this.pathname,
-              version: this.version,
-              timestamp: new Date(this.endTs).toISOString(),
-            },
-            this.user_id
-          )
-        })
-      })
-
-      describe('with an existing document in the current project', function () {
-        beforeEach(async function () {
-          this.ProjectGetter.promises.getProject = sinon.stub()
-          this.ProjectGetter.promises.getProject
-            .withArgs(this.project_id)
-            .resolves({
-              overleaf: { history: { rangesSupportEnabled: true } },
-              rootDoc_id: 'root-doc-id',
-            })
-          this.ProjectLocator.promises.findElementByPath = sinon
-            .stub()
-            .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
-          this.EditorController.promises.deleteEntity = sinon.stub().resolves()
-
-          this.data = await this.RestoreManager.promises.revertFile(
-            this.user_id,
-            this.project_id,
-            this.version,
-            this.pathname
-          )
-        })
-
-        it('should delete the existing document', async function () {
-          expect(
-            this.EditorController.promises.deleteEntity
-          ).to.have.been.calledWith(
-            this.project_id,
-            'mock-file-id',
-            'doc',
-            {
-              kind: 'file-restore',
-              path: this.pathname,
-              version: this.version,
-              timestamp: new Date(this.endTs).toISOString(),
-            },
-            this.user_id
-          )
-        })
-
-        it('should flush the document before fetching ranges', function () {
-          expect(
-            this.DocumentUpdaterHandler.promises.flushProjectToMongo
-          ).to.have.been.calledBefore(
-            this.DocstoreManager.promises.getCommentThreadIds
-          )
-        })
-
-        it('should import the file', function () {
-          expect(
-            this.EditorController.promises.addDocWithRanges
-          ).to.have.been.calledWith(
-            this.project_id,
-            this.folder_id,
-            'foo.tex',
-            ['foo', 'bar', 'baz'],
-            { changes: this.tracked_changes, comments: this.remappedComments },
-            {
-              kind: 'file-restore',
-              path: this.pathname,
-              version: this.version,
-              timestamp: new Date(this.endTs).toISOString(),
-            }
-          )
-        })
-
-        it('should return the created entity', function () {
-          expect(this.data).to.deep.equal(this.addedFile)
-        })
-
-        it('should look up ranges', function () {
-          expect(
-            this.RestoreManager.promises._getRangesFromHistory
-          ).to.have.been.calledWith(
-            this.project_id,
-            this.version,
-            this.pathname
-          )
-        })
-      })
-
-      describe('with comments in same doc', function () {
-        // copy of the above, addition: inject and later inspect threadIds set
-        beforeEach(async function () {
-          this.ProjectGetter.promises.getProject = sinon.stub()
-          this.ProjectGetter.promises.getProject
-            .withArgs(this.project_id)
-            .resolves({
-              overleaf: { history: { rangesSupportEnabled: true } },
-              rootDoc_id: 'root-doc-id',
-            })
-          this.ProjectLocator.promises.findElementByPath = sinon
-            .stub()
-            .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
-          this.EditorController.promises.deleteEntity = sinon.stub().resolves()
-          this.ChatApiHandler.promises.generateThreadData = sinon
-            .stub()
-            .resolves(
-              (this.threadData = {
-                [this.comments[0].op.t]: {
-                  messages: [
-                    {
-                      content: 'message',
-                      timestamp: '2024-01-01T00:00:00.000Z',
-                      user_id: 'user-1',
-                    },
-                  ],
-                },
-                [this.comments[1].op.t]: {
-                  messages: [
-                    {
-                      content: 'other message',
-                      timestamp: '2024-01-01T00:00:00.000Z',
-                      user_id: 'user-1',
-                    },
-                  ],
-                },
-              })
-            )
-
-          this.threadIds = new Map([
-            [
-              'mock-file-id',
-              new Set([this.comments[0].op.t, this.comments[1].op.t]),
-            ],
-          ])
-          // Comments are updated in-place. Look up threads before reverting.
-          this.afterThreadIds = {
-            // mock-file-id removed
-            [this.addedFile._id]: [
-              this.comments[0].op.t,
-              this.comments[1].op.t,
-            ],
-          }
-          this.data = await this.RestoreManager.promises._revertSingleFile(
-            this.user_id,
-            this.project_id,
-            this.version,
-            this.pathname,
-            this.threadIds
-          )
-        })
-
-        it('should import the file with original comments minus the deleted one', function () {
-          expect(
-            this.EditorController.promises.addDocWithRanges
-          ).to.have.been.calledWith(
-            this.project_id,
-            this.folder_id,
-            'foo.tex',
-            ['foo', 'bar', 'baz'],
-            {
-              changes: this.tracked_changes,
-              comments: this.comments.slice(0, 2),
-            },
-            {
-              kind: 'file-restore',
-              path: this.pathname,
-              version: this.version,
-              timestamp: new Date(this.endTs).toISOString(),
-            }
-          )
-        })
-
-        it('should add the seen thread ids to the map', function () {
-          expect(nestedMapWithSetToObject(this.threadIds)).to.deep.equal(
-            this.afterThreadIds
-          )
-        })
-      })
-
-      describe('with remapped comments during revertProject', function () {
-        // copy of the above, addition: inject and later inspect threadIds set
-        beforeEach(async function () {
-          this.ProjectGetter.promises.getProject = sinon.stub()
-          this.ProjectGetter.promises.getProject
-            .withArgs(this.project_id)
-            .resolves({
-              overleaf: { history: { rangesSupportEnabled: true } },
-              rootDoc_id: 'root-doc-id',
-            })
-          this.ProjectLocator.promises.findElementByPath = sinon
-            .stub()
-            .resolves({ type: 'doc', element: { _id: 'mock-file-id' } })
-          this.EditorController.promises.deleteEntity = sinon.stub().resolves()
-
-          this.threadIds = new Map([
-            ['other-doc', new Set([this.comments[0].op.t])],
-          ])
-          // Comments are updated in-place. Look up threads before reverting.
-          this.afterThreadIds = {
-            // mock-file-id removed
-            'other-doc': [this.comments[0].op.t],
-            [this.addedFile._id]: [
-              this.remappedComments[0].op.t,
-              this.remappedComments[1].op.t,
-            ],
-          }
-          this.data = await this.RestoreManager.promises._revertSingleFile(
-            this.user_id,
-            this.project_id,
-            this.version,
-            this.pathname,
-            this.threadIds
-          )
-        })
-
-        it('should import the file', function () {
-          expect(
-            this.EditorController.promises.addDocWithRanges
-          ).to.have.been.calledWith(
-            this.project_id,
-            this.folder_id,
-            'foo.tex',
-            ['foo', 'bar', 'baz'],
-            { changes: this.tracked_changes, comments: this.remappedComments },
-            {
-              kind: 'file-restore',
-              path: this.pathname,
-              version: this.version,
-              timestamp: new Date(this.endTs).toISOString(),
-            }
-          )
-        })
-
-        it('should add the seen thread ids to the map', function () {
-          expect(nestedMapWithSetToObject(this.threadIds)).to.deep.equal(
-            this.afterThreadIds
-          )
-        })
-      })
-
-      describe('when restored file has the same id as root doc', function () {
-        beforeEach(async function () {
-          this.ProjectGetter.promises.getProject = sinon.stub()
-          this.ProjectGetter.promises.getProject
-            .withArgs(this.project_id)
-            .resolves({
-              overleaf: { history: { rangesSupportEnabled: true } },
-              rootDoc_id: 'root-doc-id',
-            })
-          this.ProjectLocator.promises.findElementByPath = sinon
-            .stub()
-            .resolves({ type: 'doc', element: { _id: 'root-doc-id' } })
-          this.EditorController.promises.deleteEntity = sinon.stub().resolves()
-          this.EditorController.promises.addDocWithRanges = sinon
-            .stub()
-            .resolves((this.addedFile = { _id: 'new-doc-id', type: 'doc' }))
-          this.EditorController.promises.setRootDoc = sinon.stub().resolves()
-
-          this.data = await this.RestoreManager.promises.revertFile(
-            this.user_id,
-            this.project_id,
-            this.version,
-            this.pathname
-          )
-        })
-
-        it('should delete the existing root document', async function () {
-          expect(
-            this.EditorController.promises.deleteEntity
-          ).to.have.been.calledWith(
-            this.project_id,
-            'root-doc-id',
-            'doc',
-            {
-              kind: 'file-restore',
-              path: this.pathname,
-              version: this.version,
-              timestamp: new Date(this.endTs).toISOString(),
-            },
-            this.user_id
-          )
-        })
-
-        it('should import the file', function () {
-          expect(
-            this.EditorController.promises.addDocWithRanges
-          ).to.have.been.calledWith(
-            this.project_id,
-            this.folder_id,
-            'foo.tex',
-            ['foo', 'bar', 'baz'],
-            { changes: this.tracked_changes, comments: this.remappedComments },
-            {
-              kind: 'file-restore',
-              path: this.pathname,
-              version: this.version,
-              timestamp: new Date(this.endTs).toISOString(),
-            }
-          )
-        })
-
-        it('should return the created entity with root doc id', function () {
-          expect(this.data).to.deep.equal(this.addedFile)
-          expect(this.data._id).to.equal('new-doc-id')
-        })
-
-        it('should set the restored document as the new root doc', function () {
-          expect(
-            this.EditorController.promises.setRootDoc
-          ).to.have.been.calledWith(this.project_id, this.addedFile._id)
-        })
-      })
-    })
-
-    describe('reverting a file or document with metadata', function () {
-      beforeEach(function () {
-        this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
-        this.EditorController.promises.addDocWithRanges = sinon.stub()
-        this.RestoreManager.promises._getUpdatesFromHistory = sinon
-          .stub()
-          .resolves([
-            { toV: this.version, meta: { end_ts: (this.endTs = new Date()) } },
-          ])
-
-        this.EditorController.promises.upsertFile = sinon
-          .stub()
-          .resolves({ _id: 'mock-file-id', type: 'file' })
-        this.RestoreManager.promises._getRangesFromHistory = sinon
-          .stub()
-          .resolves({
-            changes: [],
-            comments: [],
-          })
-        this.EditorController.promises.addDocWithRanges = sinon
-          .stub()
-          .resolves((this.addedFile = { _id: 'mock-doc-id', type: 'doc' }))
-
-        this.DocstoreManager.promises.getCommentThreadIds = sinon
-          .stub()
-          .resolves({})
-        this.ChatApiHandler.promises.generateThreadData = sinon
-          .stub()
-          .resolves({})
-        this.ChatManager.promises.injectUserInfoIntoThreads = sinon
-          .stub()
-          .resolves({})
-        this.EditorRealTimeController.emitToRoom = sinon.stub()
-      })
-
-      describe('when reverting a linked file', function () {
-        beforeEach(async function () {
-          this.pathname = 'foo.png'
-          this.FileSystemImportManager.promises.importFile = sinon
-            .stub()
-            .resolves({ type: 'file' })
-          this.RestoreManager.promises._getMetadataFromHistory = sinon
-            .stub()
-            .resolves({ metadata: { provider: 'bar' } })
-          this.result = await this.RestoreManager.promises.revertFile(
-            this.user_id,
-            this.project_id,
-            this.version,
-            this.pathname
-          )
-        })
-
-        it('should revert it as a file', function () {
-          expect(this.result).to.deep.equal({
-            _id: 'mock-file-id',
-            type: 'file',
-          })
-        })
-
-        it('should upload to the project as a file', function () {
-          expect(
-            this.EditorController.promises.upsertFile
-          ).to.have.been.calledWith(
-            this.project_id,
-            'mock-folder-id',
-            'foo.png',
-            this.fsPath,
-            { provider: 'bar' },
-            {
-              kind: 'file-restore',
-              path: this.pathname,
-              version: this.version,
-              timestamp: new Date(this.endTs).toISOString(),
-            },
-            this.user_id
-          )
-        })
-
-        it('should not look up ranges', function () {
-          expect(this.RestoreManager.promises._getRangesFromHistory).to.not.have
-            .been.called
-        })
-
-        it('should not try to add a document', function () {
-          expect(this.EditorController.promises.addDocWithRanges).to.not.have
-            .been.called
-        })
-      })
-
-      describe('when reverting a linked document with provider', function () {
-        beforeEach(async function () {
-          this.pathname = 'foo.tex'
-          this.FileSystemImportManager.promises.importFile = sinon
-            .stub()
-            .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
-          this.RestoreManager.promises._getMetadataFromHistory = sinon
-            .stub()
-            .resolves({ metadata: { provider: 'bar' } })
-          this.result = await this.RestoreManager.promises.revertFile(
-            this.user_id,
-            this.project_id,
-            this.version,
-            this.pathname
-          )
-        })
-
-        it('should revert it as a file', function () {
-          expect(this.result).to.deep.equal({
-            _id: 'mock-file-id',
-            type: 'file',
-          })
-        })
-
-        it('should upload to the project as a file', function () {
-          expect(
-            this.EditorController.promises.upsertFile
-          ).to.have.been.calledWith(
-            this.project_id,
-            'mock-folder-id',
-            'foo.tex',
-            this.fsPath,
-            { provider: 'bar' },
-            {
-              kind: 'file-restore',
-              path: this.pathname,
-              version: this.version,
-              timestamp: new Date(this.endTs).toISOString(),
-            },
-            this.user_id
-          )
-        })
-
-        it('should not look up ranges', function () {
-          expect(this.RestoreManager.promises._getRangesFromHistory).to.not.have
-            .been.called
-        })
-
-        it('should not try to add a document', function () {
-          expect(this.EditorController.promises.addDocWithRanges).to.not.have
-            .been.called
-        })
-      })
-
-      describe('when reverting a linked document with { main: true }', function () {
-        beforeEach(async function () {
-          this.pathname = 'foo.tex'
-          this.FileSystemImportManager.promises.importFile = sinon
-            .stub()
-            .resolves({ type: 'doc', lines: ['foo', 'bar', 'baz'] })
-          this.RestoreManager.promises._getMetadataFromHistory = sinon
-            .stub()
-            .resolves({ metadata: { main: true } })
-          this.result = await this.RestoreManager.promises.revertFile(
-            this.user_id,
-            this.project_id,
-            this.version,
-            this.pathname
-          )
-        })
-
-        it('should revert it as a document', function () {
-          expect(this.result).to.deep.equal({
-            _id: 'mock-doc-id',
-            type: 'doc',
-          })
-        })
-
-        it('should not upload to the project as a file', function () {
-          expect(this.EditorController.promises.upsertFile).to.not.have.been
-            .called
-        })
-
-        it('should look up ranges', function () {
-          expect(this.RestoreManager.promises._getRangesFromHistory).to.have
-            .been.called
-        })
-
-        it('should add the document', function () {
-          expect(
-            this.EditorController.promises.addDocWithRanges
-          ).to.have.been.calledWith(
-            this.project_id,
-            this.folder_id,
-            'foo.tex',
-            ['foo', 'bar', 'baz'],
-            { changes: [], comments: [] }
-          )
-        })
-      })
-    })
-
-    describe('when reverting a binary file', function () {
-      beforeEach(async function () {
-        this.pathname = 'foo.png'
-        this.FileSystemImportManager.promises.importFile = sinon
-          .stub()
-          .resolves({ type: 'file' })
-        this.EditorController.promises.upsertFile = sinon
-          .stub()
-          .resolves({ _id: 'mock-file-id', type: 'file' })
-        this.EditorController.promises.deleteEntity = sinon.stub().resolves()
-        this.RestoreManager.promises._getUpdatesFromHistory = sinon
-          .stub()
-          .resolves([{ toV: this.version, meta: { end_ts: Date.now() } }])
-      })
-
-      it('should return the created entity if file exists', async function () {
-        this.ProjectLocator.promises.findElementByPath = sinon
-          .stub()
-          .resolves({ type: 'file', element: { _id: 'existing-file-id' } })
-
-        const revertRes = await this.RestoreManager.promises.revertFile(
-          this.user_id,
-          this.project_id,
-          this.version,
-          this.pathname
-        )
-
-        expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
-      })
-
-      it('should return the created entity if file does not exists', async function () {
-        this.ProjectLocator.promises.findElementByPath = sinon.stub().rejects()
-
-        const revertRes = await this.RestoreManager.promises.revertFile(
-          this.user_id,
-          this.project_id,
-          this.version,
-          this.pathname
-        )
-
-        expect(revertRes).to.deep.equal({ _id: 'mock-file-id', type: 'file' })
-      })
-    })
-  })
-
-  describe('revertProject', function () {
-    beforeEach(function () {
-      this.ProjectGetter.promises.getProject = sinon.stub()
-      this.ProjectGetter.promises.getProject
-        .withArgs(this.project_id)
-        .resolves({ overleaf: { history: { rangesSupportEnabled: true } } })
-      this.RestoreManager.promises._revertSingleFile = sinon.stub().resolves({
-        _id: 'mock-doc-id',
-        type: 'doc',
-      })
-      this.RestoreManager.promises._getProjectPathsAtVersion = sinon
-        .stub()
-        .resolves([])
-      this.ProjectEntityHandler.promises.getAllEntities = sinon
-        .stub()
-        .resolves({ docs: [], files: [] })
-      this.EditorController.promises.deleteEntityWithPath = sinon
-        .stub()
-        .resolves()
-      this.RestoreManager.promises._getUpdatesFromHistory = sinon
-        .stub()
-        .resolves([
-          { toV: this.version, meta: { end_ts: (this.end_ts = Date.now()) } },
-        ])
-    })
-
-    describe('reverting a project without ranges support', function () {
-      beforeEach(function () {
-        this.ProjectGetter.promises.getProject = sinon.stub().resolves({
-          overleaf: { history: { rangesSupportEnabled: false } },
-        })
-      })
-
-      it('should throw an error', async function () {
-        await expect(
-          this.RestoreManager.promises.revertProject(
-            this.user_id,
-            this.project_id,
-            this.version
-          )
-        ).to.eventually.be.rejectedWith('project does not have ranges support')
-      })
-    })
-
-    describe('for a project with overlap in current files and old files', function () {
-      beforeEach(async function () {
-        this.ProjectEntityHandler.promises.getAllEntities = sinon
-          .stub()
-          .resolves({
-            docs: [{ path: '/main.tex' }, { path: '/new-file.tex' }],
-            files: [{ path: '/figures/image.png' }],
-          })
-        this.RestoreManager.promises._getProjectPathsAtVersion = sinon
-          .stub()
-          .resolves(['main.tex', 'figures/image.png', 'since-deleted.tex'])
-
-        await this.RestoreManager.promises.revertProject(
-          this.user_id,
-          this.project_id,
-          this.version
-        )
-        this.origin = {
-          kind: 'project-restore',
-          version: this.version,
-          timestamp: new Date(this.end_ts).toISOString(),
-        }
-      })
-
-      it('should delete the old files', function () {
-        expect(
-          this.EditorController.promises.deleteEntityWithPath
-        ).to.have.been.calledWith(
-          this.project_id,
-          'new-file.tex',
-          this.origin,
-          this.user_id
-        )
-      })
-
-      it('should not delete the current files', function () {
-        expect(
-          this.EditorController.promises.deleteEntityWithPath
-        ).to.not.have.been.calledWith(
-          this.project_id,
-          'main.tex',
-          this.origin,
-          this.user_id
-        )
-
-        expect(
-          this.EditorController.promises.deleteEntityWithPath
-        ).to.not.have.been.calledWith(
-          this.project_id,
-          'figures/image.png',
-          this.origin,
-          this.user_id
-        )
-      })
-
-      it('should revert the old files', function () {
-        expect(
-          this.RestoreManager.promises._revertSingleFile
-        ).to.have.been.calledWith(
-          this.user_id,
-          this.project_id,
-          this.version,
-          'main.tex'
-        )
-
-        expect(
-          this.RestoreManager.promises._revertSingleFile
-        ).to.have.been.calledWith(
-          this.user_id,
-          this.project_id,
-          this.version,
-          'figures/image.png'
-        )
-
-        expect(
-          this.RestoreManager.promises._revertSingleFile
-        ).to.have.been.calledWith(
-          this.user_id,
-          this.project_id,
-          this.version,
-          'since-deleted.tex'
-        )
-      })
-
-      it('should not revert the current files', function () {
-        expect(
-          this.RestoreManager.promises._revertSingleFile
-        ).to.not.have.been.calledWith(
-          this.user_id,
-          this.project_id,
-          this.version,
-          'new-file.tex'
-        )
-      })
-    })
-  })
-})

+ 181 - 0
services/web/test/unit/src/InactiveData/InactiveProjectManager.test.mjs

@@ -0,0 +1,181 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb, { ReadPreference } from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
+
+const modulePath =
+  '../../../../app/src/Features/InactiveData/InactiveProjectManager'
+
+describe('InactiveProjectManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.settings = {}
+    ctx.metrics = { inc: sinon.stub() }
+    ctx.DocstoreManager = {
+      promises: {
+        unarchiveProject: sinon.stub(),
+        archiveProject: sinon.stub(),
+      },
+    }
+    ctx.DocumentUpdaterHandler = {
+      promises: {
+        flushProjectToMongoAndDelete: sinon.stub(),
+      },
+    }
+    ctx.ProjectUpdateHandler = {
+      promises: {
+        markAsActive: sinon.stub(),
+        markAsInactive: sinon.stub(),
+      },
+    }
+    ctx.ProjectGetter = { promises: { getProject: sinon.stub() } }
+    ctx.Modules = { promises: { hooks: { fire: sinon.stub() } } }
+
+    vi.doMock('mongodb-legacy', () => ({
+      default: { ObjectId },
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: ctx.metrics,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({
+      default: ctx.DocstoreManager,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
+      () => ({
+        default: ctx.DocumentUpdaterHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectUpdateHandler',
+      () => ({
+        default: ctx.ProjectUpdateHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: ctx.ProjectGetter,
+    }))
+
+    vi.doMock('../../../../app/src/models/Project', () => ({}))
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: ctx.Modules,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({
+      default: {
+        ObjectId,
+        READ_PREFERENCE_SECONDARY: ReadPreference.secondaryPreferred.mode,
+      },
+    }))
+
+    ctx.InactiveProjectManager = (await import(modulePath)).default
+    ctx.project_id = '1234'
+  })
+
+  describe('reactivateProjectIfRequired', function () {
+    beforeEach(function (ctx) {
+      ctx.project = { active: false }
+      ctx.ProjectGetter.promises.getProject.resolves(ctx.project)
+      ctx.ProjectUpdateHandler.promises.markAsActive.resolves()
+    })
+
+    it('should call unarchiveProject', async function (ctx) {
+      ctx.DocstoreManager.promises.unarchiveProject.resolves()
+      await ctx.InactiveProjectManager.promises.reactivateProjectIfRequired(
+        ctx.project_id
+      )
+
+      ctx.DocstoreManager.promises.unarchiveProject
+        .calledWith(ctx.project_id)
+        .should.equal(true)
+      ctx.ProjectUpdateHandler.promises.markAsActive
+        .calledWith(ctx.project_id)
+        .should.equal(true)
+    })
+
+    it('should not mark project as active if error with unarchiving', async function (ctx) {
+      ctx.DocstoreManager.promises.unarchiveProject.rejects()
+      await expect(
+        ctx.InactiveProjectManager.promises.reactivateProjectIfRequired(
+          ctx.project_id
+        )
+      ).to.be.rejected
+
+      ctx.DocstoreManager.promises.unarchiveProject
+        .calledWith(ctx.project_id)
+        .should.equal(true)
+      ctx.ProjectUpdateHandler.promises.markAsActive
+        .calledWith(ctx.project_id)
+        .should.equal(false)
+    })
+
+    it('should not call unarchiveProject if it is active', async function (ctx) {
+      ctx.project.active = true
+      ctx.DocstoreManager.promises.unarchiveProject.resolves()
+      await ctx.InactiveProjectManager.promises.reactivateProjectIfRequired(
+        ctx.project_id
+      )
+      ctx.DocstoreManager.promises.unarchiveProject
+        .calledWith(ctx.project_id)
+        .should.equal(false)
+      ctx.ProjectUpdateHandler.promises.markAsActive
+        .calledWith(ctx.project_id)
+        .should.equal(false)
+    })
+  })
+
+  describe('deactivateProject', function () {
+    it('should call archiveProject and markAsInactive after flushing', async function (ctx) {
+      ctx.DocstoreManager.promises.archiveProject.resolves()
+      ctx.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete.resolves()
+      ctx.ProjectUpdateHandler.promises.markAsInactive.resolves()
+      ctx.Modules.promises.hooks.fire.resolves()
+
+      await ctx.InactiveProjectManager.promises.deactivateProject(
+        ctx.project_id
+      )
+      ctx.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
+        .calledWith(ctx.project_id)
+        .should.equal(true)
+      ctx.Modules.promises.hooks.fire
+        .calledWith('deactivateProject', ctx.project_id)
+        .should.equal(true)
+      ctx.DocstoreManager.promises.archiveProject
+        .calledWith(ctx.project_id)
+        .should.equal(true)
+      ctx.ProjectUpdateHandler.promises.markAsInactive
+        .calledWith(ctx.project_id)
+        .should.equal(true)
+    })
+
+    it('should not call markAsInactive if there was a problem archiving in docstore', async function (ctx) {
+      ctx.DocstoreManager.promises.archiveProject.rejects()
+      ctx.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete.resolves()
+      ctx.ProjectUpdateHandler.promises.markAsInactive.resolves()
+      ctx.Modules.promises.hooks.fire.resolves()
+
+      await expect(
+        ctx.InactiveProjectManager.promises.deactivateProject(ctx.project_id)
+      ).to.be.rejected
+      ctx.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
+        .calledWith(ctx.project_id)
+        .should.equal(true)
+      ctx.DocstoreManager.promises.archiveProject
+        .calledWith(ctx.project_id)
+        .should.equal(true)
+      ctx.ProjectUpdateHandler.promises.markAsInactive
+        .calledWith(ctx.project_id)
+        .should.equal(false)
+    })
+  })
+})

+ 0 - 151
services/web/test/unit/src/InactiveData/InactiveProjectManagerTests.js

@@ -1,151 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const modulePath = path.join(
-  __dirname,
-  '../../../../app/src/Features/InactiveData/InactiveProjectManager'
-)
-const { ObjectId, ReadPreference } = require('mongodb-legacy')
-const { expect } = require('chai')
-
-describe('InactiveProjectManager', function () {
-  beforeEach(function () {
-    this.settings = {}
-    this.metrics = { inc: sinon.stub() }
-    this.DocstoreManager = {
-      promises: {
-        unarchiveProject: sinon.stub(),
-        archiveProject: sinon.stub(),
-      },
-    }
-    this.DocumentUpdaterHandler = {
-      promises: {
-        flushProjectToMongoAndDelete: sinon.stub(),
-      },
-    }
-    this.ProjectUpdateHandler = {
-      promises: {
-        markAsActive: sinon.stub(),
-        markAsInactive: sinon.stub(),
-      },
-    }
-    this.ProjectGetter = { promises: { getProject: sinon.stub() } }
-    this.Modules = { promises: { hooks: { fire: sinon.stub() } } }
-    this.InactiveProjectManager = SandboxedModule.require(modulePath, {
-      requires: {
-        'mongodb-legacy': { ObjectId },
-        '@overleaf/settings': this.settings,
-        '@overleaf/metrics': this.metrics,
-        '../Docstore/DocstoreManager': this.DocstoreManager,
-        '../DocumentUpdater/DocumentUpdaterHandler':
-          this.DocumentUpdaterHandler,
-        '../Project/ProjectUpdateHandler': this.ProjectUpdateHandler,
-        '../Project/ProjectGetter': this.ProjectGetter,
-        '../../models/Project': {},
-        '../../infrastructure/Modules': this.Modules,
-        '../../infrastructure/mongodb': {
-          ObjectId,
-          READ_PREFERENCE_SECONDARY: ReadPreference.secondaryPreferred.mode,
-        },
-      },
-    })
-    this.project_id = '1234'
-  })
-
-  describe('reactivateProjectIfRequired', function () {
-    beforeEach(function () {
-      this.project = { active: false }
-      this.ProjectGetter.promises.getProject.resolves(this.project)
-      this.ProjectUpdateHandler.promises.markAsActive.resolves()
-    })
-
-    it('should call unarchiveProject', async function () {
-      this.DocstoreManager.promises.unarchiveProject.resolves()
-      await this.InactiveProjectManager.promises.reactivateProjectIfRequired(
-        this.project_id
-      )
-
-      this.DocstoreManager.promises.unarchiveProject
-        .calledWith(this.project_id)
-        .should.equal(true)
-      this.ProjectUpdateHandler.promises.markAsActive
-        .calledWith(this.project_id)
-        .should.equal(true)
-    })
-
-    it('should not mark project as active if error with unarchiving', async function () {
-      this.DocstoreManager.promises.unarchiveProject.rejects()
-      await expect(
-        this.InactiveProjectManager.promises.reactivateProjectIfRequired(
-          this.project_id
-        )
-      ).to.be.rejected
-
-      this.DocstoreManager.promises.unarchiveProject
-        .calledWith(this.project_id)
-        .should.equal(true)
-      this.ProjectUpdateHandler.promises.markAsActive
-        .calledWith(this.project_id)
-        .should.equal(false)
-    })
-
-    it('should not call unarchiveProject if it is active', async function () {
-      this.project.active = true
-      this.DocstoreManager.promises.unarchiveProject.resolves()
-      await this.InactiveProjectManager.promises.reactivateProjectIfRequired(
-        this.project_id
-      )
-      this.DocstoreManager.promises.unarchiveProject
-        .calledWith(this.project_id)
-        .should.equal(false)
-      this.ProjectUpdateHandler.promises.markAsActive
-        .calledWith(this.project_id)
-        .should.equal(false)
-    })
-  })
-
-  describe('deactivateProject', function () {
-    it('should call archiveProject and markAsInactive after flushing', async function () {
-      this.DocstoreManager.promises.archiveProject.resolves()
-      this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete.resolves()
-      this.ProjectUpdateHandler.promises.markAsInactive.resolves()
-      this.Modules.promises.hooks.fire.resolves()
-
-      await this.InactiveProjectManager.promises.deactivateProject(
-        this.project_id
-      )
-      this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
-        .calledWith(this.project_id)
-        .should.equal(true)
-      this.Modules.promises.hooks.fire
-        .calledWith('deactivateProject', this.project_id)
-        .should.equal(true)
-      this.DocstoreManager.promises.archiveProject
-        .calledWith(this.project_id)
-        .should.equal(true)
-      this.ProjectUpdateHandler.promises.markAsInactive
-        .calledWith(this.project_id)
-        .should.equal(true)
-    })
-
-    it('should not call markAsInactive if there was a problem archiving in docstore', async function () {
-      this.DocstoreManager.promises.archiveProject.rejects()
-      this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete.resolves()
-      this.ProjectUpdateHandler.promises.markAsInactive.resolves()
-      this.Modules.promises.hooks.fire.resolves()
-
-      await expect(
-        this.InactiveProjectManager.promises.deactivateProject(this.project_id)
-      ).to.be.rejected
-      this.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
-        .calledWith(this.project_id)
-        .should.equal(true)
-      this.DocstoreManager.promises.archiveProject
-        .calledWith(this.project_id)
-        .should.equal(true)
-      this.ProjectUpdateHandler.promises.markAsInactive
-        .calledWith(this.project_id)
-        .should.equal(false)
-    })
-  })
-})

+ 461 - 0
services/web/test/unit/src/Project/ProjectDuplicator.test.mjs

@@ -0,0 +1,461 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
+
+const MODULE_PATH = '../../../../app/src/Features/Project/ProjectDuplicator.mjs'
+
+describe('ProjectDuplicator', function () {
+  beforeEach(async function (ctx) {
+    ctx.doc0 = { _id: 'doc0_id', name: 'rootDocHere' }
+    ctx.doc1 = { _id: 'doc1_id', name: 'level1folderDocName' }
+    ctx.doc2 = { _id: 'doc2_id', name: 'level2folderDocName' }
+    ctx.doc0Lines = ['zero']
+    ctx.doc1Lines = ['one']
+    ctx.doc2Lines = ['two']
+    ctx.file0 = { name: 'file0', _id: 'file0', hash: 'abcde' }
+    ctx.file1 = { name: 'file1', _id: 'file1', hash: 'fffff' }
+    ctx.file2 = {
+      name: 'file2',
+      _id: 'file2',
+      created: '2024-07-05T14:18:31.401+00:00',
+      linkedFileData: { provider: 'url' },
+      hash: '123456',
+    }
+    ctx.level2folder = {
+      name: 'level2folderName',
+      _id: 'level2folderId',
+      docs: [ctx.doc2, undefined],
+      folders: [],
+      fileRefs: [ctx.file2],
+    }
+    ctx.level1folder = {
+      name: 'level1folder',
+      _id: 'level1folderId',
+      docs: [ctx.doc1],
+      folders: [ctx.level2folder],
+      fileRefs: [ctx.file1, null], // the null is intentional to test null docs/files
+    }
+    ctx.rootFolder = {
+      name: 'rootFolder',
+      _id: 'rootFolderId',
+      docs: [ctx.doc0],
+      folders: [ctx.level1folder, {}],
+      fileRefs: [ctx.file0],
+    }
+    ctx.project = {
+      _id: 'this_is_the_old_project_id',
+      rootDoc_id: ctx.doc0._id,
+      rootFolder: [ctx.rootFolder],
+      compiler: 'this_is_a_Compiler',
+      overleaf: { history: { id: 123456 } },
+    }
+    ctx.doc0Path = '/rootDocHere'
+    ctx.doc1Path = '/level1folder/level1folderDocName'
+    ctx.doc2Path = '/level1folder/level2folderName/level2folderDocName'
+    ctx.file0Path = '/file0'
+    ctx.file1Path = '/level1folder/file1'
+    ctx.file2Path = '/level1folder/level2folderName/file2'
+
+    ctx.docContents = [
+      { _id: ctx.doc0._id, lines: ctx.doc0Lines },
+      { _id: ctx.doc1._id, lines: ctx.doc1Lines },
+      { _id: ctx.doc2._id, lines: ctx.doc2Lines },
+    ]
+
+    ctx.rootDoc = ctx.doc0
+    ctx.rootDocPath = '/rootDocHere'
+    ctx.owner = { _id: 'this_is_the_owner' }
+    ctx.newBlankProject = {
+      _id: 'new_project_id',
+      overleaf: { history: { id: 339123 } },
+      readOnly_refs: [],
+      collaberator_refs: [],
+      rootFolder: [{ _id: 'new_root_folder_id' }],
+    }
+    ctx.newFolder = { _id: 'newFolderId' }
+    ctx.filestoreUrl = 'filestore-url'
+    ctx.newProjectVersion = 2
+
+    ctx.newDocId = new ObjectId()
+    ctx.newFileId = new ObjectId()
+    ctx.newDoc0 = { ...ctx.doc0, _id: ctx.newDocId }
+    ctx.newDoc1 = { ...ctx.doc1, _id: ctx.newDocId }
+    ctx.newDoc2 = { ...ctx.doc2, _id: ctx.newDocId }
+    ctx.newFile0 = { ...ctx.file0, _id: ctx.newFileId }
+    ctx.newFile1 = { ...ctx.file1, _id: ctx.newFileId }
+    ctx.newFile2 = { ...ctx.file2, _id: ctx.newFileId }
+
+    ctx.docEntries = [
+      {
+        path: ctx.doc0Path,
+        doc: ctx.newDoc0,
+        docLines: ctx.doc0Lines.join('\n'),
+      },
+      {
+        path: ctx.doc1Path,
+        doc: ctx.newDoc1,
+        docLines: ctx.doc1Lines.join('\n'),
+      },
+      {
+        path: ctx.doc2Path,
+        doc: ctx.newDoc2,
+        docLines: ctx.doc2Lines.join('\n'),
+      },
+    ]
+    ctx.fileEntries = [
+      {
+        createdBlob: true,
+        path: ctx.file0Path,
+        file: ctx.newFile0,
+      },
+      {
+        createdBlob: true,
+        path: ctx.file1Path,
+        file: ctx.newFile1,
+      },
+      {
+        createdBlob: true,
+        path: ctx.file2Path,
+        file: ctx.newFile2,
+      },
+    ]
+
+    ctx.Doc = sinon.stub().callsFake(props => ({ _id: ctx.newDocId, ...props }))
+    ctx.File = sinon
+      .stub()
+      .callsFake(props => ({ _id: ctx.newFileId, ...props }))
+
+    ctx.DocstoreManager = {
+      promises: {
+        updateDoc: sinon.stub().resolves(),
+        getAllDocs: sinon.stub().resolves(ctx.docContents),
+      },
+    }
+    ctx.DocumentUpdaterHandler = {
+      promises: {
+        flushProjectToMongo: sinon.stub().resolves(),
+        updateProjectStructure: sinon.stub().resolves(),
+      },
+    }
+    ctx.HistoryManager = {
+      promises: {
+        copyBlob: sinon.stub().callsFake((historyId, newHistoryId, hash) => {
+          if (hash === '500') {
+            return Promise.reject(new Error('copy blob error'))
+          }
+          return Promise.resolve()
+        }),
+      },
+    }
+    ctx.TagsHandler = {
+      promises: {
+        addProjectToTags: sinon.stub().resolves({
+          _id: 'project-1',
+        }),
+        countTagsForProject: sinon.stub().resolves(1),
+      },
+    }
+    ctx.ProjectCreationHandler = {
+      promises: {
+        createBlankProject: sinon.stub().resolves(ctx.newBlankProject),
+      },
+    }
+    ctx.ProjectDeleter = {
+      promises: {
+        deleteProject: sinon.stub().resolves(),
+      },
+    }
+    ctx.ProjectEntityMongoUpdateHandler = {
+      promises: {
+        createNewFolderStructure: sinon.stub().resolves(ctx.newProjectVersion),
+      },
+    }
+    ctx.ProjectEntityUpdateHandler = {
+      isPathValidForRootDoc: sinon.stub().returns(true),
+      promises: {
+        setRootDoc: sinon.stub().resolves(),
+      },
+    }
+    ctx.ProjectGetter = {
+      promises: {
+        getProject: sinon
+          .stub()
+          .withArgs(ctx.project._id)
+          .resolves(ctx.project),
+      },
+    }
+    ctx.ProjectLocator = {
+      promises: {
+        findRootDoc: sinon.stub().resolves({
+          element: ctx.rootDoc,
+          path: { fileSystem: ctx.rootDocPath },
+        }),
+        findElementByPath: sinon
+          .stub()
+          .withArgs({
+            project_id: ctx.newBlankProject._id,
+            path: ctx.rootDocPath,
+            exactCaseMatch: true,
+          })
+          .resolves({ element: ctx.doc0 }),
+      },
+    }
+    ctx.ProjectOptionsHandler = {
+      promises: {
+        setCompiler: sinon.stub().resolves(),
+      },
+    }
+    ctx.TpdsProjectFlusher = {
+      promises: {
+        flushProjectToTpds: sinon.stub().resolves(),
+      },
+    }
+
+    vi.doMock('../../../../app/src/models/Doc', () => ({
+      Doc: ctx.Doc,
+    }))
+
+    vi.doMock('../../../../app/src/models/File', () => ({
+      File: ctx.File,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({
+      default: ctx.DocstoreManager,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
+      () => ({
+        default: ctx.DocumentUpdaterHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectCreationHandler',
+      () => ({
+        default: ctx.ProjectCreationHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectDeleter', () => ({
+      default: ctx.ProjectDeleter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityMongoUpdateHandler',
+      () => ({
+        default: ctx.ProjectEntityMongoUpdateHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityUpdateHandler',
+      () => ({
+        default: ctx.ProjectEntityUpdateHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: ctx.ProjectGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
+      default: ctx.ProjectLocator,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectOptionsHandler',
+      () => ({
+        default: ctx.ProjectOptionsHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/ThirdPartyDataStore/TpdsProjectFlusher',
+      () => ({
+        default: ctx.TpdsProjectFlusher,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Tags/TagsHandler', () => ({
+      default: ctx.TagsHandler,
+    }))
+
+    vi.doMock('../../../../app/src/Features/History/HistoryManager', () => ({
+      default: ctx.HistoryManager,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Compile/ClsiCacheManager', () => ({
+      default: {
+        prepareClsiCache: sinon.stub().rejects(new Error('ignore this')),
+      },
+    }))
+
+    ctx.ProjectDuplicator = (await import(MODULE_PATH)).default
+  })
+
+  describe('when the copy succeeds', function () {
+    beforeEach(async function (ctx) {
+      ctx.newProjectName = 'New project name'
+      ctx.newProject = await ctx.ProjectDuplicator.promises.duplicate(
+        ctx.owner,
+        ctx.project._id,
+        ctx.newProjectName
+      )
+    })
+
+    it('should flush the original project to mongo', function (ctx) {
+      ctx.DocumentUpdaterHandler.promises.flushProjectToMongo.should.have.been.calledWith(
+        ctx.project._id
+      )
+    })
+
+    it('should copy docs to docstore', function (ctx) {
+      for (const docLines of [ctx.doc0Lines, ctx.doc1Lines, ctx.doc2Lines]) {
+        ctx.DocstoreManager.promises.updateDoc.should.have.been.calledWith(
+          ctx.newProject._id.toString(),
+          ctx.newDocId.toString(),
+          docLines,
+          0,
+          {}
+        )
+      }
+    })
+
+    it('should duplicate the files with hashes by copying the blobs in history v1', function (ctx) {
+      for (const file of [ctx.file0, ctx.file1, ctx.file2]) {
+        ctx.HistoryManager.promises.copyBlob.should.have.been.calledWith(
+          ctx.project.overleaf.history.id,
+          ctx.newProject.overleaf.history.id,
+          file.hash
+        )
+      }
+    })
+
+    it('should create a blank project', function (ctx) {
+      ctx.ProjectCreationHandler.promises.createBlankProject.should.have.been.calledWith(
+        ctx.owner._id,
+        ctx.newProjectName
+      )
+      ctx.newProject._id.should.equal(ctx.newBlankProject._id)
+    })
+
+    it('should use the same compiler', function (ctx) {
+      ctx.ProjectOptionsHandler.promises.setCompiler.should.have.been.calledWith(
+        ctx.newProject._id,
+        ctx.project.compiler
+      )
+    })
+
+    it('should use the same root doc', function (ctx) {
+      ctx.ProjectEntityUpdateHandler.promises.setRootDoc.should.have.been.calledWith(
+        ctx.newProject._id,
+        ctx.rootFolder.docs[0]._id
+      )
+    })
+
+    it('should not copy the collaborators or read only refs', function (ctx) {
+      ctx.newProject.collaberator_refs.length.should.equal(0)
+      ctx.newProject.readOnly_refs.length.should.equal(0)
+    })
+
+    it('should copy all documents and files', function (ctx) {
+      ctx.ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure.should.have.been.calledWith(
+        ctx.newProject._id,
+        ctx.docEntries,
+        ctx.fileEntries
+      )
+    })
+
+    it('should notify document updater of changes', function (ctx) {
+      ctx.DocumentUpdaterHandler.promises.updateProjectStructure.should.have.been.calledWith(
+        ctx.newProject._id,
+        ctx.newProject.overleaf.history.id,
+        ctx.owner._id,
+        {
+          newDocs: ctx.docEntries,
+          newFiles: ctx.fileEntries,
+          newProject: { version: ctx.newProjectVersion },
+        },
+        null
+      )
+    })
+
+    it('should flush the project to TPDS', function (ctx) {
+      ctx.TpdsProjectFlusher.promises.flushProjectToTpds.should.have.been.calledWith(
+        ctx.newProject._id
+      )
+    })
+  })
+
+  describe('without a root doc', function () {
+    beforeEach(async function (ctx) {
+      ctx.ProjectLocator.promises.findRootDoc.resolves({
+        element: null,
+        path: null,
+      })
+      ctx.newProject = await ctx.ProjectDuplicator.promises.duplicate(
+        ctx.owner,
+        ctx.project._id,
+        'Copy of project'
+      )
+    })
+
+    it('should not set the root doc on the copy', function (ctx) {
+      ctx.ProjectEntityUpdateHandler.promises.setRootDoc.should.not.have.been
+        .called
+    })
+  })
+
+  describe('with an invalid root doc', function () {
+    beforeEach(async function (ctx) {
+      ctx.ProjectEntityUpdateHandler.isPathValidForRootDoc.returns(false)
+      ctx.newProject = await ctx.ProjectDuplicator.promises.duplicate(
+        ctx.owner,
+        ctx.project._id,
+        'Copy of project'
+      )
+    })
+
+    it('should not set the root doc on the copy', function (ctx) {
+      ctx.ProjectEntityUpdateHandler.promises.setRootDoc.should.not.have.been
+        .called
+    })
+  })
+
+  describe('when cloning in history-v1 fails', function () {
+    it('should fail the clone operation', async function (ctx) {
+      ctx.file0.hash = '500'
+      await expect(
+        ctx.ProjectDuplicator.promises.duplicate(
+          ctx.owner,
+          ctx.project._id,
+          'name'
+        )
+      ).to.be.rejectedWith('copy blob error')
+    })
+  })
+
+  describe('when there is an error', function () {
+    beforeEach(async function (ctx) {
+      ctx.ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure.rejects()
+      await expect(
+        ctx.ProjectDuplicator.promises.duplicate(ctx.owner, ctx.project._id, '')
+      ).to.be.rejected
+    })
+
+    it('should delete the broken cloned project', function (ctx) {
+      ctx.ProjectDeleter.promises.deleteProject.should.have.been.calledWith(
+        ctx.newBlankProject._id
+      )
+    })
+
+    it('should not delete the original project', function (ctx) {
+      ctx.ProjectDeleter.promises.deleteProject.should.not.have.been.calledWith(
+        ctx.project._id
+      )
+    })
+  })
+})

+ 0 - 408
services/web/test/unit/src/Project/ProjectDuplicatorTests.js

@@ -1,408 +0,0 @@
-const { expect } = require('chai')
-const sinon = require('sinon')
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
-
-const MODULE_PATH = '../../../../app/src/Features/Project/ProjectDuplicator.js'
-
-describe('ProjectDuplicator', function () {
-  beforeEach(function () {
-    this.doc0 = { _id: 'doc0_id', name: 'rootDocHere' }
-    this.doc1 = { _id: 'doc1_id', name: 'level1folderDocName' }
-    this.doc2 = { _id: 'doc2_id', name: 'level2folderDocName' }
-    this.doc0Lines = ['zero']
-    this.doc1Lines = ['one']
-    this.doc2Lines = ['two']
-    this.file0 = { name: 'file0', _id: 'file0', hash: 'abcde' }
-    this.file1 = { name: 'file1', _id: 'file1', hash: 'fffff' }
-    this.file2 = {
-      name: 'file2',
-      _id: 'file2',
-      created: '2024-07-05T14:18:31.401+00:00',
-      linkedFileData: { provider: 'url' },
-      hash: '123456',
-    }
-    this.level2folder = {
-      name: 'level2folderName',
-      _id: 'level2folderId',
-      docs: [this.doc2, undefined],
-      folders: [],
-      fileRefs: [this.file2],
-    }
-    this.level1folder = {
-      name: 'level1folder',
-      _id: 'level1folderId',
-      docs: [this.doc1],
-      folders: [this.level2folder],
-      fileRefs: [this.file1, null], // the null is intentional to test null docs/files
-    }
-    this.rootFolder = {
-      name: 'rootFolder',
-      _id: 'rootFolderId',
-      docs: [this.doc0],
-      folders: [this.level1folder, {}],
-      fileRefs: [this.file0],
-    }
-    this.project = {
-      _id: 'this_is_the_old_project_id',
-      rootDoc_id: this.doc0._id,
-      rootFolder: [this.rootFolder],
-      compiler: 'this_is_a_Compiler',
-      overleaf: { history: { id: 123456 } },
-    }
-    this.doc0Path = '/rootDocHere'
-    this.doc1Path = '/level1folder/level1folderDocName'
-    this.doc2Path = '/level1folder/level2folderName/level2folderDocName'
-    this.file0Path = '/file0'
-    this.file1Path = '/level1folder/file1'
-    this.file2Path = '/level1folder/level2folderName/file2'
-
-    this.docContents = [
-      { _id: this.doc0._id, lines: this.doc0Lines },
-      { _id: this.doc1._id, lines: this.doc1Lines },
-      { _id: this.doc2._id, lines: this.doc2Lines },
-    ]
-
-    this.rootDoc = this.doc0
-    this.rootDocPath = '/rootDocHere'
-    this.owner = { _id: 'this_is_the_owner' }
-    this.newBlankProject = {
-      _id: 'new_project_id',
-      overleaf: { history: { id: 339123 } },
-      readOnly_refs: [],
-      collaberator_refs: [],
-      rootFolder: [{ _id: 'new_root_folder_id' }],
-    }
-    this.newFolder = { _id: 'newFolderId' }
-    this.filestoreUrl = 'filestore-url'
-    this.newProjectVersion = 2
-
-    this.newDocId = new ObjectId()
-    this.newFileId = new ObjectId()
-    this.newDoc0 = { ...this.doc0, _id: this.newDocId }
-    this.newDoc1 = { ...this.doc1, _id: this.newDocId }
-    this.newDoc2 = { ...this.doc2, _id: this.newDocId }
-    this.newFile0 = { ...this.file0, _id: this.newFileId }
-    this.newFile1 = { ...this.file1, _id: this.newFileId }
-    this.newFile2 = { ...this.file2, _id: this.newFileId }
-
-    this.docEntries = [
-      {
-        path: this.doc0Path,
-        doc: this.newDoc0,
-        docLines: this.doc0Lines.join('\n'),
-      },
-      {
-        path: this.doc1Path,
-        doc: this.newDoc1,
-        docLines: this.doc1Lines.join('\n'),
-      },
-      {
-        path: this.doc2Path,
-        doc: this.newDoc2,
-        docLines: this.doc2Lines.join('\n'),
-      },
-    ]
-    this.fileEntries = [
-      {
-        createdBlob: true,
-        path: this.file0Path,
-        file: this.newFile0,
-      },
-      {
-        createdBlob: true,
-        path: this.file1Path,
-        file: this.newFile1,
-      },
-      {
-        createdBlob: true,
-        path: this.file2Path,
-        file: this.newFile2,
-      },
-    ]
-
-    this.Doc = sinon
-      .stub()
-      .callsFake(props => ({ _id: this.newDocId, ...props }))
-    this.File = sinon
-      .stub()
-      .callsFake(props => ({ _id: this.newFileId, ...props }))
-
-    this.DocstoreManager = {
-      promises: {
-        updateDoc: sinon.stub().resolves(),
-        getAllDocs: sinon.stub().resolves(this.docContents),
-      },
-    }
-    this.DocumentUpdaterHandler = {
-      promises: {
-        flushProjectToMongo: sinon.stub().resolves(),
-        updateProjectStructure: sinon.stub().resolves(),
-      },
-    }
-    this.HistoryManager = {
-      promises: {
-        copyBlob: sinon.stub().callsFake((historyId, newHistoryId, hash) => {
-          if (hash === '500') {
-            return Promise.reject(new Error('copy blob error'))
-          }
-          return Promise.resolve()
-        }),
-      },
-    }
-    this.TagsHandler = {
-      promises: {
-        addProjectToTags: sinon.stub().resolves({
-          _id: 'project-1',
-        }),
-        countTagsForProject: sinon.stub().resolves(1),
-      },
-    }
-    this.ProjectCreationHandler = {
-      promises: {
-        createBlankProject: sinon.stub().resolves(this.newBlankProject),
-      },
-    }
-    this.ProjectDeleter = {
-      promises: {
-        deleteProject: sinon.stub().resolves(),
-      },
-    }
-    this.ProjectEntityMongoUpdateHandler = {
-      promises: {
-        createNewFolderStructure: sinon.stub().resolves(this.newProjectVersion),
-      },
-    }
-    this.ProjectEntityUpdateHandler = {
-      isPathValidForRootDoc: sinon.stub().returns(true),
-      promises: {
-        setRootDoc: sinon.stub().resolves(),
-      },
-    }
-    this.ProjectGetter = {
-      promises: {
-        getProject: sinon
-          .stub()
-          .withArgs(this.project._id)
-          .resolves(this.project),
-      },
-    }
-    this.ProjectLocator = {
-      promises: {
-        findRootDoc: sinon.stub().resolves({
-          element: this.rootDoc,
-          path: { fileSystem: this.rootDocPath },
-        }),
-        findElementByPath: sinon
-          .stub()
-          .withArgs({
-            project_id: this.newBlankProject._id,
-            path: this.rootDocPath,
-            exactCaseMatch: true,
-          })
-          .resolves({ element: this.doc0 }),
-      },
-    }
-    this.ProjectOptionsHandler = {
-      promises: {
-        setCompiler: sinon.stub().resolves(),
-      },
-    }
-    this.TpdsProjectFlusher = {
-      promises: {
-        flushProjectToTpds: sinon.stub().resolves(),
-      },
-    }
-
-    this.ProjectDuplicator = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '../../models/Doc': { Doc: this.Doc },
-        '../../models/File': { File: this.File },
-        '../Docstore/DocstoreManager': this.DocstoreManager,
-        '../DocumentUpdater/DocumentUpdaterHandler':
-          this.DocumentUpdaterHandler,
-        './ProjectCreationHandler': this.ProjectCreationHandler,
-        './ProjectDeleter': this.ProjectDeleter,
-        './ProjectEntityMongoUpdateHandler':
-          this.ProjectEntityMongoUpdateHandler,
-        './ProjectEntityUpdateHandler': this.ProjectEntityUpdateHandler,
-        './ProjectGetter': this.ProjectGetter,
-        './ProjectLocator': this.ProjectLocator,
-        './ProjectOptionsHandler': this.ProjectOptionsHandler,
-        '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
-        '../Tags/TagsHandler': this.TagsHandler,
-        '../History/HistoryManager': this.HistoryManager,
-        '../Compile/ClsiCacheManager': {
-          prepareClsiCache: sinon.stub().rejects(new Error('ignore this')),
-        },
-      },
-    })
-  })
-
-  describe('when the copy succeeds', function () {
-    beforeEach(async function () {
-      this.newProjectName = 'New project name'
-      this.newProject = await this.ProjectDuplicator.promises.duplicate(
-        this.owner,
-        this.project._id,
-        this.newProjectName
-      )
-    })
-
-    it('should flush the original project to mongo', function () {
-      this.DocumentUpdaterHandler.promises.flushProjectToMongo.should.have.been.calledWith(
-        this.project._id
-      )
-    })
-
-    it('should copy docs to docstore', function () {
-      for (const docLines of [this.doc0Lines, this.doc1Lines, this.doc2Lines]) {
-        this.DocstoreManager.promises.updateDoc.should.have.been.calledWith(
-          this.newProject._id.toString(),
-          this.newDocId.toString(),
-          docLines,
-          0,
-          {}
-        )
-      }
-    })
-
-    it('should duplicate the files with hashes by copying the blobs in history v1', function () {
-      for (const file of [this.file0, this.file1, this.file2]) {
-        this.HistoryManager.promises.copyBlob.should.have.been.calledWith(
-          this.project.overleaf.history.id,
-          this.newProject.overleaf.history.id,
-          file.hash
-        )
-      }
-    })
-
-    it('should create a blank project', function () {
-      this.ProjectCreationHandler.promises.createBlankProject.should.have.been.calledWith(
-        this.owner._id,
-        this.newProjectName
-      )
-      this.newProject._id.should.equal(this.newBlankProject._id)
-    })
-
-    it('should use the same compiler', function () {
-      this.ProjectOptionsHandler.promises.setCompiler.should.have.been.calledWith(
-        this.newProject._id,
-        this.project.compiler
-      )
-    })
-
-    it('should use the same root doc', function () {
-      this.ProjectEntityUpdateHandler.promises.setRootDoc.should.have.been.calledWith(
-        this.newProject._id,
-        this.rootFolder.docs[0]._id
-      )
-    })
-
-    it('should not copy the collaborators or read only refs', function () {
-      this.newProject.collaberator_refs.length.should.equal(0)
-      this.newProject.readOnly_refs.length.should.equal(0)
-    })
-
-    it('should copy all documents and files', function () {
-      this.ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure.should.have.been.calledWith(
-        this.newProject._id,
-        this.docEntries,
-        this.fileEntries
-      )
-    })
-
-    it('should notify document updater of changes', function () {
-      this.DocumentUpdaterHandler.promises.updateProjectStructure.should.have.been.calledWith(
-        this.newProject._id,
-        this.newProject.overleaf.history.id,
-        this.owner._id,
-        {
-          newDocs: this.docEntries,
-          newFiles: this.fileEntries,
-          newProject: { version: this.newProjectVersion },
-        },
-        null
-      )
-    })
-
-    it('should flush the project to TPDS', function () {
-      this.TpdsProjectFlusher.promises.flushProjectToTpds.should.have.been.calledWith(
-        this.newProject._id
-      )
-    })
-  })
-
-  describe('without a root doc', function () {
-    beforeEach(async function () {
-      this.ProjectLocator.promises.findRootDoc.resolves({
-        element: null,
-        path: null,
-      })
-      this.newProject = await this.ProjectDuplicator.promises.duplicate(
-        this.owner,
-        this.project._id,
-        'Copy of project'
-      )
-    })
-
-    it('should not set the root doc on the copy', function () {
-      this.ProjectEntityUpdateHandler.promises.setRootDoc.should.not.have.been
-        .called
-    })
-  })
-
-  describe('with an invalid root doc', function () {
-    beforeEach(async function () {
-      this.ProjectEntityUpdateHandler.isPathValidForRootDoc.returns(false)
-      this.newProject = await this.ProjectDuplicator.promises.duplicate(
-        this.owner,
-        this.project._id,
-        'Copy of project'
-      )
-    })
-
-    it('should not set the root doc on the copy', function () {
-      this.ProjectEntityUpdateHandler.promises.setRootDoc.should.not.have.been
-        .called
-    })
-  })
-
-  describe('when cloning in history-v1 fails', function () {
-    it('should fail the clone operation', async function () {
-      this.file0.hash = '500'
-      await expect(
-        this.ProjectDuplicator.promises.duplicate(
-          this.owner,
-          this.project._id,
-          'name'
-        )
-      ).to.be.rejectedWith('copy blob error')
-    })
-  })
-
-  describe('when there is an error', function () {
-    beforeEach(async function () {
-      this.ProjectEntityMongoUpdateHandler.promises.createNewFolderStructure.rejects()
-      await expect(
-        this.ProjectDuplicator.promises.duplicate(
-          this.owner,
-          this.project._id,
-          ''
-        )
-      ).to.be.rejected
-    })
-
-    it('should delete the broken cloned project', function () {
-      this.ProjectDeleter.promises.deleteProject.should.have.been.calledWith(
-        this.newBlankProject._id
-      )
-    })
-
-    it('should not delete the original project', function () {
-      this.ProjectDeleter.promises.deleteProject.should.not.have.been.calledWith(
-        this.project._id
-      )
-    })
-  })
-})