Jelajahi Sumber

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

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

GitOrigin-RevId: cf11a7584e39c4d4de08e2f924240e488a4066c4
Antoine Clausse 1 tahun lalu
induk
melakukan
46715191e3
44 mengubah file dengan 2806 tambahan dan 2642 penghapusan
  1. 1 1
      services/web/app/src/Features/Chat/ChatController.mjs
  2. 4 4
      services/web/app/src/Features/Chat/ChatManager.mjs
  3. 1 1
      services/web/app/src/Features/Compile/CompileController.mjs
  4. 12 15
      services/web/app/src/Features/Compile/CompileManager.mjs
  5. 1 1
      services/web/app/src/Features/Errors/ErrorController.mjs
  6. 1 1
      services/web/app/src/Features/History/RestoreManager.mjs
  7. 17 15
      services/web/app/src/Features/LinkedFiles/LinkedFilesController.mjs
  8. 2 2
      services/web/app/src/Features/LinkedFiles/LinkedFilesErrors.mjs
  9. 4 5
      services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs
  10. 5 5
      services/web/app/src/Features/LinkedFiles/ProjectFileAgent.mjs
  11. 5 6
      services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs
  12. 3 1
      services/web/app/src/Features/LinkedFiles/UrlAgent.mjs
  13. 3 3
      services/web/app/src/Features/Project/ProjectController.mjs
  14. 1 1
      services/web/app/src/Features/Project/ProjectListController.mjs
  15. 8 8
      services/web/app/src/Features/SamlLog/SamlLogHandler.mjs
  16. 1 1
      services/web/app/src/Features/ServerAdmin/AdminController.mjs
  17. 6 6
      services/web/app/src/Features/Spelling/SpellingHandler.mjs
  18. 38 38
      services/web/app/src/Features/Subscription/SubscriptionController.mjs
  19. 1 1
      services/web/app/src/Features/Subscription/SubscriptionGroupHandler.mjs
  20. 1 1
      services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
  21. 1 1
      services/web/app/src/Features/SystemMessages/SystemMessageController.mjs
  22. 5 7
      services/web/app/src/Features/SystemMessages/SystemMessageManager.mjs
  23. 7 7
      services/web/app/src/Features/Templates/TemplatesRouter.mjs
  24. 1 1
      services/web/app/src/Features/ThirdPartyDataStore/TpdsController.mjs
  25. 1 1
      services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs
  26. 12 12
      services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs
  27. 1 1
      services/web/app/src/Features/Tutorial/TutorialController.mjs
  28. 2 2
      services/web/app/src/Features/Tutorial/TutorialHandler.mjs
  29. 2 2
      services/web/app/src/router.mjs
  30. 1 1
      services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs
  31. 1 1
      services/web/test/unit/src/Chat/ChatController.test.mjs
  32. 129 0
      services/web/test/unit/src/Chat/ChatManager.test.mjs
  33. 0 131
      services/web/test/unit/src/Chat/ChatManagerTests.js
  34. 457 0
      services/web/test/unit/src/Compile/CompileManager.test.mjs
  35. 0 426
      services/web/test/unit/src/Compile/CompileManagerTests.js
  36. 6 3
      services/web/test/unit/src/Editor/EditorHttpController.test.mjs
  37. 24 20
      services/web/test/unit/src/SamlLog/SamlLogHandler.test.mjs
  38. 1560 0
      services/web/test/unit/src/Subscription/SubscriptionController.test.mjs
  39. 0 1453
      services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
  40. 50 0
      services/web/test/unit/src/SystemMessages/SystemMessageManager.test.mjs
  41. 0 50
      services/web/test/unit/src/SystemMessages/SystemMessageManagerTests.js
  42. 412 0
      services/web/test/unit/src/ThirdPartyDataStore/UpdateMerger.test.mjs
  43. 0 387
      services/web/test/unit/src/ThirdPartyDataStore/UpdateMergerTests.js
  44. 19 20
      services/web/test/unit/src/Tutorial/TutorialHandler.test.mjs

+ 1 - 1
services/web/app/src/Features/Chat/ChatController.mjs

