Ver Fonte

Merge pull request #28273 from overleaf/ac-some-web-esm-migration

[web] Convert some Features files to ES modules (part 1)

GitOrigin-RevId: d19b024efad315143e022143e2a2683df8071744
Antoine Clausse há 1 ano atrás
pai
commit
009bc4463d
36 ficheiros alterados com 2314 adições e 2104 exclusões
  1. 10 10
      services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs
  2. 9 9
      services/web/app/src/Features/Chat/ChatController.mjs
  3. 1 1
      services/web/app/src/Features/Collaborators/CollaboratorsController.mjs
  4. 37 38
      services/web/app/src/Features/Collaborators/CollaboratorsGetter.js
  5. 1 1
      services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs
  6. 16 16
      services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs
  7. 14 14
      services/web/app/src/Features/Compile/ClsiCacheController.mjs
  8. 16 14
      services/web/app/src/Features/Editor/EditorHttpController.mjs
  9. 1 1
      services/web/app/src/Features/Editor/EditorRouter.mjs
  10. 8 8
      services/web/app/src/Features/Errors/ErrorController.mjs
  11. 2 2
      services/web/app/src/Features/GlobalMetrics/GlobalMetricsManager.mjs
  12. 21 19
      services/web/app/src/Features/History/HistoryController.mjs
  13. 1 1
      services/web/app/src/Features/History/HistoryRouter.mjs
  14. 1 1
      services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
  15. 15 12
      services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs
  16. 1 1
      services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs
  17. 1 1
      services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs
  18. 7 4
      services/web/app/src/Features/Project/ProjectCollabratecDetailsHandler.mjs
  19. 1 1
      services/web/app/src/Features/StaticPages/HomeController.mjs
  20. 1 1
      services/web/app/src/Features/Subscription/TeamInvitesController.mjs
  21. 1 1
      services/web/app/src/infrastructure/Server.mjs
  22. 4 4
      services/web/app/src/router.mjs
  23. 1 1
      services/web/modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs
  24. 1 1
      services/web/modules/user-activate/app/src/UserActivateController.mjs
  25. 1 1
      services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs
  26. 152 0
      services/web/test/unit/src/Chat/ChatController.test.mjs
  27. 0 125
      services/web/test/unit/src/Chat/ChatControllerTests.js
  28. 1 1
      services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs
  29. 532 0
      services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs
  30. 0 494
      services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js
  31. 735 0
      services/web/test/unit/src/Editor/EditorHttpController.test.mjs
  32. 0 630
      services/web/test/unit/src/Editor/EditorHttpControllerTests.js
  33. 297 0
      services/web/test/unit/src/History/HistoryController.test.mjs
  34. 0 265
      services/web/test/unit/src/History/HistoryControllerTests.js
  35. 425 0
      services/web/test/unit/src/Project/ProjectCollabratecDetails.test.mjs
  36. 0 426
      services/web/test/unit/src/Project/ProjectCollabratecDetailsTest.js

+ 10 - 10
services/web/app/src/Features/Captcha/CaptchaMiddleware.js → services/web/app/src/Features/Captcha/CaptchaMiddleware.mjs