@@ -5,7 +5,7 @@ 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'
+import ChatManager from './ChatManager.mjs'
 
 async function sendMessage(req, res) {
   const { project_id: projectId } = req.params

+ 4 - 4
services/web/app/src/Features/Chat/ChatManager.js → services/web/app/src/Features/Chat/ChatManager.mjs

@@ -1,6 +1,6 @@
-const UserInfoController = require('../User/UserInfoController')
-const UserGetter = require('../User/UserGetter')
-const { callbackify } = require('@overleaf/promise-utils')
+import UserInfoController from '../User/UserInfoController.js'
+import UserGetter from '../User/UserGetter.js'
+import { callbackify } from '@overleaf/promise-utils'
 
 async function injectUserInfoIntoThreads(threads) {
   const userIds = new Set()
@@ -38,7 +38,7 @@ async function injectUserInfoIntoThreads(threads) {
   return threads
 }
 
-module.exports = {
+export default {
   injectUserInfoIntoThreads: callbackify(injectUserInfoIntoThreads),
   promises: {
     injectUserInfoIntoThreads,

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

@@ -4,7 +4,7 @@ import { Cookie } from 'tough-cookie'
 import OError from '@overleaf/o-error'
 import Metrics from '@overleaf/metrics'
 import ProjectGetter from '../Project/ProjectGetter.js'
-import CompileManager from './CompileManager.js'
+import CompileManager from './CompileManager.mjs'
 import ClsiManager from './ClsiManager.js'
 import logger from '@overleaf/logger'
 import Settings from '@overleaf/settings'

+ 12 - 15
services/web/app/src/Features/Compile/CompileManager.js → services/web/app/src/Features/Compile/CompileManager.mjs

@@ -1,19 +1,16 @@
+import Crypto from 'node:crypto'
+import Settings from '@overleaf/settings'
+import RedisWrapper from '../../infrastructure/RedisWrapper.js'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import ProjectRootDocManager from '../Project/ProjectRootDocManager.js'
+import UserGetter from '../User/UserGetter.js'
+import ClsiManager from './ClsiManager.js'
+import Metrics from '@overleaf/metrics'
+import { RateLimiter } from '../../infrastructure/RateLimiter.js'
+import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.js'
+import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
 let CompileManager
-const Crypto = require('crypto')
-const Settings = require('@overleaf/settings')
-const RedisWrapper = require('../../infrastructure/RedisWrapper')
 const rclient = RedisWrapper.client('clsi_recently_compiled')
-const ProjectGetter = require('../Project/ProjectGetter')
-const ProjectRootDocManager = require('../Project/ProjectRootDocManager')
-const UserGetter = require('../User/UserGetter')
-const ClsiManager = require('./ClsiManager')
-const Metrics = require('@overleaf/metrics')
-const { RateLimiter } = require('../../infrastructure/RateLimiter')
-const UserAnalyticsIdCache = require('../Analytics/UserAnalyticsIdCache')
-const {
-  callbackify,
-  callbackifyMultiResult,
-} = require('@overleaf/promise-utils')
 
 function instrumentWithTimer(fn, key) {
   return async (...args) => {
@@ -196,7 +193,7 @@ async function deleteAuxFiles(projectId, userId, clsiserverid) {
   )
 }
 
-module.exports = CompileManager = {
+export default CompileManager = {
   promises: {
     compile: instrumentedCompile,
     deleteAuxFiles,

+ 1 - 1
services/web/app/src/Features/Errors/ErrorController.mjs

@@ -1,7 +1,7 @@
 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 SamlLogHandler from '../SamlLog/SamlLogHandler.mjs'
 import HttpErrorHandler from './HttpErrorHandler.js'
 import { plainTextResponse } from '../../infrastructure/Response.js'
 import { expressifyErrorHandler } from '@overleaf/promise-utils'

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

@@ -14,7 +14,7 @@ import ChatApiHandler from '../Chat/ChatApiHandler.js'
 import DocstoreManager from '../Docstore/DocstoreManager.js'
 import logger from '@overleaf/logger'
 import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
-import ChatManager from '../Chat/ChatManager.js'
+import ChatManager from '../Chat/ChatManager.mjs'
 import OError from '@overleaf/o-error'
 import ProjectGetter from '../Project/ProjectGetter.js'
 import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'

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

@@ -15,21 +15,7 @@ import Settings from '@overleaf/settings'
 import _ from 'lodash'
 import AnalyticsManager from '../../../../app/src/Features/Analytics/AnalyticsManager.js'
 import LinkedFilesHandler from './LinkedFilesHandler.mjs'
-import {
-  CompileFailedError,
-  UrlFetchFailedError,
-  InvalidUrlError,
-  AccessDeniedError,
-  BadEntityTypeError,
-  BadDataError,
-  ProjectNotFoundError,
-  V1ProjectNotFoundError,
-  SourceFileNotFoundError,
-  NotOriginalImporterError,
-  FeatureNotAvailableError,
-  RemoteServiceError,
-  FileCannotRefreshError,
-} from './LinkedFilesErrors.js'
+import LinkedFilesErrors from './LinkedFilesErrors.mjs'
 import {
   OutputFileFetchFailedError,
   FileTooLargeError,
@@ -45,6 +31,22 @@ import ProjectOutputFileAgent from './ProjectOutputFileAgent.mjs'
 import ProjectFileAgent from './ProjectFileAgent.mjs'
 import UrlAgent from './UrlAgent.mjs'
 
+const {
+  CompileFailedError,
+  UrlFetchFailedError,
+  InvalidUrlError,
+  AccessDeniedError,
+  BadEntityTypeError,
+  BadDataError,
+  ProjectNotFoundError,
+  V1ProjectNotFoundError,
+  SourceFileNotFoundError,
+  NotOriginalImporterError,
+  FeatureNotAvailableError,
+  RemoteServiceError,
+  FileCannotRefreshError,
+} = LinkedFilesErrors
+
 let LinkedFilesController
 
 const createLinkedFileSchema = z.object({

+ 2 - 2
services/web/app/src/Features/LinkedFiles/LinkedFilesErrors.js → services/web/app/src/Features/LinkedFiles/LinkedFilesErrors.mjs

@@ -1,4 +1,4 @@
-const { BackwardCompatibleError } = require('../Errors/Errors')
+import { BackwardCompatibleError } from '../Errors/Errors.js'
 
 class UrlFetchFailedError extends BackwardCompatibleError {}
 
@@ -26,7 +26,7 @@ class RemoteServiceError extends BackwardCompatibleError {}
 
 class FileCannotRefreshError extends BackwardCompatibleError {}
 
-module.exports = {
+export default {
   CompileFailedError,
   UrlFetchFailedError,
   InvalidUrlError,

+ 4 - 5
services/web/app/src/Features/LinkedFiles/LinkedFilesHandler.mjs

@@ -3,13 +3,12 @@ import EditorController from '../Editor/EditorController.js'
 import ProjectLocator from '../Project/ProjectLocator.js'
 import { Project } from '../../models/Project.js'
 import ProjectGetter from '../Project/ProjectGetter.js'
-import {
-  ProjectNotFoundError,
-  V1ProjectNotFoundError,
-  BadDataError,
-} from './LinkedFilesErrors.js'
+import LinkedFilesErrors from './LinkedFilesErrors.mjs'
 import { callbackifyAll } from '@overleaf/promise-utils'
 
+const { ProjectNotFoundError, V1ProjectNotFoundError, BadDataError } =
+  LinkedFilesErrors
+
 const LinkedFilesHandler = {
   async getFileById(projectId, fileId) {
     const { element, path, folder } = await ProjectLocator.promises.findElement(

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

@@ -16,16 +16,16 @@ import DocstoreManager from '../Docstore/DocstoreManager.js'
 import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
 import _ from 'lodash'
 import LinkedFilesHandler from './LinkedFilesHandler.mjs'
+import LinkedFilesErrors from './LinkedFilesErrors.mjs'
+import { promisify } from '@overleaf/promise-utils'
+import HistoryManager from '../History/HistoryManager.js'
 
-import {
+const {
   BadDataError,
   AccessDeniedError,
   BadEntityTypeError,
   SourceFileNotFoundError,
-} from './LinkedFilesErrors.js'
-
-import { promisify } from '@overleaf/promise-utils'
-import HistoryManager from '../History/HistoryManager.js'
+} = LinkedFilesErrors
 
 let ProjectFileAgent
 

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

@@ -1,17 +1,16 @@
 import AuthorizationManager from '../Authorization/AuthorizationManager.js'
-import CompileManager from '../Compile/CompileManager.js'
+import CompileManager from '../Compile/CompileManager.mjs'
 import ClsiManager from '../Compile/ClsiManager.js'
 import ProjectFileAgent from './ProjectFileAgent.mjs'
 import _ from 'lodash'
-import {
-  CompileFailedError,
-  BadDataError,
-  AccessDeniedError,
-} from './LinkedFilesErrors.js'
+import LinkedFilesErrors from './LinkedFilesErrors.mjs'
 import { OutputFileFetchFailedError } from '../Errors/Errors.js'
 import LinkedFilesHandler from './LinkedFilesHandler.mjs'
 import { promisify } from '@overleaf/promise-utils'
 
+const { CompileFailedError, BadDataError, AccessDeniedError } =
+  LinkedFilesErrors
+
 function _prepare(projectId, linkedFileData, userId, callback) {
   _checkAuth(projectId, linkedFileData, userId, (err, allowed) => {
     if (err) {

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

@@ -1,12 +1,14 @@
 import logger from '@overleaf/logger'
 import urlValidator from 'valid-url'
-import { InvalidUrlError, UrlFetchFailedError } from './LinkedFilesErrors.js'
+import LinkedFilesErrors from './LinkedFilesErrors.mjs'
 import LinkedFilesHandler from './LinkedFilesHandler.mjs'
 import UrlHelper from '../Helpers/UrlHelper.js'
 import { fetchStream, RequestFailedError } from '@overleaf/fetch-utils'
 import { callbackify } from '@overleaf/promise-utils'
 import { FileTooLargeError } from '../Errors/Errors.js'
 
+const { InvalidUrlError, UrlFetchFailedError } = LinkedFilesErrors
+
 async function createLinkedFile(
   projectId,
   linkedFileData,

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

@@ -35,20 +35,20 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.js'
 import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
 import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js'
 import FeaturesUpdater from '../Subscription/FeaturesUpdater.js'
-import SpellingHandler from '../Spelling/SpellingHandler.js'
+import SpellingHandler from '../Spelling/SpellingHandler.mjs'
 import { hasAdminAccess } from '../Helpers/AdminAuthorizationHelper.js'
 import InstitutionsFeatures from '../Institutions/InstitutionsFeatures.js'
 import InstitutionsGetter from '../Institutions/InstitutionsGetter.js'
 import ProjectAuditLogHandler from './ProjectAuditLogHandler.mjs'
 import PublicAccessLevels from '../Authorization/PublicAccessLevels.js'
 import TagsHandler from '../Tags/TagsHandler.js'
-import TutorialHandler from '../Tutorial/TutorialHandler.js'
+import TutorialHandler from '../Tutorial/TutorialHandler.mjs'
 import UserUpdater from '../User/UserUpdater.js'
 import Modules from '../../infrastructure/Modules.js'
 import { z, zz, validateReq } from '../../infrastructure/Validation.js'
 import UserGetter from '../User/UserGetter.js'
 import { isStandaloneAiAddOnPlanCode } from '../Subscription/AiHelper.js'
-import SubscriptionController from '../Subscription/SubscriptionController.js'
+import SubscriptionController from '../Subscription/SubscriptionController.mjs'
 import { formatCurrency } from '../../util/currency.js'
 
 const { ObjectId } = mongodb

+ 1 - 1
services/web/app/src/Features/Project/ProjectListController.mjs

@@ -25,7 +25,7 @@ import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
 import GeoIpLookup from '../../infrastructure/GeoIpLookup.js'
 import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
 import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js'
-import TutorialHandler from '../Tutorial/TutorialHandler.js'
+import TutorialHandler from '../Tutorial/TutorialHandler.mjs'
 import SubscriptionHelper from '../Subscription/SubscriptionHelper.js'
 import PermissionsManager from '../Authorization/PermissionsManager.js'
 import AnalyticsManager from '../Analytics/AnalyticsManager.js'

+ 8 - 8
services/web/app/src/Features/SamlLog/SamlLogHandler.js → services/web/app/src/Features/SamlLog/SamlLogHandler.mjs

@@ -1,9 +1,9 @@
-const { SamlLog } = require('../../models/SamlLog')
-const SessionManager = require('../Authentication/SessionManager')
-const logger = require('@overleaf/logger')
-const { err: errSerializer } = require('@overleaf/logger/serializers')
-const { callbackify } = require('util')
-const Settings = require('@overleaf/settings')
+import { SamlLog } from '../../models/SamlLog.js'
+import SessionManager from '../Authentication/SessionManager.js'
+import logger from '@overleaf/logger'
+import loggerSerializers from '@overleaf/logger/serializers.js'
+import { callbackify } from 'node:util'
+import Settings from '@overleaf/settings'
 
 const ALLOWED_PATHS = Settings.saml?.logAllowList || ['/saml/']
 
@@ -33,7 +33,7 @@ async function log(req, data, samlAssertion) {
     data.samlSession = saml
 
     if (data.error instanceof Error) {
-      const errSerialized = errSerializer(data.error)
+      const errSerialized = loggerSerializers.err(data.error)
       if (data.error.tryAgain) {
         errSerialized.tryAgain = data.error.tryAgain
       }
@@ -82,4 +82,4 @@ const SamlLogHandler = {
   },
 }
 
-module.exports = SamlLogHandler
+export default SamlLogHandler

+ 1 - 1
services/web/app/src/Features/ServerAdmin/AdminController.mjs

@@ -5,7 +5,7 @@ import Settings from '@overleaf/settings'
 import TpdsUpdateSender from '../ThirdPartyDataStore/TpdsUpdateSender.js'
 import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.js'
 import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
-import SystemMessageManager from '../SystemMessages/SystemMessageManager.js'
+import SystemMessageManager from '../SystemMessages/SystemMessageManager.mjs'
 
 const AdminController = {
   _sendDisconnectAllUsersMessage: delay => {

+ 6 - 6
services/web/app/src/Features/Spelling/SpellingHandler.js → services/web/app/src/Features/Spelling/SpellingHandler.mjs

@@ -1,9 +1,9 @@
-const OError = require('@overleaf/o-error')
-const Metrics = require('@overleaf/metrics')
-const { promisifyAll } = require('@overleaf/promise-utils')
-const LearnedWordsManager = require('./LearnedWordsManager')
+import OError from '@overleaf/o-error'
+import Metrics from '@overleaf/metrics'
+import { promisifyAll } from '@overleaf/promise-utils'
+import LearnedWordsManager from './LearnedWordsManager.js'
 
-module.exports = {
+const SpellingHandler = {
   getUserDictionary(userId, callback) {
     const timer = new Metrics.Timer('spelling_get_dict')
     LearnedWordsManager.getLearnedWords(userId, (error, words) => {
@@ -26,4 +26,4 @@ module.exports = {
   },
 }
 
-module.exports.promises = promisifyAll(module.exports)
+export default { ...SpellingHandler, promises: promisifyAll(SpellingHandler) }

+ 38 - 38
services/web/app/src/Features/Subscription/SubscriptionController.js → services/web/app/src/Features/Subscription/SubscriptionController.mjs

@@ -1,48 +1,48 @@
 // @ts-check
 
-const SessionManager = require('../Authentication/SessionManager')
-const SubscriptionHandler = require('./SubscriptionHandler')
-const SubscriptionHelper = require('./SubscriptionHelper')
-const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
-const LimitationsManager = require('./LimitationsManager')
-const RecurlyWrapper = require('./RecurlyWrapper')
-const Settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
-const FeaturesUpdater = require('./FeaturesUpdater')
-const GroupPlansData = require('./GroupPlansData')
-const V1SubscriptionManager = require('./V1SubscriptionManager')
-const AnalyticsManager = require('../Analytics/AnalyticsManager')
-const RecurlyEventHandler = require('./RecurlyEventHandler')
-const { expressify } = require('@overleaf/promise-utils')
-const OError = require('@overleaf/o-error')
+import SessionManager from '../Authentication/SessionManager.js'
+import SubscriptionHandler from './SubscriptionHandler.js'
+import SubscriptionHelper from './SubscriptionHelper.js'
+import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.js'
+import LimitationsManager from './LimitationsManager.js'
+import RecurlyWrapper from './RecurlyWrapper.js'
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import GeoIpLookup from '../../infrastructure/GeoIpLookup.js'
+import FeaturesUpdater from './FeaturesUpdater.js'
+import GroupPlansData from './GroupPlansData.js'
+import V1SubscriptionManager from './V1SubscriptionManager.js'
+import AnalyticsManager from '../Analytics/AnalyticsManager.js'
+import RecurlyEventHandler from './RecurlyEventHandler.js'
+import { expressify } from '@overleaf/promise-utils'
+import OError from '@overleaf/o-error'
+import Errors from './Errors.js'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
+import AuthorizationManager from '../Authorization/AuthorizationManager.js'
+import Modules from '../../infrastructure/Modules.js'
+import async from 'async'
+import HttpErrorHandler from '../Errors/HttpErrorHandler.js'
+import RecurlyClient from './RecurlyClient.js'
+import {
+  AI_ADD_ON_CODE,
+  subscriptionChangeIsAiAssistUpgrade,
+} from './AiHelper.js'
+import PlansLocator from './PlansLocator.js'
+import { User } from '../../models/User.js'
+import UserGetter from '../User/UserGetter.js'
+import PermissionsManager from '../Authorization/PermissionsManager.js'
+import { sanitizeSessionUserForFrontEnd } from '../../infrastructure/FrontEndUser.js'
+import { z, validateReq } from '../../infrastructure/Validation.js'
+import { IndeterminateInvoiceError } from '../Errors/Errors.js'
+import SubscriptionLocator from './SubscriptionLocator.js'
+
 const {
   DuplicateAddOnError,
   AddOnNotPresentError,
   PaymentActionRequiredError,
   PaymentFailedError,
   MissingBillingInfoError,
-} = require('./Errors')
-const SplitTestHandler = require('../SplitTests/SplitTestHandler')
-const AuthorizationManager = require('../Authorization/AuthorizationManager')
-const Modules = require('../../infrastructure/Modules')
-const async = require('async')
-const HttpErrorHandler = require('../Errors/HttpErrorHandler')
-const RecurlyClient = require('./RecurlyClient')
-const {
-  AI_ADD_ON_CODE,
-  subscriptionChangeIsAiAssistUpgrade,
-} = require('./AiHelper')
-const PlansLocator = require('./PlansLocator')
-const { User } = require('../../models/User')
-const UserGetter = require('../User/UserGetter')
-const PermissionsManager = require('../Authorization/PermissionsManager')
-const {
-  sanitizeSessionUserForFrontEnd,
-} = require('../../infrastructure/FrontEndUser')
-const { z, validateReq } = require('../../infrastructure/Validation')
-const { IndeterminateInvoiceError } = require('../Errors/Errors')
-const SubscriptionLocator = require('./SubscriptionLocator')
+} = Errors
 
 const SUBSCRIPTION_PAUSED_REDIRECT_PATH =
   '/user/subscription?redirect-reason=subscription-paused'
@@ -1101,7 +1101,7 @@ function makeChangePreview(
   }
 }
 
-module.exports = {
+export default {
   userSubscriptionPage: expressify(userSubscriptionPage),
   successfulSubscription: expressify(successfulSubscription),
   cancelSubscription,

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

@@ -3,7 +3,7 @@ import _ from 'lodash'
 import OError from '@overleaf/o-error'
 import SubscriptionUpdater from './SubscriptionUpdater.js'
 import SubscriptionLocator from './SubscriptionLocator.js'
-import SubscriptionController from './SubscriptionController.js'
+import SubscriptionController from './SubscriptionController.mjs'
 import SubscriptionHelper from './SubscriptionHelper.js'
 import { Subscription } from '../../models/Subscription.js'
 import { User } from '../../models/User.js'

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

@@ -1,6 +1,6 @@
 import AuthenticationController from '../Authentication/AuthenticationController.js'
 import PermissionsController from '../Authorization/PermissionsController.mjs'
-import SubscriptionController from './SubscriptionController.js'
+import SubscriptionController from './SubscriptionController.mjs'
 import SubscriptionGroupController from './SubscriptionGroupController.mjs'
 import TeamInvitesController from './TeamInvitesController.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'

+ 1 - 1
services/web/app/src/Features/SystemMessages/SystemMessageController.mjs

@@ -1,6 +1,6 @@
 import Settings from '@overleaf/settings'
 import SessionManager from '../Authentication/SessionManager.js'
-import SystemMessageManager from './SystemMessageManager.js'
+import SystemMessageManager from './SystemMessageManager.mjs'
 
 const ProjectController = {
   getMessages(req, res, next) {

+ 5 - 7
services/web/app/src/Features/SystemMessages/SystemMessageManager.js → services/web/app/src/Features/SystemMessages/SystemMessageManager.mjs

@@ -1,9 +1,7 @@
-const { SystemMessage } = require('../../models/SystemMessage')
-const {
-  addRequiredCleanupHandlerBeforeDrainingConnections,
-} = require('../../infrastructure/GracefulShutdown')
-const { callbackifyAll } = require('@overleaf/promise-utils')
-const logger = require('@overleaf/logger')
+import { SystemMessage } from '../../models/SystemMessage.js'
+import { addRequiredCleanupHandlerBeforeDrainingConnections } from '../../infrastructure/GracefulShutdown.js'
+import { callbackifyAll } from '@overleaf/promise-utils'
+import logger from '@overleaf/logger'
 
 const SystemMessageManager = {
   _cachedMessages: [],
@@ -52,7 +50,7 @@ addRequiredCleanupHandlerBeforeDrainingConnections(
   }
 )
 
-module.exports = {
+export default {
   getMessages: SystemMessageManager.getMessages.bind(SystemMessageManager),
   ...callbackifyAll(SystemMessageManager, { without: ['getMessages'] }),
   promises: SystemMessageManager,

+ 7 - 7
services/web/app/src/Features/Templates/TemplatesRouter.js → services/web/app/src/Features/Templates/TemplatesRouter.mjs

@@ -1,16 +1,16 @@
-const AuthenticationController = require('../Authentication/AuthenticationController')
-const TemplatesController = require('./TemplatesController')
-const TemplatesMiddleware = require('./TemplatesMiddleware')
-const { RateLimiter } = require('../../infrastructure/RateLimiter')
-const RateLimiterMiddleware = require('../Security/RateLimiterMiddleware')
-const AnalyticsRegistrationSourceMiddleware = require('../Analytics/AnalyticsRegistrationSourceMiddleware')
+import AuthenticationController from '../Authentication/AuthenticationController.js'
+import TemplatesController from './TemplatesController.js'
+import TemplatesMiddleware from './TemplatesMiddleware.js'
+import { RateLimiter } from '../../infrastructure/RateLimiter.js'
+import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
+import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.js'
 
 const rateLimiter = new RateLimiter('create-project-from-template', {
   points: 20,
   duration: 60,
 })
 
-module.exports = {
+export default {
   rateLimiter,
   apply(app) {
     app.get(

+ 1 - 1
services/web/app/src/Features/ThirdPartyDataStore/TpdsController.mjs

@@ -1,6 +1,6 @@
 import { expressify } from '@overleaf/promise-utils'
 import TpdsUpdateHandler from './TpdsUpdateHandler.mjs'
-import UpdateMerger from './UpdateMerger.js'
+import UpdateMerger from './UpdateMerger.mjs'
 import Errors from '../Errors/Errors.js'
 import logger from '@overleaf/logger'
 import Path from 'node:path'

+ 1 - 1
services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs

@@ -1,5 +1,5 @@
 import { callbackify } from 'node:util'
-import UpdateMerger from './UpdateMerger.js'
+import UpdateMerger from './UpdateMerger.mjs'
 import logger from '@overleaf/logger'
 import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
 import ProjectCreationHandler from '../Project/ProjectCreationHandler.js'

+ 12 - 12
services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.js → services/web/app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs

@@ -1,14 +1,14 @@
-const { callbackify } = require('util')
-const _ = require('lodash')
-const fsPromises = require('fs/promises')
-const fs = require('fs')
-const logger = require('@overleaf/logger')
-const EditorController = require('../Editor/EditorController')
-const FileTypeManager = require('../Uploads/FileTypeManager')
-const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
-const crypto = require('crypto')
-const Settings = require('@overleaf/settings')
-const { pipeline } = require('stream/promises')
+import { callbackify } from 'node:util'
+import _ from 'lodash'
+import fsPromises from 'node:fs/promises'
+import fs from 'node:fs'
+import logger from '@overleaf/logger'
+import EditorController from '../Editor/EditorController.js'
+import FileTypeManager from '../Uploads/FileTypeManager.js'
+import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
+import crypto from 'node:crypto'
+import Settings from '@overleaf/settings'
+import { pipeline } from 'node:stream/promises'
 
 async function mergeUpdate(userId, projectId, path, updateRequest, source) {
   const fsPath = await writeUpdateToDisk(projectId, updateRequest)
@@ -185,7 +185,7 @@ async function createFolder(projectId, path, userId) {
   return folder
 }
 
-module.exports = {
+export default {
   mergeUpdate: callbackify(mergeUpdate),
   _mergeUpdate: callbackify(_mergeUpdate),
   deleteUpdate: callbackify(deleteUpdate),

+ 1 - 1
services/web/app/src/Features/Tutorial/TutorialController.mjs

@@ -1,5 +1,5 @@
 import SessionManager from '../Authentication/SessionManager.js'
-import TutorialHandler from './TutorialHandler.js'
+import TutorialHandler from './TutorialHandler.mjs'
 import { expressify } from '@overleaf/promise-utils'
 
 const VALID_KEYS = [

+ 2 - 2
services/web/app/src/Features/Tutorial/TutorialHandler.js → services/web/app/src/Features/Tutorial/TutorialHandler.mjs

@@ -1,4 +1,4 @@
-const UserUpdater = require('../User/UserUpdater')
+import UserUpdater from '../User/UserUpdater.js'
 
 const POSTPONE_DURATION_MS = 24 * 60 * 60 * 1000 // 1 day
 
@@ -59,4 +59,4 @@ function getInactiveTutorials(user, tutorialKey) {
   return inactiveTutorials
 }
 
-module.exports = { setTutorialState, getInactiveTutorials }
+export default { setTutorialState, getInactiveTutorials }

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

@@ -24,7 +24,7 @@ import UserEmailsController from './Features/User/UserEmailsController.js'
 import UserPagesController from './Features/User/UserPagesController.mjs'
 import TutorialController from './Features/Tutorial/TutorialController.mjs'
 import DocumentController from './Features/Documents/DocumentController.mjs'
-import CompileManager from './Features/Compile/CompileManager.js'
+import CompileManager from './Features/Compile/CompileManager.mjs'
 import CompileController from './Features/Compile/CompileController.mjs'
 import HealthCheckController from './Features/HealthCheck/HealthCheckController.mjs'
 import ProjectDownloadsController from './Features/Downloads/ProjectDownloadsController.mjs'
@@ -52,7 +52,7 @@ import MetaController from './Features/Metadata/MetaController.mjs'
 import TokenAccessController from './Features/TokenAccess/TokenAccessController.mjs'
 import TokenAccessRouter from './Features/TokenAccess/TokenAccessRouter.mjs'
 import LinkedFilesRouter from './Features/LinkedFiles/LinkedFilesRouter.mjs'
-import TemplatesRouter from './Features/Templates/TemplatesRouter.js'
+import TemplatesRouter from './Features/Templates/TemplatesRouter.mjs'
 import UserMembershipRouter from './Features/UserMembership/UserMembershipRouter.mjs'
 import SystemMessageController from './Features/SystemMessages/SystemMessageController.mjs'
 import AnalyticsRegistrationSourceMiddleware from './Features/Analytics/AnalyticsRegistrationSourceMiddleware.js'

+ 1 - 1
services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs

@@ -1,5 +1,5 @@
 import AbstractMockApi from './AbstractMockApi.mjs'
-import SubscriptionController from '../../../../app/src/Features/Subscription/SubscriptionController.js'
+import SubscriptionController from '../../../../app/src/Features/Subscription/SubscriptionController.mjs'
 import { xmlResponse } from '../../../../app/src/infrastructure/Response.js'
 
 class MockRecurlyApi extends AbstractMockApi {

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

@@ -33,7 +33,7 @@ describe('ChatController', function () {
       default: ctx.ChatApiHandler,
     }))
 
-    vi.doMock('../../../../app/src/Features/Chat/ChatManager.js', () => ({
+    vi.doMock('../../../../app/src/Features/Chat/ChatManager.mjs', () => ({
       default: ctx.ChatManager,
     }))
 

+ 129 - 0
services/web/test/unit/src/Chat/ChatManager.test.mjs

@@ -0,0 +1,129 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
+const modulePath = '../../../../app/src/Features/Chat/ChatManager.mjs'
+
+describe('ChatManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.user_id = 'mock-user-id'
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: (ctx.UserGetter = { promises: {} }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserInfoController', () => ({
+      default: (ctx.UserInfoController = {}),
+    }))
+
+    ctx.ChatManager = (await import(modulePath)).default
+    ctx.req = {
+      params: {
+        project_id: ctx.project_id,
+      },
+    }
+    ctx.res = {
+      json: sinon.stub(),
+      send: sinon.stub(),
+      sendStatus: sinon.stub(),
+    }
+  })
+
+  describe('injectUserInfoIntoThreads', function () {
+    beforeEach(function (ctx) {
+      ctx.users = {
+        user_id_1: {
+          _id: 'user_id_1',
+        },
+        user_id_2: {
+          _id: 'user_id_2',
+        },
+      }
+      ctx.UserGetter.promises.getUsers = userIds =>
+        Promise.resolve(
+          Array.from(userIds)
+            .map(id => ctx.users[id])
+            .filter(u => !!u)
+        )
+
+      sinon.spy(ctx.UserGetter.promises, 'getUsers')
+      ctx.UserInfoController.formatPersonalInfo = user => ({
+        formatted: { id: user._id.toString() },
+      })
+    })
+
+    it('should inject a user object into messaged and resolved data', async function (ctx) {
+      const threads = await ctx.ChatManager.promises.injectUserInfoIntoThreads({
+        thread1: {
+          resolved: true,
+          resolved_by_user_id: 'user_id_1',
+          messages: [
+            {
+              user_id: 'user_id_1',
+              content: 'foo',
+            },
+            {
+              user_id: 'user_id_2',
+              content: 'bar',
+            },
+          ],
+        },
+        thread2: {
+          messages: [
+            {
+              user_id: 'user_id_1',
+              content: 'baz',
+            },
+          ],
+        },
+      })
+
+      expect(threads).to.deep.equal({
+        thread1: {
+          resolved: true,
+          resolved_by_user_id: 'user_id_1',
+          resolved_by_user: { formatted: { id: 'user_id_1' } },
+          messages: [
+            {
+              user_id: 'user_id_1',
+              user: { formatted: { id: 'user_id_1' } },
+              content: 'foo',
+            },
+            {
+              user_id: 'user_id_2',
+              user: { formatted: { id: 'user_id_2' } },
+              content: 'bar',
+            },
+          ],
+        },
+        thread2: {
+          messages: [
+            {
+              user_id: 'user_id_1',
+              user: { formatted: { id: 'user_id_1' } },
+              content: 'baz',
+            },
+          ],
+        },
+      })
+    })
+
+    it('should lookup all users in a single batch', async function (ctx) {
+      await ctx.ChatManager.promises.injectUserInfoIntoThreads([
+        {
+          messages: [
+            {
+              user_id: 'user_id_1',
+              content: 'foo',
+            },
+            {
+              user_id: 'user_id_1',
+              content: 'bar',
+            },
+          ],
+        },
+      ])
+
+      ctx.UserGetter.promises.getUsers.should.have.been.calledOnce
+    })
+  })
+})

+ 0 - 131
services/web/test/unit/src/Chat/ChatManagerTests.js

@@ -1,131 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const modulePath = path.join(
-  __dirname,
-  '../../../../app/src/Features/Chat/ChatManager'
-)
-const { expect } = require('chai')
-
-describe('ChatManager', function () {
-  beforeEach(function () {
-    this.user_id = 'mock-user-id'
-    this.ChatManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '../User/UserGetter': (this.UserGetter = { promises: {} }),
-        '../User/UserInfoController': (this.UserInfoController = {}),
-      },
-    })
-    this.req = {
-      params: {
-        project_id: this.project_id,
-      },
-    }
-    this.res = {
-      json: sinon.stub(),
-      send: sinon.stub(),
-      sendStatus: sinon.stub(),
-    }
-  })
-
-  describe('injectUserInfoIntoThreads', function () {
-    beforeEach(function () {
-      this.users = {
-        user_id_1: {
-          _id: 'user_id_1',
-        },
-        user_id_2: {
-          _id: 'user_id_2',
-        },
-      }
-      this.UserGetter.promises.getUsers = userIds =>
-        Promise.resolve(
-          Array.from(userIds)
-            .map(id => this.users[id])
-            .filter(u => !!u)
-        )
-
-      sinon.spy(this.UserGetter.promises, 'getUsers')
-      return (this.UserInfoController.formatPersonalInfo = user => ({
-        formatted: { id: user._id.toString() },
-      }))
-    })
-
-    it('should inject a user object into messaged and resolved data', async function () {
-      const threads = await this.ChatManager.promises.injectUserInfoIntoThreads(
-        {
-          thread1: {
-            resolved: true,
-            resolved_by_user_id: 'user_id_1',
-            messages: [
-              {
-                user_id: 'user_id_1',
-                content: 'foo',
-              },
-              {
-                user_id: 'user_id_2',
-                content: 'bar',
-              },
-            ],
-          },
-          thread2: {
-            messages: [
-              {
-                user_id: 'user_id_1',
-                content: 'baz',
-              },
-            ],
-          },
-        }
-      )
-
-      expect(threads).to.deep.equal({
-        thread1: {
-          resolved: true,
-          resolved_by_user_id: 'user_id_1',
-          resolved_by_user: { formatted: { id: 'user_id_1' } },
-          messages: [
-            {
-              user_id: 'user_id_1',
-              user: { formatted: { id: 'user_id_1' } },
-              content: 'foo',
-            },
-            {
-              user_id: 'user_id_2',
-              user: { formatted: { id: 'user_id_2' } },
-              content: 'bar',
-            },
-          ],
-        },
-        thread2: {
-          messages: [
-            {
-              user_id: 'user_id_1',
-              user: { formatted: { id: 'user_id_1' } },
-              content: 'baz',
-            },
-          ],
-        },
-      })
-    })
-
-    it('should lookup all users in a single batch', async function () {
-      await this.ChatManager.promises.injectUserInfoIntoThreads([
-        {
-          messages: [
-            {
-              user_id: 'user_id_1',
-              content: 'foo',
-            },
-            {
-              user_id: 'user_id_1',
-              content: 'bar',
-            },
-          ],
-        },
-      ])
-
-      this.UserGetter.promises.getUsers.should.have.been.calledOnce
-    })
-  })
-})

+ 457 - 0
services/web/test/unit/src/Compile/CompileManager.test.mjs

@@ -0,0 +1,457 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
+const MODULE_PATH = '../../../../app/src/Features/Compile/CompileManager.mjs'
+
+describe('CompileManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.rateLimiter = {
+      consume: sinon.stub().resolves(),
+    }
+    ctx.timer = {
+      done: sinon.stub(),
+    }
+    ctx.Metrics = {
+      Timer: sinon.stub().returns(ctx.timer),
+      inc: sinon.stub(),
+    }
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {
+        apis: {
+          clsi: { submissionBackendClass: 'n2d' },
+        },
+        redis: { web: { host: '127.0.0.1', port: 42 } },
+        rateLimit: { autoCompile: {} },
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/RedisWrapper', () => ({
+      default: {
+        client: () =>
+          (ctx.rclient = {
+            auth() {},
+          }),
+      },
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectRootDocManager',
+      () => ({
+        default: (ctx.ProjectRootDocManager = {
+          promises: {},
+        }),
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: (ctx.ProjectGetter = { promises: {} }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: (ctx.UserGetter = { promises: {} }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Compile/ClsiManager', () => ({
+      default: (ctx.ClsiManager = { promises: {} }),
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/RateLimiter.js', () => ({
+      RateLimiter: sinon.stub().returns(ctx.rateLimiter),
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: ctx.Metrics,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/UserAnalyticsIdCache',
+      () => ({
+        default: (ctx.UserAnalyticsIdCache = {
+          get: sinon.stub().resolves('abc'),
+        }),
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: (ctx.SplitTestHandler = {
+          promises: {},
+        }),
+      })
+    )
+
+    ctx.CompileManager = (await import(MODULE_PATH)).default
+    ctx.project_id = 'mock-project-id-123'
+    ctx.user_id = 'mock-user-id-123'
+    ctx.callback = sinon.stub()
+    ctx.limits = {
+      timeout: 42,
+      compileGroup: 'standard',
+    }
+  })
+
+  describe('compile', function () {
+    beforeEach(function (ctx) {
+      ctx.CompileManager._checkIfRecentlyCompiled = sinon.stub().resolves(false)
+      ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet = sinon
+        .stub()
+        .resolves()
+      ctx.CompileManager.promises.getProjectCompileLimits = sinon
+        .stub()
+        .resolves(ctx.limits)
+      ctx.ClsiManager.promises.sendRequest = sinon.stub().resolves({
+        status: (ctx.status = 'mock-status'),
+        outputFiles: (ctx.outputFiles = []),
+        clsiServerId: (ctx.output = 'mock output'),
+      })
+    })
+
+    describe('succesfully', function () {
+      let result
+      beforeEach(async function (ctx) {
+        ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
+          isAutoCompile,
+          compileGroup
+        ) => true
+        ctx.ProjectGetter.promises.getProject = sinon
+          .stub()
+          .resolves(
+            (ctx.project = { owner_ref: (ctx.owner_id = 'owner-id-123') })
+          )
+        ctx.UserGetter.promises.getUser = sinon.stub().resolves(
+          (ctx.user = {
+            features: { compileTimeout: '20s', compileGroup: 'standard' },
+            analyticsId: 'abc',
+          })
+        )
+        result = await ctx.CompileManager.promises.compile(
+          ctx.project_id,
+          ctx.user_id,
+          {}
+        )
+      })
+
+      it('should check the project has not been recently compiled', function (ctx) {
+        ctx.CompileManager._checkIfRecentlyCompiled
+          .calledWith(ctx.project_id, ctx.user_id)
+          .should.equal(true)
+      })
+
+      it('should ensure that the root document is set', function (ctx) {
+        ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet
+          .calledWith(ctx.project_id)
+          .should.equal(true)
+      })
+
+      it('should get the project compile limits', function (ctx) {
+        ctx.CompileManager.promises.getProjectCompileLimits
+          .calledWith(ctx.project_id)
+          .should.equal(true)
+      })
+
+      it('should run the compile with the compile limits', function (ctx) {
+        ctx.ClsiManager.promises.sendRequest
+          .calledWith(ctx.project_id, ctx.user_id, {
+            timeout: ctx.limits.timeout,
+            compileGroup: 'standard',
+            buildId: sinon.match(/[a-f0-9]+-[a-f0-9]+/),
+          })
+          .should.equal(true)
+      })
+
+      it('should resolve with the output', function (ctx) {
+        expect(result).to.haveOwnProperty('status', ctx.status)
+        expect(result).to.haveOwnProperty('clsiServerId', ctx.output)
+        expect(result).to.haveOwnProperty('outputFiles', ctx.outputFiles)
+      })
+
+      it('should time the compile', function (ctx) {
+        ctx.timer.done.called.should.equal(true)
+      })
+    })
+
+    describe('when the project has been recently compiled', function () {
+      it('should return', async function (ctx) {
+        ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
+          isAutoCompile,
+          compileGroup
+        ) => true
+        ctx.CompileManager._checkIfRecentlyCompiled = sinon
+          .stub()
+          .resolves(true)
+        const { status } = await ctx.CompileManager.promises.compile(
+          ctx.project_id,
+          ctx.user_id,
+          {}
+        )
+        status.should.equal('too-recently-compiled')
+      })
+    })
+
+    describe('should check the rate limit', function () {
+      it('should return', async function (ctx) {
+        ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit = sinon
+          .stub()
+          .resolves(false)
+        const { status } = await ctx.CompileManager.promises.compile(
+          ctx.project_id,
+          ctx.user_id,
+          {}
+        )
+
+        expect(status).to.equal('autocompile-backoff')
+      })
+    })
+  })
+
+  describe('getProjectCompileLimits', function () {
+    beforeEach(async function (ctx) {
+      ctx.features = {
+        compileTimeout: (ctx.timeout = 42),
+        compileGroup: (ctx.group = 'priority'),
+      }
+      ctx.ProjectGetter.promises.getProject = sinon
+        .stub()
+        .resolves(
+          (ctx.project = { owner_ref: (ctx.owner_id = 'owner-id-123') })
+        )
+      ctx.UserGetter.promises.getUser = sinon
+        .stub()
+        .resolves((ctx.user = { features: ctx.features, analyticsId: 'abc' }))
+      try {
+        const result =
+          await ctx.CompileManager.promises.getProjectCompileLimits(
+            ctx.project_id
+          )
+        ctx.callback(null, result)
+      } catch (error) {
+        ctx.callback(error)
+      }
+    })
+
+    it('should look up the owner of the project', function (ctx) {
+      ctx.ProjectGetter.promises.getProject
+        .calledWith(ctx.project_id, { owner_ref: 1 })
+        .should.equal(true)
+    })
+
+    it("should look up the owner's features", function (ctx) {
+      ctx.UserGetter.promises.getUser
+        .calledWith(ctx.project.owner_ref, {
+          _id: 1,
+          alphaProgram: 1,
+          analyticsId: 1,
+          betaProgram: 1,
+          features: 1,
+        })
+        .should.equal(true)
+    })
+
+    it('should return the limits', function (ctx) {
+      ctx.callback
+        .calledWith(null, {
+          timeout: ctx.timeout,
+          compileGroup: ctx.group,
+          compileBackendClass: 'c2d',
+          ownerAnalyticsId: 'abc',
+        })
+        .should.equal(true)
+    })
+  })
+
+  describe('compileBackendClass', function () {
+    beforeEach(function (ctx) {
+      ctx.features = {
+        compileTimeout: 42,
+        compileGroup: 'standard',
+      }
+      ctx.ProjectGetter.promises.getProject = sinon
+        .stub()
+        .resolves({ owner_ref: 'owner-id-123' })
+      ctx.UserGetter.promises.getUser = sinon
+        .stub()
+        .resolves({ features: ctx.features, analyticsId: 'abc' })
+    })
+
+    describe('with priority compile', function () {
+      beforeEach(function (ctx) {
+        ctx.features.compileGroup = 'priority'
+      })
+      it('should return the default class', async function (ctx) {
+        const { compileBackendClass } =
+          await ctx.CompileManager.promises.getProjectCompileLimits(
+            ctx.project_id
+          )
+        expect(compileBackendClass).to.equal('c2d')
+      })
+    })
+  })
+
+  describe('deleteAuxFiles', function () {
+    let result
+
+    beforeEach(async function (ctx) {
+      ctx.CompileManager.promises.getProjectCompileLimits = sinon
+        .stub()
+        .resolves((ctx.limits = { compileGroup: 'mock-compile-group' }))
+      ctx.ClsiManager.promises.deleteAuxFiles = sinon.stub().resolves('test')
+      result = await ctx.CompileManager.promises.deleteAuxFiles(
+        ctx.project_id,
+        ctx.user_id
+      )
+    })
+
+    it('should look up the compile group to use', function (ctx) {
+      ctx.CompileManager.promises.getProjectCompileLimits
+        .calledWith(ctx.project_id)
+        .should.equal(true)
+    })
+
+    it('should delete the aux files', function (ctx) {
+      ctx.ClsiManager.promises.deleteAuxFiles
+        .calledWith(ctx.project_id, ctx.user_id, ctx.limits)
+        .should.equal(true)
+    })
+
+    it('should resolve', function () {
+      expect(result).not.to.be.undefined
+    })
+  })
+
+  describe('_checkIfRecentlyCompiled', function () {
+    describe('when the key exists in redis', function () {
+      let result
+
+      beforeEach(async function (ctx) {
+        ctx.rclient.set = sinon.stub().resolves(null)
+        result = await ctx.CompileManager._checkIfRecentlyCompiled(
+          ctx.project_id,
+          ctx.user_id
+        )
+      })
+
+      it('should try to set the key', function (ctx) {
+        ctx.rclient.set
+          .calledWith(
+            `compile:${ctx.project_id}:${ctx.user_id}`,
+            true,
+            'EX',
+            ctx.CompileManager.COMPILE_DELAY,
+            'NX'
+          )
+          .should.equal(true)
+      })
+
+      it('should resolve with true', function () {
+        result.should.equal(true)
+      })
+    })
+
+    describe('when the key does not exist in redis', function () {
+      let result
+
+      beforeEach(async function (ctx) {
+        ctx.rclient.set = sinon.stub().resolves('OK')
+        result = await ctx.CompileManager._checkIfRecentlyCompiled(
+          ctx.project_id,
+          ctx.user_id
+        )
+      })
+
+      it('should try to set the key', function (ctx) {
+        ctx.rclient.set
+          .calledWith(
+            `compile:${ctx.project_id}:${ctx.user_id}`,
+            true,
+            'EX',
+            ctx.CompileManager.COMPILE_DELAY,
+            'NX'
+          )
+          .should.equal(true)
+      })
+
+      it('should resolve with false', function () {
+        result.should.equal(false)
+      })
+    })
+  })
+
+  describe('_checkIfAutoCompileLimitHasBeenHit', function () {
+    it('should be able to compile if it is not an autocompile', async function (ctx) {
+      const canCompile =
+        await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit(
+          false,
+          'everyone'
+        )
+      expect(canCompile).to.equal(true)
+    })
+
+    it('should be able to compile if rate limit has remaining', async function (ctx) {
+      const canCompile =
+        await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit(
+          true,
+          'everyone'
+        )
+
+      expect(ctx.rateLimiter.consume).to.have.been.calledWith('global')
+      expect(canCompile).to.equal(true)
+    })
+
+    it('should be not able to compile if rate limit has no remianing', async function (ctx) {
+      ctx.rateLimiter.consume.rejects({ remainingPoints: 0 })
+      const canCompile =
+        await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit(
+          true,
+          'everyone'
+        )
+
+      expect(canCompile).to.equal(false)
+    })
+
+    it('should return false if there is an error in the rate limit', async function (ctx) {
+      ctx.rateLimiter.consume.rejects(new Error('BOOM!'))
+      const canCompile =
+        await ctx.CompileManager._checkIfAutoCompileLimitHasBeenHit(
+          true,
+          'everyone'
+        )
+
+      expect(canCompile).to.equal(false)
+    })
+  })
+
+  describe('wordCount', function () {
+    let result
+    const wordCount = 1
+
+    beforeEach(async function (ctx) {
+      ctx.CompileManager.promises.getProjectCompileLimits = sinon
+        .stub()
+        .resolves((ctx.limits = { compileGroup: 'mock-compile-group' }))
+      ctx.ClsiManager.promises.wordCount = sinon.stub().resolves(wordCount)
+      result = await ctx.CompileManager.promises.wordCount(
+        ctx.project_id,
+        ctx.user_id,
+        false
+      )
+    })
+
+    it('should look up the compile group to use', function (ctx) {
+      ctx.CompileManager.promises.getProjectCompileLimits
+        .calledWith(ctx.project_id)
+        .should.equal(true)
+    })
+
+    it('should call wordCount for project', function (ctx) {
+      ctx.ClsiManager.promises.wordCount
+        .calledWith(ctx.project_id, ctx.user_id, false, ctx.limits)
+        .should.equal(true)
+    })
+
+    it('should resolve with the wordCount from the ClsiManager', function () {
+      expect(result).to.equal(wordCount)
+    })
+  })
+})

+ 0 - 426
services/web/test/unit/src/Compile/CompileManagerTests.js

@@ -1,426 +0,0 @@
-const { expect } = require('chai')
-const sinon = require('sinon')
-const SandboxedModule = require('sandboxed-module')
-
-const MODULE_PATH = '../../../../app/src/Features/Compile/CompileManager.js'
-
-describe('CompileManager', function () {
-  beforeEach(function () {
-    this.rateLimiter = {
-      consume: sinon.stub().resolves(),
-    }
-    this.RateLimiter = {
-      RateLimiter: sinon.stub().returns(this.rateLimiter),
-    }
-    this.timer = {
-      done: sinon.stub(),
-    }
-    this.Metrics = {
-      Timer: sinon.stub().returns(this.timer),
-      inc: sinon.stub(),
-    }
-    this.CompileManager = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '@overleaf/settings': (this.settings = {
-          apis: {
-            clsi: { submissionBackendClass: 'n2d' },
-          },
-          redis: { web: { host: '127.0.0.1', port: 42 } },
-          rateLimit: { autoCompile: {} },
-        }),
-        '../../infrastructure/RedisWrapper': {
-          client: () =>
-            (this.rclient = {
-              auth() {},
-            }),
-        },
-        '../Project/ProjectRootDocManager': (this.ProjectRootDocManager = {
-          promises: {},
-        }),
-        '../Project/ProjectGetter': (this.ProjectGetter = { promises: {} }),
-        '../User/UserGetter': (this.UserGetter = { promises: {} }),
-        './ClsiManager': (this.ClsiManager = { promises: {} }),
-        '../../infrastructure/RateLimiter': this.RateLimiter,
-        '@overleaf/metrics': this.Metrics,
-        '../Analytics/UserAnalyticsIdCache': (this.UserAnalyticsIdCache = {
-          get: sinon.stub().resolves('abc'),
-        }),
-        '../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
-          promises: {},
-        }),
-      },
-    })
-    this.project_id = 'mock-project-id-123'
-    this.user_id = 'mock-user-id-123'
-    this.callback = sinon.stub()
-    this.limits = {
-      timeout: 42,
-      compileGroup: 'standard',
-    }
-  })
-
-  describe('compile', function () {
-    beforeEach(function () {
-      this.CompileManager._checkIfRecentlyCompiled = sinon
-        .stub()
-        .resolves(false)
-      this.ProjectRootDocManager.promises.ensureRootDocumentIsSet = sinon
-        .stub()
-        .resolves()
-      this.CompileManager.promises.getProjectCompileLimits = sinon
-        .stub()
-        .resolves(this.limits)
-      this.ClsiManager.promises.sendRequest = sinon.stub().resolves({
-        status: (this.status = 'mock-status'),
-        outputFiles: (this.outputFiles = []),
-        clsiServerId: (this.output = 'mock output'),
-      })
-    })
-
-    describe('succesfully', function () {
-      let result
-      beforeEach(async function () {
-        this.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
-          isAutoCompile,
-          compileGroup
-        ) => true
-        this.ProjectGetter.promises.getProject = sinon
-          .stub()
-          .resolves(
-            (this.project = { owner_ref: (this.owner_id = 'owner-id-123') })
-          )
-        this.UserGetter.promises.getUser = sinon.stub().resolves(
-          (this.user = {
-            features: { compileTimeout: '20s', compileGroup: 'standard' },
-            analyticsId: 'abc',
-          })
-        )
-        result = await this.CompileManager.promises.compile(
-          this.project_id,
-          this.user_id,
-          {}
-        )
-      })
-
-      it('should check the project has not been recently compiled', function () {
-        this.CompileManager._checkIfRecentlyCompiled
-          .calledWith(this.project_id, this.user_id)
-          .should.equal(true)
-      })
-
-      it('should ensure that the root document is set', function () {
-        this.ProjectRootDocManager.promises.ensureRootDocumentIsSet
-          .calledWith(this.project_id)
-          .should.equal(true)
-      })
-
-      it('should get the project compile limits', function () {
-        this.CompileManager.promises.getProjectCompileLimits
-          .calledWith(this.project_id)
-          .should.equal(true)
-      })
-
-      it('should run the compile with the compile limits', function () {
-        this.ClsiManager.promises.sendRequest
-          .calledWith(this.project_id, this.user_id, {
-            timeout: this.limits.timeout,
-            compileGroup: 'standard',
-            buildId: sinon.match(/[a-f0-9]+-[a-f0-9]+/),
-          })
-          .should.equal(true)
-      })
-
-      it('should resolve with the output', function () {
-        expect(result).to.haveOwnProperty('status', this.status)
-        expect(result).to.haveOwnProperty('clsiServerId', this.output)
-        expect(result).to.haveOwnProperty('outputFiles', this.outputFiles)
-      })
-
-      it('should time the compile', function () {
-        this.timer.done.called.should.equal(true)
-      })
-    })
-
-    describe('when the project has been recently compiled', function () {
-      it('should return', async function () {
-        this.CompileManager._checkIfAutoCompileLimitHasBeenHit = async (
-          isAutoCompile,
-          compileGroup
-        ) => true
-        this.CompileManager._checkIfRecentlyCompiled = sinon
-          .stub()
-          .resolves(true)
-        const { status } = await this.CompileManager.promises.compile(
-          this.project_id,
-          this.user_id,
-          {}
-        )
-        status.should.equal('too-recently-compiled')
-      })
-    })
-
-    describe('should check the rate limit', function () {
-      it('should return', async function () {
-        this.CompileManager._checkIfAutoCompileLimitHasBeenHit = sinon
-          .stub()
-          .resolves(false)
-        const { status } = await this.CompileManager.promises.compile(
-          this.project_id,
-          this.user_id,
-          {}
-        )
-
-        expect(status).to.equal('autocompile-backoff')
-      })
-    })
-  })
-
-  describe('getProjectCompileLimits', function () {
-    beforeEach(async function () {
-      this.features = {
-        compileTimeout: (this.timeout = 42),
-        compileGroup: (this.group = 'priority'),
-      }
-      this.ProjectGetter.promises.getProject = sinon
-        .stub()
-        .resolves(
-          (this.project = { owner_ref: (this.owner_id = 'owner-id-123') })
-        )
-      this.UserGetter.promises.getUser = sinon
-        .stub()
-        .resolves((this.user = { features: this.features, analyticsId: 'abc' }))
-      try {
-        const result =
-          await this.CompileManager.promises.getProjectCompileLimits(
-            this.project_id
-          )
-        this.callback(null, result)
-      } catch (error) {
-        this.callback(error)
-      }
-    })
-
-    it('should look up the owner of the project', function () {
-      this.ProjectGetter.promises.getProject
-        .calledWith(this.project_id, { owner_ref: 1 })
-        .should.equal(true)
-    })
-
-    it("should look up the owner's features", function () {
-      this.UserGetter.promises.getUser
-        .calledWith(this.project.owner_ref, {
-          _id: 1,
-          alphaProgram: 1,
-          analyticsId: 1,
-          betaProgram: 1,
-          features: 1,
-        })
-        .should.equal(true)
-    })
-
-    it('should return the limits', function () {
-      this.callback
-        .calledWith(null, {
-          timeout: this.timeout,
-          compileGroup: this.group,
-          compileBackendClass: 'c2d',
-          ownerAnalyticsId: 'abc',
-        })
-        .should.equal(true)
-    })
-  })
-
-  describe('compileBackendClass', function () {
-    beforeEach(function () {
-      this.features = {
-        compileTimeout: 42,
-        compileGroup: 'standard',
-      }
-      this.ProjectGetter.promises.getProject = sinon
-        .stub()
-        .resolves({ owner_ref: 'owner-id-123' })
-      this.UserGetter.promises.getUser = sinon
-        .stub()
-        .resolves({ features: this.features, analyticsId: 'abc' })
-    })
-
-    describe('with priority compile', function () {
-      beforeEach(function () {
-        this.features.compileGroup = 'priority'
-      })
-      it('should return the default class', async function () {
-        const { compileBackendClass } =
-          await this.CompileManager.promises.getProjectCompileLimits(
-            this.project_id
-          )
-        expect(compileBackendClass).to.equal('c2d')
-      })
-    })
-  })
-
-  describe('deleteAuxFiles', function () {
-    let result
-
-    beforeEach(async function () {
-      this.CompileManager.promises.getProjectCompileLimits = sinon
-        .stub()
-        .resolves((this.limits = { compileGroup: 'mock-compile-group' }))
-      this.ClsiManager.promises.deleteAuxFiles = sinon.stub().resolves('test')
-      result = await this.CompileManager.promises.deleteAuxFiles(
-        this.project_id,
-        this.user_id
-      )
-    })
-
-    it('should look up the compile group to use', function () {
-      this.CompileManager.promises.getProjectCompileLimits
-        .calledWith(this.project_id)
-        .should.equal(true)
-    })
-
-    it('should delete the aux files', function () {
-      this.ClsiManager.promises.deleteAuxFiles
-        .calledWith(this.project_id, this.user_id, this.limits)
-        .should.equal(true)
-    })
-
-    it('should resolve', function () {
-      expect(result).not.to.be.undefined
-    })
-  })
-
-  describe('_checkIfRecentlyCompiled', function () {
-    describe('when the key exists in redis', function () {
-      let result
-
-      beforeEach(async function () {
-        this.rclient.set = sinon.stub().resolves(null)
-        result = await this.CompileManager._checkIfRecentlyCompiled(
-          this.project_id,
-          this.user_id
-        )
-      })
-
-      it('should try to set the key', function () {
-        this.rclient.set
-          .calledWith(
-            `compile:${this.project_id}:${this.user_id}`,
-            true,
-            'EX',
-            this.CompileManager.COMPILE_DELAY,
-            'NX'
-          )
-          .should.equal(true)
-      })
-
-      it('should resolve with true', function () {
-        result.should.equal(true)
-      })
-    })
-
-    describe('when the key does not exist in redis', function () {
-      let result
-
-      beforeEach(async function () {
-        this.rclient.set = sinon.stub().resolves('OK')
-        result = await this.CompileManager._checkIfRecentlyCompiled(
-          this.project_id,
-          this.user_id
-        )
-      })
-
-      it('should try to set the key', function () {
-        this.rclient.set
-          .calledWith(
-            `compile:${this.project_id}:${this.user_id}`,
-            true,
-            'EX',
-            this.CompileManager.COMPILE_DELAY,
-            'NX'
-          )
-          .should.equal(true)
-      })
-
-      it('should resolve with false', function () {
-        result.should.equal(false)
-      })
-    })
-  })
-
-  describe('_checkIfAutoCompileLimitHasBeenHit', function () {
-    it('should be able to compile if it is not an autocompile', async function () {
-      const canCompile =
-        await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
-          false,
-          'everyone'
-        )
-      expect(canCompile).to.equal(true)
-    })
-
-    it('should be able to compile if rate limit has remaining', async function () {
-      const canCompile =
-        await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
-          true,
-          'everyone'
-        )
-
-      expect(this.rateLimiter.consume).to.have.been.calledWith('global')
-      expect(canCompile).to.equal(true)
-    })
-
-    it('should be not able to compile if rate limit has no remianing', async function () {
-      this.rateLimiter.consume.rejects({ remainingPoints: 0 })
-      const canCompile =
-        await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
-          true,
-          'everyone'
-        )
-
-      expect(canCompile).to.equal(false)
-    })
-
-    it('should return false if there is an error in the rate limit', async function () {
-      this.rateLimiter.consume.rejects(new Error('BOOM!'))
-      const canCompile =
-        await this.CompileManager._checkIfAutoCompileLimitHasBeenHit(
-          true,
-          'everyone'
-        )
-
-      expect(canCompile).to.equal(false)
-    })
-  })
-
-  describe('wordCount', function () {
-    let result
-    const wordCount = 1
-
-    beforeEach(async function () {
-      this.CompileManager.promises.getProjectCompileLimits = sinon
-        .stub()
-        .resolves((this.limits = { compileGroup: 'mock-compile-group' }))
-      this.ClsiManager.promises.wordCount = sinon.stub().resolves(wordCount)
-      result = await this.CompileManager.promises.wordCount(
-        this.project_id,
-        this.user_id,
-        false
-      )
-    })
-
-    it('should look up the compile group to use', function () {
-      this.CompileManager.promises.getProjectCompileLimits
-        .calledWith(this.project_id)
-        .should.equal(true)
-    })
-
-    it('should call wordCount for project', function () {
-      this.ClsiManager.promises.wordCount
-        .calledWith(this.project_id, this.user_id, false, this.limits)
-        .should.equal(true)
-    })
-
-    it('should resolve with the wordCount from the ClsiManager', function () {
-      expect(result).to.equal(wordCount)
-    })
-  })
-})

+ 6 - 3
services/web/test/unit/src/Editor/EditorHttpController.test.mjs

@@ -252,9 +252,12 @@ describe('EditorHttpController', function () {
         default: ctx.SplitTestHandler,
       })
     )
-    vi.doMock('../../../../app/src/Features/Compile/CompileManager.js', () => ({
-      default: {},
-    }))
+    vi.doMock(
+      '../../../../app/src/Features/Compile/CompileManager.mjs',
+      () => ({
+        default: {},
+      })
+    )
     vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({
       default: ctx.UserGetter,
     }))

+ 24 - 20
services/web/test/unit/src/SamlLog/SamlLogHandlerTests.js → services/web/test/unit/src/SamlLog/SamlLogHandler.test.mjs

@@ -1,27 +1,31 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
 
-const modulePath = '../../../../app/src/Features/SamlLog/SamlLogHandler'
+const modulePath = '../../../../app/src/Features/SamlLog/SamlLogHandler.mjs'
 
 describe('SamlLogHandler', function () {
-  let SamlLog, SamlLogHandler, SamlLogModel
+  let SamlLog, SamlLogHandler
 
   let data, providerId, samlLog, sessionId
 
-  beforeEach(function () {
+  beforeEach(async function (ctx) {
     samlLog = {
       save: sinon.stub(),
     }
     SamlLog = function () {
       return samlLog
     }
-    SamlLogModel = { SamlLog }
-    SamlLogHandler = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../models/SamlLog': SamlLogModel,
-      },
-    })
+
+    ctx.logger = {
+      error: sinon.stub(),
+    }
+    vi.doMock('@overleaf/logger', () => ({
+      default: ctx.logger,
+    }))
+
+    vi.doMock('../../../../app/src/models/SamlLog', () => ({ SamlLog }))
+
+    SamlLogHandler = (await import(modulePath)).default
 
     data = { foo: true }
     providerId = 'provider-id'
@@ -69,13 +73,13 @@ describe('SamlLogHandler', function () {
       )
     })
 
-    it('should log without data and log error', function () {
+    it('should log without data and log error', function (ctx) {
       samlLog.providerId.should.equal(providerId)
       samlLog.sessionId.should.equal(sessionId.substr(0, 8))
       expect(samlLog.data).to.be.undefined
       expect(samlLog.jsonData).to.be.undefined
       samlLog.save.should.have.been.calledOnce
-      this.logger.error.should.have.been.calledOnce.and.calledWithMatch(
+      ctx.logger.error.should.have.been.calledOnce.and.calledWithMatch(
         { providerId, sessionId: sessionId.substr(0, 8) },
         'SamlLog JSON.stringify Error'
       )
@@ -99,8 +103,8 @@ describe('SamlLogHandler', function () {
       )
     })
 
-    it('should log error', function () {
-      this.logger.error.should.have.been.calledOnce.and.calledWithMatch(
+    it('should log error', function (ctx) {
+      ctx.logger.error.should.have.been.calledOnce.and.calledWithMatch(
         {
           err,
           sessionId: sessionId.substr(0, 8),
@@ -127,8 +131,8 @@ describe('SamlLogHandler', function () {
       )
     })
 
-    it('should log error', function () {
-      this.logger.error.should.have.been.calledOnce.and.calledWithMatch(
+    it('should log error', function (ctx) {
+      ctx.logger.error.should.have.been.calledOnce.and.calledWithMatch(
         {
           err,
           sessionId: sessionId.substr(0, 8),
@@ -155,8 +159,8 @@ describe('SamlLogHandler', function () {
       )
     })
 
-    it('should not log any error', function () {
-      this.logger.error.should.not.have.been.called
+    it('should not log any error', function (ctx) {
+      ctx.logger.error.should.not.have.been.called
     })
   })
 })

+ 1560 - 0
services/web/test/unit/src/Subscription/SubscriptionController.test.mjs

@@ -0,0 +1,1560 @@
+import { vi, assert, expect } from 'vitest'
+import sinon from 'sinon'
+import MockRequest from '../helpers/MockRequest.js'
+import MockResponse from '../helpers/MockResponse.js'
+import SubscriptionErrors from '../../../../app/src/Features/Subscription/Errors.js'
+import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.js'
+import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.js'
+
+const modulePath =
+  '../../../../app/src/Features/Subscription/SubscriptionController.mjs'
+
+const mockSubscriptions = {
+  'subscription-123-active': {
+    uuid: 'subscription-123-active',
+    plan: {
+      name: 'Gold',
+      plan_code: 'gold',
+    },
+    current_period_ends_at: new Date(),
+    state: 'active',
+    unit_amount_in_cents: 999,
+    account: {
+      account_code: 'user-123',
+    },
+  },
+}
+
+describe('SubscriptionController', function () {
+  beforeEach(async function (ctx) {
+    ctx.logger = {
+      debug: sinon.stub(),
+      warn: sinon.stub(),
+    }
+    vi.doMock('@overleaf/logger', () => ({
+      default: ctx.logger,
+    }))
+
+    ctx.user = {
+      email: 'tom@yahoo.com',
+      _id: 'one',
+      signUpDate: new Date('2000-10-01'),
+      emails: [{ email: 'tom@yahoo.com', confirmedAt: new Date('2000-10-02') }],
+    }
+    ctx.activeRecurlySubscription = mockSubscriptions['subscription-123-active']
+
+    ctx.SessionManager = {
+      getLoggedInUser: sinon.stub().callsArgWith(1, null, ctx.user),
+      getLoggedInUserId: sinon.stub().returns(ctx.user._id),
+      getSessionUser: sinon.stub().returns(ctx.user),
+      isUserLoggedIn: sinon.stub().returns(true),
+    }
+    ctx.SubscriptionHandler = {
+      createSubscription: sinon.stub().callsArgWith(3),
+      updateSubscription: sinon.stub().callsArgWith(3),
+      reactivateSubscription: sinon.stub().callsArgWith(1),
+      cancelSubscription: sinon.stub().callsArgWith(1),
+      syncSubscription: sinon.stub().yields(),
+      attemptPaypalInvoiceCollection: sinon.stub().yields(),
+      startFreeTrial: sinon.stub(),
+      revertPlanChange: sinon.stub(),
+      promises: {
+        createSubscription: sinon.stub().resolves(),
+        updateSubscription: sinon.stub().resolves(),
+        reactivateSubscription: sinon.stub().resolves(),
+        cancelSubscription: sinon.stub().resolves(),
+        pauseSubscription: sinon.stub().resolves(),
+        resumeSubscription: sinon.stub().resolves(),
+        syncSubscription: sinon.stub().resolves(),
+        attemptPaypalInvoiceCollection: sinon.stub().resolves(),
+        startFreeTrial: sinon.stub().resolves(),
+        purchaseAddon: sinon.stub().resolves(),
+        previewAddonPurchase: sinon.stub().resolves({
+          subscription: {
+            currency: 'USD',
+            netTerms: 0,
+            periodEnd: new Date(),
+            taxRate: 0,
+          },
+          immediateCharge: { amount: 0 },
+          nextPlanCode: 'professional',
+          nextPlanName: 'Professional',
+          nextPlanPrice: 2000,
+          nextAddOns: [],
+          subtotal: 2000,
+          tax: 0,
+          total: 2000,
+        }),
+        revertPlanChange: sinon.stub().resolves(),
+      },
+    }
+
+    ctx.LimitationsManager = {
+      hasPaidSubscription: sinon.stub(),
+      userHasSubscription: sinon
+        .stub()
+        .yields(null, { hasSubscription: false }),
+      promises: {
+        hasPaidSubscription: sinon.stub().resolves(),
+        userHasSubscription: sinon.stub().resolves({ hasSubscription: false }),
+      },
+    }
+
+    ctx.SubscriptionViewModelBuilder = {
+      buildUsersSubscriptionViewModel: sinon.stub().callsArgWith(1, null, {}),
+      buildPlansList: sinon.stub(),
+      promises: {
+        buildUsersSubscriptionViewModel: sinon.stub().resolves({}),
+      },
+      buildPlansListForSubscriptionDash: sinon
+        .stub()
+        .returns({ plans: [], planCodesChangingAtTermEnd: [] }),
+    }
+    ctx.settings = {
+      coupon_codes: {
+        upgradeToAnnualPromo: {
+          student: 'STUDENTCODEHERE',
+          collaborator: 'COLLABORATORCODEHERE',
+        },
+      },
+      groupPlanModalOptions: {
+        plan_codes: [],
+        currencies: [
+          {
+            display: 'GBP (£)',
+            code: 'GBP',
+          },
+        ],
+        sizes: ['42'],
+        usages: [{ code: 'foo', display: 'Foo' }],
+      },
+      apis: {
+        recurly: {
+          subdomain: 'sl',
+        },
+      },
+      planReverts: {
+        enabled: false,
+      },
+      siteUrl: 'http://de.overleaf.dev:3000',
+    }
+    ctx.AuthorizationManager = {
+      promises: {
+        isUserSiteAdmin: sinon.stub().resolves(false),
+      },
+    }
+    ctx.GeoIpLookup = {
+      isValidCurrencyParam: sinon.stub().returns(true),
+      getCurrencyCode: sinon.stub().yields('USD', 'US'),
+      promises: {
+        getCurrencyCode: sinon.stub().resolves({
+          countryCode: 'US',
+          currencyCode: 'USD',
+        }),
+      },
+    }
+    ctx.UserGetter = {
+      getUser: sinon.stub().callsArgWith(2, null, ctx.user),
+      promises: {
+        getUser: sinon.stub().resolves(ctx.user),
+        getWritefullData: sinon
+          .stub()
+          .resolves({ isPremium: false, premiumSource: null }),
+      },
+    }
+    ctx.SplitTestV2Hander = {
+      promises: {
+        getAssignment: sinon.stub().resolves({ variant: 'default' }),
+      },
+    }
+    ctx.Features = {
+      hasFeature: sinon.stub().returns(false),
+    }
+
+    vi.doMock(
+      '../../../../app/src/Features/Authorization/AuthorizationManager',
+      () => ({
+        default: ctx.AuthorizationManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: ctx.SplitTestV2Hander,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionHandler',
+      () => ({
+        default: ctx.SubscriptionHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionHelper',
+      () => ({
+        default: SubscriptionHelper,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder',
+      () => ({
+        default: ctx.SubscriptionViewModelBuilder,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/LimitationsManager',
+      () => ({
+        default: ctx.LimitationsManager,
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/GeoIpLookup', () => ({
+      default: ctx.GeoIpLookup,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/RecurlyWrapper',
+      () => ({
+        default: (ctx.RecurlyWrapper = {
+          promises: {
+            updateAccountEmailAddress: sinon.stub().resolves(),
+            getSubscription: sinon.stub().resolves({}),
+          },
+        }),
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/RecurlyEventHandler',
+      () => ({
+        default: {
+          sendRecurlyAnalyticsEvent: sinon.stub().resolves(),
+        },
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/FeaturesUpdater',
+      () => ({
+        default: (ctx.FeaturesUpdater = {
+          promises: {
+            refreshFeatures: sinon.stub().resolves({ features: {} }),
+          },
+        }),
+      })
+    )
+
+    vi.doMock('celebrate', () => ({
+      default: (ctx.celebrate = {
+        celebrate: sinon.stub(),
+        errors: sinon.stub(),
+        Joi: {
+          any: sinon.stub(),
+          extend: sinon.stub(),
+        },
+      }),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/GroupPlansData',
+      () => ({
+        default: (ctx.GroupPlansData = {}),
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/V1SubscriptionManager',
+      () => ({
+        default: (ctx.V1SubscriptionManager = {}),
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
+      default: (ctx.HttpErrorHandler = {
+        unprocessableEntity: sinon.stub().callsFake((req, res, message) => {
+          res.status(422)
+          res.json({ message })
+        }),
+        badRequest: sinon.stub().callsFake((req, res, message) => {
+          res.status(400)
+          res.json({ message })
+        }),
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Subscription/Errors', () => ({
+      default: SubscriptionErrors,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager',
+      () => ({
+        default: (ctx.AnalyticsManager = {
+          recordEventForUser: sinon.stub(),
+          recordEventForUserInBackground: sinon.stub(),
+          recordEventForSession: sinon.stub(),
+          setUserPropertyForUser: sinon.stub(),
+        }),
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: (ctx.Modules = {
+        promises: { hooks: { fire: sinon.stub().resolves() } },
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Features', () => ({
+      default: ctx.Features,
+    }))
+
+    vi.doMock('../../../../app/src/util/currency', () => ({
+      default: (ctx.currency = {
+        formatCurrency: sinon.stub(),
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/models/User', () => ({
+      User: {
+        findById: sinon.stub().resolves(ctx.user),
+      },
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: (ctx.SubscriptionLocator = {
+          promises: {
+            getUsersSubscription: sinon.stub().resolves(null),
+          },
+        }),
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authorization/PermissionsManager',
+      () => ({
+        default: (ctx.PermissionsManager = {
+          promises: {
+            checkUserPermissions: sinon.stub().resolves(true),
+          },
+        }),
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/RecurlyClient',
+      () => ({
+        default: (ctx.RecurlyClient = {
+          promises: {
+            getAddOn: sinon.stub().resolves({
+              code: 'ai-assistant',
+              name: 'AI Assistant',
+            }),
+          },
+        }),
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
+      default: (ctx.PlansLocator = {
+        findLocalPlanInSettings: sinon.stub().returns({
+          annual: false,
+        }),
+      }),
+    }))
+
+    ctx.SubscriptionController = (await import(modulePath)).default
+
+    ctx.res = new MockResponse()
+    ctx.req = new MockRequest()
+    ctx.req.body = {}
+    ctx.req.query = { planCode: '123123' }
+
+    ctx.stubbedCurrencyCode = 'GBP'
+  })
+
+  describe('successfulSubscription', function () {
+    it('without a personal subscription', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
+          {}
+        )
+        ctx.res.redirect = url => {
+          url.should.equal('/user/subscription/plans')
+          resolve()
+        }
+        ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
+      })
+    })
+
+    it('with a personal subscription', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
+          {
+            personalSubscription: 'foo',
+          }
+        )
+        ctx.res.render = (url, variables) => {
+          url.should.equal('subscriptions/successful-subscription-react')
+          assert.deepEqual(variables, {
+            title: 'thank_you',
+            personalSubscription: 'foo',
+            postCheckoutRedirect: undefined,
+            user: {
+              _id: ctx.user._id,
+              features: ctx.user.features,
+            },
+          })
+          resolve()
+        }
+        ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
+      })
+    })
+
+    it('with an error', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
+          undefined
+        )
+        ctx.SubscriptionController.successfulSubscription(
+          ctx.req,
+          ctx.res,
+          error => {
+            assert.isNotNull(error)
+            resolve()
+          }
+        )
+      })
+    })
+  })
+
+  describe('userSubscriptionPage', function () {
+    beforeEach(async function (ctx) {
+      await new Promise((resolve, reject) => {
+        ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
+          {
+            personalSubscription: (ctx.personalSubscription = {
+              'personal-subscription': 'mock',
+            }),
+            memberGroupSubscriptions: (ctx.memberGroupSubscriptions = {
+              'group-subscriptions': 'mock',
+            }),
+          }
+        )
+        ctx.SubscriptionViewModelBuilder.buildPlansList.returns(
+          (ctx.plans = { plans: 'mock' })
+        )
+        ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash.returns(
+          {
+            plans: ctx.plans,
+            planCodesChangingAtTermEnd: [],
+          }
+        )
+        ctx.LimitationsManager.promises.userHasSubscription.resolves({
+          hasSubscription: false,
+        })
+        ctx.res.render = (view, data) => {
+          ctx.data = data
+          expect(view).to.equal('subscriptions/dashboard-react')
+          resolve()
+        }
+        ctx.SubscriptionController.userSubscriptionPage(
+          ctx.req,
+          ctx.res,
+          ctx.rejectOnError(reject)
+        )
+      })
+    })
+
+    it('should load the personal, groups and v1 subscriptions', function (ctx) {
+      expect(ctx.data.personalSubscription).to.deep.equal(
+        ctx.personalSubscription
+      )
+      expect(ctx.data.memberGroupSubscriptions).to.deep.equal(
+        ctx.memberGroupSubscriptions
+      )
+    })
+
+    it('should load the user', function (ctx) {
+      expect(ctx.data.user).to.deep.equal(ctx.user)
+    })
+
+    it('should load the plans', function (ctx) {
+      expect(ctx.data.plans).to.deep.equal(ctx.plans)
+    })
+
+    it('should load an empty list of groups with settings available', function (ctx) {
+      expect(ctx.data.groupSettingsEnabledFor).to.deep.equal([])
+    })
+  })
+
+  describe('updateAccountEmailAddress via put', function () {
+    beforeEach(function (ctx) {
+      ctx.req.body = {
+        account_email: 'current_account_email@overleaf.com',
+      }
+    })
+
+    it('should send the user and subscriptionId to "updateAccountEmailAddress" hooks', async function (ctx) {
+      ctx.res.sendStatus = sinon.spy()
+
+      await ctx.SubscriptionController.updateAccountEmailAddress(
+        ctx.req,
+        ctx.res
+      )
+
+      expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
+        'updateAccountEmailAddress',
+        ctx.user._id,
+        ctx.user.email
+      )
+    })
+
+    it('should respond with 200', async function (ctx) {
+      ctx.res.sendStatus = sinon.spy()
+      await ctx.SubscriptionController.updateAccountEmailAddress(
+        ctx.req,
+        ctx.res
+      )
+      ctx.res.sendStatus.calledWith(200).should.equal(true)
+    })
+
+    it('should send the error to the next handler when updating recurly account email fails', async function (ctx) {
+      ctx.Modules.promises.hooks.fire
+        .withArgs('updateAccountEmailAddress', ctx.user._id, ctx.user.email)
+        .rejects(new Error())
+
+      ctx.next = sinon.spy(error => {
+        expect(error).to.be.instanceOf(Error)
+      })
+      await ctx.SubscriptionController.updateAccountEmailAddress(
+        ctx.req,
+        ctx.res,
+        ctx.next
+      )
+      expect(ctx.next.calledOnce).to.be.true
+    })
+  })
+
+  describe('reactivateSubscription', function () {
+    describe('when the user has permission', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.res = {
+            redirect() {
+              resolve()
+            },
+          }
+          ctx.req.assertPermission = sinon.stub()
+          ctx.next = sinon.stub().callsFake(error => {
+            resolve(error)
+          })
+          sinon.spy(ctx.res, 'redirect')
+          ctx.SubscriptionController.reactivateSubscription(
+            ctx.req,
+            ctx.res,
+            ctx.next
+          )
+        })
+      })
+
+      it('should assert the user has permission to reactivate their subscription', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.req.assertPermission
+            .calledWith('reactivate-subscription')
+            .should.equal(true)
+          resolve()
+        })
+      })
+
+      it('should tell the handler to reactivate this user', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.SubscriptionHandler.reactivateSubscription
+            .calledWith(ctx.user)
+            .should.equal(true)
+          resolve()
+        })
+      })
+
+      it('should redurect to the subscription page', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.res.redirect.calledWith('/user/subscription').should.equal(true)
+          resolve()
+        })
+      })
+    })
+
+    describe('when the user does not have permission', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.res = {
+            redirect() {
+              resolve()
+            },
+          }
+          ctx.req.assertPermission = sinon.stub().throws()
+          ctx.next = sinon.stub().callsFake(() => {
+            resolve()
+          })
+          sinon.spy(ctx.res, 'redirect')
+          ctx.SubscriptionController.reactivateSubscription(
+            ctx.req,
+            ctx.res,
+            ctx.next
+          )
+        })
+      })
+
+      it('should not reactivate the user', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.req.assertPermission = sinon.stub().throws()
+          ctx.SubscriptionHandler.reactivateSubscription.called.should.equal(
+            false
+          )
+          resolve()
+        })
+      })
+
+      it('should call next with an error', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
+          resolve()
+        })
+      })
+    })
+  })
+
+  describe('pauseSubscription', function () {
+    it('should throw an error if no pause length is provided', async function (ctx) {
+      ctx.res = new MockResponse()
+      ctx.req = new MockRequest()
+      ctx.next = sinon.stub()
+      await expect(
+        ctx.SubscriptionController.pauseSubscription(ctx.req, ctx.res, ctx.next)
+      ).to.be.rejectedWith('Not found')
+    })
+
+    it('should throw an error if an invalid pause length is provided', async function (ctx) {
+      ctx.res = new MockResponse()
+      ctx.req = new MockRequest()
+      ctx.req.params = { pauseCycles: '-10' }
+      ctx.next = sinon.stub()
+      await ctx.SubscriptionController.pauseSubscription(
+        ctx.req,
+        ctx.res,
+        ctx.next
+      )
+      expect(ctx.res.statusCode).to.equal(400)
+    })
+
+    it('should return a 200 when requesting a pause', async function (ctx) {
+      ctx.res = new MockResponse()
+      ctx.req = new MockRequest()
+      ctx.req.params = { pauseCycles: '3' }
+      ctx.next = sinon.stub()
+      await ctx.SubscriptionController.pauseSubscription(
+        ctx.req,
+        ctx.res,
+        ctx.next
+      )
+      expect(ctx.res.statusCode).to.equal(200)
+    })
+  })
+
+  describe('resumeSubscription', function () {
+    it('should return a 200 when resuming a subscription', async function (ctx) {
+      ctx.res = new MockResponse()
+      ctx.req = new MockRequest()
+      ctx.next = sinon.stub()
+      await ctx.SubscriptionController.resumeSubscription(
+        ctx.req,
+        ctx.res,
+        ctx.next
+      )
+      expect(ctx.res.statusCode).to.equal(200)
+    })
+  })
+
+  describe('cancelSubscription', function () {
+    it('should tell the handler to cancel this user', async function (ctx) {
+      ctx.next = sinon.stub()
+      await ctx.SubscriptionController.cancelSubscription(
+        ctx.req,
+        ctx.res,
+        ctx.next
+      )
+      ctx.SubscriptionHandler.promises.cancelSubscription
+        .calledWith(ctx.user)
+        .should.equal(true)
+    })
+
+    it('should return a 200 on success', async function (ctx) {
+      ctx.next = sinon.stub()
+      await ctx.SubscriptionController.cancelSubscription(
+        ctx.req,
+        ctx.res,
+        ctx.next
+      )
+      expect(ctx.res.statusCode).to.equal(200)
+    })
+
+    it('should call next with error', async function (ctx) {
+      ctx.SubscriptionHandler.promises.cancelSubscription.rejects(
+        new Error('cancel error')
+      )
+      ctx.next = sinon.stub()
+      await ctx.SubscriptionController.cancelSubscription(
+        ctx.req,
+        ctx.res,
+        ctx.next
+      )
+      ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
+    })
+  })
+
+  describe('recurly callback', function () {
+    describe('with a sync subscription request', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.req = {
+            body: {
+              expired_subscription_notification: {
+                account: {
+                  account_code: ctx.user._id,
+                },
+                subscription: {
+                  uuid: ctx.activeRecurlySubscription.uuid,
+                  plan: {
+                    plan_code: 'collaborator',
+                    state: 'active',
+                  },
+                },
+              },
+            },
+          }
+          ctx.res = {
+            sendStatus() {
+              resolve()
+            },
+          }
+          sinon.spy(ctx.res, 'sendStatus')
+          ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
+        })
+      })
+
+      it('should tell the SubscriptionHandler to process the recurly callback', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.SubscriptionHandler.syncSubscription.called.should.equal(true)
+          resolve()
+        })
+      })
+
+      it('should send a 200', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.res.sendStatus.calledWith(200)
+          resolve()
+        })
+      })
+    })
+
+    describe('with a billing info updated request', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.req = {
+            body: {
+              billing_info_updated_notification: {
+                account: {
+                  account_code: 'mock-account-code',
+                },
+              },
+            },
+          }
+          ctx.res = {
+            sendStatus() {
+              resolve()
+            },
+          }
+          sinon.spy(ctx.res, 'sendStatus')
+          ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
+        })
+      })
+
+      it('should call attemptPaypalInvoiceCollection', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.SubscriptionHandler.attemptPaypalInvoiceCollection
+            .calledWith('mock-account-code')
+            .should.equal(true)
+          resolve()
+        })
+      })
+
+      it('should send a 200', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.res.sendStatus.calledWith(200)
+          resolve()
+        })
+      })
+    })
+
+    describe('with a non-actionable request', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.user.id = ctx.activeRecurlySubscription.account.account_code
+          ctx.req = {
+            body: {
+              renewed_subscription_notification: {
+                account: {
+                  account_code: ctx.user._id,
+                },
+                subscription: {
+                  uuid: ctx.activeRecurlySubscription.uuid,
+                  plan: {
+                    plan_code: 'collaborator',
+                    state: 'active',
+                  },
+                },
+              },
+            },
+          }
+          ctx.res = {
+            sendStatus() {
+              resolve()
+            },
+          }
+          sinon.spy(ctx.res, 'sendStatus')
+          ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
+        })
+      })
+
+      it('should not call the subscriptionshandler', function (ctx) {
+        ctx.SubscriptionHandler.syncSubscription.called.should.equal(false)
+        ctx.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal(
+          false
+        )
+      })
+
+      it('should respond with a 200 status', function (ctx) {
+        ctx.res.sendStatus.calledWith(200)
+      })
+    })
+
+    describe('with a failed payment notification', function () {
+      describe('with planReverts disabled in settings', function () {
+        beforeEach(async function (ctx) {
+          await new Promise(resolve => {
+            ctx.settings.planReverts = { enabled: false }
+            ctx.SubscriptionHandler.revertPlanChange = sinon.stub()
+
+            ctx.req.body = {
+              failed_payment_notification: {
+                transaction: {
+                  subscription_id: 'subscription-123',
+                },
+              },
+            }
+
+            ctx.res = {
+              sendStatus() {
+                resolve()
+              },
+            }
+            sinon.spy(ctx.res, 'sendStatus')
+            ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
+          })
+        })
+        it('should not call revertPlanChange', function (ctx) {
+          expect(ctx.SubscriptionHandler.revertPlanChange.called).to.be.false
+        })
+
+        it('should respond with 200', async function (ctx) {
+          await new Promise(resolve => {
+            ctx.res.sendStatus.calledWith(200)
+            resolve()
+          })
+        })
+      })
+
+      describe('with planReverts enabled in settings', function () {
+        beforeEach(function (ctx) {
+          ctx.settings.planReverts = { enabled: true }
+        })
+
+        describe('with no valid restore point', function () {
+          beforeEach(async function (ctx) {
+            await new Promise(resolve => {
+              ctx.SubscriptionHandler.getSubscriptionRestorePoint = sinon
+                .stub()
+                .yields(null, null)
+              ctx.SubscriptionHandler.revertPlanChange = sinon.stub()
+
+              ctx.req.body = {
+                failed_payment_notification: {
+                  transaction: {
+                    subscription_id: 'subscription-123',
+                  },
+                },
+              }
+              ctx.res = {
+                sendStatus() {
+                  resolve()
+                },
+              }
+              sinon.spy(ctx.res, 'sendStatus')
+              ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
+            })
+          })
+          it('should not call revertPlanChange()', function (ctx) {
+            expect(ctx.SubscriptionHandler.revertPlanChange.called).to.be.false
+          })
+
+          it('should respond with 200', function (ctx) {
+            ctx.res.sendStatus.calledWith(200)
+          })
+        })
+
+        describe('with a valid restore point', function () {
+          beforeEach(async function (ctx) {
+            await new Promise(resolve => {
+              ctx.addOns = [
+                {
+                  addOnCode: 'addon-1',
+                  quantity: 2,
+                  unitAmountInCents: 500,
+                },
+                {
+                  addOnCode: 'addon-2',
+                  quantity: 1,
+                  unitAmountInCents: 600,
+                },
+              ]
+              ctx.lastSubscription = {
+                planCode: 'gold',
+                addOns: ctx.addOns,
+              }
+              ctx.SubscriptionHandler.getSubscriptionRestorePoint = sinon
+                .stub()
+                .yields(null, ctx.lastSubscription)
+              ctx.SubscriptionHandler.revertPlanChange = sinon.stub().yields()
+              ctx.req.body = {
+                failed_payment_notification: {
+                  transaction: {
+                    subscription_id: 'subscription-123',
+                  },
+                },
+              }
+              ctx.res = {
+                sendStatus() {
+                  resolve()
+                },
+              }
+              sinon.spy(ctx.res, 'sendStatus')
+              ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
+            })
+          })
+
+          it('should get the subscription restore point', function (ctx) {
+            expect(
+              ctx.SubscriptionHandler.getSubscriptionRestorePoint.calledWith(
+                'subscription-123'
+              )
+            ).to.be.true
+          })
+
+          it('should call revertPlanChange()', function (ctx) {
+            expect(
+              ctx.SubscriptionHandler.revertPlanChange.calledWith(
+                'subscription-123',
+                ctx.lastSubscription
+              )
+            ).to.be.true
+          })
+
+          it('should respond with 200', function (ctx) {
+            ctx.res.sendStatus.calledWith(200)
+          })
+        })
+      })
+    })
+  })
+
+  describe('purchaseAddon', function () {
+    beforeEach(function (ctx) {
+      ctx.SessionManager.getSessionUser.returns(ctx.user) // Make sure getSessionUser returns the user
+      ctx.next = sinon.stub()
+      ctx.req.params = { addOnCode: AI_ADD_ON_CODE } // Mock add-on code
+    })
+
+    it('should return 200 on successful purchase of AI add-on', async function (ctx) {
+      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
+      ctx.res.sendStatus = sinon.spy()
+
+      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
+
+      expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.have.been.called
+      expect(
+        ctx.SubscriptionHandler.promises.purchaseAddon
+      ).to.have.been.calledWith(ctx.user._id, AI_ADD_ON_CODE, 1)
+      expect(
+        ctx.FeaturesUpdater.promises.refreshFeatures
+      ).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
+      expect(ctx.res.sendStatus).to.have.been.calledWith(200)
+      expect(ctx.logger.debug).to.have.been.calledWith(
+        { userId: ctx.user._id, addOnCode: AI_ADD_ON_CODE },
+        'purchasing add-ons'
+      )
+    })
+
+    it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) {
+      ctx.req.params = { addOnCode: 'some-other-addon' }
+      ctx.res.sendStatus = sinon.spy()
+
+      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
+
+      expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.not.have.been
+        .called
+      expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
+        .called
+      expect(ctx.res.sendStatus).to.have.been.calledWith(404)
+    })
+
+    it('should handle DuplicateAddOnError and send badRequest while sending 200', async function (ctx) {
+      ctx.req.params.addOnCode = AI_ADD_ON_CODE
+      ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
+        new SubscriptionErrors.DuplicateAddOnError()
+      )
+
+      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
+
+      expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith(
+        ctx.req,
+        ctx.res,
+        'Your subscription already includes this add-on',
+        { addon: AI_ADD_ON_CODE }
+      )
+      expect(
+        ctx.FeaturesUpdater.promises.refreshFeatures
+      ).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
+      expect(ctx.res.sendStatus).to.have.been.calledWith(200)
+    })
+
+    it('should handle PaymentActionRequiredError and return 402 with details', async function (ctx) {
+      ctx.req.params.addOnCode = AI_ADD_ON_CODE
+      const paymentError = new SubscriptionErrors.PaymentActionRequiredError({
+        clientSecret: 'secret123',
+        publicKey: 'pubkey456',
+      })
+      ctx.SubscriptionHandler.promises.purchaseAddon.rejects(paymentError)
+
+      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
+
+      ctx.res.status.calledWith(402).should.equal(true)
+      ctx.res.json
+        .calledWith({
+          message: 'Payment action required',
+          clientSecret: 'secret123',
+          publicKey: 'pubkey456',
+        })
+        .should.equal(true)
+
+      expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
+        .called
+    })
+
+    it('should refresh features', async function (ctx) {
+      ctx.req.params.addOnCode = 'assistant'
+      ctx.SubscriptionHandler.promises.purchaseAddon = sinon.stub().resolves()
+      ctx.FeaturesUpdater.promises.refreshFeatures = sinon.stub().resolves()
+
+      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)
+
+      expect(
+        ctx.FeaturesUpdater.promises.refreshFeatures.calledWith(
+          ctx.user._id,
+          'add-on-purchase'
+        )
+      ).to.be.true
+    })
+
+    it('should respond with a bad request if the subscription already includes the addOn', async function (ctx) {
+      ctx.req.params.addOnCode = 'assistant'
+      ctx.SubscriptionHandler.promises.purchaseAddon = sinon
+        .stub()
+        .rejects(new SubscriptionErrors.DuplicateAddOnError())
+
+      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)
+
+      expect(
+        ctx.HttpErrorHandler.badRequest.calledWith(
+          ctx.req,
+          ctx.res,
+          'Your subscription already includes this add-on',
+          { addon: 'assistant' }
+        )
+      ).to.be.true
+    })
+  })
+
+  describe('checkSubscriptionPauseStatus', function () {
+    beforeEach(function (ctx) {
+      ctx.user = {
+        _id: 'user-id-123',
+        email: 'test@example.com',
+      }
+    })
+
+    it('should return isPaused: false when user has no subscription', async function (ctx) {
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription: null,
+      })
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when subscription has no paymentProvider', async function (ctx) {
+      const subscription = {
+        planCode: 'professional',
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when subscription has no subscriptionId', async function (ctx) {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: null,
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when Stripe subscription has no remaining pause cycles', async function (ctx) {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: 'sub-123',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const paymentRecord = {
+        subscription: {
+          remainingPauseCycles: 0,
+        },
+      }
+      ctx.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord', subscription)
+        .resolves([paymentRecord])
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when Stripe subscription has no remainingPauseCycles property', async function (ctx) {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: 'sub-123',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const paymentRecord = {
+        subscription: {},
+      }
+      ctx.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord', subscription)
+        .resolves([paymentRecord])
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: true with redirect path when Stripe subscription has remaining pause cycles', async function (ctx) {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: 'sub-123',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const paymentRecord = {
+        subscription: {
+          remainingPauseCycles: 2,
+        },
+      }
+      ctx.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord', subscription)
+        .resolves([paymentRecord])
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({
+        isPaused: true,
+        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
+      })
+    })
+
+    it('should return isPaused: true when remainingPauseCycles is exactly 1', async function (ctx) {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: 'sub-123',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const paymentRecord = {
+        subscription: {
+          remainingPauseCycles: 1,
+        },
+      }
+      ctx.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord', subscription)
+        .resolves([paymentRecord])
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({
+        isPaused: true,
+        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
+      })
+    })
+
+    it('should return isPaused: false when userHasSubscription throws error', async function (ctx) {
+      const error = new Error('Something bad happened')
+      ctx.LimitationsManager.promises.userHasSubscription.rejects(error)
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when getPaymentFromRecord throws error', async function (ctx) {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: 'sub-123',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const error = new Error('Something bad happened')
+      ctx.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord', subscription)
+        .rejects(error)
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when Recurly subscription is not paused', async function (ctx) {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'active',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: true when Recurly subscription is paused', async function (ctx) {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'paused',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({
+        isPaused: true,
+        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
+      })
+    })
+
+    it('should return isPaused: true when Recurly subscription has pending pause cycles', async function (ctx) {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'active',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const recurlySubscriptionData = {
+        remaining_pause_cycles: 2,
+      }
+      ctx.RecurlyWrapper.promises.getSubscription.resolves(
+        recurlySubscriptionData
+      )
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({
+        isPaused: true,
+        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
+      })
+      expect(
+        ctx.RecurlyWrapper.promises.getSubscription
+      ).to.have.been.calledWith('uuid-123')
+    })
+
+    it('should return isPaused: false when Recurly subscription has no remaining pause cycles', async function (ctx) {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'active',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const recurlySubscriptionData = {
+        remaining_pause_cycles: 0,
+      }
+      ctx.RecurlyWrapper.promises.getSubscription.resolves(
+        recurlySubscriptionData
+      )
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when Recurly subscription has no remaining_pause_cycles property', async function (ctx) {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'active',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const recurlySubscriptionData = {}
+      ctx.RecurlyWrapper.promises.getSubscription.resolves(
+        recurlySubscriptionData
+      )
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when Recurly API call fails', async function (ctx) {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'active',
+        },
+      }
+      ctx.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const error = new Error('Recurly API failed')
+      ctx.RecurlyWrapper.promises.getSubscription.rejects(error)
+
+      const result =
+        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+  })
+
+  describe('previewAddonPurchase', function () {
+    beforeEach(function (ctx) {
+      ctx.req = new MockRequest()
+      ctx.req.params = { addOnCode: 'assistant' }
+      ctx.req.query = { purchaseReferrer: 'fake-referrer' }
+      ctx.res = new MockResponse()
+
+      ctx.Modules.promises.hooks.fire
+        .withArgs('getPaymentMethod')
+        .resolves(['fake-method'])
+      ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(null)
+    })
+
+    describe('when user has manual or custom subscription', function () {
+      it('should redirect with ai-assist-unavailable when subscription has customAccount = true', async function (ctx) {
+        const customSubscription = {
+          _id: 'sub-123',
+          customAccount: true,
+          collectionMethod: 'automatic',
+        }
+        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
+          customSubscription
+        )
+
+        ctx.res.redirect = sinon.stub()
+
+        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
+
+        expect(ctx.res.redirect).to.have.been.calledWith(
+          '/user/subscription?redirect-reason=ai-assist-unavailable'
+        )
+      })
+
+      it('should redirect with ai-assist-unavailable when subscription has collectionMethod = manual', async function (ctx) {
+        const manualSubscription = {
+          _id: 'sub-123',
+          customAccount: false,
+          collectionMethod: 'manual',
+        }
+        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
+          manualSubscription
+        )
+
+        ctx.res.redirect = sinon.stub()
+
+        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
+
+        expect(ctx.res.redirect).to.have.been.calledWith(
+          '/user/subscription?redirect-reason=ai-assist-unavailable'
+        )
+      })
+
+      it('should redirect with ai-assist-unavailable when subscription has both customAccount and manual collection', async function (ctx) {
+        const customManualSubscription = {
+          _id: 'sub-123',
+          customAccount: true,
+          collectionMethod: 'manual',
+        }
+        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
+          customManualSubscription
+        )
+
+        ctx.res.redirect = sinon.stub()
+
+        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
+
+        expect(ctx.res.redirect).to.have.been.calledWith(
+          '/user/subscription?redirect-reason=ai-assist-unavailable'
+        )
+      })
+    })
+
+    describe('when user has normal subscription', function () {
+      it('should proceed with preview when subscription is not manual or custom', async function (ctx) {
+        const normalSubscription = {
+          _id: 'sub-123',
+          customAccount: false,
+          collectionMethod: 'automatic',
+        }
+        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
+          normalSubscription
+        )
+
+        ctx.res.render = sinon.stub()
+
+        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
+
+        expect(ctx.res.render).to.have.been.calledWith(
+          'subscriptions/preview-change'
+        )
+        expect(
+          ctx.SubscriptionHandler.promises.previewAddonPurchase
+        ).to.have.been.calledWith(ctx.user._id, 'assistant')
+      })
+
+      it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function (ctx) {
+        const normalSubscription = {
+          _id: 'sub-123',
+          // customAccount: undefined (not set)
+          collectionMethod: 'automatic',
+        }
+        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
+          normalSubscription
+        )
+
+        ctx.res.render = sinon.stub()
+
+        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)
+
+        expect(ctx.res.render).to.have.been.calledWith(
+          'subscriptions/preview-change'
+        )
+        expect(
+          ctx.SubscriptionHandler.promises.previewAddonPurchase
+        ).to.have.been.calledWith(ctx.user._id, 'assistant')
+      })
+    })
+  })
+})