@@ -1,12 +1,12 @@
-const { fetchJson } = require('@overleaf/fetch-utils')
-const logger = require('@overleaf/logger')
-const Settings = require('@overleaf/settings')
-const Metrics = require('@overleaf/metrics')
-const OError = require('@overleaf/o-error')
-const DeviceHistory = require('./DeviceHistory')
-const AuthenticationController = require('../Authentication/AuthenticationController')
-const { expressify } = require('@overleaf/promise-utils')
-const EmailsHelper = require('../Helpers/EmailHelper')
+import { fetchJson } from '@overleaf/fetch-utils'
+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 AuthenticationController from '../Authentication/AuthenticationController.js'
+import { expressify } from '@overleaf/promise-utils'
+import EmailsHelper from '../Helpers/EmailHelper.js'
 
 function respondInvalidCaptcha(req, res) {
   res.status(400).json({
@@ -114,7 +114,7 @@ function validateCaptcha(action) {
   })
 }
 
-module.exports = {
+export default {
   respondInvalidCaptcha,
   validateCaptcha,
   canSkipCaptcha: expressify(canSkipCaptcha),

+ 9 - 9
services/web/app/src/Features/Chat/ChatController.js → services/web/app/src/Features/Chat/ChatController.mjs

@@ -1,11 +1,11 @@
-const { expressify } = require('@overleaf/promise-utils')
-const Modules = require('../../infrastructure/Modules')
-const ChatApiHandler = require('./ChatApiHandler')
-const EditorRealTimeController = require('../Editor/EditorRealTimeController')
-const SessionManager = require('../Authentication/SessionManager')
-const UserInfoManager = require('../User/UserInfoManager')
-const UserInfoController = require('../User/UserInfoController')
-const ChatManager = require('./ChatManager')
+import { expressify } from '@overleaf/promise-utils'
+import Modules from '../../infrastructure/Modules.js'
+import ChatApiHandler from './ChatApiHandler.js'
+import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
+import SessionManager from '../Authentication/SessionManager.js'
+import UserInfoManager from '../User/UserInfoManager.js'
+import UserInfoController from '../User/UserInfoController.js'
+import ChatManager from './ChatManager.js'
 
 async function sendMessage(req, res) {
   const { project_id: projectId } = req.params
@@ -48,7 +48,7 @@ async function getMessages(req, res) {
   res.json(messages)
 }
 
-module.exports = {
+export default {
   sendMessage: expressify(sendMessage),
   getMessages: expressify(getMessages),
 }

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

@@ -3,7 +3,7 @@ import HttpErrorHandler from '../../Features/Errors/HttpErrorHandler.js'
 import mongodb from 'mongodb-legacy'
 import CollaboratorsHandler from './CollaboratorsHandler.js'
 import CollaboratorsGetter from './CollaboratorsGetter.js'
-import OwnershipTransferHandler from './OwnershipTransferHandler.js'
+import OwnershipTransferHandler from './OwnershipTransferHandler.mjs'
 import SessionManager from '../Authentication/SessionManager.js'
 import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
 import TagsHandler from '../Tags/TagsHandler.js'

+ 37 - 38
services/web/app/src/Features/Collaborators/CollaboratorsGetter.js

@@ -12,42 +12,6 @@ const ProjectEditorHandler = require('../Project/ProjectEditorHandler')
 const Sources = require('../Authorization/Sources')
 const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
 
-module.exports = {
-  getMemberIdsWithPrivilegeLevels: callbackify(getMemberIdsWithPrivilegeLevels),
-  getMemberIds: callbackify(getMemberIds),
-  getInvitedMemberIds: callbackify(getInvitedMemberIds),
-  getInvitedMembersWithPrivilegeLevelsFromFields: callbackify(
-    getInvitedMembersWithPrivilegeLevelsFromFields
-  ),
-  getMemberIdPrivilegeLevel: callbackify(getMemberIdPrivilegeLevel),
-  getProjectsUserIsMemberOf: callbackify(getProjectsUserIsMemberOf),
-  dangerouslyGetAllProjectsUserIsMemberOf: callbackify(
-    dangerouslyGetAllProjectsUserIsMemberOf
-  ),
-  isUserInvitedMemberOfProject: callbackify(isUserInvitedMemberOfProject),
-  getPublicShareTokens: callbackify(getPublicShareTokens),
-  userIsTokenMember: callbackify(userIsTokenMember),
-  getAllInvitedMembers: callbackify(getAllInvitedMembers),
-  promises: {
-    getProjectAccess,
-    getMemberIdsWithPrivilegeLevels,
-    getMemberIds,
-    getInvitedMemberIds,
-    getInvitedMembersWithPrivilegeLevelsFromFields,
-    getMemberIdPrivilegeLevel,
-    getInvitedEditCollaboratorCount,
-    getInvitedPendingEditorCount,
-    getProjectsUserIsMemberOf,
-    dangerouslyGetAllProjectsUserIsMemberOf,
-    isUserInvitedMemberOfProject,
-    isUserInvitedReadWriteMemberOfProject,
-    getPublicShareTokens,
-    userIsTokenMember,
-    userIsReadWriteTokenMember,
-    getAllInvitedMembers,
-  },
-}
-
 /**
  * @typedef ProjectMember
  * @property {string} id
@@ -241,8 +205,6 @@ class ProjectAccess {
   }
 }
 
-module.exports.ProjectAccess = ProjectAccess
-
 async function getProjectAccess(projectId) {
   const project = await ProjectGetter.promises.getProject(projectId, {
     owner_ref: 1,
@@ -577,3 +539,40 @@ async function _loadMembers(members) {
     })
     .filter(r => r != null)
 }
+
+module.exports = {
+  getMemberIdsWithPrivilegeLevels: callbackify(getMemberIdsWithPrivilegeLevels),
+  getMemberIds: callbackify(getMemberIds),
+  getInvitedMemberIds: callbackify(getInvitedMemberIds),
+  getInvitedMembersWithPrivilegeLevelsFromFields: callbackify(
+    getInvitedMembersWithPrivilegeLevelsFromFields
+  ),
+  getMemberIdPrivilegeLevel: callbackify(getMemberIdPrivilegeLevel),
+  getProjectsUserIsMemberOf: callbackify(getProjectsUserIsMemberOf),
+  dangerouslyGetAllProjectsUserIsMemberOf: callbackify(
+    dangerouslyGetAllProjectsUserIsMemberOf
+  ),
+  isUserInvitedMemberOfProject: callbackify(isUserInvitedMemberOfProject),
+  getPublicShareTokens: callbackify(getPublicShareTokens),
+  userIsTokenMember: callbackify(userIsTokenMember),
+  getAllInvitedMembers: callbackify(getAllInvitedMembers),
+  promises: {
+    getProjectAccess,
+    getMemberIdsWithPrivilegeLevels,
+    getMemberIds,
+    getInvitedMemberIds,
+    getInvitedMembersWithPrivilegeLevelsFromFields,
+    getMemberIdPrivilegeLevel,
+    getInvitedEditCollaboratorCount,
+    getInvitedPendingEditorCount,
+    getProjectsUserIsMemberOf,
+    dangerouslyGetAllProjectsUserIsMemberOf,
+    isUserInvitedMemberOfProject,
+    isUserInvitedReadWriteMemberOfProject,
+    getPublicShareTokens,
+    userIsTokenMember,
+    userIsReadWriteTokenMember,
+    getAllInvitedMembers,
+  },
+  ProjectAccess,
+}

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

@@ -5,7 +5,7 @@ import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
 import CollaboratorsInviteController from './CollaboratorsInviteController.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
 import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
-import CaptchaMiddleware from '../Captcha/CaptchaMiddleware.js'
+import CaptchaMiddleware from '../Captcha/CaptchaMiddleware.mjs'
 import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.js'
 import { Joi, validate } from '../../infrastructure/Validation.js'
 

+ 16 - 16
services/web/app/src/Features/Collaborators/OwnershipTransferHandler.js → services/web/app/src/Features/Collaborators/OwnershipTransferHandler.mjs

@@ -1,19 +1,19 @@
-const logger = require('@overleaf/logger')
-const { Project } = require('../../models/Project')
-const ProjectGetter = require('../Project/ProjectGetter')
-const UserGetter = require('../User/UserGetter')
-const CollaboratorsHandler = require('./CollaboratorsHandler')
-const EmailHandler = require('../Email/EmailHandler')
-const Errors = require('../Errors/Errors')
-const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
-const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher')
-const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler')
-const AnalyticsManager = require('../Analytics/AnalyticsManager')
-const OError = require('@overleaf/o-error')
-const TagsHandler = require('../Tags/TagsHandler')
-const { promiseMapWithLimit } = require('@overleaf/promise-utils')
-
-module.exports = {
+import logger from '@overleaf/logger'
+import { Project } from '../../models/Project.js'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import UserGetter from '../User/UserGetter.js'
+import CollaboratorsHandler from './CollaboratorsHandler.js'
+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 AnalyticsManager from '../Analytics/AnalyticsManager.js'
+import OError from '@overleaf/o-error'
+import TagsHandler from '../Tags/TagsHandler.js'
+import { promiseMapWithLimit } from '@overleaf/promise-utils'
+
+export default {
   promises: {
     transferOwnership,
     transferAllProjectsToUser,

+ 14 - 14
services/web/app/src/Features/Compile/ClsiCacheController.js → services/web/app/src/Features/Compile/ClsiCacheController.mjs

@@ -1,18 +1,18 @@
-const { NotFoundError, ResourceGoneError } = require('../Errors/Errors')
-const {
+import { NotFoundError, ResourceGoneError } from '../Errors/Errors.js'
+import {
   fetchStreamWithResponse,
   RequestFailedError,
-} = require('@overleaf/fetch-utils')
-const Path = require('path')
-const { pipeline } = require('stream/promises')
-const logger = require('@overleaf/logger')
-const ClsiCacheManager = require('./ClsiCacheManager')
-const CompileController = require('./CompileController')
-const { expressify } = require('@overleaf/promise-utils')
-const ClsiCacheHandler = require('./ClsiCacheHandler')
-const ProjectGetter = require('../Project/ProjectGetter')
-const { MeteredStream } = require('@overleaf/stream-utils')
-const Metrics = require('@overleaf/metrics')
+} from '@overleaf/fetch-utils'
+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 { expressify } from '@overleaf/promise-utils'
+import ClsiCacheHandler from './ClsiCacheHandler.js'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import { MeteredStream } from '@overleaf/stream-utils'
+import Metrics from '@overleaf/metrics'
 
 /**
  * Download a file from a specific build on the clsi-cache.
@@ -150,7 +150,7 @@ async function getLatestBuildFromCache(req, res) {
   }
 }
 
-module.exports = {
+export default {
   downloadFromCache: expressify(downloadFromCache),
   getLatestBuildFromCache: expressify(getLatestBuildFromCache),
 }

+ 16 - 14
services/web/app/src/Features/Editor/EditorHttpController.js → services/web/app/src/Features/Editor/EditorHttpController.mjs

@@ -1,18 +1,20 @@
-const ProjectDeleter = require('../Project/ProjectDeleter')
-const EditorController = require('./EditorController')
-const ProjectGetter = require('../Project/ProjectGetter')
-const AuthorizationManager = require('../Authorization/AuthorizationManager')
-const ProjectEditorHandler = require('../Project/ProjectEditorHandler')
-const Metrics = require('@overleaf/metrics')
-const CollaboratorsInviteGetter = require('../Collaborators/CollaboratorsInviteGetter')
-const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
-const SessionManager = require('../Authentication/SessionManager')
-const Errors = require('../Errors/Errors')
-const { expressify } = require('@overleaf/promise-utils')
-const Settings = require('@overleaf/settings')
-const { ProjectAccess } = require('../Collaborators/CollaboratorsGetter')
+import ProjectDeleter from '../Project/ProjectDeleter.js'
+import EditorController from './EditorController.js'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import AuthorizationManager from '../Authorization/AuthorizationManager.js'
+import ProjectEditorHandler from '../Project/ProjectEditorHandler.js'
+import Metrics from '@overleaf/metrics'
+import CollaboratorsInviteGetter from '../Collaborators/CollaboratorsInviteGetter.js'
+import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
+import SessionManager from '../Authentication/SessionManager.js'
+import Errors from '../Errors/Errors.js'
+import { expressify } from '@overleaf/promise-utils'
+import Settings from '@overleaf/settings'
+import CollaboratorsGetter from '../Collaborators/CollaboratorsGetter.js'
 
-module.exports = {
+const ProjectAccess = CollaboratorsGetter.ProjectAccess
+
+export default {
   joinProject: expressify(joinProject),
   addDoc: expressify(addDoc),
   addFolder: expressify(addFolder),

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

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

+ 8 - 8
services/web/app/src/Features/Errors/ErrorController.js → services/web/app/src/Features/Errors/ErrorController.mjs

@@ -1,10 +1,10 @@
-const { isZodErrorLike, fromZodError } = require('zod-validation-error')
-const Errors = require('./Errors')
-const SessionManager = require('../Authentication/SessionManager')
-const SamlLogHandler = require('../SamlLog/SamlLogHandler')
-const HttpErrorHandler = require('./HttpErrorHandler')
-const { plainTextResponse } = require('../../infrastructure/Response')
-const { expressifyErrorHandler } = require('@overleaf/promise-utils')
+import { isZodErrorLike, fromZodError } from 'zod-validation-error'
+import Errors from './Errors.js'
+import SessionManager from '../Authentication/SessionManager.js'
+import SamlLogHandler from '../SamlLog/SamlLogHandler.js'
+import HttpErrorHandler from './HttpErrorHandler.js'
+import { plainTextResponse } from '../../infrastructure/Response.js'
+import { expressifyErrorHandler } from '@overleaf/promise-utils'
 
 function notFound(req, res) {
   res.status(404)
@@ -135,7 +135,7 @@ function handleApiError(err, req, res, next) {
   }
 }
 
-module.exports = {
+export default {
   notFound,
   forbidden,
   serverError,

+ 2 - 2
services/web/app/src/Features/GlobalMetrics/GlobalMetricsManager.js → services/web/app/src/Features/GlobalMetrics/GlobalMetricsManager.mjs

@@ -1,4 +1,4 @@
-const { GlobalMetric } = require('../../models/GlobalMetric')
+import { GlobalMetric } from '../../models/GlobalMetric.js'
 /**
  * A Generic collection used to track metrics shared across the entirety of the application
  * examples:
@@ -31,7 +31,7 @@ async function incrementMetric(key, value = 1) {
   )
 }
 
-module.exports = {
+export default {
   getMetric,
   setMetric,
   incrementMetric,

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

@@ -1,28 +1,30 @@
 // @ts-check
 
-const { setTimeout } = require('timers/promises')
-const { pipeline } = require('stream/promises')
-const OError = require('@overleaf/o-error')
-const logger = require('@overleaf/logger')
-const { expressify } = require('@overleaf/promise-utils')
-const {
+import { setTimeout } from 'node:timers/promises'
+import { pipeline } from 'node:stream/promises'
+import OError from '@overleaf/o-error'
+import logger from '@overleaf/logger'
+import { expressify } from '@overleaf/promise-utils'
+
+import {
   fetchStream,
   fetchStreamWithResponse,
   fetchJson,
   fetchNothing,
   RequestFailedError,
-} = require('@overleaf/fetch-utils')
-const settings = require('@overleaf/settings')
-const SessionManager = require('../Authentication/SessionManager')
-const UserGetter = require('../User/UserGetter')
-const ProjectGetter = require('../Project/ProjectGetter')
-const Errors = require('../Errors/Errors')
-const HistoryManager = require('./HistoryManager')
-const ProjectDetailsHandler = require('../Project/ProjectDetailsHandler')
-const ProjectEntityUpdateHandler = require('../Project/ProjectEntityUpdateHandler')
-const RestoreManager = require('./RestoreManager')
-const { prepareZipAttachment } = require('../../infrastructure/Response')
-const Features = require('../../infrastructure/Features')
+} from '@overleaf/fetch-utils'
+
+import settings from '@overleaf/settings'
+import SessionManager from '../Authentication/SessionManager.js'
+import UserGetter from '../User/UserGetter.js'
+import ProjectGetter from '../Project/ProjectGetter.js'
+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 { prepareZipAttachment } from '../../infrastructure/Response.js'
+import Features from '../../infrastructure/Features.js'
 
 // Number of seconds after which the browser should send a request to revalidate
 // blobs
@@ -508,7 +510,7 @@ function isPrematureClose(err) {
   )
 }
 
-module.exports = {
+export default {
   getBlob: expressify(getBlob),
   headBlob: expressify(headBlob),
   proxyToHistoryApi: expressify(proxyToHistoryApi),

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

@@ -6,7 +6,7 @@ import { RateLimiter } from '../../infrastructure/RateLimiter.js'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
 import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.js'
 import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
-import HistoryController from './HistoryController.js'
+import HistoryController from './HistoryController.mjs'
 
 const rateLimiters = {
   downloadProjectRevision: new RateLimiter('download-project-revision', {

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

@@ -41,7 +41,7 @@ import ReferencesHandler from '../References/ReferencesHandler.mjs'
 import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
 import { expressify } from '@overleaf/promise-utils'
 import ProjectOutputFileAgent from './ProjectOutputFileAgent.mjs'
-import ProjectFileAgent from './ProjectFileAgent.js'
+import ProjectFileAgent from './ProjectFileAgent.mjs'
 import UrlAgent from './UrlAgent.mjs'
 
 let LinkedFilesController

+ 15 - 12
services/web/app/src/Features/LinkedFiles/ProjectFileAgent.js → services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs

@@ -10,23 +10,26 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let ProjectFileAgent
-const AuthorizationManager = require('../Authorization/AuthorizationManager')
-const ProjectLocator = require('../Project/ProjectLocator')
-const DocstoreManager = require('../Docstore/DocstoreManager')
-const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-const _ = require('lodash')
-const LinkedFilesHandler = require('./LinkedFilesHandler')
-const {
+import AuthorizationManager from '../Authorization/AuthorizationManager.js'
+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 {
   BadDataError,
   AccessDeniedError,
   BadEntityTypeError,
   SourceFileNotFoundError,
-} = require('./LinkedFilesErrors')
-const { promisify } = require('@overleaf/promise-utils')
-const HistoryManager = require('../History/HistoryManager')
+} from './LinkedFilesErrors.js'
+
+import { promisify } from '@overleaf/promise-utils'
+import HistoryManager from '../History/HistoryManager.js'
+
+let ProjectFileAgent
 
-module.exports = ProjectFileAgent = {
+export default ProjectFileAgent = {
   createLinkedFile(
     projectId,
     linkedFileData,

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

@@ -1,7 +1,7 @@
 import AuthorizationManager from '../Authorization/AuthorizationManager.js'
 import CompileManager from '../Compile/CompileManager.js'
 import ClsiManager from '../Compile/ClsiManager.js'
-import ProjectFileAgent from './ProjectFileAgent.js'
+import ProjectFileAgent from './ProjectFileAgent.mjs'
 import _ from 'lodash'
 import {
   CompileFailedError,

+ 1 - 1
services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs

@@ -1,6 +1,6 @@
 import PasswordResetController from './PasswordResetController.mjs'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
-import CaptchaMiddleware from '../../Features/Captcha/CaptchaMiddleware.js'
+import CaptchaMiddleware from '../../Features/Captcha/CaptchaMiddleware.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
 import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
 import { Joi, validate } from '../../infrastructure/Validation.js'

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

@@ -1,6 +1,9 @@
-const { ObjectId } = require('mongodb-legacy')
-const { Project } = require('../../models/Project')
-const { callbackifyAll } = require('@overleaf/promise-utils')
+import mongodb from 'mongodb-legacy'
+
+import { Project } from '../../models/Project.js'
+import { callbackifyAll } from '@overleaf/promise-utils'
+
+const { ObjectId } = mongodb
 
 const ProjectCollabratecDetailsHandler = {
   async initializeCollabratecProject(
@@ -94,7 +97,7 @@ const ProjectCollabratecDetailsHandler = {
   },
 }
 
-module.exports = {
+export default {
   ...callbackifyAll(ProjectCollabratecDetailsHandler),
   promises: ProjectCollabratecDetailsHandler,
 }

+ 1 - 1
services/web/app/src/Features/StaticPages/HomeController.mjs

@@ -2,7 +2,7 @@ import Features from '../../infrastructure/Features.js'
 import AnalyticsManager from '../Analytics/AnalyticsManager.js'
 import Path from 'node:path'
 import fs from 'node:fs'
-import ErrorController from '../Errors/ErrorController.js'
+import ErrorController from '../Errors/ErrorController.mjs'
 import SessionManager from '../Authentication/SessionManager.js'
 import { expressify } from '@overleaf/promise-utils'
 import logger from '@overleaf/logger'

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

@@ -5,7 +5,7 @@ import TeamInvitesHandler from './TeamInvitesHandler.js'
 import SessionManager from '../Authentication/SessionManager.js'
 import SubscriptionLocator from './SubscriptionLocator.js'
 import SubscriptionHelper from './SubscriptionHelper.js'
-import ErrorController from '../Errors/ErrorController.js'
+import ErrorController from '../Errors/ErrorController.mjs'
 import EmailHelper from '../Helpers/EmailHelper.js'
 import UserGetter from '../User/UserGetter.js'
 import { expressify } from '@overleaf/promise-utils'

+ 1 - 1
services/web/app/src/infrastructure/Server.mjs

@@ -25,7 +25,7 @@ import RedirectManager from './RedirectManager.js'
 import translations from './Translations.js'
 import Views from './Views.js'
 import Features from './Features.js'
-import ErrorController from '../Features/Errors/ErrorController.js'
+import ErrorController from '../Features/Errors/ErrorController.mjs'
 import HttpErrorHandler from '../Features/Errors/HttpErrorHandler.js'
 import UserSessionsManager from '../Features/User/UserSessionsManager.js'
 import AuthenticationController from '../Features/Authentication/AuthenticationController.js'

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

@@ -1,5 +1,5 @@
 import AdminController from './Features/ServerAdmin/AdminController.js'
-import ErrorController from './Features/Errors/ErrorController.js'
+import ErrorController from './Features/Errors/ErrorController.mjs'
 import Features from './infrastructure/Features.js'
 import ProjectController from './Features/Project/ProjectController.js'
 import ProjectApiController from './Features/Project/ProjectApiController.mjs'
@@ -34,7 +34,7 @@ import HistoryRouter from './Features/History/HistoryRouter.mjs'
 import ExportsController from './Features/Exports/ExportsController.mjs'
 import PasswordResetRouter from './Features/PasswordReset/PasswordResetRouter.mjs'
 import StaticPagesRouter from './Features/StaticPages/StaticPagesRouter.mjs'
-import ChatController from './Features/Chat/ChatController.js'
+import ChatController from './Features/Chat/ChatController.mjs'
 import Modules from './infrastructure/Modules.js'
 import {
   RateLimiter,
@@ -57,7 +57,7 @@ import UserMembershipRouter from './Features/UserMembership/UserMembershipRouter
 import SystemMessageController from './Features/SystemMessages/SystemMessageController.js'
 import AnalyticsRegistrationSourceMiddleware from './Features/Analytics/AnalyticsRegistrationSourceMiddleware.js'
 import AnalyticsUTMTrackingMiddleware from './Features/Analytics/AnalyticsUTMTrackingMiddleware.mjs'
-import CaptchaMiddleware from './Features/Captcha/CaptchaMiddleware.js'
+import CaptchaMiddleware from './Features/Captcha/CaptchaMiddleware.mjs'
 import { Joi, validate } from './infrastructure/Validation.js'
 import UnsupportedBrowserMiddleware from './infrastructure/UnsupportedBrowserMiddleware.js'
 import logger from '@overleaf/logger'
@@ -65,7 +65,7 @@ import _ from 'lodash'
 import { plainTextResponse } from './infrastructure/Response.js'
 import PublicAccessLevels from './Features/Authorization/PublicAccessLevels.js'
 import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs'
-import ClsiCacheController from './Features/Compile/ClsiCacheController.js'
+import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs'
 import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.js'
 
 const { renderUnsupportedBrowserPage, unsupportedBrowserMiddleware } =

+ 1 - 1
services/web/modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs

@@ -1,6 +1,6 @@
 import { ObjectId } from '../../../app/src/infrastructure/mongodb.js'
 import minimist from 'minimist'
-import OwnershipTransferHandler from '../../../app/src/Features/Collaborators/OwnershipTransferHandler.js'
+import OwnershipTransferHandler from '../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs'
 import UserGetter from '../../../app/src/Features/User/UserGetter.js'
 import EmailHelper from '../../../app/src/Features/Helpers/EmailHelper.js'
 

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

@@ -2,7 +2,7 @@ import Path from 'node:path'
 import { fileURLToPath } from 'node:url'
 import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
 import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.js'
-import ErrorController from '../../../../app/src/Features/Errors/ErrorController.js'
+import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs'
 import { expressify } from '@overleaf/promise-utils'
 
 const __dirname = Path.dirname(fileURLToPath(import.meta.url))

+ 1 - 1
services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs

@@ -42,7 +42,7 @@ describe('UserActivateController', function () {
     )
 
     vi.doMock(
-      '../../../../../app/src/Features/Errors/ErrorController.js',
+      '../../../../../app/src/Features/Errors/ErrorController.mjs',
       () => ({
         default: ctx.ErrorController,
       })

+ 152 - 0
services/web/test/unit/src/Chat/ChatController.test.mjs

@@ -0,0 +1,152 @@
+import { vi } from 'vitest'
+import sinon from 'sinon'
+
+const MODULE_PATH = '../../../../app/src/Features/Chat/ChatController.mjs'
+
+describe('ChatController', function () {
+  beforeEach(async function (ctx) {
+    ctx.user_id = 'mock-user-id'
+    ctx.settings = {}
+    ctx.ChatApiHandler = { promises: {} }
+    ctx.ChatManager = { promises: {} }
+    ctx.EditorRealTimeController = { emitToRoom: sinon.stub() }
+    ctx.SessionManager = {
+      getLoggedInUserId: sinon.stub().returns(ctx.user_id),
+    }
+    ctx.UserInfoManager = {
+      promises: {},
+    }
+    ctx.UserInfoController = {}
+    ctx.Modules = {
+      promises: {
+        hooks: {
+          fire: sinon.stub().resolves(),
+        },
+      },
+    }
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Chat/ChatApiHandler.js', () => ({
+      default: ctx.ChatApiHandler,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Chat/ChatManager.js', () => ({
+      default: ctx.ChatManager,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Editor/EditorRealTimeController.js',
+      () => ({
+        default: ctx.EditorRealTimeController,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager.js',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserInfoManager.js', () => ({
+      default: ctx.UserInfoManager,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/User/UserInfoController.js',
+      () => ({
+        default: ctx.UserInfoController,
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Modules.js', () => ({
+      default: ctx.Modules,
+    }))
+
+    ctx.ChatController = (await import(MODULE_PATH)).default
+    ctx.req = {
+      params: {
+        project_id: ctx.project_id,
+      },
+    }
+    ctx.res = {
+      json: sinon.stub(),
+      send: sinon.stub(),
+      sendStatus: sinon.stub(),
+    }
+  })
+
+  describe('sendMessage', function () {
+    beforeEach(async function (ctx) {
+      ctx.req.body = { content: (ctx.content = 'message-content') }
+      ctx.UserInfoManager.promises.getPersonalInfo = sinon
+        .stub()
+        .resolves((ctx.user = { unformatted: 'user' }))
+      ctx.UserInfoController.formatPersonalInfo = sinon
+        .stub()
+        .returns((ctx.formatted_user = { formatted: 'user' }))
+      ctx.ChatApiHandler.promises.sendGlobalMessage = sinon
+        .stub()
+        .resolves((ctx.message = { mock: 'message', user_id: ctx.user_id }))
+      await ctx.ChatController.sendMessage(ctx.req, ctx.res)
+    })
+
+    it('should look up the user', function (ctx) {
+      ctx.UserInfoManager.promises.getPersonalInfo
+        .calledWith(ctx.user_id)
+        .should.equal(true)
+    })
+
+    it('should format and inject the user into the message', function (ctx) {
+      ctx.UserInfoController.formatPersonalInfo
+        .calledWith(ctx.user)
+        .should.equal(true)
+      ctx.message.user.should.deep.equal(ctx.formatted_user)
+    })
+
+    it('should tell the chat handler about the message', function (ctx) {
+      ctx.ChatApiHandler.promises.sendGlobalMessage
+        .calledWith(ctx.project_id, ctx.user_id, ctx.content)
+        .should.equal(true)
+    })
+
+    it('should tell the editor real time controller about the update with the data from the chat handler', function (ctx) {
+      ctx.EditorRealTimeController.emitToRoom
+        .calledWith(ctx.project_id, 'new-chat-message', ctx.message)
+        .should.equal(true)
+    })
+
+    it('should return a 204 status code', function (ctx) {
+      ctx.res.sendStatus.calledWith(204).should.equal(true)
+    })
+  })
+
+  describe('getMessages', function () {
+    beforeEach(async function (ctx) {
+      ctx.req.query = {
+        limit: (ctx.limit = '30'),
+        before: (ctx.before = '12345'),
+      }
+      ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon
+        .stub()
+        .resolves()
+      ctx.ChatApiHandler.promises.getGlobalMessages = sinon
+        .stub()
+        .resolves((ctx.messages = ['mock', 'messages']))
+      await ctx.ChatController.getMessages(ctx.req, ctx.res)
+    })
+
+    it('should ask the chat handler about the request', function (ctx) {
+      ctx.ChatApiHandler.promises.getGlobalMessages
+        .calledWith(ctx.project_id, ctx.limit, ctx.before)
+        .should.equal(true)
+    })
+
+    it('should return the messages', function (ctx) {
+      ctx.res.json.calledWith(ctx.messages).should.equal(true)
+    })
+  })
+})

+ 0 - 125
services/web/test/unit/src/Chat/ChatControllerTests.js

@@ -1,125 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-
-const MODULE_PATH = path.join(
-  __dirname,
-  '../../../../app/src/Features/Chat/ChatController'
-)
-
-describe('ChatController', function () {
-  beforeEach(function () {
-    this.user_id = 'mock-user-id'
-    this.settings = {}
-    this.ChatApiHandler = { promises: {} }
-    this.ChatManager = { promises: {} }
-    this.EditorRealTimeController = { emitToRoom: sinon.stub() }
-    this.SessionManager = {
-      getLoggedInUserId: sinon.stub().returns(this.user_id),
-    }
-    this.UserInfoManager = {
-      promises: {},
-    }
-    this.UserInfoController = {}
-    this.Modules = {
-      promises: {
-        hooks: {
-          fire: sinon.stub().resolves(),
-        },
-      },
-    }
-    this.ChatController = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '@overleaf/settings': this.settings,
-        './ChatApiHandler': this.ChatApiHandler,
-        './ChatManager': this.ChatManager,
-        '../Editor/EditorRealTimeController': this.EditorRealTimeController,
-        '../Authentication/SessionManager': this.SessionManager,
-        '../User/UserInfoManager': this.UserInfoManager,
-        '../User/UserInfoController': this.UserInfoController,
-        '../../infrastructure/Modules': this.Modules,
-      },
-    })
-    this.req = {
-      params: {
-        project_id: this.project_id,
-      },
-    }
-    this.res = {
-      json: sinon.stub(),
-      send: sinon.stub(),
-      sendStatus: sinon.stub(),
-    }
-  })
-
-  describe('sendMessage', function () {
-    beforeEach(async function () {
-      this.req.body = { content: (this.content = 'message-content') }
-      this.UserInfoManager.promises.getPersonalInfo = sinon
-        .stub()
-        .resolves((this.user = { unformatted: 'user' }))
-      this.UserInfoController.formatPersonalInfo = sinon
-        .stub()
-        .returns((this.formatted_user = { formatted: 'user' }))
-      this.ChatApiHandler.promises.sendGlobalMessage = sinon
-        .stub()
-        .resolves((this.message = { mock: 'message', user_id: this.user_id }))
-      await this.ChatController.sendMessage(this.req, this.res)
-    })
-
-    it('should look up the user', function () {
-      this.UserInfoManager.promises.getPersonalInfo
-        .calledWith(this.user_id)
-        .should.equal(true)
-    })
-
-    it('should format and inject the user into the message', function () {
-      this.UserInfoController.formatPersonalInfo
-        .calledWith(this.user)
-        .should.equal(true)
-      this.message.user.should.deep.equal(this.formatted_user)
-    })
-
-    it('should tell the chat handler about the message', function () {
-      this.ChatApiHandler.promises.sendGlobalMessage
-        .calledWith(this.project_id, this.user_id, this.content)
-        .should.equal(true)
-    })
-
-    it('should tell the editor real time controller about the update with the data from the chat handler', function () {
-      this.EditorRealTimeController.emitToRoom
-        .calledWith(this.project_id, 'new-chat-message', this.message)
-        .should.equal(true)
-    })
-
-    it('should return a 204 status code', function () {
-      this.res.sendStatus.calledWith(204).should.equal(true)
-    })
-  })
-
-  describe('getMessages', function () {
-    beforeEach(async function () {
-      this.req.query = {
-        limit: (this.limit = '30'),
-        before: (this.before = '12345'),
-      }
-      this.ChatManager.promises.injectUserInfoIntoThreads = sinon
-        .stub()
-        .resolves()
-      this.ChatApiHandler.promises.getGlobalMessages = sinon
-        .stub()
-        .resolves((this.messages = ['mock', 'messages']))
-      await this.ChatController.getMessages(this.req, this.res)
-    })
-
-    it('should ask the chat handler about the request', function () {
-      this.ChatApiHandler.promises.getGlobalMessages
-        .calledWith(this.project_id, this.limit, this.before)
-        .should.equal(true)
-    })
-
-    it('should return the messages', function () {
-      this.res.json.calledWith(this.messages).should.equal(true)
-    })
-  })
-})

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

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

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

@@ -0,0 +1,532 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.js'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+import mongodb from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
+
+const MODULE_PATH =
+  '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs'
+
+describe('OwnershipTransferHandler', function () {
+  beforeEach(async function (ctx) {
+    ctx.user = { _id: new ObjectId(), email: 'owner@example.com' }
+    ctx.collaborator = {
+      _id: new ObjectId(),
+      email: 'collaborator@example.com',
+    }
+    ctx.readOnlyCollaborator = {
+      _id: new ObjectId(),
+      email: 'readonly@example.com',
+    }
+    ctx.reviewer = {
+      _id: new ObjectId(),
+      email: 'reviewer@example.com',
+    }
+    ctx.project = {
+      _id: new ObjectId(),
+      name: 'project',
+      owner_ref: ctx.user._id,
+      collaberator_refs: [ctx.collaborator._id],
+      readOnly_refs: [ctx.readOnlyCollaborator._id],
+      reviewer_refs: [ctx.reviewer._id],
+    }
+    ctx.ProjectGetter = {
+      promises: {
+        getProject: sinon.stub().resolves(ctx.project),
+      },
+    }
+    ctx.ProjectModel = {
+      find: sinon.stub().resolves([]),
+      updateOne: sinon.stub().returns({
+        exec: sinon.stub().resolves(),
+      }),
+    }
+    ctx.UserGetter = {
+      promises: {
+        getUser: sinon.stub().resolves(),
+      },
+    }
+    ctx.TpdsUpdateSender = {
+      promises: {
+        moveEntity: sinon.stub().resolves(),
+      },
+    }
+    ctx.TpdsProjectFlusher = {
+      promises: {
+        flushProjectToTpds: sinon.stub().resolves(),
+      },
+    }
+    ctx.CollaboratorsHandler = {
+      promises: {
+        removeUserFromProject: sinon.stub().resolves(),
+        addUserIdToProject: sinon.stub().resolves(),
+      },
+    }
+    ctx.EmailHandler = {
+      promises: {
+        sendEmail: sinon.stub().resolves(),
+      },
+    }
+    ctx.ProjectAuditLogHandler = {
+      promises: {
+        addEntry: sinon.stub().resolves(),
+      },
+    }
+    ctx.TagsHandler = {
+      promises: {
+        createTag: sinon.stub().resolves(),
+        addProjectsToTag: sinon.stub().resolves(),
+      },
+    }
+
+    vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+      vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({
+      default: ctx.ProjectGetter,
+    }))
+
+    vi.doMock('../../../../app/src/models/Project.js', () => ({
+      Project: ctx.ProjectModel,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Tags/TagsHandler.js', () => ({
+      default: ctx.TagsHandler,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/ThirdPartyDataStore/TpdsProjectFlusher.js',
+      () => ({
+        default: ctx.TpdsProjectFlusher,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectAuditLogHandler.js',
+      () => ({
+        default: ctx.ProjectAuditLogHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Email/EmailHandler.js', () => ({
+      default: ctx.EmailHandler,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js',
+      () => ({
+        default: ctx.CollaboratorsHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager',
+      () => ({
+        default: {
+          recordEventForUserInBackground: (ctx.recordEventForUserInBackground =
+            sinon.stub()),
+        },
+      })
+    )
+
+    ctx.handler = (await import(MODULE_PATH)).default
+  })
+
+  describe('transferOwnership', function () {
+    beforeEach(function (ctx) {
+      ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
+      ctx.UserGetter.promises.getUser
+        .withArgs(ctx.collaborator._id)
+        .resolves(ctx.collaborator)
+      ctx.UserGetter.promises.getUser
+        .withArgs(ctx.readOnlyCollaborator._id)
+        .resolves(ctx.readOnlyCollaborator)
+      ctx.UserGetter.promises.getUser
+        .withArgs(ctx.reviewer._id)
+        .resolves(ctx.reviewer)
+    })
+
+    it("should return a not found error if the project can't be found", async function (ctx) {
+      ctx.ProjectGetter.promises.getProject.resolves(null)
+      await expect(
+        ctx.handler.promises.transferOwnership('abc', ctx.collaborator._id)
+      ).to.be.rejectedWith(Errors.ProjectNotFoundError)
+    })
+
+    it("should return a not found error if the user can't be found", async function (ctx) {
+      ctx.UserGetter.promises.getUser
+        .withArgs(ctx.collaborator._id)
+        .resolves(null)
+      await expect(
+        ctx.handler.promises.transferOwnership(
+          ctx.project._id,
+          ctx.collaborator._id
+        )
+      ).to.be.rejectedWith(Errors.UserNotFoundError)
+    })
+
+    it('should return an error if user cannot be removed as collaborator ', async function (ctx) {
+      ctx.CollaboratorsHandler.promises.removeUserFromProject.rejects(
+        new Error('user-cannot-be-removed')
+      )
+      await expect(
+        ctx.handler.promises.transferOwnership(
+          ctx.project._id,
+          ctx.collaborator._id
+        )
+      ).to.be.rejected
+    })
+
+    it('should transfer ownership of the project', async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.collaborator._id
+      )
+      expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
+        { _id: ctx.project._id },
+        sinon.match({ $set: { owner_ref: ctx.collaborator._id } })
+      )
+    })
+
+    it('should transfer ownership of the project to a read-only collaborator', async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.readOnlyCollaborator._id
+      )
+      expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
+        { _id: ctx.project._id },
+        sinon.match({ $set: { owner_ref: ctx.readOnlyCollaborator._id } })
+      )
+    })
+
+    it('gives old owner read-only permissions if new owner was previously a viewer', async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.readOnlyCollaborator._id
+      )
+      expect(
+        ctx.CollaboratorsHandler.promises.addUserIdToProject
+      ).to.have.been.calledWith(
+        ctx.project._id,
+        ctx.readOnlyCollaborator._id,
+        ctx.user._id,
+        PrivilegeLevels.READ_ONLY
+      )
+    })
+
+    it('should do nothing if transferring back to the owner', async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.user._id
+      )
+      expect(ctx.ProjectModel.updateOne).not.to.have.been.called
+    })
+
+    it("should remove the user from the project's collaborators", async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.collaborator._id
+      )
+      expect(
+        ctx.CollaboratorsHandler.promises.removeUserFromProject
+      ).to.have.been.calledWith(ctx.project._id, ctx.collaborator._id)
+    })
+
+    it('should add the former project owner as a read/write collaborator', async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.collaborator._id
+      )
+      expect(
+        ctx.CollaboratorsHandler.promises.addUserIdToProject
+      ).to.have.been.calledWith(
+        ctx.project._id,
+        ctx.collaborator._id,
+        ctx.user._id,
+        PrivilegeLevels.READ_AND_WRITE
+      )
+    })
+
+    it('should transfer ownership of the project to a reviewer', async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.reviewer._id
+      )
+      expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
+        { _id: ctx.project._id },
+        sinon.match({ $set: { owner_ref: ctx.reviewer._id } })
+      )
+    })
+
+    it('gives old owner reviewer permissions if new owner was previously a reviewer', async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.reviewer._id
+      )
+      expect(
+        ctx.CollaboratorsHandler.promises.addUserIdToProject
+      ).to.have.been.calledWith(
+        ctx.project._id,
+        ctx.reviewer._id,
+        ctx.user._id,
+        PrivilegeLevels.REVIEW
+      )
+    })
+
+    it('should flush the project to tpds', async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.collaborator._id
+      )
+      expect(
+        ctx.TpdsProjectFlusher.promises.flushProjectToTpds
+      ).to.have.been.calledWith(ctx.project._id)
+    })
+
+    it('should send an email notification', async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.collaborator._id
+      )
+      expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
+        'ownershipTransferConfirmationPreviousOwner',
+        {
+          to: ctx.user.email,
+          project: ctx.project,
+          newOwner: ctx.collaborator,
+        }
+      )
+      expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
+        'ownershipTransferConfirmationNewOwner',
+        {
+          to: ctx.collaborator.email,
+          project: ctx.project,
+          previousOwner: ctx.user,
+        }
+      )
+    })
+
+    it('should not send an email notification with the skipEmails option', async function (ctx) {
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.collaborator._id,
+        { skipEmails: true }
+      )
+      expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
+    })
+
+    it('should track the change in BigQuery', async function (ctx) {
+      const sessionUserId = new ObjectId()
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.collaborator._id,
+        { sessionUserId }
+      )
+      expect(ctx.recordEventForUserInBackground).to.have.been.calledWith(
+        ctx.user._id,
+        'project-ownership-transfer',
+        {
+          projectId: ctx.project._id,
+          newOwnerId: ctx.collaborator._id,
+        }
+      )
+    })
+
+    it('should write an entry in the audit log', async function (ctx) {
+      const sessionUserId = new ObjectId()
+      const ipAddress = '1.2.3.4'
+      await ctx.handler.promises.transferOwnership(
+        ctx.project._id,
+        ctx.collaborator._id,
+        { sessionUserId, ipAddress }
+      )
+      expect(
+        ctx.ProjectAuditLogHandler.promises.addEntry
+      ).to.have.been.calledWith(
+        ctx.project._id,
+        'transfer-ownership',
+        sessionUserId,
+        ipAddress,
+        {
+          previousOwnerId: ctx.user._id,
+          newOwnerId: ctx.collaborator._id,
+        }
+      )
+    })
+
+    it('should decline to transfer ownership to a non-collaborator', async function (ctx) {
+      ctx.project.collaberator_refs = []
+      ctx.project.readOnly_refs = []
+      await expect(
+        ctx.handler.promises.transferOwnership(
+          ctx.project._id,
+          ctx.collaborator._id
+        )
+      ).to.be.rejectedWith(Errors.UserNotCollaboratorError)
+    })
+  })
+
+  describe('transferAllProjectsToUser', function () {
+    const fromUserEmail = 'user.one@example.com'
+    const ipAddress = '1.2.3.4'
+    let fromUserId, toUserId
+    beforeEach(function () {
+      fromUserId = new ObjectId().toString()
+      toUserId = new ObjectId().toString()
+    })
+
+    describe('with missing user', function () {
+      it('should throw an error', async function (ctx) {
+        ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves(null)
+        ctx.UserGetter.promises.getUser
+          .withArgs(toUserId)
+          .resolves({ _id: new ObjectId(toUserId) })
+        await expect(
+          ctx.handler.promises.transferAllProjectsToUser({
+            toUserId,
+            fromUserId,
+            ipAddress,
+          })
+        ).to.be.rejectedWith(/missing source user/)
+
+        ctx.UserGetter.promises.getUser
+          .withArgs(fromUserId)
+          .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
+        ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves(null)
+        await expect(
+          ctx.handler.promises.transferAllProjectsToUser({
+            fromUserId,
+            toUserId,
+            ipAddress,
+          })
+        ).to.be.rejectedWith(/missing destination user/)
+      })
+    })
+
+    describe('with the same id', function () {
+      it('should throw an error', async function (ctx) {
+        ctx.UserGetter.promises.getUser
+          .withArgs(fromUserId)
+          .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
+        await expect(
+          ctx.handler.promises.transferAllProjectsToUser({
+            fromUserId,
+            toUserId: fromUserId,
+            ipAddress,
+          })
+        ).to.be.rejectedWith(/rejecting transfer between identical users/)
+      })
+    })
+
+    describe('happy path', function () {
+      let tag, fromUserEmail, projects
+
+      beforeEach(function (ctx) {
+        tag = {
+          _id: new ObjectId(),
+          name: 'some-tag-name',
+        }
+        projects = [
+          { _id: 'project-1' },
+          { _id: 'project-2' },
+          { _id: 'project-3' },
+        ]
+
+        ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves({
+          _id: new ObjectId(fromUserId),
+          email: fromUserEmail,
+        })
+        ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves({
+          _id: new ObjectId(toUserId),
+        })
+        ctx.ProjectModel.find.resolves(projects)
+        ctx.TagsHandler.promises.createTag.resolves({
+          _id: tag._id,
+          name: 'some-tag-name',
+        })
+        ctx.TagsHandler.promises.addProjectsToTag.resolves()
+      })
+
+      it('creates a tag', async function (ctx) {
+        await ctx.handler.promises.transferAllProjectsToUser({
+          fromUserId,
+          toUserId,
+          ipAddress,
+        })
+        expect(ctx.TagsHandler.promises.createTag).to.have.been.calledWith(
+          toUserId,
+          `transferred-from-${fromUserEmail}`,
+          '#434AF0',
+          { truncate: true }
+        )
+      })
+
+      it('returns a projectCount, and tag name', async function (ctx) {
+        const result = await ctx.handler.promises.transferAllProjectsToUser({
+          fromUserId,
+          toUserId,
+          ipAddress,
+        })
+        expect(result.projectCount).to.equal(projects.length)
+        expect(result.newTagName).to.equal('some-tag-name')
+      })
+
+      it('gets the user records', async function (ctx) {
+        await ctx.handler.promises.transferAllProjectsToUser({
+          fromUserId,
+          toUserId,
+          ipAddress,
+        })
+        expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
+          fromUserId
+        )
+        expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
+          toUserId
+        )
+      })
+
+      it('gets the list of affected projects', async function (ctx) {
+        await ctx.handler.promises.transferAllProjectsToUser({
+          fromUserId,
+          toUserId,
+          ipAddress,
+        })
+        expect(ctx.ProjectModel.find).to.have.been.calledWith({
+          owner_ref: fromUserId,
+        })
+      })
+
+      it('transfers all of the projects', async function (ctx) {
+        await ctx.handler.promises.transferAllProjectsToUser({
+          fromUserId,
+          toUserId,
+          ipAddress,
+        })
+
+        expect(ctx.ProjectModel.updateOne.callCount).to.equal(3)
+        expect(ctx.TagsHandler.promises.addProjectsToTag.callCount).to.equal(1)
+
+        for (const project of projects) {
+          expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
+            { _id: project._id },
+            sinon.match({ $set: { owner_ref: toUserId } })
+          )
+        }
+        expect(
+          ctx.TagsHandler.promises.addProjectsToTag
+        ).to.have.been.calledWith(
+          toUserId,
+          tag._id,
+          projects.map(p => p._id)
+        )
+      })
+    })
+  })
+})

+ 0 - 494
services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js

@@ -1,494 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const PrivilegeLevels = require('../../../../app/src/Features/Authorization/PrivilegeLevels')
-const Errors = require('../../../../app/src/Features/Errors/Errors')
-const { ObjectId } = require('mongodb-legacy')
-
-const MODULE_PATH =
-  '../../../../app/src/Features/Collaborators/OwnershipTransferHandler'
-
-describe('OwnershipTransferHandler', function () {
-  beforeEach(function () {
-    this.user = { _id: new ObjectId(), email: 'owner@example.com' }
-    this.collaborator = {
-      _id: new ObjectId(),
-      email: 'collaborator@example.com',
-    }
-    this.readOnlyCollaborator = {
-      _id: new ObjectId(),
-      email: 'readonly@example.com',
-    }
-    this.reviewer = {
-      _id: new ObjectId(),
-      email: 'reviewer@example.com',
-    }
-    this.project = {
-      _id: new ObjectId(),
-      name: 'project',
-      owner_ref: this.user._id,
-      collaberator_refs: [this.collaborator._id],
-      readOnly_refs: [this.readOnlyCollaborator._id],
-      reviewer_refs: [this.reviewer._id],
-    }
-    this.ProjectGetter = {
-      promises: {
-        getProject: sinon.stub().resolves(this.project),
-      },
-    }
-    this.ProjectModel = {
-      find: sinon.stub().resolves([]),
-      updateOne: sinon.stub().returns({
-        exec: sinon.stub().resolves(),
-      }),
-    }
-    this.UserGetter = {
-      promises: {
-        getUser: sinon.stub().resolves(),
-      },
-    }
-    this.TpdsUpdateSender = {
-      promises: {
-        moveEntity: sinon.stub().resolves(),
-      },
-    }
-    this.TpdsProjectFlusher = {
-      promises: {
-        flushProjectToTpds: sinon.stub().resolves(),
-      },
-    }
-    this.CollaboratorsHandler = {
-      promises: {
-        removeUserFromProject: sinon.stub().resolves(),
-        addUserIdToProject: sinon.stub().resolves(),
-      },
-    }
-    this.EmailHandler = {
-      promises: {
-        sendEmail: sinon.stub().resolves(),
-      },
-    }
-    this.ProjectAuditLogHandler = {
-      promises: {
-        addEntry: sinon.stub().resolves(),
-      },
-    }
-    this.TagsHandler = {
-      promises: {
-        createTag: sinon.stub().resolves(),
-        addProjectsToTag: sinon.stub().resolves(),
-      },
-    }
-    this.handler = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '../Project/ProjectGetter': this.ProjectGetter,
-        '../../models/Project': {
-          Project: this.ProjectModel,
-        },
-        '../Tags/TagsHandler': this.TagsHandler,
-        '../User/UserGetter': this.UserGetter,
-        '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
-        '../Project/ProjectAuditLogHandler': this.ProjectAuditLogHandler,
-        '../Email/EmailHandler': this.EmailHandler,
-        './CollaboratorsHandler': this.CollaboratorsHandler,
-        '../Analytics/AnalyticsManager': {
-          recordEventForUserInBackground: (this.recordEventForUserInBackground =
-            sinon.stub()),
-        },
-      },
-    })
-  })
-
-  describe('transferOwnership', function () {
-    beforeEach(function () {
-      this.UserGetter.promises.getUser
-        .withArgs(this.user._id)
-        .resolves(this.user)
-      this.UserGetter.promises.getUser
-        .withArgs(this.collaborator._id)
-        .resolves(this.collaborator)
-      this.UserGetter.promises.getUser
-        .withArgs(this.readOnlyCollaborator._id)
-        .resolves(this.readOnlyCollaborator)
-      this.UserGetter.promises.getUser
-        .withArgs(this.reviewer._id)
-        .resolves(this.reviewer)
-    })
-
-    it("should return a not found error if the project can't be found", async function () {
-      this.ProjectGetter.promises.getProject.resolves(null)
-      await expect(
-        this.handler.promises.transferOwnership('abc', this.collaborator._id)
-      ).to.be.rejectedWith(Errors.ProjectNotFoundError)
-    })
-
-    it("should return a not found error if the user can't be found", async function () {
-      this.UserGetter.promises.getUser
-        .withArgs(this.collaborator._id)
-        .resolves(null)
-      await expect(
-        this.handler.promises.transferOwnership(
-          this.project._id,
-          this.collaborator._id
-        )
-      ).to.be.rejectedWith(Errors.UserNotFoundError)
-    })
-
-    it('should return an error if user cannot be removed as collaborator ', async function () {
-      this.CollaboratorsHandler.promises.removeUserFromProject.rejects(
-        new Error('user-cannot-be-removed')
-      )
-      await expect(
-        this.handler.promises.transferOwnership(
-          this.project._id,
-          this.collaborator._id
-        )
-      ).to.be.rejected
-    })
-
-    it('should transfer ownership of the project', async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.collaborator._id
-      )
-      expect(this.ProjectModel.updateOne).to.have.been.calledWith(
-        { _id: this.project._id },
-        sinon.match({ $set: { owner_ref: this.collaborator._id } })
-      )
-    })
-
-    it('should transfer ownership of the project to a read-only collaborator', async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.readOnlyCollaborator._id
-      )
-      expect(this.ProjectModel.updateOne).to.have.been.calledWith(
-        { _id: this.project._id },
-        sinon.match({ $set: { owner_ref: this.readOnlyCollaborator._id } })
-      )
-    })
-
-    it('gives old owner read-only permissions if new owner was previously a viewer', async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.readOnlyCollaborator._id
-      )
-      expect(
-        this.CollaboratorsHandler.promises.addUserIdToProject
-      ).to.have.been.calledWith(
-        this.project._id,
-        this.readOnlyCollaborator._id,
-        this.user._id,
-        PrivilegeLevels.READ_ONLY
-      )
-    })
-
-    it('should do nothing if transferring back to the owner', async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.user._id
-      )
-      expect(this.ProjectModel.updateOne).not.to.have.been.called
-    })
-
-    it("should remove the user from the project's collaborators", async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.collaborator._id
-      )
-      expect(
-        this.CollaboratorsHandler.promises.removeUserFromProject
-      ).to.have.been.calledWith(this.project._id, this.collaborator._id)
-    })
-
-    it('should add the former project owner as a read/write collaborator', async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.collaborator._id
-      )
-      expect(
-        this.CollaboratorsHandler.promises.addUserIdToProject
-      ).to.have.been.calledWith(
-        this.project._id,
-        this.collaborator._id,
-        this.user._id,
-        PrivilegeLevels.READ_AND_WRITE
-      )
-    })
-
-    it('should transfer ownership of the project to a reviewer', async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.reviewer._id
-      )
-      expect(this.ProjectModel.updateOne).to.have.been.calledWith(
-        { _id: this.project._id },
-        sinon.match({ $set: { owner_ref: this.reviewer._id } })
-      )
-    })
-
-    it('gives old owner reviewer permissions if new owner was previously a reviewer', async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.reviewer._id
-      )
-      expect(
-        this.CollaboratorsHandler.promises.addUserIdToProject
-      ).to.have.been.calledWith(
-        this.project._id,
-        this.reviewer._id,
-        this.user._id,
-        PrivilegeLevels.REVIEW
-      )
-    })
-
-    it('should flush the project to tpds', async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.collaborator._id
-      )
-      expect(
-        this.TpdsProjectFlusher.promises.flushProjectToTpds
-      ).to.have.been.calledWith(this.project._id)
-    })
-
-    it('should send an email notification', async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.collaborator._id
-      )
-      expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
-        'ownershipTransferConfirmationPreviousOwner',
-        {
-          to: this.user.email,
-          project: this.project,
-          newOwner: this.collaborator,
-        }
-      )
-      expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
-        'ownershipTransferConfirmationNewOwner',
-        {
-          to: this.collaborator.email,
-          project: this.project,
-          previousOwner: this.user,
-        }
-      )
-    })
-
-    it('should not send an email notification with the skipEmails option', async function () {
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.collaborator._id,
-        { skipEmails: true }
-      )
-      expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called
-    })
-
-    it('should track the change in BigQuery', async function () {
-      const sessionUserId = new ObjectId()
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.collaborator._id,
-        { sessionUserId }
-      )
-      expect(this.recordEventForUserInBackground).to.have.been.calledWith(
-        this.user._id,
-        'project-ownership-transfer',
-        {
-          projectId: this.project._id,
-          newOwnerId: this.collaborator._id,
-        }
-      )
-    })
-
-    it('should write an entry in the audit log', async function () {
-      const sessionUserId = new ObjectId()
-      const ipAddress = '1.2.3.4'
-      await this.handler.promises.transferOwnership(
-        this.project._id,
-        this.collaborator._id,
-        { sessionUserId, ipAddress }
-      )
-      expect(
-        this.ProjectAuditLogHandler.promises.addEntry
-      ).to.have.been.calledWith(
-        this.project._id,
-        'transfer-ownership',
-        sessionUserId,
-        ipAddress,
-        {
-          previousOwnerId: this.user._id,
-          newOwnerId: this.collaborator._id,
-        }
-      )
-    })
-
-    it('should decline to transfer ownership to a non-collaborator', async function () {
-      this.project.collaberator_refs = []
-      this.project.readOnly_refs = []
-      await expect(
-        this.handler.promises.transferOwnership(
-          this.project._id,
-          this.collaborator._id
-        )
-      ).to.be.rejectedWith(Errors.UserNotCollaboratorError)
-    })
-  })
-
-  describe('transferAllProjectsToUser', function () {
-    const fromUserEmail = 'user.one@example.com'
-    const ipAddress = '1.2.3.4'
-    let fromUserId, toUserId
-    beforeEach(function () {
-      fromUserId = new ObjectId().toString()
-      toUserId = new ObjectId().toString()
-    })
-
-    describe('with missing user', function () {
-      it('should throw an error', async function () {
-        this.UserGetter.promises.getUser.withArgs(fromUserId).resolves(null)
-        this.UserGetter.promises.getUser
-          .withArgs(toUserId)
-          .resolves({ _id: new ObjectId(toUserId) })
-        await expect(
-          this.handler.promises.transferAllProjectsToUser({
-            toUserId,
-            fromUserId,
-            ipAddress,
-          })
-        ).to.be.rejectedWith(/missing source user/)
-
-        this.UserGetter.promises.getUser
-          .withArgs(fromUserId)
-          .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
-        this.UserGetter.promises.getUser.withArgs(toUserId).resolves(null)
-        await expect(
-          this.handler.promises.transferAllProjectsToUser({
-            fromUserId,
-            toUserId,
-            ipAddress,
-          })
-        ).to.be.rejectedWith(/missing destination user/)
-      })
-    })
-
-    describe('with the same id', function () {
-      it('should throw an error', async function () {
-        this.UserGetter.promises.getUser
-          .withArgs(fromUserId)
-          .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
-        await expect(
-          this.handler.promises.transferAllProjectsToUser({
-            fromUserId,
-            toUserId: fromUserId,
-            ipAddress,
-          })
-        ).to.be.rejectedWith(/rejecting transfer between identical users/)
-      })
-    })
-
-    describe('happy path', function () {
-      let tag, fromUserEmail, projects
-
-      beforeEach(function () {
-        tag = {
-          _id: new ObjectId(),
-          name: 'some-tag-name',
-        }
-        projects = [
-          { _id: 'project-1' },
-          { _id: 'project-2' },
-          { _id: 'project-3' },
-        ]
-
-        this.UserGetter.promises.getUser.withArgs(fromUserId).resolves({
-          _id: new ObjectId(fromUserId),
-          email: fromUserEmail,
-        })
-        this.UserGetter.promises.getUser.withArgs(toUserId).resolves({
-          _id: new ObjectId(toUserId),
-        })
-        this.ProjectModel.find.resolves(projects)
-        this.TagsHandler.promises.createTag.resolves({
-          _id: tag._id,
-          name: 'some-tag-name',
-        })
-        this.TagsHandler.promises.addProjectsToTag.resolves()
-      })
-
-      it('creates a tag', async function () {
-        await this.handler.promises.transferAllProjectsToUser({
-          fromUserId,
-          toUserId,
-          ipAddress,
-        })
-        expect(this.TagsHandler.promises.createTag).to.have.been.calledWith(
-          toUserId,
-          `transferred-from-${fromUserEmail}`,
-          '#434AF0',
-          { truncate: true }
-        )
-      })
-
-      it('returns a projectCount, and tag name', async function () {
-        const result = await this.handler.promises.transferAllProjectsToUser({
-          fromUserId,
-          toUserId,
-          ipAddress,
-        })
-        expect(result.projectCount).to.equal(projects.length)
-        expect(result.newTagName).to.equal('some-tag-name')
-      })
-
-      it('gets the user records', async function () {
-        await this.handler.promises.transferAllProjectsToUser({
-          fromUserId,
-          toUserId,
-          ipAddress,
-        })
-        expect(this.UserGetter.promises.getUser).to.have.been.calledWith(
-          fromUserId
-        )
-        expect(this.UserGetter.promises.getUser).to.have.been.calledWith(
-          toUserId
-        )
-      })
-
-      it('gets the list of affected projects', async function () {
-        await this.handler.promises.transferAllProjectsToUser({
-          fromUserId,
-          toUserId,
-          ipAddress,
-        })
-        expect(this.ProjectModel.find).to.have.been.calledWith({
-          owner_ref: fromUserId,
-        })
-      })
-
-      it('transfers all of the projects', async function () {
-        await this.handler.promises.transferAllProjectsToUser({
-          fromUserId,
-          toUserId,
-          ipAddress,
-        })
-
-        expect(this.ProjectModel.updateOne.callCount).to.equal(3)
-        expect(this.TagsHandler.promises.addProjectsToTag.callCount).to.equal(1)
-
-        for (const project of projects) {
-          expect(this.ProjectModel.updateOne).to.have.been.calledWith(
-            { _id: project._id },
-            sinon.match({ $set: { owner_ref: toUserId } })
-          )
-        }
-        expect(
-          this.TagsHandler.promises.addProjectsToTag
-        ).to.have.been.calledWith(
-          toUserId,
-          tag._id,
-          projects.map(p => p._id)
-        )
-      })
-    })
-  })
-})

+ 735 - 0
services/web/test/unit/src/Editor/EditorHttpController.test.mjs

@@ -0,0 +1,735 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+import MockRequest from '../helpers/MockRequest.js'
+import MockResponse from '../helpers/MockResponse.js'
+
+const { ObjectId } = mongodb
+
+const MODULE_PATH =
+  '../../../../app/src/Features/Editor/EditorHttpController.mjs'
+
+describe('EditorHttpController', function () {
+  beforeEach(async function (ctx) {
+    ctx.ownerId = new ObjectId()
+    ctx.project = {
+      _id: new ObjectId(),
+      owner_ref: ctx.ownerId,
+    }
+    ctx.user = {
+      _id: new ObjectId(),
+      projects: {},
+    }
+    ctx.members = [
+      { user: { _id: 'owner', features: {} }, privilegeLevel: 'owner' },
+      { user: { _id: 'one' }, privilegeLevel: 'readOnly' },
+    ]
+    ctx.ownerMember = ctx.members[0]
+    ctx.invites = [{ _id: 'three' }, { _id: 'four' }]
+    ctx.projectView = {
+      _id: ctx.project._id,
+      owner: {
+        _id: 'owner',
+        email: 'owner@example.com',
+        other_property: true,
+      },
+      members: [
+        { _id: 'owner', privileges: 'owner' },
+        { _id: 'one', privileges: 'readOnly' },
+      ],
+      invites: [{ three: 3 }, { four: 4 }],
+    }
+    ctx.reducedProjectView = {
+      _id: ctx.projectView._id,
+      owner: { _id: ctx.projectView.owner._id },
+      members: [],
+      invites: [],
+    }
+    ctx.doc = { _id: new ObjectId(), name: 'excellent-original-idea.tex' }
+    ctx.file = { _id: new ObjectId() }
+    ctx.folder = { _id: new ObjectId() }
+    ctx.source = 'editor'
+
+    ctx.parentFolderId = 'mock-folder-id'
+    ctx.req = new MockRequest()
+    ctx.res = new MockResponse()
+    ctx.next = sinon.stub()
+    ctx.token = null
+    ctx.docLines = ['hello', 'overleaf']
+
+    ctx.AuthorizationManager = {
+      isRestrictedUser: sinon.stub().returns(false),
+      promises: {
+        getPrivilegeLevelForProjectWithProjectAccess: sinon
+          .stub()
+          .resolves('owner'),
+      },
+    }
+    const members = ctx.members
+    const ownerMember = ctx.ownerMember
+    ctx.CollaboratorsGetter = {
+      ProjectAccess: class {
+        loadOwnerAndInvitedMembers() {
+          return { members, ownerMember }
+        }
+
+        loadOwner() {
+          return ownerMember
+        }
+
+        isUserTokenMember() {
+          return false
+        }
+
+        isUserInvitedMember() {
+          return false
+        }
+      },
+      promises: {
+        isUserInvitedMemberOfProject: sinon.stub().resolves(false),
+      },
+    }
+    ctx.CollaboratorsHandler = {
+      promises: {
+        userIsTokenMember: sinon.stub().resolves(false),
+      },
+    }
+    ctx.invites = [
+      {
+        _id: 'invite_one',
+        email: 'user-one@example.com',
+        privileges: 'readOnly',
+        projectId: ctx.project._id,
+      },
+      {
+        _id: 'invite_two',
+        email: 'user-two@example.com',
+        privileges: 'readOnly',
+        projectId: ctx.project._id,
+      },
+    ]
+    ctx.CollaboratorsInviteGetter = {
+      promises: {
+        getAllInvites: sinon.stub().resolves(ctx.invites),
+      },
+    }
+    ctx.EditorController = {
+      promises: {
+        addDoc: sinon.stub().resolves(ctx.doc),
+        addFile: sinon.stub().resolves(ctx.file),
+        addFolder: sinon.stub().resolves(ctx.folder),
+        renameEntity: sinon.stub().resolves(),
+        moveEntity: sinon.stub().resolves(),
+        deleteEntity: sinon.stub().resolves(),
+      },
+    }
+    ctx.ProjectDeleter = {
+      promises: {
+        unmarkAsDeletedByExternalSource: sinon.stub().resolves(),
+      },
+    }
+    ctx.ProjectGetter = {
+      promises: {
+        getProjectWithoutDocLines: sinon.stub().resolves(ctx.project),
+      },
+    }
+    ctx.ProjectEditorHandler = {
+      buildProjectModelView: sinon.stub().returns(ctx.projectView),
+    }
+    ctx.Metrics = { inc: sinon.stub() }
+    ctx.TokenAccessHandler = {
+      getRequestToken: sinon.stub().returns(ctx.token),
+    }
+    ctx.SessionManager = {
+      getLoggedInUserId: sinon.stub().returns(ctx.user._id),
+    }
+    ctx.ProjectEntityUpdateHandler = {
+      promises: {
+        convertDocToFile: sinon.stub().resolves(ctx.file),
+      },
+    }
+    ctx.DocstoreManager = {
+      promises: {
+        getAllDeletedDocs: sinon.stub().resolves([]),
+      },
+    }
+    ctx.HttpErrorHandler = {
+      notFound: sinon.stub(),
+      unprocessableEntity: sinon.stub(),
+    }
+    ctx.SplitTestHandler = {
+      promises: {
+        getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
+      },
+    }
+    ctx.UserGetter = { promises: { getUser: sinon.stub().resolves(null, {}) } }
+
+    vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+      vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+    )
+    vi.doMock('../../../../app/src/Features/Project/ProjectDeleter.js', () => ({
+      default: ctx.ProjectDeleter,
+    }))
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({
+      default: ctx.ProjectGetter,
+    }))
+    vi.doMock(
+      '../../../../app/src/Features/Authorization/AuthorizationManager.js',
+      () => ({
+        default: ctx.AuthorizationManager,
+      })
+    )
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEditorHandler.js',
+      () => ({
+        default: ctx.ProjectEditorHandler,
+      })
+    )
+    vi.doMock(
+      '../../../../app/src/Features/Editor/EditorController.js',
+      () => ({
+        default: ctx.EditorController,
+      })
+    )
+    vi.doMock('@overleaf/metrics', () => ({
+      default: ctx.Metrics,
+    }))
+    vi.doMock(
+      '../../../../app/src/Features/Collaborators/CollaboratorsGetter.js',
+      () => ({
+        default: ctx.CollaboratorsGetter,
+      })
+    )
+    vi.doMock(
+      '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js',
+      () => ({
+        default: ctx.CollaboratorsHandler,
+      })
+    )
+    vi.doMock(
+      '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.js',
+      () => ({
+        default: ctx.CollaboratorsInviteGetter,
+      })
+    )
+    vi.doMock(
+      '../../../../app/src/Features/TokenAccess/TokenAccessHandler.js',
+      () => ({
+        default: ctx.TokenAccessHandler,
+      })
+    )
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager.js',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+    vi.doMock('../../../../app/src/infrastructure/FileWriter.js', () => ({
+      default: ctx.FileWriter,
+    }))
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityUpdateHandler.js',
+      () => ({
+        default: ctx.ProjectEntityUpdateHandler,
+      })
+    )
+    vi.doMock(
+      '../../../../app/src/Features/Docstore/DocstoreManager.js',
+      () => ({
+        default: ctx.DocstoreManager,
+      })
+    )
+    vi.doMock(
+      '../../../../app/src/Features/Errors/HttpErrorHandler.js',
+      () => ({
+        default: ctx.HttpErrorHandler,
+      })
+    )
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler.js',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+    vi.doMock('../../../../app/src/Features/Compile/CompileManager.js', () => ({
+      default: {},
+    }))
+    vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    ctx.EditorHttpController = (await import(MODULE_PATH)).default
+  })
+
+  describe('joinProject', function () {
+    beforeEach(function (ctx) {
+      ctx.req.params = { Project_id: ctx.project._id }
+      ctx.req.query = { user_id: ctx.user._id }
+      ctx.req.body = { userId: ctx.user._id }
+    })
+
+    describe('successfully', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          sinon
+            .stub(
+              ctx.CollaboratorsGetter.ProjectAccess.prototype,
+              'isUserInvitedMember'
+            )
+            .returns(true)
+          ctx.res.callback = resolve
+          ctx.EditorHttpController.joinProject(ctx.req, ctx.res)
+        })
+      })
+
+      it('should request a full view', function (ctx) {
+        expect(
+          ctx.ProjectEditorHandler.buildProjectModelView
+        ).to.have.been.calledWith(
+          ctx.project,
+          ctx.ownerMember,
+          ctx.members,
+          ctx.invites,
+          false
+        )
+      })
+
+      it('should return the project and privilege level', function (ctx) {
+        expect(ctx.res.json).to.have.been.calledWith({
+          project: ctx.projectView,
+          privilegeLevel: 'owner',
+          isRestrictedUser: false,
+          isTokenMember: false,
+          isInvitedMember: true,
+        })
+      })
+
+      it('should not try to unmark the project as deleted', function (ctx) {
+        expect(ctx.ProjectDeleter.promises.unmarkAsDeletedByExternalSource).not
+          .to.have.been.called
+      })
+
+      it('should send an inc metric', function (ctx) {
+        expect(ctx.Metrics.inc).to.have.been.calledWith('editor.join-project')
+      })
+    })
+
+    describe('when the project is marked as deleted', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.projectView.deletedByExternalDataSource = true
+          ctx.res.callback = resolve
+          ctx.EditorHttpController.joinProject(ctx.req, ctx.res)
+        })
+      })
+
+      it('should unmark the project as deleted', function (ctx) {
+        expect(
+          ctx.ProjectDeleter.promises.unmarkAsDeletedByExternalSource
+        ).to.have.been.calledWith(ctx.project._id)
+      })
+    })
+
+    describe('with a restricted user', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.ProjectEditorHandler.buildProjectModelView.returns(
+            ctx.reducedProjectView
+          )
+          ctx.AuthorizationManager.isRestrictedUser.returns(true)
+          ctx.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves(
+            'readOnly'
+          )
+          ctx.res.callback = resolve
+          ctx.EditorHttpController.joinProject(ctx.req, ctx.res)
+        })
+      })
+
+      it('should request a restricted view', function (ctx) {
+        expect(
+          ctx.ProjectEditorHandler.buildProjectModelView
+        ).to.have.been.calledWith(ctx.project, ctx.ownerMember, [], [], true)
+      })
+
+      it('should mark the user as restricted, and hide details of owner', function (ctx) {
+        expect(ctx.res.json).to.have.been.calledWith({
+          project: ctx.reducedProjectView,
+          privilegeLevel: 'readOnly',
+          isRestrictedUser: true,
+          isTokenMember: false,
+          isInvitedMember: false,
+        })
+      })
+    })
+
+    describe('when not authorized', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves(
+            null
+          )
+          ctx.res.callback = resolve
+          ctx.EditorHttpController.joinProject(ctx.req, ctx.res)
+        })
+      })
+
+      it('should send a 403 response', function (ctx) {
+        expect(ctx.res.statusCode).to.equal(403)
+      })
+    })
+
+    describe('with an anonymous user', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.token = 'token'
+          ctx.TokenAccessHandler.getRequestToken.returns(ctx.token)
+          ctx.ProjectEditorHandler.buildProjectModelView.returns(
+            ctx.reducedProjectView
+          )
+          ctx.req.body = {
+            userId: 'anonymous-user',
+            anonymousAccessToken: ctx.token,
+          }
+          ctx.res.callback = resolve
+          ctx.AuthorizationManager.isRestrictedUser
+            .withArgs(null, 'readOnly', false, false)
+            .returns(true)
+          ctx.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess
+            .withArgs(null, ctx.project._id, ctx.token)
+            .resolves('readOnly')
+          ctx.EditorHttpController.joinProject(ctx.req, ctx.res)
+        })
+      })
+
+      it('should request a restricted view', function (ctx) {
+        expect(
+          ctx.ProjectEditorHandler.buildProjectModelView
+        ).to.have.been.calledWith(ctx.project, ctx.ownerMember, [], [], true)
+      })
+
+      it('should mark the user as restricted', function (ctx) {
+        expect(ctx.res.json).to.have.been.calledWith({
+          project: ctx.reducedProjectView,
+          privilegeLevel: 'readOnly',
+          isRestrictedUser: true,
+          isTokenMember: false,
+          isInvitedMember: false,
+        })
+      })
+    })
+
+    describe('with a token access user', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          sinon
+            .stub(
+              ctx.CollaboratorsGetter.ProjectAccess.prototype,
+              'isUserInvitedMember'
+            )
+            .returns(false)
+          sinon
+            .stub(
+              ctx.CollaboratorsGetter.ProjectAccess.prototype,
+              'isUserTokenMember'
+            )
+            .returns(true)
+          ctx.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves(
+            'readAndWrite'
+          )
+          ctx.res.callback = resolve
+          ctx.EditorHttpController.joinProject(ctx.req, ctx.res)
+        })
+      })
+
+      it('should mark the user as being a token-access member', function (ctx) {
+        expect(ctx.res.json).to.have.been.calledWith({
+          project: ctx.projectView,
+          privilegeLevel: 'readAndWrite',
+          isRestrictedUser: false,
+          isTokenMember: true,
+          isInvitedMember: false,
+        })
+      })
+    })
+
+    describe('when project is not found', function () {
+      beforeEach(async function (ctx) {
+        ctx.ProjectGetter.promises.getProjectWithoutDocLines.resolves(null)
+        await new Promise(resolve => {
+          ctx.next.callsFake(() => resolve())
+          ctx.EditorHttpController.joinProject(ctx.req, ctx.res, ctx.next)
+        })
+      })
+
+      it('should handle return not found error', function (ctx) {
+        expect(ctx.next).to.have.been.calledWith(
+          sinon.match.instanceOf(Errors.NotFoundError)
+        )
+      })
+    })
+  })
+
+  describe('addDoc', function () {
+    beforeEach(function (ctx) {
+      ctx.req.params = { Project_id: ctx.project._id }
+      ctx.req.body = {
+        name: (ctx.docName = 'doc-name'),
+        parent_folder_id: ctx.parentFolderId,
+      }
+    })
+
+    describe('successfully', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.res.callback = resolve
+          ctx.EditorHttpController.addDoc(ctx.req, ctx.res)
+        })
+      })
+
+      it('should call EditorController.addDoc', function (ctx) {
+        expect(ctx.EditorController.promises.addDoc).to.have.been.calledWith(
+          ctx.project._id,
+          ctx.parentFolderId,
+          ctx.docName,
+          [],
+          'editor',
+          ctx.user._id
+        )
+      })
+
+      it('should send the doc back as JSON', function (ctx) {
+        expect(ctx.res.json).to.have.been.calledWith(ctx.doc)
+      })
+    })
+
+    describe('unsuccesfully', function () {
+      it('handle name too short', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.req.body.name = ''
+          ctx.res.callback = () => {
+            expect(ctx.res.statusCode).to.equal(400)
+            resolve()
+          }
+          ctx.EditorHttpController.addDoc(ctx.req, ctx.res)
+        })
+      })
+
+      it('handle too many files', async function (ctx) {
+        ctx.EditorController.promises.addDoc.rejects(
+          new Error('project_has_too_many_files')
+        )
+        await new Promise(resolve => {
+          ctx.res.callback = () => {
+            expect(ctx.res.body).to.equal('"project_has_too_many_files"')
+            expect(ctx.res.status).to.have.been.calledWith(400)
+            resolve()
+          }
+          ctx.EditorHttpController.addDoc(ctx.req, ctx.res)
+        })
+      })
+    })
+  })
+
+  describe('addFolder', function () {
+    beforeEach(function (ctx) {
+      ctx.folderName = 'folder-name'
+      ctx.req.params = { Project_id: ctx.project._id }
+      ctx.req.body = {
+        name: ctx.folderName,
+        parent_folder_id: ctx.parentFolderId,
+      }
+    })
+
+    describe('successfully', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.res.callback = resolve
+          ctx.EditorHttpController.addFolder(ctx.req, ctx.res)
+        })
+      })
+
+      it('should call EditorController.addFolder', function (ctx) {
+        expect(ctx.EditorController.promises.addFolder).to.have.been.calledWith(
+          ctx.project._id,
+          ctx.parentFolderId,
+          ctx.folderName,
+          'editor'
+        )
+      })
+
+      it('should send the folder back as JSON', function (ctx) {
+        expect(ctx.res.json).to.have.been.calledWith(ctx.folder)
+      })
+    })
+
+    describe('unsuccesfully', function () {
+      it('handle name too short', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.req.body.name = ''
+          ctx.res.callback = () => {
+            expect(ctx.res.statusCode).to.equal(400)
+            resolve()
+          }
+          ctx.EditorHttpController.addFolder(ctx.req, ctx.res)
+        })
+      })
+
+      it('handle too many files', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.EditorController.promises.addFolder.rejects(
+            new Error('project_has_too_many_files')
+          )
+          ctx.res.callback = () => {
+            expect(ctx.res.body).to.equal('"project_has_too_many_files"')
+            expect(ctx.res.statusCode).to.equal(400)
+            resolve()
+          }
+          ctx.EditorHttpController.addFolder(ctx.req, ctx.res)
+        })
+      })
+
+      it('handle invalid element name', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.EditorController.promises.addFolder.rejects(
+            new Error('invalid element name')
+          )
+          ctx.res.callback = () => {
+            expect(ctx.res.body).to.equal('"invalid_file_name"')
+            expect(ctx.res.statusCode).to.equal(400)
+            resolve()
+          }
+          ctx.EditorHttpController.addFolder(ctx.req, ctx.res)
+        })
+      })
+    })
+  })
+
+  describe('renameEntity', function () {
+    beforeEach(function (ctx) {
+      ctx.entityId = 'entity-id-123'
+      ctx.entityType = 'entity-type'
+      ctx.req.params = {
+        Project_id: ctx.project._id,
+        entity_id: ctx.entityId,
+        entity_type: ctx.entityType,
+      }
+    })
+
+    describe('successfully', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.newName = 'new-name'
+          ctx.req.body = { name: ctx.newName, source: ctx.source }
+          ctx.res.callback = resolve
+          ctx.EditorHttpController.renameEntity(ctx.req, ctx.res)
+        })
+      })
+
+      it('should call EditorController.renameEntity', function (ctx) {
+        expect(
+          ctx.EditorController.promises.renameEntity
+        ).to.have.been.calledWith(
+          ctx.project._id,
+          ctx.entityId,
+          ctx.entityType,
+          ctx.newName,
+          ctx.user._id,
+          ctx.source
+        )
+      })
+
+      it('should send back a success response', function (ctx) {
+        expect(ctx.res.sendStatus).to.have.been.calledWith(204)
+      })
+    })
+    describe('with long name', function () {
+      beforeEach(function (ctx) {
+        ctx.newName = 'long'.repeat(100)
+        ctx.req.body = { name: ctx.newName, source: ctx.source }
+        ctx.EditorHttpController.renameEntity(ctx.req, ctx.res)
+      })
+
+      it('should send back a bad request status code', function (ctx) {
+        expect(ctx.res.statusCode).to.equal(400)
+      })
+    })
+
+    describe('with 0 length name', function () {
+      beforeEach(function (ctx) {
+        ctx.newName = ''
+        ctx.req.body = { name: ctx.newName, source: ctx.source }
+        ctx.EditorHttpController.renameEntity(ctx.req, ctx.res)
+      })
+
+      it('should send back a bad request status code', function (ctx) {
+        expect(ctx.res.statusCode).to.equal(400)
+      })
+    })
+  })
+
+  describe('moveEntity', function () {
+    beforeEach(async function (ctx) {
+      await new Promise(resolve => {
+        ctx.entityId = 'entity-id-123'
+        ctx.entityType = 'entity-type'
+        ctx.folderId = 'folder-id-123'
+        ctx.req.params = {
+          Project_id: ctx.project._id,
+          entity_id: ctx.entityId,
+          entity_type: ctx.entityType,
+        }
+        ctx.req.body = { folder_id: ctx.folderId, source: ctx.source }
+        ctx.res.callback = resolve
+        ctx.EditorHttpController.moveEntity(ctx.req, ctx.res)
+      })
+    })
+
+    it('should call EditorController.moveEntity', function (ctx) {
+      expect(ctx.EditorController.promises.moveEntity).to.have.been.calledWith(
+        ctx.project._id,
+        ctx.entityId,
+        ctx.folderId,
+        ctx.entityType,
+        ctx.user._id,
+        ctx.source
+      )
+    })
+
+    it('should send back a success response', function (ctx) {
+      expect(ctx.res.statusCode).to.equal(204)
+    })
+  })
+
+  describe('deleteEntity', function () {
+    beforeEach(async function (ctx) {
+      await new Promise(resolve => {
+        ctx.entityId = 'entity-id-123'
+        ctx.entityType = 'entity-type'
+        ctx.req.params = {
+          Project_id: ctx.project._id,
+          entity_id: ctx.entityId,
+          entity_type: ctx.entityType,
+        }
+        ctx.res.callback = resolve
+        ctx.EditorHttpController.deleteEntity(ctx.req, ctx.res)
+      })
+    })
+
+    it('should call EditorController.deleteEntity', function (ctx) {
+      expect(
+        ctx.EditorController.promises.deleteEntity
+      ).to.have.been.calledWith(
+        ctx.project._id,
+        ctx.entityId,
+        ctx.entityType,
+        'editor',
+        ctx.user._id
+      )
+    })
+
+    it('should send back a success response', function (ctx) {
+      expect(ctx.res.statusCode).to.equal(204)
+    })
+  })
+})

+ 0 - 630
services/web/test/unit/src/Editor/EditorHttpControllerTests.js

@@ -1,630 +0,0 @@
-/* eslint-disable mocha/handle-done-callback */
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const { ObjectId } = require('mongodb-legacy')
-const Errors = require('../../../../app/src/Features/Errors/Errors')
-const MockRequest = require('../helpers/MockRequest')
-const MockResponse = require('../helpers/MockResponse')
-
-const MODULE_PATH = '../../../../app/src/Features/Editor/EditorHttpController'
-
-describe('EditorHttpController', function () {
-  beforeEach(function () {
-    this.ownerId = new ObjectId()
-    this.project = {
-      _id: new ObjectId(),
-      owner_ref: this.ownerId,
-    }
-    this.user = {
-      _id: new ObjectId(),
-      projects: {},
-    }
-    this.members = [
-      { user: { _id: 'owner', features: {} }, privilegeLevel: 'owner' },
-      { user: { _id: 'one' }, privilegeLevel: 'readOnly' },
-    ]
-    this.ownerMember = this.members[0]
-    this.invites = [{ _id: 'three' }, { _id: 'four' }]
-    this.projectView = {
-      _id: this.project._id,
-      owner: {
-        _id: 'owner',
-        email: 'owner@example.com',
-        other_property: true,
-      },
-      members: [
-        { _id: 'owner', privileges: 'owner' },
-        { _id: 'one', privileges: 'readOnly' },
-      ],
-      invites: [{ three: 3 }, { four: 4 }],
-    }
-    this.reducedProjectView = {
-      _id: this.projectView._id,
-      owner: { _id: this.projectView.owner._id },
-      members: [],
-      invites: [],
-    }
-    this.doc = { _id: new ObjectId(), name: 'excellent-original-idea.tex' }
-    this.file = { _id: new ObjectId() }
-    this.folder = { _id: new ObjectId() }
-    this.source = 'editor'
-
-    this.parentFolderId = 'mock-folder-id'
-    this.req = new MockRequest()
-    this.res = new MockResponse()
-    this.next = sinon.stub()
-    this.token = null
-    this.docLines = ['hello', 'overleaf']
-
-    this.AuthorizationManager = {
-      isRestrictedUser: sinon.stub().returns(false),
-      promises: {
-        getPrivilegeLevelForProjectWithProjectAccess: sinon
-          .stub()
-          .resolves('owner'),
-      },
-    }
-    const members = this.members
-    const ownerMember = this.ownerMember
-    this.CollaboratorsGetter = {
-      ProjectAccess: class {
-        loadOwnerAndInvitedMembers() {
-          return { members, ownerMember }
-        }
-
-        loadOwner() {
-          return ownerMember
-        }
-
-        isUserTokenMember() {
-          return false
-        }
-
-        isUserInvitedMember() {
-          return false
-        }
-      },
-      promises: {
-        isUserInvitedMemberOfProject: sinon.stub().resolves(false),
-      },
-    }
-    this.CollaboratorsHandler = {
-      promises: {
-        userIsTokenMember: sinon.stub().resolves(false),
-      },
-    }
-    this.invites = [
-      {
-        _id: 'invite_one',
-        email: 'user-one@example.com',
-        privileges: 'readOnly',
-        projectId: this.project._id,
-      },
-      {
-        _id: 'invite_two',
-        email: 'user-two@example.com',
-        privileges: 'readOnly',
-        projectId: this.project._id,
-      },
-    ]
-    this.CollaboratorsInviteGetter = {
-      promises: {
-        getAllInvites: sinon.stub().resolves(this.invites),
-      },
-    }
-    this.EditorController = {
-      promises: {
-        addDoc: sinon.stub().resolves(this.doc),
-        addFile: sinon.stub().resolves(this.file),
-        addFolder: sinon.stub().resolves(this.folder),
-        renameEntity: sinon.stub().resolves(),
-        moveEntity: sinon.stub().resolves(),
-        deleteEntity: sinon.stub().resolves(),
-      },
-    }
-    this.ProjectDeleter = {
-      promises: {
-        unmarkAsDeletedByExternalSource: sinon.stub().resolves(),
-      },
-    }
-    this.ProjectGetter = {
-      promises: {
-        getProjectWithoutDocLines: sinon.stub().resolves(this.project),
-      },
-    }
-    this.ProjectEditorHandler = {
-      buildProjectModelView: sinon.stub().returns(this.projectView),
-    }
-    this.Metrics = { inc: sinon.stub() }
-    this.TokenAccessHandler = {
-      getRequestToken: sinon.stub().returns(this.token),
-    }
-    this.SessionManager = {
-      getLoggedInUserId: sinon.stub().returns(this.user._id),
-    }
-    this.ProjectEntityUpdateHandler = {
-      promises: {
-        convertDocToFile: sinon.stub().resolves(this.file),
-      },
-    }
-    this.DocstoreManager = {
-      promises: {
-        getAllDeletedDocs: sinon.stub().resolves([]),
-      },
-    }
-    this.HttpErrorHandler = {
-      notFound: sinon.stub(),
-      unprocessableEntity: sinon.stub(),
-    }
-    this.SplitTestHandler = {
-      promises: {
-        getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
-      },
-    }
-    this.UserGetter = { promises: { getUser: sinon.stub().resolves(null, {}) } }
-    this.EditorHttpController = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '../Project/ProjectDeleter': this.ProjectDeleter,
-        '../Project/ProjectGetter': this.ProjectGetter,
-        '../Authorization/AuthorizationManager': this.AuthorizationManager,
-        '../Project/ProjectEditorHandler': this.ProjectEditorHandler,
-        './EditorController': this.EditorController,
-        '@overleaf/metrics': this.Metrics,
-        '../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter,
-        '../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
-        '../Collaborators/CollaboratorsInviteGetter':
-          this.CollaboratorsInviteGetter,
-        '../TokenAccess/TokenAccessHandler': this.TokenAccessHandler,
-        '../Authentication/SessionManager': this.SessionManager,
-        '../../infrastructure/FileWriter': this.FileWriter,
-        '../Project/ProjectEntityUpdateHandler':
-          this.ProjectEntityUpdateHandler,
-        '../Docstore/DocstoreManager': this.DocstoreManager,
-        '../Errors/HttpErrorHandler': this.HttpErrorHandler,
-        '../SplitTests/SplitTestHandler': this.SplitTestHandler,
-        '../Compile/CompileManager': {},
-        '../User/UserGetter': this.UserGetter,
-      },
-    })
-  })
-
-  describe('joinProject', function () {
-    beforeEach(function () {
-      this.req.params = { Project_id: this.project._id }
-      this.req.query = { user_id: this.user._id }
-      this.req.body = { userId: this.user._id }
-    })
-
-    describe('successfully', function () {
-      beforeEach(function (done) {
-        sinon
-          .stub(
-            this.CollaboratorsGetter.ProjectAccess.prototype,
-            'isUserInvitedMember'
-          )
-          .returns(true)
-        this.res.callback = done
-        this.EditorHttpController.joinProject(this.req, this.res)
-      })
-
-      it('should request a full view', function () {
-        expect(
-          this.ProjectEditorHandler.buildProjectModelView
-        ).to.have.been.calledWith(
-          this.project,
-          this.ownerMember,
-          this.members,
-          this.invites,
-          false
-        )
-      })
-
-      it('should return the project and privilege level', function () {
-        expect(this.res.json).to.have.been.calledWith({
-          project: this.projectView,
-          privilegeLevel: 'owner',
-          isRestrictedUser: false,
-          isTokenMember: false,
-          isInvitedMember: true,
-        })
-      })
-
-      it('should not try to unmark the project as deleted', function () {
-        expect(this.ProjectDeleter.promises.unmarkAsDeletedByExternalSource).not
-          .to.have.been.called
-      })
-
-      it('should send an inc metric', function () {
-        expect(this.Metrics.inc).to.have.been.calledWith('editor.join-project')
-      })
-    })
-
-    describe('when the project is marked as deleted', function () {
-      beforeEach(function (done) {
-        this.projectView.deletedByExternalDataSource = true
-        this.res.callback = done
-        this.EditorHttpController.joinProject(this.req, this.res)
-      })
-
-      it('should unmark the project as deleted', function () {
-        expect(
-          this.ProjectDeleter.promises.unmarkAsDeletedByExternalSource
-        ).to.have.been.calledWith(this.project._id)
-      })
-    })
-
-    describe('with a restricted user', function () {
-      beforeEach(function (done) {
-        this.ProjectEditorHandler.buildProjectModelView.returns(
-          this.reducedProjectView
-        )
-        this.AuthorizationManager.isRestrictedUser.returns(true)
-        this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves(
-          'readOnly'
-        )
-        this.res.callback = done
-        this.EditorHttpController.joinProject(this.req, this.res)
-      })
-
-      it('should request a restricted view', function () {
-        expect(
-          this.ProjectEditorHandler.buildProjectModelView
-        ).to.have.been.calledWith(this.project, this.ownerMember, [], [], true)
-      })
-
-      it('should mark the user as restricted, and hide details of owner', function () {
-        expect(this.res.json).to.have.been.calledWith({
-          project: this.reducedProjectView,
-          privilegeLevel: 'readOnly',
-          isRestrictedUser: true,
-          isTokenMember: false,
-          isInvitedMember: false,
-        })
-      })
-    })
-
-    describe('when not authorized', function () {
-      beforeEach(function (done) {
-        this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves(
-          null
-        )
-        this.res.callback = done
-        this.EditorHttpController.joinProject(this.req, this.res)
-      })
-
-      it('should send a 403 response', function () {
-        expect(this.res.statusCode).to.equal(403)
-      })
-    })
-
-    describe('with an anonymous user', function () {
-      beforeEach(function (done) {
-        this.token = 'token'
-        this.TokenAccessHandler.getRequestToken.returns(this.token)
-        this.ProjectEditorHandler.buildProjectModelView.returns(
-          this.reducedProjectView
-        )
-        this.req.body = {
-          userId: 'anonymous-user',
-          anonymousAccessToken: this.token,
-        }
-        this.res.callback = done
-        this.AuthorizationManager.isRestrictedUser
-          .withArgs(null, 'readOnly', false, false)
-          .returns(true)
-        this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess
-          .withArgs(null, this.project._id, this.token)
-          .resolves('readOnly')
-        this.EditorHttpController.joinProject(this.req, this.res)
-      })
-
-      it('should request a restricted view', function () {
-        expect(
-          this.ProjectEditorHandler.buildProjectModelView
-        ).to.have.been.calledWith(this.project, this.ownerMember, [], [], true)
-      })
-
-      it('should mark the user as restricted', function () {
-        expect(this.res.json).to.have.been.calledWith({
-          project: this.reducedProjectView,
-          privilegeLevel: 'readOnly',
-          isRestrictedUser: true,
-          isTokenMember: false,
-          isInvitedMember: false,
-        })
-      })
-    })
-
-    describe('with a token access user', function () {
-      beforeEach(function (done) {
-        sinon
-          .stub(
-            this.CollaboratorsGetter.ProjectAccess.prototype,
-            'isUserInvitedMember'
-          )
-          .returns(false)
-        sinon
-          .stub(
-            this.CollaboratorsGetter.ProjectAccess.prototype,
-            'isUserTokenMember'
-          )
-          .returns(true)
-        this.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves(
-          'readAndWrite'
-        )
-        this.res.callback = done
-        this.EditorHttpController.joinProject(this.req, this.res)
-      })
-
-      it('should mark the user as being a token-access member', function () {
-        expect(this.res.json).to.have.been.calledWith({
-          project: this.projectView,
-          privilegeLevel: 'readAndWrite',
-          isRestrictedUser: false,
-          isTokenMember: true,
-          isInvitedMember: false,
-        })
-      })
-    })
-
-    describe('when project is not found', function () {
-      beforeEach(function (done) {
-        this.ProjectGetter.promises.getProjectWithoutDocLines.resolves(null)
-        this.next.callsFake(() => done())
-        this.EditorHttpController.joinProject(this.req, this.res, this.next)
-      })
-
-      it('should handle return not found error', function () {
-        expect(this.next).to.have.been.calledWith(
-          sinon.match.instanceOf(Errors.NotFoundError)
-        )
-      })
-    })
-  })
-
-  describe('addDoc', function () {
-    beforeEach(function () {
-      this.req.params = { Project_id: this.project._id }
-      this.req.body = {
-        name: (this.name = 'doc-name'),
-        parent_folder_id: this.parentFolderId,
-      }
-    })
-
-    describe('successfully', function () {
-      beforeEach(function (done) {
-        this.res.callback = done
-        this.EditorHttpController.addDoc(this.req, this.res)
-      })
-
-      it('should call EditorController.addDoc', function () {
-        expect(this.EditorController.promises.addDoc).to.have.been.calledWith(
-          this.project._id,
-          this.parentFolderId,
-          this.name,
-          [],
-          'editor',
-          this.user._id
-        )
-      })
-
-      it('should send the doc back as JSON', function () {
-        expect(this.res.json).to.have.been.calledWith(this.doc)
-      })
-    })
-
-    describe('unsuccesfully', function () {
-      it('handle name too short', function (done) {
-        this.req.body.name = ''
-        this.res.callback = () => {
-          expect(this.res.statusCode).to.equal(400)
-          done()
-        }
-        this.EditorHttpController.addDoc(this.req, this.res)
-      })
-
-      it('handle too many files', function (done) {
-        this.EditorController.promises.addDoc.rejects(
-          new Error('project_has_too_many_files')
-        )
-        this.res.callback = () => {
-          expect(this.res.body).to.equal('"project_has_too_many_files"')
-          expect(this.res.status).to.have.been.calledWith(400)
-          done()
-        }
-        this.EditorHttpController.addDoc(this.req, this.res)
-      })
-    })
-  })
-
-  describe('addFolder', function () {
-    beforeEach(function () {
-      this.folderName = 'folder-name'
-      this.req.params = { Project_id: this.project._id }
-      this.req.body = {
-        name: this.folderName,
-        parent_folder_id: this.parentFolderId,
-      }
-    })
-
-    describe('successfully', function () {
-      beforeEach(function (done) {
-        this.res.callback = done
-        this.EditorHttpController.addFolder(this.req, this.res)
-      })
-
-      it('should call EditorController.addFolder', function () {
-        expect(
-          this.EditorController.promises.addFolder
-        ).to.have.been.calledWith(
-          this.project._id,
-          this.parentFolderId,
-          this.folderName,
-          'editor'
-        )
-      })
-
-      it('should send the folder back as JSON', function () {
-        expect(this.res.json).to.have.been.calledWith(this.folder)
-      })
-    })
-
-    describe('unsuccesfully', function () {
-      it('handle name too short', function (done) {
-        this.req.body.name = ''
-        this.res.callback = () => {
-          expect(this.res.statusCode).to.equal(400)
-          done()
-        }
-        this.EditorHttpController.addFolder(this.req, this.res)
-      })
-
-      it('handle too many files', function (done) {
-        this.EditorController.promises.addFolder.rejects(
-          new Error('project_has_too_many_files')
-        )
-        this.res.callback = () => {
-          expect(this.res.body).to.equal('"project_has_too_many_files"')
-          expect(this.res.statusCode).to.equal(400)
-          done()
-        }
-        this.EditorHttpController.addFolder(this.req, this.res)
-      })
-
-      it('handle invalid element name', function (done) {
-        this.EditorController.promises.addFolder.rejects(
-          new Error('invalid element name')
-        )
-        this.res.callback = () => {
-          expect(this.res.body).to.equal('"invalid_file_name"')
-          expect(this.res.statusCode).to.equal(400)
-          done()
-        }
-        this.EditorHttpController.addFolder(this.req, this.res)
-      })
-    })
-  })
-
-  describe('renameEntity', function () {
-    beforeEach(function () {
-      this.entityId = 'entity-id-123'
-      this.entityType = 'entity-type'
-      this.req.params = {
-        Project_id: this.project._id,
-        entity_id: this.entityId,
-        entity_type: this.entityType,
-      }
-    })
-
-    describe('successfully', function () {
-      beforeEach(function (done) {
-        this.newName = 'new-name'
-        this.req.body = { name: this.newName, source: this.source }
-        this.res.callback = done
-        this.EditorHttpController.renameEntity(this.req, this.res)
-      })
-
-      it('should call EditorController.renameEntity', function () {
-        expect(
-          this.EditorController.promises.renameEntity
-        ).to.have.been.calledWith(
-          this.project._id,
-          this.entityId,
-          this.entityType,
-          this.newName,
-          this.user._id,
-          this.source
-        )
-      })
-
-      it('should send back a success response', function () {
-        expect(this.res.sendStatus).to.have.been.calledWith(204)
-      })
-    })
-    describe('with long name', function () {
-      beforeEach(function () {
-        this.newName = 'long'.repeat(100)
-        this.req.body = { name: this.newName, source: this.source }
-        this.EditorHttpController.renameEntity(this.req, this.res)
-      })
-
-      it('should send back a bad request status code', function () {
-        expect(this.res.statusCode).to.equal(400)
-      })
-    })
-
-    describe('with 0 length name', function () {
-      beforeEach(function () {
-        this.newName = ''
-        this.req.body = { name: this.newName, source: this.source }
-        this.EditorHttpController.renameEntity(this.req, this.res)
-      })
-
-      it('should send back a bad request status code', function () {
-        expect(this.res.statusCode).to.equal(400)
-      })
-    })
-  })
-
-  describe('moveEntity', function () {
-    beforeEach(function (done) {
-      this.entityId = 'entity-id-123'
-      this.entityType = 'entity-type'
-      this.folderId = 'folder-id-123'
-      this.req.params = {
-        Project_id: this.project._id,
-        entity_id: this.entityId,
-        entity_type: this.entityType,
-      }
-      this.req.body = { folder_id: this.folderId, source: this.source }
-      this.res.callback = done
-      this.EditorHttpController.moveEntity(this.req, this.res)
-    })
-
-    it('should call EditorController.moveEntity', function () {
-      expect(this.EditorController.promises.moveEntity).to.have.been.calledWith(
-        this.project._id,
-        this.entityId,
-        this.folderId,
-        this.entityType,
-        this.user._id,
-        this.source
-      )
-    })
-
-    it('should send back a success response', function () {
-      expect(this.res.statusCode).to.equal(204)
-    })
-  })
-
-  describe('deleteEntity', function () {
-    beforeEach(function (done) {
-      this.entityId = 'entity-id-123'
-      this.entityType = 'entity-type'
-      this.req.params = {
-        Project_id: this.project._id,
-        entity_id: this.entityId,
-        entity_type: this.entityType,
-      }
-      this.res.callback = done
-      this.EditorHttpController.deleteEntity(this.req, this.res)
-    })
-
-    it('should call EditorController.deleteEntity', function () {
-      expect(
-        this.EditorController.promises.deleteEntity
-      ).to.have.been.calledWith(
-        this.project._id,
-        this.entityId,
-        this.entityType,
-        'editor',
-        this.user._id
-      )
-    })
-
-    it('should send back a success response', function () {
-      expect(this.res.statusCode).to.equal(204)
-    })
-  })
-})

+ 297 - 0
services/web/test/unit/src/History/HistoryController.test.mjs

@@ -0,0 +1,297 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import { RequestFailedError } from '@overleaf/fetch-utils'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+import mongodb from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
+
+const modulePath = '../../../../app/src/Features/History/HistoryController.mjs'
+
+describe('HistoryController', function () {
+  beforeEach(async function (ctx) {
+    ctx.callback = sinon.stub()
+    ctx.user_id = 'user-id-123'
+    ctx.project_id = 'mock-project-id'
+    ctx.stream = sinon.stub()
+    ctx.fetchResponse = {
+      headers: {
+        get: sinon.stub(),
+      },
+    }
+    ctx.next = sinon.stub()
+
+    ctx.SessionManager = {
+      getLoggedInUserId: sinon.stub().returns(ctx.user_id),
+    }
+
+    ctx.Stream = {
+      pipeline: sinon.stub().resolves(),
+    }
+
+    ctx.HistoryManager = {
+      promises: {
+        injectUserDetails: sinon.stub(),
+      },
+    }
+
+    ctx.ProjectEntityUpdateHandler = {
+      promises: {
+        resyncProjectHistory: sinon.stub().resolves(),
+      },
+    }
+
+    ctx.fetchJson = sinon.stub()
+    ctx.fetchStream = sinon.stub().resolves(ctx.stream)
+    ctx.fetchStreamWithResponse = sinon
+      .stub()
+      .resolves({ stream: ctx.stream, response: ctx.fetchResponse })
+    ctx.fetchNothing = sinon.stub().resolves()
+
+    vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+      vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+    )
+
+    vi.doMock('stream/promises', () => ctx.Stream)
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {}),
+    }))
+
+    vi.doMock('@overleaf/fetch-utils', () => ({
+      fetchJson: ctx.fetchJson,
+      fetchStream: ctx.fetchStream,
+      fetchStreamWithResponse: ctx.fetchStreamWithResponse,
+      fetchNothing: ctx.fetchNothing,
+    }))
+
+    vi.doMock('@overleaf/Metrics', () => ({
+      default: {},
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/mongodb.js', () => ({
+      default: { ObjectId },
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager.js',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/History/HistoryManager.js', () => ({
+      default: ctx.HistoryManager,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectDetailsHandler.js',
+      () => ({
+        default: (ctx.ProjectDetailsHandler = {}),
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityUpdateHandler.js',
+      () => ({
+        default: ctx.ProjectEntityUpdateHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({
+      default: (ctx.UserGetter = {}),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({
+      default: (ctx.ProjectGetter = {}),
+    }))
+
+    vi.doMock('../../../../app/src/Features/History/RestoreManager.js', () => ({
+      default: (ctx.RestoreManager = {}),
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Features.js', () => ({
+      default: (ctx.Features = sinon.stub().withArgs('saas').returns(true)),
+    }))
+
+    ctx.HistoryController = (await import(modulePath)).default
+    ctx.settings.apis = {
+      project_history: {
+        url: 'http://project_history.example.com',
+      },
+    }
+  })
+
+  describe('proxyToHistoryApi', function () {
+    beforeEach(async function (ctx) {
+      ctx.req = { url: '/mock/url', method: 'POST', session: sinon.stub() }
+      ctx.res = {
+        set: sinon.stub(),
+      }
+      ctx.contentType = 'application/json'
+      ctx.contentLength = 212
+      ctx.fetchResponse.headers.get
+        .withArgs('Content-Type')
+        .returns(ctx.contentType)
+      ctx.fetchResponse.headers.get
+        .withArgs('Content-Length')
+        .returns(ctx.contentLength)
+      await ctx.HistoryController.proxyToHistoryApi(ctx.req, ctx.res, ctx.next)
+    })
+
+    it('should get the user id', function (ctx) {
+      ctx.SessionManager.getLoggedInUserId.should.have.been.calledWith(
+        ctx.req.session
+      )
+    })
+
+    it('should call the project history api', function (ctx) {
+      ctx.fetchStreamWithResponse.should.have.been.calledWith(
+        `${ctx.settings.apis.project_history.url}${ctx.req.url}`,
+        {
+          method: ctx.req.method,
+          headers: {
+            'X-User-Id': ctx.user_id,
+          },
+        }
+      )
+    })
+
+    it('should pipe the response to the client', function (ctx) {
+      expect(ctx.Stream.pipeline).to.have.been.calledWith(ctx.stream, ctx.res)
+    })
+
+    it('should propagate the appropriate headers', function (ctx) {
+      expect(ctx.res.set).to.have.been.calledWith(
+        'Content-Type',
+        ctx.contentType
+      )
+      expect(ctx.res.set).to.have.been.calledWith(
+        'Content-Length',
+        ctx.contentLength
+      )
+    })
+  })
+
+  describe('proxyToHistoryApiAndInjectUserDetails', function () {
+    beforeEach(async function (ctx) {
+      ctx.req = { url: '/mock/url', method: 'POST' }
+      ctx.res = { json: sinon.stub() }
+      ctx.data = 'mock-data'
+      ctx.dataWithUsers = 'mock-injected-data'
+      ctx.fetchJson.resolves(ctx.data)
+      ctx.HistoryManager.promises.injectUserDetails.resolves(ctx.dataWithUsers)
+      await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails(
+        ctx.req,
+        ctx.res,
+        ctx.next
+      )
+    })
+
+    it('should get the user id', function (ctx) {
+      ctx.SessionManager.getLoggedInUserId.should.have.been.calledWith(
+        ctx.req.session
+      )
+    })
+
+    it('should call the project history api', function (ctx) {
+      ctx.fetchJson.should.have.been.calledWith(
+        `${ctx.settings.apis.project_history.url}${ctx.req.url}`,
+        {
+          method: ctx.req.method,
+          headers: {
+            'X-User-Id': ctx.user_id,
+          },
+        }
+      )
+    })
+
+    it('should inject the user data', function (ctx) {
+      ctx.HistoryManager.promises.injectUserDetails.should.have.been.calledWith(
+        ctx.data
+      )
+    })
+
+    it('should return the data with users to the client', function (ctx) {
+      ctx.res.json.should.have.been.calledWith(ctx.dataWithUsers)
+    })
+  })
+
+  describe('proxyToHistoryApiAndInjectUserDetails (with the history API failing)', function () {
+    beforeEach(async function (ctx) {
+      ctx.url = '/mock/url'
+      ctx.req = { url: ctx.url, method: 'POST' }
+      ctx.res = { json: sinon.stub() }
+      ctx.err = new RequestFailedError(ctx.url, {}, { status: 500 })
+      ctx.fetchJson.rejects(ctx.err)
+      await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails(
+        ctx.req,
+        ctx.res,
+        ctx.next
+      )
+    })
+
+    it('should not inject the user data', function (ctx) {
+      ctx.HistoryManager.promises.injectUserDetails.should.not.have.been.called
+    })
+
+    it('should not return the data with users to the client', function (ctx) {
+      ctx.res.json.should.not.have.been.called
+    })
+
+    it('should throw an error', function (ctx) {
+      ctx.next.should.have.been.calledWith(ctx.err)
+    })
+  })
+
+  describe('resyncProjectHistory', function () {
+    describe('for a project without project-history enabled', function () {
+      beforeEach(async function (ctx) {
+        ctx.req = { params: { Project_id: ctx.project_id }, body: {} }
+        ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
+
+        ctx.error = new Errors.ProjectHistoryDisabledError()
+        ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.rejects(
+          ctx.error
+        )
+
+        await ctx.HistoryController.resyncProjectHistory(
+          ctx.req,
+          ctx.res,
+          ctx.next
+        )
+      })
+
+      it('response with a 404', function (ctx) {
+        ctx.res.sendStatus.should.have.been.calledWith(404)
+      })
+    })
+
+    describe('for a project with project-history enabled', function () {
+      beforeEach(async function (ctx) {
+        ctx.req = { params: { Project_id: ctx.project_id }, body: {} }
+        ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
+
+        await ctx.HistoryController.resyncProjectHistory(
+          ctx.req,
+          ctx.res,
+          ctx.next
+        )
+      })
+
+      it('sets an extended response timeout', function (ctx) {
+        ctx.res.setTimeout.should.have.been.calledWith(6 * 60 * 1000)
+      })
+
+      it('resyncs the project', function (ctx) {
+        ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.should.have.been.calledWith(
+          ctx.project_id
+        )
+      })
+
+      it('responds with a 204', function (ctx) {
+        ctx.res.sendStatus.should.have.been.calledWith(204)
+      })
+    })
+  })
+})

+ 0 - 265
services/web/test/unit/src/History/HistoryControllerTests.js

@@ -1,265 +0,0 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const { RequestFailedError } = require('@overleaf/fetch-utils')
-
-const Errors = require('../../../../app/src/Features/Errors/Errors')
-
-const modulePath = '../../../../app/src/Features/History/HistoryController'
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
-
-describe('HistoryController', function () {
-  beforeEach(function () {
-    this.callback = sinon.stub()
-    this.user_id = 'user-id-123'
-    this.project_id = 'mock-project-id'
-    this.stream = sinon.stub()
-    this.fetchResponse = {
-      headers: {
-        get: sinon.stub(),
-      },
-    }
-    this.next = sinon.stub()
-
-    this.SessionManager = {
-      getLoggedInUserId: sinon.stub().returns(this.user_id),
-    }
-
-    this.Stream = {
-      pipeline: sinon.stub().resolves(),
-    }
-
-    this.HistoryManager = {
-      promises: {
-        injectUserDetails: sinon.stub(),
-      },
-    }
-
-    this.ProjectEntityUpdateHandler = {
-      promises: {
-        resyncProjectHistory: sinon.stub().resolves(),
-      },
-    }
-
-    this.fetchJson = sinon.stub()
-    this.fetchStream = sinon.stub().resolves(this.stream)
-    this.fetchStreamWithResponse = sinon
-      .stub()
-      .resolves({ stream: this.stream, response: this.fetchResponse })
-    this.fetchNothing = sinon.stub().resolves()
-
-    this.HistoryController = SandboxedModule.require(modulePath, {
-      requires: {
-        'stream/promises': this.Stream,
-        '@overleaf/settings': (this.settings = {}),
-        '@overleaf/fetch-utils': {
-          fetchJson: this.fetchJson,
-          fetchStream: this.fetchStream,
-          fetchStreamWithResponse: this.fetchStreamWithResponse,
-          fetchNothing: this.fetchNothing,
-        },
-        '@overleaf/Metrics': {},
-        '../../infrastructure/mongodb': { ObjectId },
-        '../Authentication/SessionManager': this.SessionManager,
-        './HistoryManager': this.HistoryManager,
-        '../Project/ProjectDetailsHandler': (this.ProjectDetailsHandler = {}),
-        '../Project/ProjectEntityUpdateHandler':
-          this.ProjectEntityUpdateHandler,
-        '../User/UserGetter': (this.UserGetter = {}),
-        '../Project/ProjectGetter': (this.ProjectGetter = {}),
-        './RestoreManager': (this.RestoreManager = {}),
-        '../../infrastructure/Features': (this.Features = sinon
-          .stub()
-          .withArgs('saas')
-          .returns(true)),
-      },
-    })
-    this.settings.apis = {
-      project_history: {
-        url: 'http://project_history.example.com',
-      },
-    }
-  })
-
-  describe('proxyToHistoryApi', function () {
-    beforeEach(async function () {
-      this.req = { url: '/mock/url', method: 'POST', session: sinon.stub() }
-      this.res = {
-        set: sinon.stub(),
-      }
-      this.contentType = 'application/json'
-      this.contentLength = 212
-      this.fetchResponse.headers.get
-        .withArgs('Content-Type')
-        .returns(this.contentType)
-      this.fetchResponse.headers.get
-        .withArgs('Content-Length')
-        .returns(this.contentLength)
-      await this.HistoryController.proxyToHistoryApi(
-        this.req,
-        this.res,
-        this.next
-      )
-    })
-
-    it('should get the user id', function () {
-      this.SessionManager.getLoggedInUserId.should.have.been.calledWith(
-        this.req.session
-      )
-    })
-
-    it('should call the project history api', function () {
-      this.fetchStreamWithResponse.should.have.been.calledWith(
-        `${this.settings.apis.project_history.url}${this.req.url}`,
-        {
-          method: this.req.method,
-          headers: {
-            'X-User-Id': this.user_id,
-          },
-        }
-      )
-    })
-
-    it('should pipe the response to the client', function () {
-      expect(this.Stream.pipeline).to.have.been.calledWith(
-        this.stream,
-        this.res
-      )
-    })
-
-    it('should propagate the appropriate headers', function () {
-      expect(this.res.set).to.have.been.calledWith(
-        'Content-Type',
-        this.contentType
-      )
-      expect(this.res.set).to.have.been.calledWith(
-        'Content-Length',
-        this.contentLength
-      )
-    })
-  })
-
-  describe('proxyToHistoryApiAndInjectUserDetails', function () {
-    beforeEach(async function () {
-      this.req = { url: '/mock/url', method: 'POST' }
-      this.res = { json: sinon.stub() }
-      this.data = 'mock-data'
-      this.dataWithUsers = 'mock-injected-data'
-      this.fetchJson.resolves(this.data)
-      this.HistoryManager.promises.injectUserDetails.resolves(
-        this.dataWithUsers
-      )
-      await this.HistoryController.proxyToHistoryApiAndInjectUserDetails(
-        this.req,
-        this.res,
-        this.next
-      )
-    })
-
-    it('should get the user id', function () {
-      this.SessionManager.getLoggedInUserId.should.have.been.calledWith(
-        this.req.session
-      )
-    })
-
-    it('should call the project history api', function () {
-      this.fetchJson.should.have.been.calledWith(
-        `${this.settings.apis.project_history.url}${this.req.url}`,
-        {
-          method: this.req.method,
-          headers: {
-            'X-User-Id': this.user_id,
-          },
-        }
-      )
-    })
-
-    it('should inject the user data', function () {
-      this.HistoryManager.promises.injectUserDetails.should.have.been.calledWith(
-        this.data
-      )
-    })
-
-    it('should return the data with users to the client', function () {
-      this.res.json.should.have.been.calledWith(this.dataWithUsers)
-    })
-  })
-
-  describe('proxyToHistoryApiAndInjectUserDetails (with the history API failing)', function () {
-    beforeEach(async function () {
-      this.url = '/mock/url'
-      this.req = { url: this.url, method: 'POST' }
-      this.res = { json: sinon.stub() }
-      this.err = new RequestFailedError(this.url, {}, { status: 500 })
-      this.fetchJson.rejects(this.err)
-      await this.HistoryController.proxyToHistoryApiAndInjectUserDetails(
-        this.req,
-        this.res,
-        this.next
-      )
-    })
-
-    it('should not inject the user data', function () {
-      this.HistoryManager.promises.injectUserDetails.should.not.have.been.called
-    })
-
-    it('should not return the data with users to the client', function () {
-      this.res.json.should.not.have.been.called
-    })
-
-    it('should throw an error', function () {
-      this.next.should.have.been.calledWith(this.err)
-    })
-  })
-
-  describe('resyncProjectHistory', function () {
-    describe('for a project without project-history enabled', function () {
-      beforeEach(async function () {
-        this.req = { params: { Project_id: this.project_id }, body: {} }
-        this.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
-
-        this.error = new Errors.ProjectHistoryDisabledError()
-        this.ProjectEntityUpdateHandler.promises.resyncProjectHistory.rejects(
-          this.error
-        )
-
-        await this.HistoryController.resyncProjectHistory(
-          this.req,
-          this.res,
-          this.next
-        )
-      })
-
-      it('response with a 404', function () {
-        this.res.sendStatus.should.have.been.calledWith(404)
-      })
-    })
-
-    describe('for a project with project-history enabled', function () {
-      beforeEach(async function () {
-        this.req = { params: { Project_id: this.project_id }, body: {} }
-        this.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
-
-        await this.HistoryController.resyncProjectHistory(
-          this.req,
-          this.res,
-          this.next
-        )
-      })
-
-      it('sets an extended response timeout', function () {
-        this.res.setTimeout.should.have.been.calledWith(6 * 60 * 1000)
-      })
-
-      it('resyncs the project', function () {
-        this.ProjectEntityUpdateHandler.promises.resyncProjectHistory.should.have.been.calledWith(
-          this.project_id
-        )
-      })
-
-      it('responds with a 204', function () {
-        this.res.sendStatus.should.have.been.calledWith(204)
-      })
-    })
-  })
-})

+ 425 - 0
services/web/test/unit/src/Project/ProjectCollabratecDetails.test.mjs

@@ -0,0 +1,425 @@
+import { vi, expect } from 'vitest'
+import mongodb from 'mongodb-legacy'
+import sinon from 'sinon'
+
+const { ObjectId } = mongodb
+
+const MODULE_PATH =
+  '../../../../app/src/Features/Project/ProjectCollabratecDetailsHandler.mjs'
+
+describe('ProjectCollabratecDetailsHandler', function () {
+  beforeEach(async function (ctx) {
+    ctx.projectId = new ObjectId('5bea8747c7bba6012fcaceb3')
+    ctx.userId = new ObjectId('5be316a9c7f6aa03802ea8fb')
+    ctx.userId2 = new ObjectId('5c1794b3f0e89b1d1c577eca')
+    ctx.ProjectModel = {}
+
+    vi.doMock('mongodb-legacy', () => ({
+      default: { ObjectId },
+    }))
+
+    vi.doMock('../../../../app/src/models/Project.js', () => ({
+      Project: ctx.ProjectModel,
+    }))
+
+    ctx.ProjectCollabratecDetailsHandler = (await import(MODULE_PATH)).default
+  })
+
+  describe('initializeCollabratecProject', function () {
+    describe('when update succeeds', function () {
+      beforeEach(async function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves() })
+        await ctx.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject(
+          ctx.projectId,
+          ctx.userId,
+          'collabratec-document-id',
+          'collabratec-private-group-id'
+        )
+      })
+
+      it('should update project model', function (ctx) {
+        const update = {
+          $set: {
+            collabratecUsers: [
+              {
+                user_id: ctx.userId,
+                collabratec_document_id: 'collabratec-document-id',
+                collabratec_privategroup_id: 'collabratec-private-group-id',
+              },
+            ],
+          },
+        }
+        expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
+          { _id: ctx.projectId },
+          update
+        )
+      })
+    })
+
+    describe('when update has error', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().rejects() })
+      })
+
+      it('should be rejected', async function (ctx) {
+        await expect(
+          ctx.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject(
+            ctx.projectId,
+            ctx.userId,
+            'collabratec-document-id',
+            'collabratec-private-group-id'
+          )
+        ).to.be.rejected
+      })
+    })
+
+    describe('with invalid args', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves() })
+        ctx.resultPromise =
+          ctx.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject(
+            'bad-project-id',
+            'bad-user-id',
+            'collabratec-document-id',
+            'collabratec-private-group-id'
+          )
+      })
+
+      it('should be rejected without updating', async function (ctx) {
+        await expect(ctx.resultPromise).to.be.rejected
+        expect(ctx.ProjectModel.updateOne).not.to.have.been.called
+      })
+    })
+  })
+
+  describe('isLinkedCollabratecUserProject', function () {
+    beforeEach(function (ctx) {
+      ctx.ProjectModel.findOne = sinon.stub().resolves()
+    })
+
+    describe('when find succeeds', function () {
+      describe('when user project found', function () {
+        beforeEach(async function (ctx) {
+          ctx.ProjectModel.findOne = sinon
+            .stub()
+            .returns({ exec: sinon.stub().resolves('project') })
+          ctx.result =
+            await ctx.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
+              ctx.projectId,
+              ctx.userId
+            )
+        })
+
+        it('should call find with project and user id', function (ctx) {
+          expect(ctx.ProjectModel.findOne).to.have.been.calledWithMatch({
+            _id: new ObjectId(ctx.projectId),
+            collabratecUsers: {
+              $elemMatch: {
+                user_id: new ObjectId(ctx.userId),
+              },
+            },
+          })
+        })
+
+        it('should return true', function (ctx) {
+          expect(ctx.result).to.equal(true)
+        })
+      })
+
+      describe('when user project is not found', function () {
+        beforeEach(async function (ctx) {
+          ctx.ProjectModel.findOne = sinon
+            .stub()
+            .returns({ exec: sinon.stub().resolves(null) })
+          ctx.result =
+            await ctx.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
+              ctx.projectId,
+              ctx.userId
+            )
+        })
+
+        it('should return false', function (ctx) {
+          expect(ctx.result).to.equal(false)
+        })
+      })
+    })
+
+    describe('when find has error', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectModel.findOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().rejects() })
+      })
+
+      it('should be rejected', async function (ctx) {
+        await expect(
+          ctx.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
+            ctx.projectId,
+            ctx.userId
+          )
+        ).to.be.rejected
+      })
+    })
+
+    describe('with invalid args', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectModel.findOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves() })
+        ctx.resultPromise =
+          ctx.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
+            'bad-project-id',
+            'bad-user-id'
+          )
+      })
+
+      it('should be rejected without updating', async function (ctx) {
+        await expect(ctx.resultPromise).to.be.rejected
+        expect(ctx.ProjectModel.findOne).not.to.have.been.called
+      })
+    })
+  })
+
+  describe('linkCollabratecUserProject', function () {
+    describe('when update succeeds', function () {
+      beforeEach(async function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves() })
+        await ctx.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject(
+          ctx.projectId,
+          ctx.userId,
+          'collabratec-document-id'
+        )
+      })
+
+      it('should update project model', function (ctx) {
+        const query = {
+          _id: ctx.projectId,
+          collabratecUsers: {
+            $not: {
+              $elemMatch: {
+                collabratec_document_id: 'collabratec-document-id',
+                user_id: ctx.userId,
+              },
+            },
+          },
+        }
+        const update = {
+          $push: {
+            collabratecUsers: {
+              collabratec_document_id: 'collabratec-document-id',
+              user_id: ctx.userId,
+            },
+          },
+        }
+        expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
+          query,
+          update
+        )
+      })
+    })
+
+    describe('when update has error', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().rejects() })
+      })
+
+      it('should be rejected', async function (ctx) {
+        await expect(
+          ctx.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject(
+            ctx.projectId,
+            ctx.userId,
+            'collabratec-document-id'
+          )
+        ).to.be.rejected
+      })
+    })
+
+    describe('with invalid args', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves() })
+        ctx.resultPromise =
+          ctx.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject(
+            'bad-project-id',
+            'bad-user-id',
+            'collabratec-document-id'
+          )
+      })
+
+      it('should be rejected without updating', async function (ctx) {
+        await expect(ctx.resultPromise).to.be.rejected
+        expect(ctx.ProjectModel.updateOne).not.to.have.been.called
+      })
+    })
+  })
+
+  describe('setCollabratecUsers', function () {
+    beforeEach(function (ctx) {
+      ctx.collabratecUsers = [
+        {
+          user_id: ctx.userId,
+          collabratec_document_id: 'collabratec-document-id-1',
+          collabratec_privategroup_id: 'collabratec-private-group-id-1',
+        },
+        {
+          user_id: ctx.userId2,
+          collabratec_document_id: 'collabratec-document-id-2',
+          collabratec_privategroup_id: 'collabratec-private-group-id-2',
+        },
+      ]
+    })
+
+    describe('when update succeeds', function () {
+      beforeEach(async function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves() })
+        await ctx.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
+          ctx.projectId,
+          ctx.collabratecUsers
+        )
+      })
+
+      it('should update project model', function (ctx) {
+        const update = {
+          $set: {
+            collabratecUsers: ctx.collabratecUsers,
+          },
+        }
+        expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
+          { _id: ctx.projectId },
+          update
+        )
+      })
+    })
+
+    describe('when update has error', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().rejects() })
+      })
+
+      it('should be rejected', async function (ctx) {
+        await expect(
+          ctx.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
+            ctx.projectId,
+            ctx.collabratecUsers
+          )
+        ).to.be.rejected
+      })
+    })
+
+    describe('with invalid project_id', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves() })
+        ctx.resultPromise =
+          ctx.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
+            'bad-project-id',
+            ctx.collabratecUsers
+          )
+      })
+
+      it('should be rejected without updating', async function (ctx) {
+        await expect(ctx.resultPromise).to.be.rejected
+        expect(ctx.ProjectModel.updateOne).not.to.have.been.called
+      })
+    })
+
+    describe('with invalid user_id', function () {
+      beforeEach(function (ctx) {
+        ctx.collabratecUsers[1].user_id = 'bad-user-id'
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves() })
+        ctx.resultPromise =
+          ctx.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
+            ctx.projectId,
+            ctx.collabratecUsers
+          )
+      })
+
+      it('should be rejected without updating', async function (ctx) {
+        await expect(ctx.resultPromise).to.be.rejected
+        expect(ctx.ProjectModel.updateOne).not.to.have.been.called
+      })
+    })
+  })
+
+  describe('unlinkCollabratecUserProject', function () {
+    describe('when update succeeds', function () {
+      beforeEach(async function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves() })
+        await ctx.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject(
+          ctx.projectId,
+          ctx.userId
+        )
+      })
+
+      it('should update project model', function (ctx) {
+        const query = { _id: ctx.projectId }
+        const update = {
+          $pull: {
+            collabratecUsers: {
+              user_id: ctx.userId,
+            },
+          },
+        }
+        expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
+          query,
+          update
+        )
+      })
+    })
+
+    describe('when update has error', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().rejects() })
+      })
+
+      it('should be rejected', async function (ctx) {
+        await expect(
+          ctx.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject(
+            ctx.projectId,
+            ctx.userId
+          )
+        ).to.be.rejected
+      })
+    })
+
+    describe('with invalid args', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectModel.updateOne = sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves() })
+        ctx.resultPromise =
+          ctx.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject(
+            'bad-project-id',
+            'bad-user-id'
+          )
+      })
+
+      it('should be rejected without updating', async function (ctx) {
+        await expect(ctx.resultPromise).to.be.rejected
+        expect(ctx.ProjectModel.updateOne).not.to.have.been.called
+      })
+    })
+  })
+})