+ 0 - 1453
services/web/test/unit/src/Subscription/SubscriptionControllerTests.js

@@ -1,1453 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { assert, expect } = require('chai')
-const MockRequest = require('../helpers/MockRequest')
-const MockResponse = require('../helpers/MockResponse')
-const modulePath =
-  '../../../../app/src/Features/Subscription/SubscriptionController'
-const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors')
-const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper')
-const {
-  AI_ADD_ON_CODE,
-} = require('../../../../app/src/Features/Subscription/AiHelper')
-const Errors = require('../../../../app/src/Features/Errors/Errors')
-
-const mockSubscriptions = {
-  'subscription-123-active': {
-    uuid: 'subscription-123-active',
-    plan: {
-      name: 'Gold',
-      plan_code: 'gold',
-    },
-    current_period_ends_at: new Date(),
-    state: 'active',
-    unit_amount_in_cents: 999,
-    account: {
-      account_code: 'user-123',
-    },
-  },
-}
-
-describe('SubscriptionController', function () {
-  beforeEach(function () {
-    this.user = {
-      email: 'tom@yahoo.com',
-      _id: 'one',
-      signUpDate: new Date('2000-10-01'),
-      emails: [{ email: 'tom@yahoo.com', confirmedAt: new Date('2000-10-02') }],
-    }
-    this.activeRecurlySubscription =
-      mockSubscriptions['subscription-123-active']
-
-    this.SessionManager = {
-      getLoggedInUser: sinon.stub().callsArgWith(1, null, this.user),
-      getLoggedInUserId: sinon.stub().returns(this.user._id),
-      getSessionUser: sinon.stub().returns(this.user),
-      isUserLoggedIn: sinon.stub().returns(true),
-    }
-    this.SubscriptionHandler = {
-      createSubscription: sinon.stub().callsArgWith(3),
-      updateSubscription: sinon.stub().callsArgWith(3),
-      reactivateSubscription: sinon.stub().callsArgWith(1),
-      cancelSubscription: sinon.stub().callsArgWith(1),
-      syncSubscription: sinon.stub().yields(),
-      attemptPaypalInvoiceCollection: sinon.stub().yields(),
-      startFreeTrial: sinon.stub(),
-      revertPlanChange: sinon.stub(),
-      promises: {
-        createSubscription: sinon.stub().resolves(),
-        updateSubscription: sinon.stub().resolves(),
-        reactivateSubscription: sinon.stub().resolves(),
-        cancelSubscription: sinon.stub().resolves(),
-        pauseSubscription: sinon.stub().resolves(),
-        resumeSubscription: sinon.stub().resolves(),
-        syncSubscription: sinon.stub().resolves(),
-        attemptPaypalInvoiceCollection: sinon.stub().resolves(),
-        startFreeTrial: sinon.stub().resolves(),
-        purchaseAddon: sinon.stub().resolves(),
-        previewAddonPurchase: sinon.stub().resolves({
-          subscription: {
-            currency: 'USD',
-            netTerms: 0,
-            periodEnd: new Date(),
-            taxRate: 0,
-          },
-          immediateCharge: { amount: 0 },
-          nextPlanCode: 'professional',
-          nextPlanName: 'Professional',
-          nextPlanPrice: 2000,
-          nextAddOns: [],
-          subtotal: 2000,
-          tax: 0,
-          total: 2000,
-        }),
-        revertPlanChange: sinon.stub().resolves(),
-      },
-    }
-
-    this.LimitationsManager = {
-      hasPaidSubscription: sinon.stub(),
-      userHasSubscription: sinon
-        .stub()
-        .yields(null, { hasSubscription: false }),
-      promises: {
-        hasPaidSubscription: sinon.stub().resolves(),
-        userHasSubscription: sinon.stub().resolves({ hasSubscription: false }),
-      },
-    }
-
-    this.SubscriptionViewModelBuilder = {
-      buildUsersSubscriptionViewModel: sinon.stub().callsArgWith(1, null, {}),
-      buildPlansList: sinon.stub(),
-      promises: {
-        buildUsersSubscriptionViewModel: sinon.stub().resolves({}),
-      },
-      buildPlansListForSubscriptionDash: sinon
-        .stub()
-        .returns({ plans: [], planCodesChangingAtTermEnd: [] }),
-    }
-    this.settings = {
-      coupon_codes: {
-        upgradeToAnnualPromo: {
-          student: 'STUDENTCODEHERE',
-          collaborator: 'COLLABORATORCODEHERE',
-        },
-      },
-      groupPlanModalOptions: {
-        plan_codes: [],
-        currencies: [
-          {
-            display: 'GBP (£)',
-            code: 'GBP',
-          },
-        ],
-        sizes: ['42'],
-        usages: [{ code: 'foo', display: 'Foo' }],
-      },
-      apis: {
-        recurly: {
-          subdomain: 'sl',
-        },
-      },
-      planReverts: {
-        enabled: false,
-      },
-      siteUrl: 'http://de.overleaf.dev:3000',
-    }
-    this.AuthorizationManager = {
-      promises: {
-        isUserSiteAdmin: sinon.stub().resolves(false),
-      },
-    }
-    this.GeoIpLookup = {
-      isValidCurrencyParam: sinon.stub().returns(true),
-      getCurrencyCode: sinon.stub().yields('USD', 'US'),
-      promises: {
-        getCurrencyCode: sinon.stub().resolves({
-          countryCode: 'US',
-          currencyCode: 'USD',
-        }),
-      },
-    }
-    this.UserGetter = {
-      getUser: sinon.stub().callsArgWith(2, null, this.user),
-      promises: {
-        getUser: sinon.stub().resolves(this.user),
-        getWritefullData: sinon
-          .stub()
-          .resolves({ isPremium: false, premiumSource: null }),
-      },
-    }
-    this.SplitTestV2Hander = {
-      promises: {
-        getAssignment: sinon.stub().resolves({ variant: 'default' }),
-      },
-    }
-    this.Features = {
-      hasFeature: sinon.stub().returns(false),
-    }
-    this.SubscriptionController = SandboxedModule.require(modulePath, {
-      requires: {
-        '../Authorization/AuthorizationManager': this.AuthorizationManager,
-        '../SplitTests/SplitTestHandler': this.SplitTestV2Hander,
-        '../Authentication/SessionManager': this.SessionManager,
-        './SubscriptionHandler': this.SubscriptionHandler,
-        './SubscriptionHelper': SubscriptionHelper,
-        './SubscriptionViewModelBuilder': this.SubscriptionViewModelBuilder,
-        './LimitationsManager': this.LimitationsManager,
-        '../../infrastructure/GeoIpLookup': this.GeoIpLookup,
-        '@overleaf/settings': this.settings,
-        '../User/UserGetter': this.UserGetter,
-        './RecurlyWrapper': (this.RecurlyWrapper = {
-          promises: {
-            updateAccountEmailAddress: sinon.stub().resolves(),
-            getSubscription: sinon.stub().resolves({}),
-          },
-        }),
-        './RecurlyEventHandler': {
-          sendRecurlyAnalyticsEvent: sinon.stub().resolves(),
-        },
-        './FeaturesUpdater': (this.FeaturesUpdater = {
-          promises: {
-            refreshFeatures: sinon.stub().resolves({ features: {} }),
-          },
-        }),
-        celebrate: (this.celebrate = {
-          celebrate: sinon.stub(),
-          errors: sinon.stub(),
-          Joi: {
-            any: sinon.stub(),
-            extend: sinon.stub(),
-          },
-        }),
-        './GroupPlansData': (this.GroupPlansData = {}),
-        './V1SubscriptionManager': (this.V1SubscriptionManager = {}),
-        '../Errors/HttpErrorHandler': (this.HttpErrorHandler = {
-          unprocessableEntity: sinon.stub().callsFake((req, res, message) => {
-            res.status(422)
-            res.json({ message })
-          }),
-          badRequest: sinon.stub().callsFake((req, res, message) => {
-            res.status(400)
-            res.json({ message })
-          }),
-        }),
-        './Errors': SubscriptionErrors,
-        '../Analytics/AnalyticsManager': (this.AnalyticsManager = {
-          recordEventForUser: sinon.stub(),
-          recordEventForUserInBackground: sinon.stub(),
-          recordEventForSession: sinon.stub(),
-          setUserPropertyForUser: sinon.stub(),
-        }),
-        '../../infrastructure/Modules': (this.Modules = {
-          promises: { hooks: { fire: sinon.stub().resolves() } },
-        }),
-        '../../infrastructure/Features': this.Features,
-        '../../util/currency': (this.currency = {
-          formatCurrency: sinon.stub(),
-        }),
-        '../../models/User': {
-          User: {
-            findById: sinon.stub().resolves(this.user),
-          },
-        },
-        './SubscriptionLocator': (this.SubscriptionLocator = {
-          promises: {
-            getUsersSubscription: sinon.stub().resolves(null),
-          },
-        }),
-        '../Authorization/PermissionsManager': (this.PermissionsManager = {
-          promises: {
-            checkUserPermissions: sinon.stub().resolves(true),
-          },
-        }),
-        './RecurlyClient': (this.RecurlyClient = {
-          promises: {
-            getAddOn: sinon.stub().resolves({
-              code: 'ai-assistant',
-              name: 'AI Assistant',
-            }),
-          },
-        }),
-        './PlansLocator': (this.PlansLocator = {
-          findLocalPlanInSettings: sinon.stub().returns({
-            annual: false,
-          }),
-        }),
-      },
-    })
-
-    this.res = new MockResponse()
-    this.req = new MockRequest()
-    this.req.body = {}
-    this.req.query = { planCode: '123123' }
-
-    this.stubbedCurrencyCode = 'GBP'
-  })
-
-  describe('successfulSubscription', function () {
-    it('without a personal subscription', function (done) {
-      this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
-        {}
-      )
-      this.res.redirect = url => {
-        url.should.equal('/user/subscription/plans')
-        done()
-      }
-      this.SubscriptionController.successfulSubscription(this.req, this.res)
-    })
-
-    it('with a personal subscription', function (done) {
-      this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
-        {
-          personalSubscription: 'foo',
-        }
-      )
-      this.res.render = (url, variables) => {
-        url.should.equal('subscriptions/successful-subscription-react')
-        assert.deepEqual(variables, {
-          title: 'thank_you',
-          personalSubscription: 'foo',
-          postCheckoutRedirect: undefined,
-          user: {
-            _id: this.user._id,
-            features: this.user.features,
-          },
-        })
-        done()
-      }
-      this.SubscriptionController.successfulSubscription(this.req, this.res)
-    })
-
-    it('with an error', function (done) {
-      this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
-        undefined
-      )
-      this.SubscriptionController.successfulSubscription(
-        this.req,
-        this.res,
-        error => {
-          assert.isNotNull(error)
-          done()
-        }
-      )
-    })
-  })
-
-  describe('userSubscriptionPage', function () {
-    beforeEach(function (done) {
-      this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
-        {
-          personalSubscription: (this.personalSubscription = {
-            'personal-subscription': 'mock',
-          }),
-          memberGroupSubscriptions: (this.memberGroupSubscriptions = {
-            'group-subscriptions': 'mock',
-          }),
-        }
-      )
-      this.SubscriptionViewModelBuilder.buildPlansList.returns(
-        (this.plans = { plans: 'mock' })
-      )
-      this.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash.returns(
-        {
-          plans: this.plans,
-          planCodesChangingAtTermEnd: [],
-        }
-      )
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        hasSubscription: false,
-      })
-      this.res.render = (view, data) => {
-        this.data = data
-        expect(view).to.equal('subscriptions/dashboard-react')
-        done()
-      }
-      this.SubscriptionController.userSubscriptionPage(this.req, this.res, done)
-    })
-
-    it('should load the personal, groups and v1 subscriptions', function () {
-      expect(this.data.personalSubscription).to.deep.equal(
-        this.personalSubscription
-      )
-      expect(this.data.memberGroupSubscriptions).to.deep.equal(
-        this.memberGroupSubscriptions
-      )
-    })
-
-    it('should load the user', function () {
-      expect(this.data.user).to.deep.equal(this.user)
-    })
-
-    it('should load the plans', function () {
-      expect(this.data.plans).to.deep.equal(this.plans)
-    })
-
-    it('should load an empty list of groups with settings available', function () {
-      expect(this.data.groupSettingsEnabledFor).to.deep.equal([])
-    })
-  })
-
-  describe('updateAccountEmailAddress via put', function () {
-    beforeEach(function () {
-      this.req.body = {
-        account_email: 'current_account_email@overleaf.com',
-      }
-    })
-
-    it('should send the user and subscriptionId to "updateAccountEmailAddress" hooks', async function () {
-      this.res.sendStatus = sinon.spy()
-
-      await this.SubscriptionController.updateAccountEmailAddress(
-        this.req,
-        this.res
-      )
-
-      expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
-        'updateAccountEmailAddress',
-        this.user._id,
-        this.user.email
-      )
-    })
-
-    it('should respond with 200', async function () {
-      this.res.sendStatus = sinon.spy()
-      await this.SubscriptionController.updateAccountEmailAddress(
-        this.req,
-        this.res
-      )
-      this.res.sendStatus.calledWith(200).should.equal(true)
-    })
-
-    it('should send the error to the next handler when updating recurly account email fails', async function () {
-      this.Modules.promises.hooks.fire
-        .withArgs('updateAccountEmailAddress', this.user._id, this.user.email)
-        .rejects(new Error())
-
-      this.next = sinon.spy(error => {
-        expect(error).to.be.instanceOf(Error)
-      })
-      await this.SubscriptionController.updateAccountEmailAddress(
-        this.req,
-        this.res,
-        this.next
-      )
-      expect(this.next.calledOnce).to.be.true
-    })
-  })
-
-  describe('reactivateSubscription', function () {
-    describe('when the user has permission', function () {
-      beforeEach(function (done) {
-        this.res = {
-          redirect() {
-            done()
-          },
-        }
-        this.req.assertPermission = sinon.stub()
-        this.next = sinon.stub().callsFake(error => {
-          done(error)
-        })
-        sinon.spy(this.res, 'redirect')
-        this.SubscriptionController.reactivateSubscription(
-          this.req,
-          this.res,
-          this.next
-        )
-      })
-
-      it('should assert the user has permission to reactivate their subscription', function (done) {
-        this.req.assertPermission
-          .calledWith('reactivate-subscription')
-          .should.equal(true)
-        done()
-      })
-
-      it('should tell the handler to reactivate this user', function (done) {
-        this.SubscriptionHandler.reactivateSubscription
-          .calledWith(this.user)
-          .should.equal(true)
-        done()
-      })
-
-      it('should redurect to the subscription page', function (done) {
-        this.res.redirect.calledWith('/user/subscription').should.equal(true)
-        done()
-      })
-    })
-
-    describe('when the user does not have permission', function () {
-      beforeEach(function (done) {
-        this.res = {
-          redirect() {
-            done()
-          },
-        }
-        this.req.assertPermission = sinon.stub().throws()
-        this.next = sinon.stub().callsFake(() => {
-          done()
-        })
-        sinon.spy(this.res, 'redirect')
-        this.SubscriptionController.reactivateSubscription(
-          this.req,
-          this.res,
-          this.next
-        )
-      })
-
-      it('should not reactivate the user', function (done) {
-        this.req.assertPermission = sinon.stub().throws()
-        this.SubscriptionHandler.reactivateSubscription.called.should.equal(
-          false
-        )
-        done()
-      })
-
-      it('should call next with an error', function (done) {
-        this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
-        done()
-      })
-    })
-  })
-
-  describe('pauseSubscription', function () {
-    it('should throw an error if no pause length is provided', async function () {
-      this.res = new MockResponse()
-      this.req = new MockRequest()
-      this.next = sinon.stub()
-      await expect(
-        this.SubscriptionController.pauseSubscription(
-          this.req,
-          this.res,
-          this.next
-        )
-      ).to.be.rejectedWith(Errors.NotFoundError)
-    })
-
-    it('should throw an error if an invalid pause length is provided', async function () {
-      this.res = new MockResponse()
-      this.req = new MockRequest()
-      this.req.params = { pauseCycles: '-10' }
-      this.next = sinon.stub()
-      await this.SubscriptionController.pauseSubscription(
-        this.req,
-        this.res,
-        this.next
-      )
-      expect(this.res.statusCode).to.equal(400)
-    })
-
-    it('should return a 200 when requesting a pause', async function () {
-      this.res = new MockResponse()
-      this.req = new MockRequest()
-      this.req.params = { pauseCycles: '3' }
-      this.next = sinon.stub()
-      await this.SubscriptionController.pauseSubscription(
-        this.req,
-        this.res,
-        this.next
-      )
-      expect(this.res.statusCode).to.equal(200)
-    })
-  })
-
-  describe('resumeSubscription', function () {
-    it('should return a 200 when resuming a subscription', async function () {
-      this.res = new MockResponse()
-      this.req = new MockRequest()
-      this.next = sinon.stub()
-      await this.SubscriptionController.resumeSubscription(
-        this.req,
-        this.res,
-        this.next
-      )
-      expect(this.res.statusCode).to.equal(200)
-    })
-  })
-
-  describe('cancelSubscription', function () {
-    it('should tell the handler to cancel this user', async function () {
-      this.next = sinon.stub()
-      await this.SubscriptionController.cancelSubscription(
-        this.req,
-        this.res,
-        this.next
-      )
-      this.SubscriptionHandler.promises.cancelSubscription
-        .calledWith(this.user)
-        .should.equal(true)
-    })
-
-    it('should return a 200 on success', async function () {
-      this.next = sinon.stub()
-      await this.SubscriptionController.cancelSubscription(
-        this.req,
-        this.res,
-        this.next
-      )
-      expect(this.res.statusCode).to.equal(200)
-    })
-
-    it('should call next with error', async function () {
-      this.SubscriptionHandler.promises.cancelSubscription.rejects(
-        new Error('cancel error')
-      )
-      this.next = sinon.stub()
-      await this.SubscriptionController.cancelSubscription(
-        this.req,
-        this.res,
-        this.next
-      )
-      this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
-    })
-  })
-
-  describe('recurly callback', function () {
-    describe('with a sync subscription request', function () {
-      beforeEach(function (done) {
-        this.req = {
-          body: {
-            expired_subscription_notification: {
-              account: {
-                account_code: this.user._id,
-              },
-              subscription: {
-                uuid: this.activeRecurlySubscription.uuid,
-                plan: {
-                  plan_code: 'collaborator',
-                  state: 'active',
-                },
-              },
-            },
-          },
-        }
-        this.res = {
-          sendStatus() {
-            done()
-          },
-        }
-        sinon.spy(this.res, 'sendStatus')
-        this.SubscriptionController.recurlyCallback(this.req, this.res)
-      })
-
-      it('should tell the SubscriptionHandler to process the recurly callback', function (done) {
-        this.SubscriptionHandler.syncSubscription.called.should.equal(true)
-        done()
-      })
-
-      it('should send a 200', function (done) {
-        this.res.sendStatus.calledWith(200)
-        done()
-      })
-    })
-
-    describe('with a billing info updated request', function () {
-      beforeEach(function (done) {
-        this.req = {
-          body: {
-            billing_info_updated_notification: {
-              account: {
-                account_code: 'mock-account-code',
-              },
-            },
-          },
-        }
-        this.res = {
-          sendStatus() {
-            done()
-          },
-        }
-        sinon.spy(this.res, 'sendStatus')
-        this.SubscriptionController.recurlyCallback(this.req, this.res)
-      })
-
-      it('should call attemptPaypalInvoiceCollection', function (done) {
-        this.SubscriptionHandler.attemptPaypalInvoiceCollection
-          .calledWith('mock-account-code')
-          .should.equal(true)
-        done()
-      })
-
-      it('should send a 200', function (done) {
-        this.res.sendStatus.calledWith(200)
-        done()
-      })
-    })
-
-    describe('with a non-actionable request', function () {
-      beforeEach(function (done) {
-        this.user.id = this.activeRecurlySubscription.account.account_code
-        this.req = {
-          body: {
-            renewed_subscription_notification: {
-              account: {
-                account_code: this.user._id,
-              },
-              subscription: {
-                uuid: this.activeRecurlySubscription.uuid,
-                plan: {
-                  plan_code: 'collaborator',
-                  state: 'active',
-                },
-              },
-            },
-          },
-        }
-        this.res = {
-          sendStatus() {
-            done()
-          },
-        }
-        sinon.spy(this.res, 'sendStatus')
-        this.SubscriptionController.recurlyCallback(this.req, this.res)
-      })
-
-      it('should not call the subscriptionshandler', function () {
-        this.SubscriptionHandler.syncSubscription.called.should.equal(false)
-        this.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal(
-          false
-        )
-      })
-
-      it('should respond with a 200 status', function () {
-        this.res.sendStatus.calledWith(200)
-      })
-    })
-
-    describe('with a failed payment notification', function () {
-      describe('with planReverts disabled in settings', function () {
-        beforeEach(function (done) {
-          this.settings.planReverts = { enabled: false }
-          this.SubscriptionHandler.revertPlanChange = sinon.stub()
-
-          this.req.body = {
-            failed_payment_notification: {
-              transaction: {
-                subscription_id: 'subscription-123',
-              },
-            },
-          }
-
-          this.res = {
-            sendStatus() {
-              done()
-            },
-          }
-          sinon.spy(this.res, 'sendStatus')
-          this.SubscriptionController.recurlyCallback(this.req, this.res)
-        })
-        it('should not call revertPlanChange', function () {
-          expect(this.SubscriptionHandler.revertPlanChange.called).to.be.false
-        })
-
-        it('should respond with 200', function (done) {
-          this.res.sendStatus.calledWith(200)
-          done()
-        })
-      })
-
-      describe('with planReverts enabled in settings', function () {
-        beforeEach(function () {
-          this.settings.planReverts = { enabled: true }
-        })
-
-        describe('with no valid restore point', function () {
-          beforeEach(function (done) {
-            this.SubscriptionHandler.getSubscriptionRestorePoint = sinon
-              .stub()
-              .yields(null, null)
-            this.SubscriptionHandler.revertPlanChange = sinon.stub()
-
-            this.req.body = {
-              failed_payment_notification: {
-                transaction: {
-                  subscription_id: 'subscription-123',
-                },
-              },
-            }
-            this.res = {
-              sendStatus() {
-                done()
-              },
-            }
-            sinon.spy(this.res, 'sendStatus')
-            this.SubscriptionController.recurlyCallback(this.req, this.res)
-          })
-          it('should not call revertPlanChange()', function () {
-            expect(this.SubscriptionHandler.revertPlanChange.called).to.be.false
-          })
-
-          it('should respond with 200', function () {
-            this.res.sendStatus.calledWith(200)
-          })
-        })
-
-        describe('with a valid restore point', function () {
-          beforeEach(function (done) {
-            this.addOns = [
-              {
-                addOnCode: 'addon-1',
-                quantity: 2,
-                unitAmountInCents: 500,
-              },
-              {
-                addOnCode: 'addon-2',
-                quantity: 1,
-                unitAmountInCents: 600,
-              },
-            ]
-            this.lastSubscription = {
-              planCode: 'gold',
-              addOns: this.addOns,
-            }
-            this.SubscriptionHandler.getSubscriptionRestorePoint = sinon
-              .stub()
-              .yields(null, this.lastSubscription)
-            this.SubscriptionHandler.revertPlanChange = sinon.stub().yields()
-            this.req.body = {
-              failed_payment_notification: {
-                transaction: {
-                  subscription_id: 'subscription-123',
-                },
-              },
-            }
-            this.res = {
-              sendStatus() {
-                done()
-              },
-            }
-            sinon.spy(this.res, 'sendStatus')
-            this.SubscriptionController.recurlyCallback(this.req, this.res)
-          })
-
-          it('should get the subscription restore point', function () {
-            expect(
-              this.SubscriptionHandler.getSubscriptionRestorePoint.calledWith(
-                'subscription-123'
-              )
-            ).to.be.true
-          })
-
-          it('should call revertPlanChange()', function () {
-            expect(
-              this.SubscriptionHandler.revertPlanChange.calledWith(
-                'subscription-123',
-                this.lastSubscription
-              )
-            ).to.be.true
-          })
-
-          it('should respond with 200', function () {
-            this.res.sendStatus.calledWith(200)
-          })
-        })
-      })
-    })
-  })
-
-  describe('purchaseAddon', function () {
-    beforeEach(function () {
-      this.SessionManager.getSessionUser.returns(this.user) // Make sure getSessionUser returns the user
-      this.next = sinon.stub()
-      this.req.params = { addOnCode: AI_ADD_ON_CODE } // Mock add-on code
-    })
-
-    it('should return 200 on successful purchase of AI add-on', async function () {
-      await this.SubscriptionController.purchaseAddon(
-        this.req,
-        this.res,
-        this.next
-      )
-      this.res.sendStatus = sinon.spy()
-
-      await this.SubscriptionController.purchaseAddon(
-        this.req,
-        this.res,
-        this.next
-      )
-
-      expect(this.SubscriptionHandler.promises.purchaseAddon).to.have.been
-        .called
-      expect(
-        this.SubscriptionHandler.promises.purchaseAddon
-      ).to.have.been.calledWith(this.user._id, AI_ADD_ON_CODE, 1)
-      expect(
-        this.FeaturesUpdater.promises.refreshFeatures
-      ).to.have.been.calledWith(this.user._id, 'add-on-purchase')
-      expect(this.res.sendStatus).to.have.been.calledWith(200)
-      expect(this.logger.debug).to.have.been.calledWith(
-        { userId: this.user._id, addOnCode: AI_ADD_ON_CODE },
-        'purchasing add-ons'
-      )
-    })
-
-    it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function () {
-      this.req.params = { addOnCode: 'some-other-addon' }
-      this.res.sendStatus = sinon.spy()
-
-      await this.SubscriptionController.purchaseAddon(
-        this.req,
-        this.res,
-        this.next
-      )
-
-      expect(this.SubscriptionHandler.promises.purchaseAddon).to.not.have.been
-        .called
-      expect(this.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
-        .called
-      expect(this.res.sendStatus).to.have.been.calledWith(404)
-    })
-
-    it('should handle DuplicateAddOnError and send badRequest while sending 200', async function () {
-      this.req.params.addOnCode = AI_ADD_ON_CODE
-      this.SubscriptionHandler.promises.purchaseAddon.rejects(
-        new SubscriptionErrors.DuplicateAddOnError()
-      )
-
-      await this.SubscriptionController.purchaseAddon(
-        this.req,
-        this.res,
-        this.next
-      )
-
-      expect(this.HttpErrorHandler.badRequest).to.have.been.calledWith(
-        this.req,
-        this.res,
-        'Your subscription already includes this add-on',
-        { addon: AI_ADD_ON_CODE }
-      )
-      expect(
-        this.FeaturesUpdater.promises.refreshFeatures
-      ).to.have.been.calledWith(this.user._id, 'add-on-purchase')
-      expect(this.res.sendStatus).to.have.been.calledWith(200)
-    })
-
-    it('should handle PaymentActionRequiredError and return 402 with details', async function () {
-      this.req.params.addOnCode = AI_ADD_ON_CODE
-      const paymentError = new SubscriptionErrors.PaymentActionRequiredError({
-        clientSecret: 'secret123',
-        publicKey: 'pubkey456',
-      })
-      this.SubscriptionHandler.promises.purchaseAddon.rejects(paymentError)
-
-      await this.SubscriptionController.purchaseAddon(
-        this.req,
-        this.res,
-        this.next
-      )
-
-      this.res.status.calledWith(402).should.equal(true)
-      this.res.json
-        .calledWith({
-          message: 'Payment action required',
-          clientSecret: 'secret123',
-          publicKey: 'pubkey456',
-        })
-        .should.equal(true)
-
-      expect(this.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
-        .called
-    })
-
-    it('should refresh features', async function () {
-      this.req.params.addOnCode = 'assistant'
-      this.SubscriptionHandler.promises.purchaseAddon = sinon.stub().resolves()
-      this.FeaturesUpdater.promises.refreshFeatures = sinon.stub().resolves()
-
-      await this.SubscriptionController.purchaseAddon(this.req, this.res)
-
-      expect(
-        this.FeaturesUpdater.promises.refreshFeatures.calledWith(
-          this.user._id,
-          'add-on-purchase'
-        )
-      ).to.be.true
-    })
-
-    it('should respond with a bad request if the subscription already includes the addOn', async function () {
-      this.req.params.addOnCode = 'assistant'
-      this.SubscriptionHandler.promises.purchaseAddon = sinon
-        .stub()
-        .rejects(new SubscriptionErrors.DuplicateAddOnError())
-
-      await this.SubscriptionController.purchaseAddon(this.req, this.res)
-
-      expect(
-        this.HttpErrorHandler.badRequest.calledWith(
-          this.req,
-          this.res,
-          'Your subscription already includes this add-on',
-          { addon: 'assistant' }
-        )
-      ).to.be.true
-    })
-  })
-
-  describe('checkSubscriptionPauseStatus', function () {
-    beforeEach(function () {
-      this.user = {
-        _id: 'user-id-123',
-        email: 'test@example.com',
-      }
-    })
-
-    it('should return isPaused: false when user has no subscription', async function () {
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription: null,
-      })
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-
-    it('should return isPaused: false when subscription has no paymentProvider', async function () {
-      const subscription = {
-        planCode: 'professional',
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-
-    it('should return isPaused: false when subscription has no subscriptionId', async function () {
-      const subscription = {
-        paymentProvider: {
-          service: 'stripe',
-          subscriptionId: null,
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-
-    it('should return isPaused: false when Stripe subscription has no remaining pause cycles', async function () {
-      const subscription = {
-        paymentProvider: {
-          service: 'stripe',
-          subscriptionId: 'sub-123',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const paymentRecord = {
-        subscription: {
-          remainingPauseCycles: 0,
-        },
-      }
-      this.Modules.promises.hooks.fire
-        .withArgs('getPaymentFromRecord', subscription)
-        .resolves([paymentRecord])
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-
-    it('should return isPaused: false when Stripe subscription has no remainingPauseCycles property', async function () {
-      const subscription = {
-        paymentProvider: {
-          service: 'stripe',
-          subscriptionId: 'sub-123',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const paymentRecord = {
-        subscription: {},
-      }
-      this.Modules.promises.hooks.fire
-        .withArgs('getPaymentFromRecord', subscription)
-        .resolves([paymentRecord])
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-
-    it('should return isPaused: true with redirect path when Stripe subscription has remaining pause cycles', async function () {
-      const subscription = {
-        paymentProvider: {
-          service: 'stripe',
-          subscriptionId: 'sub-123',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const paymentRecord = {
-        subscription: {
-          remainingPauseCycles: 2,
-        },
-      }
-      this.Modules.promises.hooks.fire
-        .withArgs('getPaymentFromRecord', subscription)
-        .resolves([paymentRecord])
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({
-        isPaused: true,
-        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
-      })
-    })
-
-    it('should return isPaused: true when remainingPauseCycles is exactly 1', async function () {
-      const subscription = {
-        paymentProvider: {
-          service: 'stripe',
-          subscriptionId: 'sub-123',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const paymentRecord = {
-        subscription: {
-          remainingPauseCycles: 1,
-        },
-      }
-      this.Modules.promises.hooks.fire
-        .withArgs('getPaymentFromRecord', subscription)
-        .resolves([paymentRecord])
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({
-        isPaused: true,
-        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
-      })
-    })
-
-    it('should return isPaused: false when userHasSubscription throws error', async function () {
-      const error = new Error('Something bad happened')
-      this.LimitationsManager.promises.userHasSubscription.rejects(error)
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-
-    it('should return isPaused: false when getPaymentFromRecord throws error', async function () {
-      const subscription = {
-        paymentProvider: {
-          service: 'stripe',
-          subscriptionId: 'sub-123',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const error = new Error('Something bad happened')
-      this.Modules.promises.hooks.fire
-        .withArgs('getPaymentFromRecord', subscription)
-        .rejects(error)
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-
-    it('should return isPaused: false when Recurly subscription is not paused', async function () {
-      const subscription = {
-        recurlySubscription_id: 'uuid-123',
-        recurlyStatus: {
-          state: 'active',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-
-    it('should return isPaused: true when Recurly subscription is paused', async function () {
-      const subscription = {
-        recurlySubscription_id: 'uuid-123',
-        recurlyStatus: {
-          state: 'paused',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({
-        isPaused: true,
-        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
-      })
-    })
-
-    it('should return isPaused: true when Recurly subscription has pending pause cycles', async function () {
-      const subscription = {
-        recurlySubscription_id: 'uuid-123',
-        recurlyStatus: {
-          state: 'active',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const recurlySubscriptionData = {
-        remaining_pause_cycles: 2,
-      }
-      this.RecurlyWrapper.promises.getSubscription.resolves(
-        recurlySubscriptionData
-      )
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({
-        isPaused: true,
-        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
-      })
-      expect(
-        this.RecurlyWrapper.promises.getSubscription
-      ).to.have.been.calledWith('uuid-123')
-    })
-
-    it('should return isPaused: false when Recurly subscription has no remaining pause cycles', async function () {
-      const subscription = {
-        recurlySubscription_id: 'uuid-123',
-        recurlyStatus: {
-          state: 'active',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const recurlySubscriptionData = {
-        remaining_pause_cycles: 0,
-      }
-      this.RecurlyWrapper.promises.getSubscription.resolves(
-        recurlySubscriptionData
-      )
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-
-    it('should return isPaused: false when Recurly subscription has no remaining_pause_cycles property', async function () {
-      const subscription = {
-        recurlySubscription_id: 'uuid-123',
-        recurlyStatus: {
-          state: 'active',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const recurlySubscriptionData = {}
-      this.RecurlyWrapper.promises.getSubscription.resolves(
-        recurlySubscriptionData
-      )
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-
-    it('should return isPaused: false when Recurly API call fails', async function () {
-      const subscription = {
-        recurlySubscription_id: 'uuid-123',
-        recurlyStatus: {
-          state: 'active',
-        },
-      }
-      this.LimitationsManager.promises.userHasSubscription.resolves({
-        subscription,
-      })
-
-      const error = new Error('Recurly API failed')
-      this.RecurlyWrapper.promises.getSubscription.rejects(error)
-
-      const result =
-        await this.SubscriptionController.checkSubscriptionPauseStatus(
-          this.user
-        )
-
-      expect(result).to.deep.equal({ isPaused: false })
-    })
-  })
-
-  describe('previewAddonPurchase', function () {
-    beforeEach(function () {
-      this.req = new MockRequest()
-      this.req.params = { addOnCode: 'assistant' }
-      this.req.query = { purchaseReferrer: 'fake-referrer' }
-      this.res = new MockResponse()
-
-      this.Modules.promises.hooks.fire
-        .withArgs('getPaymentMethod')
-        .resolves(['fake-method'])
-      this.SubscriptionLocator.promises.getUsersSubscription.resolves(null)
-    })
-
-    describe('when user has manual or custom subscription', function () {
-      it('should redirect with ai-assist-unavailable when subscription has customAccount = true', async function () {
-        const customSubscription = {
-          _id: 'sub-123',
-          customAccount: true,
-          collectionMethod: 'automatic',
-        }
-        this.SubscriptionLocator.promises.getUsersSubscription.resolves(
-          customSubscription
-        )
-
-        this.res.redirect = sinon.stub()
-
-        await this.SubscriptionController.previewAddonPurchase(
-          this.req,
-          this.res
-        )
-
-        expect(this.res.redirect).to.have.been.calledWith(
-          '/user/subscription?redirect-reason=ai-assist-unavailable'
-        )
-      })
-
-      it('should redirect with ai-assist-unavailable when subscription has collectionMethod = manual', async function () {
-        const manualSubscription = {
-          _id: 'sub-123',
-          customAccount: false,
-          collectionMethod: 'manual',
-        }
-        this.SubscriptionLocator.promises.getUsersSubscription.resolves(
-          manualSubscription
-        )
-
-        this.res.redirect = sinon.stub()
-
-        await this.SubscriptionController.previewAddonPurchase(
-          this.req,
-          this.res
-        )
-
-        expect(this.res.redirect).to.have.been.calledWith(
-          '/user/subscription?redirect-reason=ai-assist-unavailable'
-        )
-      })
-
-      it('should redirect with ai-assist-unavailable when subscription has both customAccount and manual collection', async function () {
-        const customManualSubscription = {
-          _id: 'sub-123',
-          customAccount: true,
-          collectionMethod: 'manual',
-        }
-        this.SubscriptionLocator.promises.getUsersSubscription.resolves(
-          customManualSubscription
-        )
-
-        this.res.redirect = sinon.stub()
-
-        await this.SubscriptionController.previewAddonPurchase(
-          this.req,
-          this.res
-        )
-
-        expect(this.res.redirect).to.have.been.calledWith(
-          '/user/subscription?redirect-reason=ai-assist-unavailable'
-        )
-      })
-    })
-
-    describe('when user has normal subscription', function () {
-      it('should proceed with preview when subscription is not manual or custom', async function () {
-        const normalSubscription = {
-          _id: 'sub-123',
-          customAccount: false,
-          collectionMethod: 'automatic',
-        }
-        this.SubscriptionLocator.promises.getUsersSubscription.resolves(
-          normalSubscription
-        )
-
-        this.res.render = sinon.stub()
-
-        await this.SubscriptionController.previewAddonPurchase(
-          this.req,
-          this.res
-        )
-
-        expect(this.res.render).to.have.been.calledWith(
-          'subscriptions/preview-change'
-        )
-        expect(
-          this.SubscriptionHandler.promises.previewAddonPurchase
-        ).to.have.been.calledWith(this.user._id, 'assistant')
-      })
-
-      it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function () {
-        const normalSubscription = {
-          _id: 'sub-123',
-          // customAccount: undefined (not set)
-          collectionMethod: 'automatic',
-        }
-        this.SubscriptionLocator.promises.getUsersSubscription.resolves(
-          normalSubscription
-        )
-
-        this.res.render = sinon.stub()
-
-        await this.SubscriptionController.previewAddonPurchase(
-          this.req,
-          this.res
-        )
-
-        expect(this.res.render).to.have.been.calledWith(
-          'subscriptions/preview-change'
-        )
-        expect(
-          this.SubscriptionHandler.promises.previewAddonPurchase
-        ).to.have.been.calledWith(this.user._id, 'assistant')
-      })
-    })
-  })
-})

+ 50 - 0
services/web/test/unit/src/SystemMessages/SystemMessageManager.test.mjs

@@ -0,0 +1,50 @@
+import { vi } from 'vitest'
+import sinon from 'sinon'
+
+const modulePath =
+  '../../../../app/src/Features/SystemMessages/SystemMessageManager.mjs'
+
+describe('SystemMessageManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.messages = ['messages-stub']
+    ctx.SystemMessage = {
+      find: sinon.stub().returns({
+        exec: sinon.stub().resolves(ctx.messages),
+      }),
+    }
+
+    vi.doMock('../../../../app/src/models/SystemMessage', () => ({
+      SystemMessage: ctx.SystemMessage,
+    }))
+
+    ctx.SystemMessageManager = (await import(modulePath)).default
+  })
+
+  it('should look the messages up in the database on import', function (ctx) {
+    sinon.assert.called(ctx.SystemMessage.find)
+  })
+
+  describe('getMessage', function () {
+    beforeEach(function (ctx) {
+      ctx.SystemMessageManager._cachedMessages = ctx.messages
+      ctx.result = ctx.SystemMessageManager.getMessages()
+    })
+
+    it('should return the messages', function (ctx) {
+      ctx.result.should.equal(ctx.messages)
+    })
+  })
+
+  describe('clearMessages', function () {
+    beforeEach(function (ctx) {
+      ctx.SystemMessage.deleteMany = sinon.stub().returns({
+        exec: sinon.stub().resolves(),
+      })
+      ctx.SystemMessageManager.promises.clearMessages()
+    })
+
+    it('should remove the messages from the database', function (ctx) {
+      ctx.SystemMessage.deleteMany.calledWith({}).should.equal(true)
+    })
+  })
+})

+ 0 - 50
services/web/test/unit/src/SystemMessages/SystemMessageManagerTests.js

@@ -1,50 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/SystemMessages/SystemMessageManager.js'
-)
-
-describe('SystemMessageManager', function () {
-  beforeEach(function () {
-    this.messages = ['messages-stub']
-    this.SystemMessage = {
-      find: sinon.stub().returns({
-        exec: sinon.stub().resolves(this.messages),
-      }),
-    }
-    this.SystemMessageManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../models/SystemMessage': { SystemMessage: this.SystemMessage },
-      },
-    })
-  })
-
-  it('should look the messages up in the database on import', function () {
-    sinon.assert.called(this.SystemMessage.find)
-  })
-
-  describe('getMessage', function () {
-    beforeEach(function () {
-      this.SystemMessageManager._cachedMessages = this.messages
-      this.result = this.SystemMessageManager.getMessages()
-    })
-
-    it('should return the messages', function () {
-      this.result.should.equal(this.messages)
-    })
-  })
-
-  describe('clearMessages', function () {
-    beforeEach(function () {
-      this.SystemMessage.deleteMany = sinon.stub().returns({
-        exec: sinon.stub().resolves(),
-      })
-      this.SystemMessageManager.promises.clearMessages()
-    })
-
-    it('should remove the messages from the database', function () {
-      this.SystemMessage.deleteMany.calledWith({}).should.equal(true)
-    })
-  })
-})

+ 412 - 0
services/web/test/unit/src/ThirdPartyDataStore/UpdateMerger.test.mjs

@@ -0,0 +1,412 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import { Writable } from 'stream'
+import mongodb from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
+
+const MODULE_PATH =
+  '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.mjs'
+
+describe('UpdateMerger :', function () {
+  beforeEach(async function (ctx) {
+    ctx.projectId = 'project_id_here'
+    ctx.userId = 'mock-user-id'
+    ctx.randomUUID = 'random-uuid'
+    ctx.dumpPath = '/dump'
+
+    ctx.docPath = ctx.newDocPath = '/folder/doc.tex'
+    ctx.filePath = ctx.newFilePath = '/folder/file.png'
+
+    ctx.existingDocPath = '/folder/other.tex'
+    ctx.existingFilePath = '/folder/fig1.pdf'
+
+    ctx.linkedFileData = { provider: 'url' }
+
+    ctx.existingDocs = [{ path: '/main.tex' }, { path: '/folder/other.tex' }]
+    ctx.existingFiles = [{ path: '/figure.pdf' }, { path: '/folder/fig1.pdf' }]
+
+    ctx.fsPath = `${ctx.dumpPath}/${ctx.projectId}_${ctx.randomUUID}`
+    ctx.fileContents = `\\documentclass{article}
+\\usepackage[utf8]{inputenc}
+
+\\title{42}
+\\author{Jane Doe}
+\\date{June 2011}`
+    ctx.docLines = ctx.fileContents.split('\n')
+    ctx.source = 'dropbox'
+    ctx.updateRequest = new Writable()
+    ctx.writeStream = new Writable()
+
+    ctx.fsPromises = {
+      unlink: sinon.stub().resolves(),
+      readFile: sinon.stub().withArgs(ctx.fsPath).resolves(ctx.fileContents),
+      mkdir: sinon.stub().resolves(),
+    }
+
+    ctx.fs = {
+      createWriteStream: sinon.stub().returns(ctx.writeStream),
+    }
+
+    ctx.doc = {
+      _id: new ObjectId(),
+      rev: 2,
+    }
+
+    ctx.file = {
+      _id: new ObjectId(),
+      rev: 6,
+    }
+
+    ctx.folder = {
+      _id: new ObjectId(),
+    }
+
+    ctx.EditorController = {
+      promises: {
+        deleteEntityWithPath: sinon.stub().resolves(new ObjectId()),
+        upsertDocWithPath: sinon
+          .stub()
+          .resolves({ doc: ctx.doc, folder: ctx.folder }),
+        upsertFileWithPath: sinon
+          .stub()
+          .resolves({ file: ctx.file, folder: ctx.folder }),
+      },
+    }
+
+    ctx.FileTypeManager = {
+      promises: {
+        getType: sinon.stub(),
+      },
+    }
+
+    ctx.crypto = {
+      randomUUID: sinon.stub().returns(ctx.randomUUID),
+    }
+
+    ctx.ProjectEntityHandler = {
+      promises: {
+        getAllEntities: sinon.stub().resolves({
+          docs: ctx.existingDocs,
+          files: ctx.existingFiles,
+        }),
+      },
+    }
+
+    ctx.Settings = { path: { dumpFolder: ctx.dumpPath } }
+
+    ctx.stream = { pipeline: sinon.stub().resolves() }
+
+    vi.doMock('fs/promises', () => ({
+      default: ctx.fsPromises,
+    }))
+
+    vi.doMock('fs', () => ({
+      default: ctx.fs,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({
+      default: ctx.EditorController,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Uploads/FileTypeManager', () => ({
+      default: ctx.FileTypeManager,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityHandler',
+      () => ({
+        default: ctx.ProjectEntityHandler,
+      })
+    )
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.Settings,
+    }))
+
+    vi.doMock('stream/promises', () => ({
+      pipeline: ctx.stream.pipeline,
+    }))
+
+    vi.doMock('crypto', () => ({
+      default: ctx.crypto,
+    }))
+
+    ctx.UpdateMerger = (await import(MODULE_PATH)).default
+  })
+
+  describe('mergeUpdate', function () {
+    describe('doc updates for a new doc', function () {
+      beforeEach(async function (ctx) {
+        ctx.FileTypeManager.promises.getType.resolves({
+          binary: false,
+          encoding: 'utf-8',
+        })
+        ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate(
+          ctx.userId,
+          ctx.projectId,
+          ctx.docPath,
+          ctx.updateRequest,
+          ctx.source
+        )
+      })
+
+      it('should look at the file contents', function (ctx) {
+        expect(ctx.FileTypeManager.promises.getType).to.have.been.called
+      })
+
+      it('should process update as doc', function (ctx) {
+        expect(
+          ctx.EditorController.promises.upsertDocWithPath
+        ).to.have.been.calledWith(
+          ctx.projectId,
+          ctx.docPath,
+          ctx.docLines,
+          ctx.source,
+          ctx.userId
+        )
+      })
+
+      it('removes the temp file from disk', function (ctx) {
+        expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath)
+      })
+
+      it('returns the entity id and rev', function (ctx) {
+        expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
+        expect(ctx.mergeUpdateResult.rev).to.equal(2)
+      })
+    })
+
+    describe('file updates for a new file ', function () {
+      beforeEach(async function (ctx) {
+        ctx.FileTypeManager.promises.getType.resolves({ binary: true })
+        ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate(
+          ctx.userId,
+          ctx.projectId,
+          ctx.filePath,
+          ctx.updateRequest,
+          ctx.source
+        )
+      })
+
+      it('should look at the file contents', function (ctx) {
+        expect(ctx.FileTypeManager.promises.getType).to.have.been.called
+      })
+
+      it('should process update as file', function (ctx) {
+        expect(
+          ctx.EditorController.promises.upsertFileWithPath
+        ).to.have.been.calledWith(
+          ctx.projectId,
+          ctx.filePath,
+          ctx.fsPath,
+          null,
+          ctx.source,
+          ctx.userId
+        )
+      })
+
+      it('removes the temp file from disk', function (ctx) {
+        expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath)
+      })
+
+      it('returns the entity id and rev', function (ctx) {
+        expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
+        expect(ctx.mergeUpdateResult.rev).to.equal(6)
+      })
+    })
+
+    describe('doc updates for an existing doc', function () {
+      beforeEach(async function (ctx) {
+        ctx.FileTypeManager.promises.getType.resolves({
+          binary: false,
+          encoding: 'utf-8',
+        })
+        ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate(
+          ctx.userId,
+          ctx.projectId,
+          ctx.existingDocPath,
+          ctx.updateRequest,
+          ctx.source
+        )
+      })
+
+      it('should look at the file contents', function (ctx) {
+        expect(ctx.FileTypeManager.promises.getType).to.have.been.called
+      })
+
+      it('should process update as doc', function (ctx) {
+        expect(
+          ctx.EditorController.promises.upsertDocWithPath
+        ).to.have.been.calledWith(
+          ctx.projectId,
+          ctx.existingDocPath,
+          ctx.docLines,
+          ctx.source,
+          ctx.userId
+        )
+      })
+
+      it('removes the temp file from disk', function (ctx) {
+        expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath)
+      })
+
+      it('returns the entity id and rev', function (ctx) {
+        expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
+        expect(ctx.mergeUpdateResult.rev).to.equal(2)
+      })
+    })
+
+    describe('file updates for an existing file', function () {
+      beforeEach(async function (ctx) {
+        ctx.FileTypeManager.promises.getType.resolves({ binary: true })
+        ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate(
+          ctx.userId,
+          ctx.projectId,
+          ctx.existingFilePath,
+          ctx.updateRequest,
+          ctx.source
+        )
+      })
+
+      it('should look at the file contents', function (ctx) {
+        expect(ctx.FileTypeManager.promises.getType).to.have.been.called
+      })
+
+      it('should process update as file', function (ctx) {
+        expect(
+          ctx.EditorController.promises.upsertFileWithPath
+        ).to.have.been.calledWith(
+          ctx.projectId,
+          ctx.existingFilePath,
+          ctx.fsPath,
+          null,
+          ctx.source,
+          ctx.userId
+        )
+      })
+
+      it('removes the temp file from disk', function (ctx) {
+        expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath)
+      })
+
+      it('returns the entity id and rev', function (ctx) {
+        expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
+        expect(ctx.mergeUpdateResult.rev).to.equal(6)
+      })
+    })
+  })
+
+  describe('file updates for an existing doc', function () {
+    beforeEach(async function (ctx) {
+      ctx.FileTypeManager.promises.getType.resolves({ binary: true })
+      ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate(
+        ctx.userId,
+        ctx.projectId,
+        ctx.existingDocPath,
+        ctx.updateRequest,
+        ctx.source
+      )
+    })
+
+    it('should look at the file contents', function (ctx) {
+      expect(ctx.FileTypeManager.promises.getType).to.have.been.called
+    })
+
+    it('should process update as file', function (ctx) {
+      expect(
+        ctx.EditorController.promises.upsertFileWithPath
+      ).to.have.been.calledWith(
+        ctx.projectId,
+        ctx.existingDocPath,
+        ctx.fsPath,
+        null,
+        ctx.source,
+        ctx.userId
+      )
+    })
+
+    it('removes the temp file from disk', function (ctx) {
+      expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath)
+    })
+
+    it('returns the entity id and rev', function (ctx) {
+      expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
+      expect(ctx.mergeUpdateResult.rev).to.equal(6)
+    })
+  })
+
+  describe('doc updates for an existing file', function () {
+    beforeEach(async function (ctx) {
+      ctx.FileTypeManager.promises.getType.resolves({ binary: true })
+      ctx.mergeUpdateResult = await ctx.UpdateMerger.promises.mergeUpdate(
+        ctx.userId,
+        ctx.projectId,
+        ctx.existingFilePath,
+        ctx.updateRequest,
+        ctx.source
+      )
+    })
+
+    it('should look at the file contents', function (ctx) {
+      expect(ctx.FileTypeManager.promises.getType).to.have.been.called
+    })
+
+    it('should not delete the existing file', function (ctx) {
+      expect(ctx.EditorController.promises.deleteEntityWithPath).to.not.have
+        .been.called
+    })
+
+    it('should process update as file', function (ctx) {
+      expect(
+        ctx.EditorController.promises.upsertFileWithPath
+      ).to.have.been.calledWith(
+        ctx.projectId,
+        ctx.existingFilePath,
+        ctx.fsPath,
+        null,
+        ctx.source,
+        ctx.userId
+      )
+    })
+
+    it('removes the temp file from disk', function (ctx) {
+      expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.fsPath)
+    })
+
+    it('returns the entity id and rev', function (ctx) {
+      expect(ctx.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
+      expect(ctx.mergeUpdateResult.rev).to.equal(6)
+    })
+  })
+
+  describe('deleteUpdate', function () {
+    beforeEach(async function (ctx) {
+      ctx.deleteUpdateResult = await ctx.UpdateMerger.promises.deleteUpdate(
+        ctx.userId,
+        ctx.projectId,
+        ctx.docPath,
+        ctx.source
+      )
+    })
+
+    afterEach(function (ctx) {
+      delete ctx.deleteUpdateResult
+    })
+
+    it('should delete the entity in the editor controller', function (ctx) {
+      expect(
+        ctx.EditorController.promises.deleteEntityWithPath
+      ).to.have.been.calledWith(
+        ctx.projectId,
+        ctx.docPath,
+        ctx.source,
+        ctx.userId
+      )
+    })
+
+    it('returns the entity id', function (ctx) {
+      expect(ctx.deleteUpdateResult).to.be.instanceOf(ObjectId)
+    })
+  })
+})

+ 0 - 387
services/web/test/unit/src/ThirdPartyDataStore/UpdateMergerTests.js

@@ -1,387 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const { Writable } = require('stream')
-const { ObjectId } = require('mongodb-legacy')
-
-const MODULE_PATH =
-  '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger.js'
-
-describe('UpdateMerger :', function () {
-  beforeEach(function () {
-    this.projectId = 'project_id_here'
-    this.userId = 'mock-user-id'
-    this.randomUUID = 'random-uuid'
-    this.dumpPath = '/dump'
-
-    this.docPath = this.newDocPath = '/folder/doc.tex'
-    this.filePath = this.newFilePath = '/folder/file.png'
-
-    this.existingDocPath = '/folder/other.tex'
-    this.existingFilePath = '/folder/fig1.pdf'
-
-    this.linkedFileData = { provider: 'url' }
-
-    this.existingDocs = [{ path: '/main.tex' }, { path: '/folder/other.tex' }]
-    this.existingFiles = [{ path: '/figure.pdf' }, { path: '/folder/fig1.pdf' }]
-
-    this.fsPath = `${this.dumpPath}/${this.projectId}_${this.randomUUID}`
-    this.fileContents = `\\documentclass{article}
-\\usepackage[utf8]{inputenc}
-
-\\title{42}
-\\author{Jane Doe}
-\\date{June 2011}`
-    this.docLines = this.fileContents.split('\n')
-    this.source = 'dropbox'
-    this.updateRequest = new Writable()
-    this.writeStream = new Writable()
-
-    this.fsPromises = {
-      unlink: sinon.stub().resolves(),
-      readFile: sinon.stub().withArgs(this.fsPath).resolves(this.fileContents),
-      mkdir: sinon.stub().resolves(),
-    }
-
-    this.fs = {
-      createWriteStream: sinon.stub().returns(this.writeStream),
-    }
-
-    this.doc = {
-      _id: new ObjectId(),
-      rev: 2,
-    }
-
-    this.file = {
-      _id: new ObjectId(),
-      rev: 6,
-    }
-
-    this.folder = {
-      _id: new ObjectId(),
-    }
-
-    this.EditorController = {
-      promises: {
-        deleteEntityWithPath: sinon.stub().resolves(new ObjectId()),
-        upsertDocWithPath: sinon
-          .stub()
-          .resolves({ doc: this.doc, folder: this.folder }),
-        upsertFileWithPath: sinon
-          .stub()
-          .resolves({ file: this.file, folder: this.folder }),
-      },
-    }
-
-    this.FileTypeManager = {
-      promises: {
-        getType: sinon.stub(),
-      },
-    }
-
-    this.crypto = {
-      randomUUID: sinon.stub().returns(this.randomUUID),
-    }
-
-    this.ProjectEntityHandler = {
-      promises: {
-        getAllEntities: sinon.stub().resolves({
-          docs: this.existingDocs,
-          files: this.existingFiles,
-        }),
-      },
-    }
-
-    this.Settings = { path: { dumpFolder: this.dumpPath } }
-
-    this.stream = { pipeline: sinon.stub().resolves() }
-
-    this.UpdateMerger = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        'fs/promises': this.fsPromises,
-        fs: this.fs,
-        '../Editor/EditorController': this.EditorController,
-        '../Uploads/FileTypeManager': this.FileTypeManager,
-        '../Project/ProjectEntityHandler': this.ProjectEntityHandler,
-        '@overleaf/settings': this.Settings,
-        'stream/promises': this.stream,
-        crypto: this.crypto,
-      },
-    })
-  })
-
-  describe('mergeUpdate', function () {
-    describe('doc updates for a new doc', function () {
-      beforeEach(async function () {
-        this.FileTypeManager.promises.getType.resolves({
-          binary: false,
-          encoding: 'utf-8',
-        })
-        this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate(
-          this.userId,
-          this.projectId,
-          this.docPath,
-          this.updateRequest,
-          this.source
-        )
-      })
-
-      it('should look at the file contents', function () {
-        expect(this.FileTypeManager.promises.getType).to.have.been.called
-      })
-
-      it('should process update as doc', function () {
-        expect(
-          this.EditorController.promises.upsertDocWithPath
-        ).to.have.been.calledWith(
-          this.projectId,
-          this.docPath,
-          this.docLines,
-          this.source,
-          this.userId
-        )
-      })
-
-      it('removes the temp file from disk', function () {
-        expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath)
-      })
-
-      it('returns the entity id and rev', function () {
-        expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
-        expect(this.mergeUpdateResult.rev).to.equal(2)
-      })
-    })
-
-    describe('file updates for a new file ', function () {
-      beforeEach(async function () {
-        this.FileTypeManager.promises.getType.resolves({ binary: true })
-        this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate(
-          this.userId,
-          this.projectId,
-          this.filePath,
-          this.updateRequest,
-          this.source
-        )
-      })
-
-      it('should look at the file contents', function () {
-        expect(this.FileTypeManager.promises.getType).to.have.been.called
-      })
-
-      it('should process update as file', function () {
-        expect(
-          this.EditorController.promises.upsertFileWithPath
-        ).to.have.been.calledWith(
-          this.projectId,
-          this.filePath,
-          this.fsPath,
-          null,
-          this.source,
-          this.userId
-        )
-      })
-
-      it('removes the temp file from disk', function () {
-        expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath)
-      })
-
-      it('returns the entity id and rev', function () {
-        expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
-        expect(this.mergeUpdateResult.rev).to.equal(6)
-      })
-    })
-
-    describe('doc updates for an existing doc', function () {
-      beforeEach(async function () {
-        this.FileTypeManager.promises.getType.resolves({
-          binary: false,
-          encoding: 'utf-8',
-        })
-        this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate(
-          this.userId,
-          this.projectId,
-          this.existingDocPath,
-          this.updateRequest,
-          this.source
-        )
-      })
-
-      it('should look at the file contents', function () {
-        expect(this.FileTypeManager.promises.getType).to.have.been.called
-      })
-
-      it('should process update as doc', function () {
-        expect(
-          this.EditorController.promises.upsertDocWithPath
-        ).to.have.been.calledWith(
-          this.projectId,
-          this.existingDocPath,
-          this.docLines,
-          this.source,
-          this.userId
-        )
-      })
-
-      it('removes the temp file from disk', function () {
-        expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath)
-      })
-
-      it('returns the entity id and rev', function () {
-        expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
-        expect(this.mergeUpdateResult.rev).to.equal(2)
-      })
-    })
-
-    describe('file updates for an existing file', function () {
-      beforeEach(async function () {
-        this.FileTypeManager.promises.getType.resolves({ binary: true })
-        this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate(
-          this.userId,
-          this.projectId,
-          this.existingFilePath,
-          this.updateRequest,
-          this.source
-        )
-      })
-
-      it('should look at the file contents', function () {
-        expect(this.FileTypeManager.promises.getType).to.have.been.called
-      })
-
-      it('should process update as file', function () {
-        expect(
-          this.EditorController.promises.upsertFileWithPath
-        ).to.have.been.calledWith(
-          this.projectId,
-          this.existingFilePath,
-          this.fsPath,
-          null,
-          this.source,
-          this.userId
-        )
-      })
-
-      it('removes the temp file from disk', function () {
-        expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath)
-      })
-
-      it('returns the entity id and rev', function () {
-        expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
-        expect(this.mergeUpdateResult.rev).to.equal(6)
-      })
-    })
-  })
-
-  describe('file updates for an existing doc', function () {
-    beforeEach(async function () {
-      this.FileTypeManager.promises.getType.resolves({ binary: true })
-      this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate(
-        this.userId,
-        this.projectId,
-        this.existingDocPath,
-        this.updateRequest,
-        this.source
-      )
-    })
-
-    it('should look at the file contents', function () {
-      expect(this.FileTypeManager.promises.getType).to.have.been.called
-    })
-
-    it('should process update as file', function () {
-      expect(
-        this.EditorController.promises.upsertFileWithPath
-      ).to.have.been.calledWith(
-        this.projectId,
-        this.existingDocPath,
-        this.fsPath,
-        null,
-        this.source,
-        this.userId
-      )
-    })
-
-    it('removes the temp file from disk', function () {
-      expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath)
-    })
-
-    it('returns the entity id and rev', function () {
-      expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
-      expect(this.mergeUpdateResult.rev).to.equal(6)
-    })
-  })
-
-  describe('doc updates for an existing file', function () {
-    beforeEach(async function () {
-      this.FileTypeManager.promises.getType.resolves({ binary: true })
-      this.mergeUpdateResult = await this.UpdateMerger.promises.mergeUpdate(
-        this.userId,
-        this.projectId,
-        this.existingFilePath,
-        this.updateRequest,
-        this.source
-      )
-    })
-
-    it('should look at the file contents', function () {
-      expect(this.FileTypeManager.promises.getType).to.have.been.called
-    })
-
-    it('should not delete the existing file', function () {
-      expect(this.EditorController.promises.deleteEntityWithPath).to.not.have
-        .been.called
-    })
-
-    it('should process update as file', function () {
-      expect(
-        this.EditorController.promises.upsertFileWithPath
-      ).to.have.been.calledWith(
-        this.projectId,
-        this.existingFilePath,
-        this.fsPath,
-        null,
-        this.source,
-        this.userId
-      )
-    })
-
-    it('removes the temp file from disk', function () {
-      expect(this.fsPromises.unlink).to.have.been.calledWith(this.fsPath)
-    })
-
-    it('returns the entity id and rev', function () {
-      expect(this.mergeUpdateResult.entityId).to.be.instanceOf(ObjectId)
-      expect(this.mergeUpdateResult.rev).to.equal(6)
-    })
-  })
-
-  describe('deleteUpdate', function () {
-    beforeEach(async function () {
-      this.deleteUpdateResult = await this.UpdateMerger.promises.deleteUpdate(
-        this.userId,
-        this.projectId,
-        this.docPath,
-        this.source
-      )
-    })
-
-    afterEach(function () {
-      delete this.deleteUpdateResult
-    })
-
-    it('should delete the entity in the editor controller', function () {
-      expect(
-        this.EditorController.promises.deleteEntityWithPath
-      ).to.have.been.calledWith(
-        this.projectId,
-        this.docPath,
-        this.source,
-        this.userId
-      )
-    })
-
-    it('returns the entity id', function () {
-      expect(this.deleteUpdateResult).to.be.instanceOf(ObjectId)
-    })
-  })
-})

+ 19 - 20
services/web/test/unit/src/Tutorial/TutorialHandlerTests.js → services/web/test/unit/src/Tutorial/TutorialHandler.test.mjs

@@ -1,19 +1,20 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const sinon = require('sinon')
-const { ObjectId } = require('mongodb-legacy')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
 
 const MODULE_PATH = '../../../../app/src/Features/Tutorial/TutorialHandler'
 
 describe('TutorialHandler', function () {
-  beforeEach(function () {
-    this.clock = sinon.useFakeTimers()
+  beforeEach(async function (ctx) {
+    ctx.clock = sinon.useFakeTimers()
 
     const THIRTY_DAYS_AGO = Date.now() - 30 * 24 * 60 * 60 * 1000
     const TOMORROW = Date.now() + 24 * 60 * 60 * 1000
     const YESTERDAY = Date.now() - 24 * 60 * 60 * 1000
 
-    this.user = {
+    ctx.user = {
       _id: new ObjectId(),
       completedTutorials: {
         'legacy-format': new Date(Date.now() - 1000),
@@ -42,28 +43,26 @@ describe('TutorialHandler', function () {
       },
     }
 
-    this.UserUpdater = {
+    ctx.UserUpdater = {
       promises: {
         updateUser: sinon.stub().resolves(),
       },
     }
 
-    this.TutorialHandler = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '../User/UserUpdater': this.UserUpdater,
-      },
-    })
+    vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
+      default: ctx.UserUpdater,
+    }))
+
+    ctx.TutorialHandler = (await import(MODULE_PATH)).default
   })
 
-  afterEach(function () {
-    this.clock.restore()
+  afterEach(function (ctx) {
+    ctx.clock.restore()
   })
 
   describe('getInactiveTutorials', function () {
-    it('returns all recorded tutorials except when they were posponed long ago', function () {
-      const hiddenTutorials = this.TutorialHandler.getInactiveTutorials(
-        this.user
-      )
+    it('returns all recorded tutorials except when they were posponed long ago', function (ctx) {
+      const hiddenTutorials = ctx.TutorialHandler.getInactiveTutorials(ctx.user)
       expect(hiddenTutorials).to.have.members([
         'legacy-format',
         'completed',
@@ -73,7 +72,7 @@ describe('TutorialHandler', function () {
 
       expect(hiddenTutorials).to.have.lengthOf(4)
 
-      const shownTutorials = Object.keys(this.user.completedTutorials).filter(
+      const shownTutorials = Object.keys(ctx.user.completedTutorials).filter(
         key => !hiddenTutorials.includes(key)
       )