+ 0 - 426
services/web/test/unit/src/Project/ProjectCollabratecDetailsTest.js

@@ -1,426 +0,0 @@
-const { ObjectId } = require('mongodb-legacy')
-const Path = require('path')
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const sinon = require('sinon')
-
-const modulePath = Path.join(
-  __dirname,
-  '../../../../app/src/Features/Project/ProjectCollabratecDetailsHandler'
-)
-
-describe('ProjectCollabratecDetailsHandler', function () {
-  beforeEach(function () {
-    this.projectId = new ObjectId('5bea8747c7bba6012fcaceb3')
-    this.userId = new ObjectId('5be316a9c7f6aa03802ea8fb')
-    this.userId2 = new ObjectId('5c1794b3f0e89b1d1c577eca')
-    this.ProjectModel = {}
-    this.ProjectCollabratecDetailsHandler = SandboxedModule.require(
-      modulePath,
-      {
-        requires: {
-          'mongodb-legacy': { ObjectId },
-          '../../models/Project': { Project: this.ProjectModel },
-        },
-      }
-    )
-  })
-
-  describe('initializeCollabratecProject', function () {
-    describe('when update succeeds', function () {
-      beforeEach(async function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().resolves() })
-        await this.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject(
-          this.projectId,
-          this.userId,
-          'collabratec-document-id',
-          'collabratec-private-group-id'
-        )
-      })
-
-      it('should update project model', function () {
-        const update = {
-          $set: {
-            collabratecUsers: [
-              {
-                user_id: this.userId,
-                collabratec_document_id: 'collabratec-document-id',
-                collabratec_privategroup_id: 'collabratec-private-group-id',
-              },
-            ],
-          },
-        }
-        expect(this.ProjectModel.updateOne).to.have.been.calledWith(
-          { _id: this.projectId },
-          update
-        )
-      })
-    })
-
-    describe('when update has error', function () {
-      beforeEach(function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().rejects() })
-      })
-
-      it('should be rejected', async function () {
-        await expect(
-          this.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject(
-            this.projectId,
-            this.userId,
-            'collabratec-document-id',
-            'collabratec-private-group-id'
-          )
-        ).to.be.rejected
-      })
-    })
-
-    describe('with invalid args', function () {
-      beforeEach(function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().resolves() })
-        this.resultPromise =
-          this.ProjectCollabratecDetailsHandler.promises.initializeCollabratecProject(
-            'bad-project-id',
-            'bad-user-id',
-            'collabratec-document-id',
-            'collabratec-private-group-id'
-          )
-      })
-
-      it('should be rejected without updating', async function () {
-        await expect(this.resultPromise).to.be.rejected
-        expect(this.ProjectModel.updateOne).not.to.have.been.called
-      })
-    })
-  })
-
-  describe('isLinkedCollabratecUserProject', function () {
-    beforeEach(function () {
-      this.ProjectModel.findOne = sinon.stub().resolves()
-    })
-
-    describe('when find succeeds', function () {
-      describe('when user project found', function () {
-        beforeEach(async function () {
-          this.ProjectModel.findOne = sinon
-            .stub()
-            .returns({ exec: sinon.stub().resolves('project') })
-          this.result =
-            await this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
-              this.projectId,
-              this.userId
-            )
-        })
-
-        it('should call find with project and user id', function () {
-          expect(this.ProjectModel.findOne).to.have.been.calledWithMatch({
-            _id: new ObjectId(this.projectId),
-            collabratecUsers: {
-              $elemMatch: {
-                user_id: new ObjectId(this.userId),
-              },
-            },
-          })
-        })
-
-        it('should return true', function () {
-          expect(this.result).to.equal(true)
-        })
-      })
-
-      describe('when user project is not found', function () {
-        beforeEach(async function () {
-          this.ProjectModel.findOne = sinon
-            .stub()
-            .returns({ exec: sinon.stub().resolves(null) })
-          this.result =
-            await this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
-              this.projectId,
-              this.userId
-            )
-        })
-
-        it('should return false', function () {
-          expect(this.result).to.equal(false)
-        })
-      })
-    })
-
-    describe('when find has error', function () {
-      beforeEach(function () {
-        this.ProjectModel.findOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().rejects() })
-      })
-
-      it('should be rejected', async function () {
-        await expect(
-          this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
-            this.projectId,
-            this.userId
-          )
-        ).to.be.rejected
-      })
-    })
-
-    describe('with invalid args', function () {
-      beforeEach(function () {
-        this.ProjectModel.findOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().resolves() })
-        this.resultPromise =
-          this.ProjectCollabratecDetailsHandler.promises.isLinkedCollabratecUserProject(
-            'bad-project-id',
-            'bad-user-id'
-          )
-      })
-
-      it('should be rejected without updating', async function () {
-        await expect(this.resultPromise).to.be.rejected
-        expect(this.ProjectModel.findOne).not.to.have.been.called
-      })
-    })
-  })
-
-  describe('linkCollabratecUserProject', function () {
-    describe('when update succeeds', function () {
-      beforeEach(async function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().resolves() })
-        await this.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject(
-          this.projectId,
-          this.userId,
-          'collabratec-document-id'
-        )
-      })
-
-      it('should update project model', function () {
-        const query = {
-          _id: this.projectId,
-          collabratecUsers: {
-            $not: {
-              $elemMatch: {
-                collabratec_document_id: 'collabratec-document-id',
-                user_id: this.userId,
-              },
-            },
-          },
-        }
-        const update = {
-          $push: {
-            collabratecUsers: {
-              collabratec_document_id: 'collabratec-document-id',
-              user_id: this.userId,
-            },
-          },
-        }
-        expect(this.ProjectModel.updateOne).to.have.been.calledWith(
-          query,
-          update
-        )
-      })
-    })
-
-    describe('when update has error', function () {
-      beforeEach(function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().rejects() })
-      })
-
-      it('should be rejected', async function () {
-        await expect(
-          this.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject(
-            this.projectId,
-            this.userId,
-            'collabratec-document-id'
-          )
-        ).to.be.rejected
-      })
-    })
-
-    describe('with invalid args', function () {
-      beforeEach(function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().resolves() })
-        this.resultPromise =
-          this.ProjectCollabratecDetailsHandler.promises.linkCollabratecUserProject(
-            'bad-project-id',
-            'bad-user-id',
-            'collabratec-document-id'
-          )
-      })
-
-      it('should be rejected without updating', async function () {
-        await expect(this.resultPromise).to.be.rejected
-        expect(this.ProjectModel.updateOne).not.to.have.been.called
-      })
-    })
-  })
-
-  describe('setCollabratecUsers', function () {
-    beforeEach(function () {
-      this.collabratecUsers = [
-        {
-          user_id: this.userId,
-          collabratec_document_id: 'collabratec-document-id-1',
-          collabratec_privategroup_id: 'collabratec-private-group-id-1',
-        },
-        {
-          user_id: this.userId2,
-          collabratec_document_id: 'collabratec-document-id-2',
-          collabratec_privategroup_id: 'collabratec-private-group-id-2',
-        },
-      ]
-    })
-
-    describe('when update succeeds', function () {
-      beforeEach(async function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().resolves() })
-        await this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
-          this.projectId,
-          this.collabratecUsers
-        )
-      })
-
-      it('should update project model', function () {
-        const update = {
-          $set: {
-            collabratecUsers: this.collabratecUsers,
-          },
-        }
-        expect(this.ProjectModel.updateOne).to.have.been.calledWith(
-          { _id: this.projectId },
-          update
-        )
-      })
-    })
-
-    describe('when update has error', function () {
-      beforeEach(function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().rejects() })
-      })
-
-      it('should be rejected', async function () {
-        await expect(
-          this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
-            this.projectId,
-            this.collabratecUsers
-          )
-        ).to.be.rejected
-      })
-    })
-
-    describe('with invalid project_id', function () {
-      beforeEach(function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().resolves() })
-        this.resultPromise =
-          this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
-            'bad-project-id',
-            this.collabratecUsers
-          )
-      })
-
-      it('should be rejected without updating', async function () {
-        await expect(this.resultPromise).to.be.rejected
-        expect(this.ProjectModel.updateOne).not.to.have.been.called
-      })
-    })
-
-    describe('with invalid user_id', function () {
-      beforeEach(function () {
-        this.collabratecUsers[1].user_id = 'bad-user-id'
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().resolves() })
-        this.resultPromise =
-          this.ProjectCollabratecDetailsHandler.promises.setCollabratecUsers(
-            this.projectId,
-            this.collabratecUsers
-          )
-      })
-
-      it('should be rejected without updating', async function () {
-        await expect(this.resultPromise).to.be.rejected
-        expect(this.ProjectModel.updateOne).not.to.have.been.called
-      })
-    })
-  })
-
-  describe('unlinkCollabratecUserProject', function () {
-    describe('when update succeeds', function () {
-      beforeEach(async function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().resolves() })
-        await this.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject(
-          this.projectId,
-          this.userId
-        )
-      })
-
-      it('should update project model', function () {
-        const query = { _id: this.projectId }
-        const update = {
-          $pull: {
-            collabratecUsers: {
-              user_id: this.userId,
-            },
-          },
-        }
-        expect(this.ProjectModel.updateOne).to.have.been.calledWith(
-          query,
-          update
-        )
-      })
-    })
-
-    describe('when update has error', function () {
-      beforeEach(function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().rejects() })
-      })
-
-      it('should be rejected', async function () {
-        await expect(
-          this.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject(
-            this.projectId,
-            this.userId
-          )
-        ).to.be.rejected
-      })
-    })
-
-    describe('with invalid args', function () {
-      beforeEach(function () {
-        this.ProjectModel.updateOne = sinon
-          .stub()
-          .returns({ exec: sinon.stub().resolves() })
-        this.resultPromise =
-          this.ProjectCollabratecDetailsHandler.promises.unlinkCollabratecUserProject(
-            'bad-project-id',
-            'bad-user-id'
-          )
-      })
-
-      it('should be rejected without updating', async function () {
-        await expect(this.resultPromise).to.be.rejected
-        expect(this.ProjectModel.updateOne).not.to.have.been.called
-      })
-    })
-  })
-})