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

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

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

GitOrigin-RevId: 0cad67f9afe0095e2b066bf2f4d3717c00540dab
Antoine Clausse 10 сар өмнө
parent
commit
33e63d79fc
42 өөрчлөгдсөн 2266 нэмэгдсэн , 2093 устгасан
  1. 5 5
      services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.mjs
  2. 1 1
      services/web/app/src/Features/Analytics/AnalyticsRouter.mjs
  3. 2 2
      services/web/app/src/Features/Authorization/PermissionsController.mjs
  4. 7 4
      services/web/app/src/Features/Authorization/PermissionsManager.mjs
  5. 2 2
      services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs
  6. 26 24
      services/web/app/src/Features/Compile/ClsiManager.mjs
  7. 1 1
      services/web/app/src/Features/Compile/CompileController.mjs
  8. 1 1
      services/web/app/src/Features/Compile/CompileManager.mjs
  9. 1 1
      services/web/app/src/Features/Editor/EditorRouter.mjs
  10. 1 1
      services/web/app/src/Features/History/HistoryRouter.mjs
  11. 1 1
      services/web/app/src/Features/LinkedFiles/LinkedFilesRouter.mjs
  12. 1 1
      services/web/app/src/Features/LinkedFiles/ProjectOutputFileAgent.mjs
  13. 1 1
      services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs
  14. 1 1
      services/web/app/src/Features/PasswordReset/PasswordResetRouter.mjs
  15. 2 2
      services/web/app/src/Features/Project/ProjectListController.mjs
  16. 5 5
      services/web/app/src/Features/Security/RateLimiterMiddleware.mjs
  17. 7 7
      services/web/app/src/Features/Spelling/LearnedWordsManager.mjs
  18. 1 1
      services/web/app/src/Features/Spelling/SpellingController.mjs
  19. 1 1
      services/web/app/src/Features/Spelling/SpellingHandler.mjs
  20. 8 6
      services/web/app/src/Features/Subscription/RecurlyEventHandler.mjs
  21. 3 3
      services/web/app/src/Features/Subscription/SubscriptionController.mjs
  22. 1 1
      services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
  23. 21 20
      services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs
  24. 1 1
      services/web/app/src/Features/Subscription/TeamInvitesController.mjs
  25. 8 8
      services/web/app/src/Features/Templates/TemplatesController.mjs
  26. 4 3
      services/web/app/src/Features/Templates/TemplatesMiddleware.mjs
  27. 4 4
      services/web/app/src/Features/Templates/TemplatesRouter.mjs
  28. 1 1
      services/web/app/src/Features/Uploads/UploadsRouter.mjs
  29. 1 1
      services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs
  30. 2 2
      services/web/app/src/router.mjs
  31. 1 1
      services/web/test/acceptance/src/helpers/Subscription.mjs
  32. 164 161
      services/web/test/unit/src/Authorization/PermissionsManager.test.mjs
  33. 315 274
      services/web/test/unit/src/Compile/ClsiManager.test.mjs
  34. 148 0
      services/web/test/unit/src/Security/RateLimiterMiddleware.test.mjs
  35. 0 129
      services/web/test/unit/src/Security/RateLimiterMiddlewareTests.js
  36. 142 0
      services/web/test/unit/src/Spelling/LearnedWordsManager.test.mjs
  37. 0 141
      services/web/test/unit/src/Spelling/LearnedWordsManagerTests.js
  38. 185 164
      services/web/test/unit/src/Subscription/RecurlyEventHandler.test.mjs
  39. 1064 0
      services/web/test/unit/src/Subscription/SubscriptionViewModelBuilder.test.mjs
  40. 0 1003
      services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
  41. 126 0
      services/web/test/unit/src/Templates/TemplatesController.test.mjs
  42. 0 108
      services/web/test/unit/src/Templates/TemplatesControllerTests.js

+ 5 - 5
services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.js → services/web/app/src/Features/Analytics/AnalyticsRegistrationSourceMiddleware.mjs

@@ -1,7 +1,7 @@
-const logger = require('@overleaf/logger')
-const OError = require('@overleaf/o-error')
-const AnalyticsRegistrationSourceHelper = require('./AnalyticsRegistrationSourceHelper')
-const SessionManager = require('../../Features/Authentication/SessionManager')
+import logger from '@overleaf/logger'
+import OError from '@overleaf/o-error'
+import AnalyticsRegistrationSourceHelper from './AnalyticsRegistrationSourceHelper.js'
+import SessionManager from '../../Features/Authentication/SessionManager.js'
 
 function setSource(medium, source) {
   return function (req, res, next) {
@@ -51,7 +51,7 @@ function setInbound() {
   }
 }
 
-module.exports = {
+export default {
   setSource,
   clearSource,
   setInbound,

+ 1 - 1
services/web/app/src/Features/Analytics/AnalyticsRouter.mjs

@@ -2,7 +2,7 @@ import AuthenticationController from './../Authentication/AuthenticationControll
 import AnalyticsController from './AnalyticsController.mjs'
 import AnalyticsProxy from './AnalyticsProxy.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
-import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
+import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
 
 const rateLimiters = {
   recordEvent: new RateLimiter('analytics-record-event', {

+ 2 - 2
services/web/app/src/Features/Authorization/PermissionsController.mjs

@@ -1,6 +1,6 @@
 // @ts-check
 import { ForbiddenError, UserNotFoundError } from '../Errors/Errors.js'
-import PermissionsManager from './PermissionsManager.js'
+import PermissionsManager from './PermissionsManager.mjs'
 import Modules from '../../infrastructure/Modules.js'
 import { expressify } from '@overleaf/promise-utils'
 import Features from '../../infrastructure/Features.js'
@@ -9,7 +9,7 @@ import Features from '../../infrastructure/Features.js'
  * @typedef {(import('express').Request)} Request
  * @typedef {(import('express').Response)} Response
  * @typedef {(import('express').NextFunction)} NextFunction
- * @typedef {import('./PermissionsManager').Capability} Capability
+ * @typedef {import('./PermissionsManager.mjs').Capability} Capability
  */
 
 const {

+ 7 - 4
services/web/app/src/Features/Authorization/PermissionsManager.js → services/web/app/src/Features/Authorization/PermissionsManager.mjs

@@ -41,9 +41,12 @@
  *     }
  */
 
-const { callbackify } = require('util')
-const { ForbiddenError } = require('../Errors/Errors')
-const Modules = require('../../infrastructure/Modules')
+import { callbackify } from 'node:util'
+
+import Errors from '../Errors/Errors.js'
+import Modules from '../../infrastructure/Modules.js'
+
+const { ForbiddenError } = Errors
 
 /**
  * @typedef {(import('../../../../types/capabilities').Capability)} Capability
@@ -466,7 +469,7 @@ async function checkUserListPermissions(userList, capabilities) {
   return true
 }
 
-module.exports = {
+export default {
   validatePolicies,
   registerCapability,
   registerPolicy,

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

@@ -3,9 +3,9 @@ import AuthenticationController from '../Authentication/AuthenticationController
 import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
 import CollaboratorsInviteController from './CollaboratorsInviteController.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
-import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
+import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
 import CaptchaMiddleware from '../Captcha/CaptchaMiddleware.mjs'
-import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.js'
+import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.mjs'
 
 const rateLimiters = {
   inviteToProjectByProjectId: new RateLimiter(

+ 26 - 24
services/web/app/src/Features/Compile/ClsiManager.js → services/web/app/src/Features/Compile/ClsiManager.mjs

@@ -1,33 +1,35 @@
-const { callbackify } = require('util')
-const { callbackifyMultiResult } = require('@overleaf/promise-utils')
-const {
+import { callbackify } from 'node:util'
+import { callbackifyMultiResult } from '@overleaf/promise-utils'
+import {
   fetchString,
   fetchStringWithResponse,
   fetchStream,
   RequestFailedError,
-} = require('@overleaf/fetch-utils')
-const Settings = require('@overleaf/settings')
-const ProjectGetter = require('../Project/ProjectGetter')
-const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
-const logger = require('@overleaf/logger')
-const OError = require('@overleaf/o-error')
-const { Cookie } = require('tough-cookie')
-const ClsiCookieManager = require('./ClsiCookieManager')(
+} from '@overleaf/fetch-utils'
+import Settings from '@overleaf/settings'
+import ProjectGetter from '../Project/ProjectGetter.js'
+import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
+import logger from '@overleaf/logger'
+import OError from '@overleaf/o-error'
+import { Cookie } from 'tough-cookie'
+import ClsiCookieManagerFactory from './ClsiCookieManager.js'
+import ClsiStateManager from './ClsiStateManager.js'
+import _ from 'lodash'
+import ClsiFormatChecker from './ClsiFormatChecker.js'
+import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.js'
+import Metrics from '@overleaf/metrics'
+import Errors from '../Errors/Errors.js'
+import ClsiCacheHandler from './ClsiCacheHandler.js'
+import HistoryManager from '../History/HistoryManager.js'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
+import AnalyticsManager from '../Analytics/AnalyticsManager.js'
+
+const ClsiCookieManager = ClsiCookieManagerFactory(
   Settings.apis.clsi?.backendGroupName
 )
-const NewBackendCloudClsiCookieManager = require('./ClsiCookieManager')(
+const NewBackendCloudClsiCookieManager = ClsiCookieManagerFactory(
   Settings.apis.clsi_new?.backendGroupName
 )
-const ClsiStateManager = require('./ClsiStateManager')
-const _ = require('lodash')
-const ClsiFormatChecker = require('./ClsiFormatChecker')
-const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
-const Metrics = require('@overleaf/metrics')
-const Errors = require('../Errors/Errors')
-const ClsiCacheHandler = require('./ClsiCacheHandler')
-const { getFilestoreBlobURL } = require('../History/HistoryManager')
-const SplitTestHandler = require('../SplitTests/SplitTestHandler')
-const AnalyticsManager = require('../Analytics/AnalyticsManager')
 
 const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex']
 const OUTPUT_FILE_TIMEOUT_MS = 60000
@@ -843,7 +845,7 @@ function _finaliseRequest(projectId, options, project, docs, files) {
     path = path.replace(/^\//, '') // Remove leading /
     resources.push({
       path,
-      url: getFilestoreBlobURL(historyId, file.hash),
+      url: HistoryManager.getFilestoreBlobURL(historyId, file.hash),
       modified: file.created?.getTime(),
     })
   }
@@ -975,7 +977,7 @@ function _getClsiServerIdFromResponse(response) {
   return null
 }
 
-module.exports = {
+export default {
   sendRequest: callbackifyMultiResult(sendRequest, [
     'status',
     'outputFiles',

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

@@ -5,7 +5,7 @@ import OError from '@overleaf/o-error'
 import Metrics from '@overleaf/metrics'
 import ProjectGetter from '../Project/ProjectGetter.js'
 import CompileManager from './CompileManager.mjs'
-import ClsiManager from './ClsiManager.js'
+import ClsiManager from './ClsiManager.mjs'
 import logger from '@overleaf/logger'
 import Settings from '@overleaf/settings'
 import Errors from '../Errors/Errors.js'

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

@@ -4,7 +4,7 @@ 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 ClsiManager from './ClsiManager.mjs'
 import Metrics from '@overleaf/metrics'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
 import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.js'

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

@@ -2,7 +2,7 @@ import EditorHttpController from './EditorHttpController.mjs'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
 import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
-import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
+import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
 
 const rateLimiters = {
   addDocToProject: new RateLimiter('add-doc-to-project', {

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

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

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

@@ -1,7 +1,7 @@
 import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mjs'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
-import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
+import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
 import LinkedFilesController from './LinkedFilesController.mjs'
 
 const rateLimiters = {

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

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

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

@@ -5,7 +5,7 @@ import OneTimeTokenHandler from '../Security/OneTimeTokenHandler.js'
 import EmailHandler from '../Email/EmailHandler.js'
 import AuthenticationManager from '../Authentication/AuthenticationManager.js'
 import { callbackify, promisify } from 'node:util'
-import PermissionsManager from '../Authorization/PermissionsManager.js'
+import PermissionsManager from '../Authorization/PermissionsManager.mjs'
 
 const assertUserPermissions = PermissionsManager.promises.assertUserPermissions
 

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

@@ -2,7 +2,7 @@ import PasswordResetController from './PasswordResetController.mjs'
 import AuthenticationController from '../Authentication/AuthenticationController.js'
 import CaptchaMiddleware from '../../Features/Captcha/CaptchaMiddleware.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
-import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
+import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
 
 const rateLimiter = new RateLimiter('password_reset_rate_limit', {
   points: 6,

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

@@ -14,7 +14,7 @@ import TagsHandler from '../Tags/TagsHandler.js'
 import { expressify } from '@overleaf/promise-utils'
 import logger from '@overleaf/logger'
 import Features from '../../infrastructure/Features.js'
-import SubscriptionViewModelBuilder from '../Subscription/SubscriptionViewModelBuilder.js'
+import SubscriptionViewModelBuilder from '../Subscription/SubscriptionViewModelBuilder.mjs'
 import NotificationsHandler from '../Notifications/NotificationsHandler.js'
 import Modules from '../../infrastructure/Modules.js'
 import { OError, V1ConnectionError } from '../Errors/Errors.js'
@@ -27,7 +27,7 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
 import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.js'
 import TutorialHandler from '../Tutorial/TutorialHandler.mjs'
 import SubscriptionHelper from '../Subscription/SubscriptionHelper.js'
-import PermissionsManager from '../Authorization/PermissionsManager.js'
+import PermissionsManager from '../Authorization/PermissionsManager.mjs'
 import AnalyticsManager from '../Analytics/AnalyticsManager.js'
 
 /**

+ 5 - 5
services/web/app/src/Features/Security/RateLimiterMiddleware.js → services/web/app/src/Features/Security/RateLimiterMiddleware.mjs

@@ -1,7 +1,7 @@
-const logger = require('@overleaf/logger')
-const SessionManager = require('../Authentication/SessionManager')
-const LoginRateLimiter = require('./LoginRateLimiter')
-const settings = require('@overleaf/settings')
+import logger from '@overleaf/logger'
+import SessionManager from '../Authentication/SessionManager.js'
+import LoginRateLimiter from './LoginRateLimiter.js'
+import settings from '@overleaf/settings'
 
 /**
  * Return a rate limiting middleware
@@ -88,4 +88,4 @@ const RateLimiterMiddleware = {
   loginRateLimitEmail,
 }
 
-module.exports = RateLimiterMiddleware
+export default RateLimiterMiddleware

+ 7 - 7
services/web/app/src/Features/Spelling/LearnedWordsManager.js → services/web/app/src/Features/Spelling/LearnedWordsManager.mjs

@@ -1,9 +1,9 @@
-// @ts-check
+import mongodb from '../../infrastructure/mongodb.js'
+import { callbackify } from 'node:util'
+import Settings from '@overleaf/settings'
+import Errors from '../Errors/Errors.js'
 
-const { db } = require('../../infrastructure/mongodb')
-const { callbackify } = require('util')
-const Settings = require('@overleaf/settings')
-const { InvalidError } = require('../Errors/Errors')
+const { db } = mongodb
 
 const LearnedWordsManager = {
   /**
@@ -15,7 +15,7 @@ const LearnedWordsManager = {
 
     const wordSize = Buffer.from(word).length
     if (wordsSize + wordSize > Settings.maxDictionarySize) {
-      throw new InvalidError('Max dictionary size reached')
+      throw new Errors.InvalidError('Max dictionary size reached')
     }
 
     return await db.spellingPreferences.updateOne(
@@ -86,7 +86,7 @@ const LearnedWordsManager = {
   },
 }
 
-module.exports = {
+export default {
   learnWord: callbackify(LearnedWordsManager.learnWord),
   unlearnWord: callbackify(LearnedWordsManager.unlearnWord),
   getLearnedWords: callbackify(LearnedWordsManager.getLearnedWords),

+ 1 - 1
services/web/app/src/Features/Spelling/SpellingController.mjs

@@ -1,7 +1,7 @@
 // @ts-check
 
 import SessionManager from '../Authentication/SessionManager.js'
-import LearnedWordsManager from './LearnedWordsManager.js'
+import LearnedWordsManager from './LearnedWordsManager.mjs'
 import { z, validateReq } from '../../infrastructure/Validation.js'
 
 const learnSchema = z.object({

+ 1 - 1
services/web/app/src/Features/Spelling/SpellingHandler.mjs

@@ -1,7 +1,7 @@
 import OError from '@overleaf/o-error'
 import Metrics from '@overleaf/metrics'
 import { promisifyAll } from '@overleaf/promise-utils'
-import LearnedWordsManager from './LearnedWordsManager.js'
+import LearnedWordsManager from './LearnedWordsManager.mjs'
 
 const SpellingHandler = {
   getUserDictionary(userId, callback) {

+ 8 - 6
services/web/app/src/Features/Subscription/RecurlyEventHandler.js → services/web/app/src/Features/Subscription/RecurlyEventHandler.mjs

@@ -1,8 +1,10 @@
-const SplitTestHandler = require('../SplitTests/SplitTestHandler')
-const AnalyticsManager = require('../Analytics/AnalyticsManager')
-const SubscriptionEmailHandler = require('./SubscriptionEmailHandler')
-const { AI_ADD_ON_CODE } = require('./AiHelper')
-const { ObjectId } = require('mongodb-legacy')
+import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
+import AnalyticsManager from '../Analytics/AnalyticsManager.js'
+import SubscriptionEmailHandler from './SubscriptionEmailHandler.js'
+import { AI_ADD_ON_CODE } from './AiHelper.js'
+import mongodb from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
 
 const INVOICE_SUBSCRIPTION_LIMIT = 10
 
@@ -389,6 +391,6 @@ function _getSubscriptionData(eventData) {
   }
 }
 
-module.exports = {
+export default {
   sendRecurlyAnalyticsEvent,
 }

+ 3 - 3
services/web/app/src/Features/Subscription/SubscriptionController.mjs

@@ -3,7 +3,7 @@
 import SessionManager from '../Authentication/SessionManager.js'
 import SubscriptionHandler from './SubscriptionHandler.js'
 import SubscriptionHelper from './SubscriptionHelper.js'
-import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.js'
+import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.mjs'
 import LimitationsManager from './LimitationsManager.js'
 import RecurlyWrapper from './RecurlyWrapper.js'
 import Settings from '@overleaf/settings'
@@ -13,7 +13,7 @@ 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 RecurlyEventHandler from './RecurlyEventHandler.mjs'
 import { expressify } from '@overleaf/promise-utils'
 import OError from '@overleaf/o-error'
 import Errors from './Errors.js'
@@ -30,7 +30,7 @@ import {
 import PlansLocator from './PlansLocator.js'
 import { User } from '../../models/User.js'
 import UserGetter from '../User/UserGetter.js'
-import PermissionsManager from '../Authorization/PermissionsManager.js'
+import PermissionsManager from '../Authorization/PermissionsManager.mjs'
 import { sanitizeSessionUserForFrontEnd } from '../../infrastructure/FrontEndUser.js'
 import { z, validateReq } from '../../infrastructure/Validation.js'
 import { IndeterminateInvoiceError } from '../Errors/Errors.js'

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

@@ -4,7 +4,7 @@ import SubscriptionController from './SubscriptionController.mjs'
 import SubscriptionGroupController from './SubscriptionGroupController.mjs'
 import TeamInvitesController from './TeamInvitesController.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
-import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
+import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
 import Settings from '@overleaf/settings'
 
 const teamInviteRateLimiter = new RateLimiter('team-invite', {

+ 21 - 20
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js → services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs

@@ -1,23 +1,24 @@
 // ts-check
-const Settings = require('@overleaf/settings')
-const PlansLocator = require('./PlansLocator')
-const { isStandaloneAiAddOnPlanCode } = require('./AiHelper')
-const { MEMBERS_LIMIT_ADD_ON_CODE } = require('./PaymentProviderEntities')
-const SubscriptionFormatters = require('./SubscriptionFormatters')
-const SubscriptionLocator = require('./SubscriptionLocator')
-const InstitutionsGetter = require('../Institutions/InstitutionsGetter')
-const InstitutionsManager = require('../Institutions/InstitutionsManager')
-const PublishersGetter = require('../Publishers/PublishersGetter')
-const sanitizeHtml = require('sanitize-html')
-const _ = require('lodash')
-const async = require('async')
-const SubscriptionHelper = require('./SubscriptionHelper')
-const { callbackify } = require('@overleaf/promise-utils')
-const { V1ConnectionError } = require('../Errors/Errors')
-const FeaturesHelper = require('./FeaturesHelper')
-const { formatCurrency } = require('../../util/currency')
-const Modules = require('../../infrastructure/Modules')
-const SplitTestHandler = require('../SplitTests/SplitTestHandler')
+import Settings from '@overleaf/settings'
+
+import PlansLocator from './PlansLocator.js'
+import { isStandaloneAiAddOnPlanCode } from './AiHelper.js'
+import { MEMBERS_LIMIT_ADD_ON_CODE } from './PaymentProviderEntities.js'
+import SubscriptionFormatters from './SubscriptionFormatters.js'
+import SubscriptionLocator from './SubscriptionLocator.js'
+import InstitutionsGetter from '../Institutions/InstitutionsGetter.js'
+import InstitutionsManager from '../Institutions/InstitutionsManager.js'
+import PublishersGetter from '../Publishers/PublishersGetter.js'
+import sanitizeHtml from 'sanitize-html'
+import _ from 'lodash'
+import async from 'async'
+import SubscriptionHelper from './SubscriptionHelper.js'
+import { callbackify } from '@overleaf/promise-utils'
+import { V1ConnectionError } from '../Errors/Errors.js'
+import FeaturesHelper from './FeaturesHelper.js'
+import { formatCurrency } from '../../util/currency.js'
+import Modules from '../../infrastructure/Modules.js'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
 
 /**
  * @import { Subscription } from "../../../../types/project/dashboard/subscription"
@@ -629,7 +630,7 @@ function buildPlansListForSubscriptionDash(currentPlan, isInTrial) {
   }
 }
 
-module.exports = {
+export default {
   buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel),
   buildPlansList,
   buildPlansListForSubscriptionDash,

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

@@ -10,7 +10,7 @@ import EmailHelper from '../Helpers/EmailHelper.js'
 import UserGetter from '../User/UserGetter.js'
 import { expressify } from '@overleaf/promise-utils'
 import HttpErrorHandler from '../Errors/HttpErrorHandler.js'
-import PermissionsManager from '../Authorization/PermissionsManager.js'
+import PermissionsManager from '../Authorization/PermissionsManager.mjs'
 import EmailHandler from '../Email/EmailHandler.js'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
 import Modules from '../../infrastructure/Modules.js'

+ 8 - 8
services/web/app/src/Features/Templates/TemplatesController.js → services/web/app/src/Features/Templates/TemplatesController.mjs

@@ -1,9 +1,9 @@
-const path = require('path')
-const SessionManager = require('../Authentication/SessionManager')
-const TemplatesManager = require('./TemplatesManager')
-const ProjectHelper = require('../Project/ProjectHelper')
-const logger = require('@overleaf/logger')
-const { expressify } = require('@overleaf/promise-utils')
+import path from 'node:path'
+import SessionManager from '../Authentication/SessionManager.js'
+import TemplatesManager from './TemplatesManager.js'
+import ProjectHelper from '../Project/ProjectHelper.js'
+import logger from '@overleaf/logger'
+import { expressify } from '@overleaf/promise-utils'
 
 const TemplatesController = {
   async getV1Template(req, res) {
@@ -27,7 +27,7 @@ const TemplatesController = {
     }
     res.render(
       path.resolve(
-        __dirname,
+        import.meta.dirname,
         '../../../views/project/editor/new_from_template'
       ),
       data
@@ -54,7 +54,7 @@ const TemplatesController = {
   },
 }
 
-module.exports = {
+export default {
   getV1Template: expressify(TemplatesController.getV1Template),
   createProjectFromV1Template: expressify(
     TemplatesController.createProjectFromV1Template

+ 4 - 3
services/web/app/src/Features/Templates/TemplatesMiddleware.js → services/web/app/src/Features/Templates/TemplatesMiddleware.mjs

@@ -8,10 +8,11 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
+import settings from '@overleaf/settings'
 
-module.exports = {
+import logger from '@overleaf/logger'
+
+export default {
   saveTemplateDataInSession(req, res, next) {
     if (req.query.templateName) {
       req.session.templateData = req.query

+ 4 - 4
services/web/app/src/Features/Templates/TemplatesRouter.mjs

@@ -1,9 +1,9 @@
 import AuthenticationController from '../Authentication/AuthenticationController.js'
-import TemplatesController from './TemplatesController.js'
-import TemplatesMiddleware from './TemplatesMiddleware.js'
+import TemplatesController from './TemplatesController.mjs'
+import TemplatesMiddleware from './TemplatesMiddleware.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
-import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
-import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.js'
+import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
+import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.mjs'
 
 const rateLimiter = new RateLimiter('create-project-from-template', {
   points: 20,

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

@@ -2,7 +2,7 @@ import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mj
 import AuthenticationController from '../Authentication/AuthenticationController.js'
 import ProjectUploadController from './ProjectUploadController.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
-import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
+import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
 import Settings from '@overleaf/settings'
 
 const rateLimiters = {

+ 1 - 1
services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs

@@ -3,7 +3,7 @@ import UserMembershipController from './UserMembershipController.mjs'
 import SubscriptionGroupController from '../Subscription/SubscriptionGroupController.mjs'
 import TeamInvitesController from '../Subscription/TeamInvitesController.mjs'
 import { RateLimiter } from '../../infrastructure/RateLimiter.js'
-import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.js'
+import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
 
 const rateLimiters = {
   createTeamInvite: new RateLimiter('create-team-invite', {

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

@@ -41,7 +41,7 @@ import {
   openProjectRateLimiter,
   overleafLoginRateLimiter,
 } from './infrastructure/RateLimiter.js'
-import RateLimiterMiddleware from './Features/Security/RateLimiterMiddleware.js'
+import RateLimiterMiddleware from './Features/Security/RateLimiterMiddleware.mjs'
 import InactiveProjectController from './Features/InactiveData/InactiveProjectController.mjs'
 import ContactRouter from './Features/Contacts/ContactRouter.mjs'
 import ReferencesController from './Features/References/ReferencesController.mjs'
@@ -55,7 +55,7 @@ import LinkedFilesRouter from './Features/LinkedFiles/LinkedFilesRouter.mjs'
 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'
+import AnalyticsRegistrationSourceMiddleware from './Features/Analytics/AnalyticsRegistrationSourceMiddleware.mjs'
 import AnalyticsUTMTrackingMiddleware from './Features/Analytics/AnalyticsUTMTrackingMiddleware.mjs'
 import CaptchaMiddleware from './Features/Captcha/CaptchaMiddleware.mjs'
 import UnsupportedBrowserMiddleware from './infrastructure/UnsupportedBrowserMiddleware.js'

+ 1 - 1
services/web/test/acceptance/src/helpers/Subscription.mjs

@@ -2,7 +2,7 @@ import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.js'
 import { expect } from 'chai'
 import { callbackifyClass } from '@overleaf/promise-utils'
 import SubscriptionUpdater from '../../../../app/src/Features/Subscription/SubscriptionUpdater.js'
-import PermissionsManager from '../../../../app/src/Features/Authorization/PermissionsManager.js'
+import PermissionsManager from '../../../../app/src/Features/Authorization/PermissionsManager.mjs'
 import SSOConfigManager from '../../../../modules/group-settings/app/src/sso/SSOConfigManager.mjs'
 import { Subscription as SubscriptionModel } from '../../../../app/src/models/Subscription.js'
 import { DeletedSubscription as DeletedSubscriptionModel } from '../../../../app/src/models/DeletedSubscription.js'

+ 164 - 161
services/web/test/unit/src/Authorization/PermissionsManagerTests.js → services/web/test/unit/src/Authorization/PermissionsManager.test.mjs

@@ -1,44 +1,43 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
 const modulePath =
-  '../../../../app/src/Features/Authorization/PermissionsManager.js'
-const SandboxedModule = require('sandboxed-module')
-const { ForbiddenError } = require('../../../../app/src/Features/Errors/Errors')
+  '../../../../app/src/Features/Authorization/PermissionsManager.mjs'
 
 describe('PermissionsManager', function () {
-  beforeEach(function () {
-    this.PermissionsManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../infrastructure/Modules': (this.Modules = {
-          promises: {
-            hooks: {
-              fire: (this.hooksFire = sinon.stub().resolves([[]])),
-            },
+  beforeEach(async function (ctx) {
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: (ctx.Modules = {
+        promises: {
+          hooks: {
+            fire: (ctx.hooksFire = sinon.stub().resolves([[]])),
           },
-        }),
-      },
-    })
-    this.PermissionsManager.registerCapability('capability1', {
+        },
+      }),
+    }))
+
+    ctx.PermissionsManager = (await import(modulePath)).default
+    ctx.PermissionsManager.registerCapability('capability1', {
       default: true,
     })
-    this.PermissionsManager.registerCapability('capability2', {
+    ctx.PermissionsManager.registerCapability('capability2', {
       default: true,
     })
-    this.PermissionsManager.registerCapability('capability3', {
+    ctx.PermissionsManager.registerCapability('capability3', {
       default: true,
     })
-    this.PermissionsManager.registerCapability('capability4', {
+    ctx.PermissionsManager.registerCapability('capability4', {
       default: false,
     })
-    this.PermissionsManager.registerPolicy('openPolicy', {
+    ctx.PermissionsManager.registerPolicy('openPolicy', {
       capability1: true,
       capability2: true,
     })
-    this.PermissionsManager.registerPolicy('restrictivePolicy', {
+    ctx.PermissionsManager.registerPolicy('restrictivePolicy', {
       capability1: true,
       capability2: false,
     })
-    this.openPolicyResponseSet = [
+    ctx.openPolicyResponseSet = [
       [
         {
           managedUsersEnabled: true,
@@ -50,7 +49,7 @@ describe('PermissionsManager', function () {
         },
       ],
     ]
-    this.restrictivePolicyResponseSet = [
+    ctx.restrictivePolicyResponseSet = [
       [
         {
           managedUsersEnabled: true,
@@ -65,40 +64,40 @@ describe('PermissionsManager', function () {
   })
 
   describe('validatePolicies', function () {
-    it('accepts empty object', function () {
-      expect(() => this.PermissionsManager.validatePolicies({})).not.to.throw
+    it('accepts empty object', function (ctx) {
+      expect(() => ctx.PermissionsManager.validatePolicies({})).not.to.throw
     })
 
-    it('accepts object with registered policies', function () {
+    it('accepts object with registered policies', function (ctx) {
       expect(() =>
-        this.PermissionsManager.validatePolicies({
+        ctx.PermissionsManager.validatePolicies({
           openPolicy: true,
           restrictivePolicy: false,
         })
       ).not.to.throw
     })
 
-    it('accepts object with policies containing non-boolean values', function () {
+    it('accepts object with policies containing non-boolean values', function (ctx) {
       expect(() =>
-        this.PermissionsManager.validatePolicies({
+        ctx.PermissionsManager.validatePolicies({
           openPolicy: 1,
         })
       ).to.throw('policy value must be a boolean: openPolicy = 1')
       expect(() =>
-        this.PermissionsManager.validatePolicies({
+        ctx.PermissionsManager.validatePolicies({
           openPolicy: undefined,
         })
       ).to.throw('policy value must be a boolean: openPolicy = undefined')
       expect(() =>
-        this.PermissionsManager.validatePolicies({
+        ctx.PermissionsManager.validatePolicies({
           openPolicy: null,
         })
       ).to.throw('policy value must be a boolean: openPolicy = null')
     })
 
-    it('throws error on object with policies that are not registered', function () {
+    it('throws error on object with policies that are not registered', function (ctx) {
       expect(() =>
-        this.PermissionsManager.validatePolicies({
+        ctx.PermissionsManager.validatePolicies({
           openPolicy: true,
           unregisteredPolicy: false,
         })
@@ -108,20 +107,20 @@ describe('PermissionsManager', function () {
 
   describe('hasPermission', function () {
     describe('when no policies apply to the user', function () {
-      it('should return true if default permission is true', function () {
+      it('should return true if default permission is true', function (ctx) {
         const groupPolicy = {}
         const capability = 'capability1'
-        const result = this.PermissionsManager.hasPermission(
+        const result = ctx.PermissionsManager.hasPermission(
           groupPolicy,
           capability
         )
         expect(result).to.be.true
       })
 
-      it('should return false if the default permission is false', function () {
+      it('should return false if the default permission is false', function (ctx) {
         const groupPolicy = {}
         const capability = 'capability4'
-        const result = this.PermissionsManager.hasPermission(
+        const result = ctx.PermissionsManager.hasPermission(
           groupPolicy,
           capability
         )
@@ -130,8 +129,8 @@ describe('PermissionsManager', function () {
     })
 
     describe('when a policy applies to the user', function () {
-      it('should return true if the user has the capability after the policy is applied', function () {
-        this.PermissionsManager.registerPolicy('policy', {
+      it('should return true if the user has the capability after the policy is applied', function (ctx) {
+        ctx.PermissionsManager.registerPolicy('policy', {
           capability1: true,
           capability2: false,
         })
@@ -139,15 +138,15 @@ describe('PermissionsManager', function () {
           policy: true,
         }
         const capability = 'capability1'
-        const result = this.PermissionsManager.hasPermission(
+        const result = ctx.PermissionsManager.hasPermission(
           groupPolicy,
           capability
         )
         expect(result).to.be.true
       })
 
-      it('should return false if the user does not have the capability after the policy is applied', function () {
-        this.PermissionsManager.registerPolicy('policy', {
+      it('should return false if the user does not have the capability after the policy is applied', function (ctx) {
+        ctx.PermissionsManager.registerPolicy('policy', {
           capability1: true,
           capability2: false,
         })
@@ -155,15 +154,15 @@ describe('PermissionsManager', function () {
           policy: true,
         }
         const capability = 'capability2'
-        const result = this.PermissionsManager.hasPermission(
+        const result = ctx.PermissionsManager.hasPermission(
           groupPolicy,
           capability
         )
         expect(result).to.be.false
       })
 
-      it('should return the default permission if the policy does not apply to the capability', function () {
-        this.PermissionsManager.registerPolicy('policy', {
+      it('should return the default permission if the policy does not apply to the capability', function (ctx) {
+        ctx.PermissionsManager.registerPolicy('policy', {
           capability1: true,
           capability2: false,
         })
@@ -172,7 +171,7 @@ describe('PermissionsManager', function () {
         }
         {
           const capability = 'capability3'
-          const result = this.PermissionsManager.hasPermission(
+          const result = ctx.PermissionsManager.hasPermission(
             groupPolicy,
             capability
           )
@@ -180,7 +179,7 @@ describe('PermissionsManager', function () {
         }
         {
           const capability = 'capability4'
-          const result = this.PermissionsManager.hasPermission(
+          const result = ctx.PermissionsManager.hasPermission(
             groupPolicy,
             capability
           )
@@ -188,8 +187,8 @@ describe('PermissionsManager', function () {
         }
       })
 
-      it('should return the default permission if the policy is not enforced', function () {
-        this.PermissionsManager.registerPolicy('policy', {
+      it('should return the default permission if the policy is not enforced', function (ctx) {
+        ctx.PermissionsManager.registerPolicy('policy', {
           capability1: true,
           capability2: false,
         })
@@ -197,12 +196,12 @@ describe('PermissionsManager', function () {
           policy: false,
         }
         const capability1 = 'capability1'
-        const result1 = this.PermissionsManager.hasPermission(
+        const result1 = ctx.PermissionsManager.hasPermission(
           groupPolicy,
           capability1
         )
         const capability2 = 'capability2'
-        const result2 = this.PermissionsManager.hasPermission(
+        const result2 = ctx.PermissionsManager.hasPermission(
           groupPolicy,
           capability2
         )
@@ -212,13 +211,13 @@ describe('PermissionsManager', function () {
     })
 
     describe('when multiple policies apply to the user', function () {
-      it('should return true if all policies allow the capability', function () {
-        this.PermissionsManager.registerPolicy('policy1', {
+      it('should return true if all policies allow the capability', function (ctx) {
+        ctx.PermissionsManager.registerPolicy('policy1', {
           capability1: true,
           capability2: true,
         })
 
-        this.PermissionsManager.registerPolicy('policy2', {
+        ctx.PermissionsManager.registerPolicy('policy2', {
           capability1: true,
           capability2: true,
         })
@@ -227,20 +226,20 @@ describe('PermissionsManager', function () {
           policy2: true,
         }
         const capability = 'capability1'
-        const result = this.PermissionsManager.hasPermission(
+        const result = ctx.PermissionsManager.hasPermission(
           groupPolicy,
           capability
         )
         expect(result).to.be.true
       })
 
-      it('should return false if any policy denies the capability', function () {
-        this.PermissionsManager.registerPolicy('policy1', {
+      it('should return false if any policy denies the capability', function (ctx) {
+        ctx.PermissionsManager.registerPolicy('policy1', {
           capability1: true,
           capability2: true,
         })
 
-        this.PermissionsManager.registerPolicy('policy2', {
+        ctx.PermissionsManager.registerPolicy('policy2', {
           capability1: false,
           capability2: true,
         })
@@ -249,20 +248,20 @@ describe('PermissionsManager', function () {
           policy2: true,
         }
         const capability = 'capability1'
-        const result = this.PermissionsManager.hasPermission(
+        const result = ctx.PermissionsManager.hasPermission(
           groupPolicy,
           capability
         )
         expect(result).to.be.false
       })
 
-      it('should return the default permssion when the applicable policy is not enforced', function () {
-        this.PermissionsManager.registerPolicy('policy1', {
+      it('should return the default permssion when the applicable policy is not enforced', function (ctx) {
+        ctx.PermissionsManager.registerPolicy('policy1', {
           capability1: true,
           capability2: true,
         })
 
-        this.PermissionsManager.registerPolicy('policy2', {
+        ctx.PermissionsManager.registerPolicy('policy2', {
           capability1: false,
           capability2: true,
         })
@@ -271,15 +270,15 @@ describe('PermissionsManager', function () {
           policy2: false,
         }
         const capability = 'capability1'
-        const result = this.PermissionsManager.hasPermission(
+        const result = ctx.PermissionsManager.hasPermission(
           groupPolicy,
           capability
         )
         expect(result).to.be.true
       })
 
-      it('should return the default permission if the policies do not restrict to the capability', function () {
-        this.PermissionsManager.registerPolicy('policy', {
+      it('should return the default permission if the policies do not restrict to the capability', function (ctx) {
+        ctx.PermissionsManager.registerPolicy('policy', {
           capability1: true,
           capability2: false,
         })
@@ -288,7 +287,7 @@ describe('PermissionsManager', function () {
         }
         {
           const capability = 'capability3'
-          const result = this.PermissionsManager.hasPermission(
+          const result = ctx.PermissionsManager.hasPermission(
             groupPolicy,
             capability
           )
@@ -296,7 +295,7 @@ describe('PermissionsManager', function () {
         }
         {
           const capability = 'capability4'
-          const result = this.PermissionsManager.hasPermission(
+          const result = ctx.PermissionsManager.hasPermission(
             groupPolicy,
             capability
           )
@@ -307,17 +306,17 @@ describe('PermissionsManager', function () {
   })
 
   describe('getUserCapabilities', function () {
-    it('should return the default capabilities when no group policy is provided', function () {
+    it('should return the default capabilities when no group policy is provided', function (ctx) {
       const groupPolicy = {}
       const capabilities =
-        this.PermissionsManager.getUserCapabilities(groupPolicy)
+        ctx.PermissionsManager.getUserCapabilities(groupPolicy)
       expect(capabilities).to.deep.equal(
         new Set(['capability1', 'capability2', 'capability3'])
       )
     })
 
-    it('should return a reduced capability set when a group policy is provided', function () {
-      this.PermissionsManager.registerPolicy('policy', {
+    it('should return a reduced capability set when a group policy is provided', function (ctx) {
+      ctx.PermissionsManager.registerPolicy('policy', {
         capability1: true,
         capability2: false,
       })
@@ -325,18 +324,18 @@ describe('PermissionsManager', function () {
         policy: true,
       }
       const capabilities =
-        this.PermissionsManager.getUserCapabilities(groupPolicy)
+        ctx.PermissionsManager.getUserCapabilities(groupPolicy)
       expect(capabilities).to.deep.equal(
         new Set(['capability1', 'capability3'])
       )
     })
 
-    it('should return a reduced capability set when multiple group policies are provided', function () {
-      this.PermissionsManager.registerPolicy('policy1', {
+    it('should return a reduced capability set when multiple group policies are provided', function (ctx) {
+      ctx.PermissionsManager.registerPolicy('policy1', {
         capability1: true,
         capability2: false,
       })
-      this.PermissionsManager.registerPolicy('policy2', {
+      ctx.PermissionsManager.registerPolicy('policy2', {
         capability1: false,
         capability2: true,
       })
@@ -346,20 +345,20 @@ describe('PermissionsManager', function () {
         policy2: true,
       }
       const capabilities =
-        this.PermissionsManager.getUserCapabilities(groupPolicy)
+        ctx.PermissionsManager.getUserCapabilities(groupPolicy)
       expect(capabilities).to.deep.equal(new Set(['capability3']))
     })
 
-    it('should return an empty capability set when group policies remove all permissions', function () {
-      this.PermissionsManager.registerPolicy('policy1', {
+    it('should return an empty capability set when group policies remove all permissions', function (ctx) {
+      ctx.PermissionsManager.registerPolicy('policy1', {
         capability1: true,
         capability2: false,
       })
-      this.PermissionsManager.registerPolicy('policy2', {
+      ctx.PermissionsManager.registerPolicy('policy2', {
         capability1: false,
         capability2: true,
       })
-      this.PermissionsManager.registerPolicy('policy3', {
+      ctx.PermissionsManager.registerPolicy('policy3', {
         capability1: true,
         capability2: true,
         capability3: false,
@@ -370,14 +369,14 @@ describe('PermissionsManager', function () {
         policy3: true,
       }
       const capabilities =
-        this.PermissionsManager.getUserCapabilities(groupPolicy)
+        ctx.PermissionsManager.getUserCapabilities(groupPolicy)
       expect(capabilities).to.deep.equal(new Set())
     })
   })
 
   describe('getUserValidationStatus', function () {
-    it('should return the status for the policy when the user conforms', async function () {
-      this.PermissionsManager.registerPolicy(
+    it('should return the status for the policy when the user conforms', async function (ctx) {
+      ctx.PermissionsManager.registerPolicy(
         'policy',
         {},
         {
@@ -392,7 +391,7 @@ describe('PermissionsManager', function () {
       const user = { prop: 'allowed' }
       const subscription = { prop: 'managed' }
       const result =
-        await this.PermissionsManager.promises.getUserValidationStatus({
+        await ctx.PermissionsManager.promises.getUserValidationStatus({
           user,
           groupPolicy,
           subscription,
@@ -400,8 +399,8 @@ describe('PermissionsManager', function () {
       expect(result).to.deep.equal(new Map([['policy', true]]))
     })
 
-    it('should return the status for the policy when the user does not conform', async function () {
-      this.PermissionsManager.registerPolicy(
+    it('should return the status for the policy when the user does not conform', async function (ctx) {
+      ctx.PermissionsManager.registerPolicy(
         'policy',
         {},
         {
@@ -416,15 +415,15 @@ describe('PermissionsManager', function () {
       const user = { prop: 'not allowed' }
       const subscription = { prop: 'managed' }
       const result =
-        await this.PermissionsManager.promises.getUserValidationStatus({
+        await ctx.PermissionsManager.promises.getUserValidationStatus({
           user,
           groupPolicy,
           subscription,
         })
       expect(result).to.deep.equal(new Map([['policy', false]]))
     })
-    it('should return the status for multiple policies according to whether the user conforms', async function () {
-      this.PermissionsManager.registerPolicy(
+    it('should return the status for multiple policies according to whether the user conforms', async function (ctx) {
+      ctx.PermissionsManager.registerPolicy(
         'policy1',
         {},
         {
@@ -433,7 +432,7 @@ describe('PermissionsManager', function () {
           },
         }
       )
-      this.PermissionsManager.registerPolicy(
+      ctx.PermissionsManager.registerPolicy(
         'policy2',
         {},
         {
@@ -442,7 +441,7 @@ describe('PermissionsManager', function () {
           },
         }
       )
-      this.PermissionsManager.registerPolicy(
+      ctx.PermissionsManager.registerPolicy(
         'policy3',
         {},
         {
@@ -460,7 +459,7 @@ describe('PermissionsManager', function () {
       const user = { prop: 'allowed' }
       const subscription = { prop: 'managed' }
       const result =
-        await this.PermissionsManager.promises.getUserValidationStatus({
+        await ctx.PermissionsManager.promises.getUserValidationStatus({
           user,
           groupPolicy,
           subscription,
@@ -475,20 +474,20 @@ describe('PermissionsManager', function () {
   })
   describe('assertUserPermissions', function () {
     describe('allowed', function () {
-      it('should not error when managedUsersEnabled is not enabled for user', async function () {
+      it('should not error when managedUsersEnabled is not enabled for user', async function (ctx) {
         const result =
-          await this.PermissionsManager.promises.assertUserPermissions(
+          await ctx.PermissionsManager.promises.assertUserPermissions(
             { _id: 'user123' },
             ['add-secondary-email']
           )
         expect(result).to.be.undefined
       })
 
-      it('should not error when default capability is true', async function () {
-        this.PermissionsManager.registerCapability('some-policy-to-check', {
+      it('should not error when default capability is true', async function (ctx) {
+        ctx.PermissionsManager.registerCapability('some-policy-to-check', {
           default: true,
         })
-        this.hooksFire.resolves([
+        ctx.hooksFire.resolves([
           [
             {
               managedUsersEnabled: true,
@@ -497,21 +496,21 @@ describe('PermissionsManager', function () {
           ],
         ])
         const result =
-          await this.PermissionsManager.promises.assertUserPermissions(
+          await ctx.PermissionsManager.promises.assertUserPermissions(
             { _id: 'user123' },
             ['some-policy-to-check']
           )
         expect(result).to.be.undefined
       })
 
-      it('should not error when default permission is false but user has permission', async function () {
-        this.PermissionsManager.registerCapability('some-policy-to-check', {
+      it('should not error when default permission is false but user has permission', async function (ctx) {
+        ctx.PermissionsManager.registerCapability('some-policy-to-check', {
           default: false,
         })
-        this.PermissionsManager.registerPolicy('userCanDoSomePolicy', {
+        ctx.PermissionsManager.registerPolicy('userCanDoSomePolicy', {
           'some-policy-to-check': true,
         })
-        this.hooksFire.resolves([
+        ctx.hooksFire.resolves([
           [
             {
               managedUsersEnabled: true,
@@ -522,7 +521,7 @@ describe('PermissionsManager', function () {
           ],
         ])
         const result =
-          await this.PermissionsManager.promises.assertUserPermissions(
+          await ctx.PermissionsManager.promises.assertUserPermissions(
             { _id: 'user123' },
             ['some-policy-to-check']
           )
@@ -531,21 +530,21 @@ describe('PermissionsManager', function () {
     })
 
     describe('not allowed', function () {
-      it('should return error when managedUsersEnabled is enabled for user but there is no group policy', async function () {
-        this.hooksFire.resolves([[{ managedUsersEnabled: true }]])
+      it('should return error when managedUsersEnabled is enabled for user but there is no group policy', async function (ctx) {
+        ctx.hooksFire.resolves([[{ managedUsersEnabled: true }]])
         await expect(
-          this.PermissionsManager.promises.assertUserPermissions(
+          ctx.PermissionsManager.promises.assertUserPermissions(
             { _id: 'user123' },
             ['add-secondary-email']
           )
         ).to.be.rejectedWith(Error, 'unknown capability: add-secondary-email')
       })
 
-      it('should return error when default permission is false', async function () {
-        this.PermissionsManager.registerCapability('some-policy-to-check', {
+      it('should return error when default permission is false', async function (ctx) {
+        ctx.PermissionsManager.registerCapability('some-policy-to-check', {
           default: false,
         })
-        this.hooksFire.resolves([
+        ctx.hooksFire.resolves([
           [
             {
               managedUsersEnabled: true,
@@ -554,21 +553,23 @@ describe('PermissionsManager', function () {
           ],
         ])
         await expect(
-          this.PermissionsManager.promises.assertUserPermissions(
+          ctx.PermissionsManager.promises.assertUserPermissions(
             { _id: 'user123' },
             ['some-policy-to-check']
           )
-        ).to.be.rejectedWith(ForbiddenError)
+        ).to.be.rejectedWith(
+          'user does not have one or more permissions within some-policy-to-check'
+        )
       })
 
-      it('should return error when default permission is true but user does not have permission', async function () {
-        this.PermissionsManager.registerCapability('some-policy-to-check', {
+      it('should return error when default permission is true but user does not have permission', async function (ctx) {
+        ctx.PermissionsManager.registerCapability('some-policy-to-check', {
           default: true,
         })
-        this.PermissionsManager.registerPolicy('userCannotDoSomePolicy', {
+        ctx.PermissionsManager.registerPolicy('userCannotDoSomePolicy', {
           'some-policy-to-check': false,
         })
-        this.hooksFire.resolves([
+        ctx.hooksFire.resolves([
           [
             {
               managedUsersEnabled: true,
@@ -577,33 +578,35 @@ describe('PermissionsManager', function () {
           ],
         ])
         await expect(
-          this.PermissionsManager.promises.assertUserPermissions(
+          ctx.PermissionsManager.promises.assertUserPermissions(
             { _id: 'user123' },
             ['some-policy-to-check']
           )
-        ).to.be.rejectedWith(ForbiddenError)
+        ).to.be.rejectedWith(
+          'user does not have one or more permissions within some-policy-to-check'
+        )
       })
     })
   })
 
   describe('registerAllowedProperty', function () {
-    it('allows us to register a property', async function () {
-      this.PermissionsManager.registerAllowedProperty('metadata1')
-      const result = await this.PermissionsManager.getAllowedProperties()
+    it('allows us to register a property', async function (ctx) {
+      ctx.PermissionsManager.registerAllowedProperty('metadata1')
+      const result = await ctx.PermissionsManager.getAllowedProperties()
       expect(result).to.deep.equal(new Set(['metadata1']))
     })
 
     // used if multiple modules would require the same prop, since we dont know which will load first, both must register
-    it('should handle multiple registrations of the same property', async function () {
-      this.PermissionsManager.registerAllowedProperty('metadata1')
-      this.PermissionsManager.registerAllowedProperty('metadata1')
-      const result = await this.PermissionsManager.getAllowedProperties()
+    it('should handle multiple registrations of the same property', async function (ctx) {
+      ctx.PermissionsManager.registerAllowedProperty('metadata1')
+      ctx.PermissionsManager.registerAllowedProperty('metadata1')
+      const result = await ctx.PermissionsManager.getAllowedProperties()
       expect(result).to.deep.equal(new Set(['metadata1']))
     })
   })
 
   describe('combineAllowedProperties', function () {
-    it('should handle multiple occurences of the same property, preserving the first occurence', async function () {
+    it('should handle multiple occurences of the same property, preserving the first occurence', async function (ctx) {
       const policy1 = {
         groupPolicy: {
           policy: false,
@@ -618,17 +621,17 @@ describe('PermissionsManager', function () {
       }
 
       const results = [policy1, policy2]
-      this.PermissionsManager.registerAllowedProperty('prop1')
+      ctx.PermissionsManager.registerAllowedProperty('prop1')
 
       const combinedProps =
-        this.PermissionsManager.combineAllowedProperties(results)
+        ctx.PermissionsManager.combineAllowedProperties(results)
 
       expect(combinedProps).to.deep.equal({
         prop1: 'some other value here',
       })
     })
 
-    it('should add registered properties to the set', async function () {
+    it('should add registered properties to the set', async function (ctx) {
       const policy = {
         groupPolicy: {
           policy: false,
@@ -645,11 +648,11 @@ describe('PermissionsManager', function () {
       }
 
       const results = [policy, policy2]
-      this.PermissionsManager.registerAllowedProperty('prop1')
-      this.PermissionsManager.registerAllowedProperty('prop2')
+      ctx.PermissionsManager.registerAllowedProperty('prop1')
+      ctx.PermissionsManager.registerAllowedProperty('prop2')
 
       const combinedProps =
-        this.PermissionsManager.combineAllowedProperties(results)
+        ctx.PermissionsManager.combineAllowedProperties(results)
 
       expect(combinedProps).to.deep.equal({
         prop1: 'some value here',
@@ -657,7 +660,7 @@ describe('PermissionsManager', function () {
       })
     })
 
-    it('should not add unregistered properties to the req object', async function () {
+    it('should not add unregistered properties to the req object', async function (ctx) {
       const policy = {
         groupPolicy: {
           policy: false,
@@ -671,36 +674,36 @@ describe('PermissionsManager', function () {
         },
         prop2: 'some value here',
       }
-      this.PermissionsManager.registerAllowedProperty('prop1')
+      ctx.PermissionsManager.registerAllowedProperty('prop1')
 
       const results = [policy, policy2]
 
       const combinedProps =
-        this.PermissionsManager.combineAllowedProperties(results)
+        ctx.PermissionsManager.combineAllowedProperties(results)
 
       expect(combinedProps).to.deep.equal({ prop1: 'some value here' })
     })
 
-    it('should handle an empty array', async function () {
+    it('should handle an empty array', async function (ctx) {
       const results = []
 
       const combinedProps =
-        this.PermissionsManager.combineAllowedProperties(results)
+        ctx.PermissionsManager.combineAllowedProperties(results)
 
       expect(combinedProps).to.deep.equal({})
     })
   })
 
   describe('combineGroupPolicies', function () {
-    it('should return an empty object when an empty array is passed', async function () {
+    it('should return an empty object when an empty array is passed', async function (ctx) {
       const results = []
 
       const combinedPolicy =
-        this.PermissionsManager.combineGroupPolicies(results)
+        ctx.PermissionsManager.combineGroupPolicies(results)
       expect(combinedPolicy).to.deep.equal({})
     })
 
-    it('should combine multiple group policies into a single policy object', async function () {
+    it('should combine multiple group policies into a single policy object', async function (ctx) {
       const groupPolicy = {
         policy1: true,
       }
@@ -709,12 +712,12 @@ describe('PermissionsManager', function () {
         policy2: false,
         policy3: true,
       }
-      this.PermissionsManager.registerAllowedProperty('prop1')
+      ctx.PermissionsManager.registerAllowedProperty('prop1')
 
       const results = [groupPolicy, groupPolicy2]
 
       const combinedPolicy =
-        this.PermissionsManager.combineGroupPolicies(results)
+        ctx.PermissionsManager.combineGroupPolicies(results)
 
       expect(combinedPolicy).to.deep.equal({
         policy1: true,
@@ -722,7 +725,7 @@ describe('PermissionsManager', function () {
       })
     })
 
-    it('should handle duplicate enforced policies across different group policies', async function () {
+    it('should handle duplicate enforced policies across different group policies', async function (ctx) {
       const groupPolicy = {
         policy1: false,
         policy2: true,
@@ -732,12 +735,12 @@ describe('PermissionsManager', function () {
         policy2: true,
         policy3: true,
       }
-      this.PermissionsManager.registerAllowedProperty('prop1')
+      ctx.PermissionsManager.registerAllowedProperty('prop1')
 
       const results = [groupPolicy, groupPolicy2]
 
       const combinedPolicy =
-        this.PermissionsManager.combineGroupPolicies(results)
+        ctx.PermissionsManager.combineGroupPolicies(results)
 
       expect(combinedPolicy).to.deep.equal({
         policy2: true,
@@ -745,7 +748,7 @@ describe('PermissionsManager', function () {
       })
     })
 
-    it('should handle group policies with no enforced policies', async function () {
+    it('should handle group policies with no enforced policies', async function (ctx) {
       const groupPolicy = {
         policy1: false,
         policy2: false,
@@ -755,17 +758,17 @@ describe('PermissionsManager', function () {
         policy2: false,
         policy3: true,
       }
-      this.PermissionsManager.registerAllowedProperty('prop1')
+      ctx.PermissionsManager.registerAllowedProperty('prop1')
 
       const results = [groupPolicy, groupPolicy2]
 
       const combinedPolicy =
-        this.PermissionsManager.combineGroupPolicies(results)
+        ctx.PermissionsManager.combineGroupPolicies(results)
 
       expect(combinedPolicy).to.deep.equal({ policy3: true })
     })
 
-    it('should choose the stricter option between two policy values', async function () {
+    it('should choose the stricter option between two policy values', async function (ctx) {
       const groupPolicy = {
         policy1: false,
         policy2: true,
@@ -777,12 +780,12 @@ describe('PermissionsManager', function () {
         policy3: true,
         policy4: false,
       }
-      this.PermissionsManager.registerAllowedProperty('prop1')
+      ctx.PermissionsManager.registerAllowedProperty('prop1')
 
       const results = [groupPolicy, groupPolicy2]
 
       const combinedPolicy =
-        this.PermissionsManager.combineGroupPolicies(results)
+        ctx.PermissionsManager.combineGroupPolicies(results)
 
       expect(combinedPolicy).to.deep.equal({
         policy2: true,
@@ -793,30 +796,30 @@ describe('PermissionsManager', function () {
   })
 
   describe('checkUserListPermissions', function () {
-    it('should return true when all users have permissions required', async function () {
+    it('should return true when all users have permissions required', async function (ctx) {
       const userList = ['user1', 'user2', 'user3']
       const capabilities = ['capability1', 'capability2']
-      this.hooksFire.onCall(0).resolves(this.openPolicyResponseSet)
-      this.hooksFire.onCall(1).resolves(this.openPolicyResponseSet)
-      this.hooksFire.onCall(2).resolves(this.openPolicyResponseSet)
+      ctx.hooksFire.onCall(0).resolves(ctx.openPolicyResponseSet)
+      ctx.hooksFire.onCall(1).resolves(ctx.openPolicyResponseSet)
+      ctx.hooksFire.onCall(2).resolves(ctx.openPolicyResponseSet)
 
       const usersHavePermission =
-        await this.PermissionsManager.promises.checkUserListPermissions(
+        await ctx.PermissionsManager.promises.checkUserListPermissions(
           userList,
           capabilities
         )
       expect(usersHavePermission).to.equal(true)
     })
 
-    it('should return false if any user does not have permission', async function () {
+    it('should return false if any user does not have permission', async function (ctx) {
       const userList = ['user1', 'user2', 'user3']
       const capabilities = ['capability1', 'capability2']
-      this.hooksFire.onCall(0).resolves(this.openPolicyResponseSet)
-      this.hooksFire.onCall(1).resolves(this.restrictivePolicyResponseSet)
-      this.hooksFire.onCall(2).resolves(this.openPolicyResponseSet)
+      ctx.hooksFire.onCall(0).resolves(ctx.openPolicyResponseSet)
+      ctx.hooksFire.onCall(1).resolves(ctx.restrictivePolicyResponseSet)
+      ctx.hooksFire.onCall(2).resolves(ctx.openPolicyResponseSet)
 
       const usersHavePermission =
-        await this.PermissionsManager.promises.checkUserListPermissions(
+        await ctx.PermissionsManager.promises.checkUserListPermissions(
           userList,
           capabilities
         )

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 315 - 274
services/web/test/unit/src/Compile/ClsiManager.test.mjs


+ 148 - 0
services/web/test/unit/src/Security/RateLimiterMiddleware.test.mjs

@@ -0,0 +1,148 @@
+import { vi } from 'vitest'
+import sinon from 'sinon'
+
+const modulePath =
+  '../../../../app/src/Features/Security/RateLimiterMiddleware.mjs'
+
+describe('RateLimiterMiddleware', function () {
+  beforeEach(async function (ctx) {
+    ctx.SessionManager = {
+      getLoggedInUserId: () => ctx.req.session?.user?._id,
+    }
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {}),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Security/LoginRateLimiter', () => ({
+      default: {},
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    ctx.RateLimiterMiddleware = (await import(modulePath)).default
+    ctx.req = { params: {} }
+    ctx.res = {
+      status: sinon.stub(),
+      write: sinon.stub(),
+      end: sinon.stub(),
+    }
+    ctx.next = sinon.stub()
+  })
+
+  describe('rateLimit', function () {
+    beforeEach(function (ctx) {
+      ctx.projectId = 'project-id'
+      ctx.docId = 'doc-id'
+      ctx.rateLimiter = {
+        consume: sinon.stub().resolves({ remainingPoints: 2 }),
+      }
+      ctx.middleware = ctx.RateLimiterMiddleware.rateLimit(ctx.rateLimiter, {
+        params: ['projectId', 'docId'],
+      })
+      ctx.req.params = { projectId: ctx.projectId, docId: ctx.docId }
+    })
+
+    describe('when there is no session', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.req.ip = ctx.ip = '1.2.3.4'
+          ctx.middleware(ctx.req, ctx.res, () => {
+            resolve()
+          })
+        })
+      })
+
+      it('should call the rate limiter with the ip address', function (ctx) {
+        ctx.rateLimiter.consume.should.have.been.calledWith(
+          `${ctx.projectId}:${ctx.docId}:${ctx.ip}`
+        )
+      })
+    })
+
+    describe('when smoke test user', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.userId = 'smoke-test-user-id'
+          ctx.req.session = {
+            user: { _id: ctx.userId },
+          }
+          ctx.settings.smokeTest = { userId: ctx.userId }
+          ctx.middleware(ctx.req, ctx.res, () => {
+            resolve()
+          })
+        })
+      })
+
+      it('should not call the rate limiter', function (ctx) {
+        ctx.rateLimiter.consume.should.not.have.been.called
+      })
+    })
+
+    describe('when under the rate limit with logged in user', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.userId = 'user-id'
+          ctx.req.session = {
+            user: { _id: ctx.userId },
+          }
+          ctx.middleware(ctx.req, ctx.res, () => {
+            resolve()
+          })
+        })
+      })
+
+      it('should call the rate limiter backend with the userId', function (ctx) {
+        ctx.rateLimiter.consume.should.have.been.calledWith(
+          `${ctx.projectId}:${ctx.docId}:${ctx.userId}`
+        )
+      })
+    })
+
+    describe('when under the rate limit with anonymous user', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.req.ip = '1.2.3.4'
+          ctx.middleware(ctx.req, ctx.res, () => {
+            resolve()
+          })
+        })
+      })
+
+      it('should call the rate limiter backend with the ip address', function (ctx) {
+        ctx.rateLimiter.consume.should.have.been.calledWith(
+          `${ctx.projectId}:${ctx.docId}:${ctx.req.ip}`
+        )
+      })
+    })
+
+    describe('when over the rate limit', function () {
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.userId = 'user-id'
+          ctx.req.session = {
+            user: { _id: ctx.userId },
+          }
+          ctx.res.end.callsFake(() => {
+            resolve()
+          })
+          ctx.rateLimiter.consume.rejects({ remainingPoints: 0 })
+          ctx.middleware(ctx.req, ctx.res, ctx.next)
+        })
+      })
+
+      it('should return a 429', function (ctx) {
+        ctx.res.status.should.have.been.calledWith(429)
+      })
+
+      it('should not continue', function (ctx) {
+        ctx.next.should.not.have.been.called
+      })
+    })
+  })
+})

+ 0 - 129
services/web/test/unit/src/Security/RateLimiterMiddlewareTests.js

@@ -1,129 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/Security/RateLimiterMiddleware'
-)
-
-describe('RateLimiterMiddleware', function () {
-  beforeEach(function () {
-    this.SessionManager = {
-      getLoggedInUserId: () => this.req.session?.user?._id,
-    }
-    this.RateLimiterMiddleware = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': (this.settings = {}),
-        './LoginRateLimiter': {},
-        '../Authentication/SessionManager': this.SessionManager,
-      },
-    })
-    this.req = { params: {} }
-    this.res = {
-      status: sinon.stub(),
-      write: sinon.stub(),
-      end: sinon.stub(),
-    }
-    this.next = sinon.stub()
-  })
-
-  describe('rateLimit', function () {
-    beforeEach(function () {
-      this.projectId = 'project-id'
-      this.docId = 'doc-id'
-      this.rateLimiter = {
-        consume: sinon.stub().resolves({ remainingPoints: 2 }),
-      }
-      this.middleware = this.RateLimiterMiddleware.rateLimit(this.rateLimiter, {
-        params: ['projectId', 'docId'],
-      })
-      this.req.params = { projectId: this.projectId, docId: this.docId }
-    })
-
-    describe('when there is no session', function () {
-      beforeEach(function (done) {
-        this.req.ip = this.ip = '1.2.3.4'
-        this.middleware(this.req, this.res, () => {
-          done()
-        })
-      })
-
-      it('should call the rate limiter with the ip address', function () {
-        this.rateLimiter.consume.should.have.been.calledWith(
-          `${this.projectId}:${this.docId}:${this.ip}`
-        )
-      })
-    })
-
-    describe('when smoke test user', function () {
-      beforeEach(function (done) {
-        this.userId = 'smoke-test-user-id'
-        this.req.session = {
-          user: { _id: this.userId },
-        }
-        this.settings.smokeTest = { userId: this.userId }
-        this.middleware(this.req, this.res, () => {
-          done()
-        })
-      })
-
-      it('should not call the rate limiter', function () {
-        this.rateLimiter.consume.should.not.have.been.called
-      })
-    })
-
-    describe('when under the rate limit with logged in user', function () {
-      beforeEach(function (done) {
-        this.userId = 'user-id'
-        this.req.session = {
-          user: { _id: this.userId },
-        }
-        this.middleware(this.req, this.res, () => {
-          done()
-        })
-      })
-
-      it('should call the rate limiter backend with the userId', function () {
-        this.rateLimiter.consume.should.have.been.calledWith(
-          `${this.projectId}:${this.docId}:${this.userId}`
-        )
-      })
-    })
-
-    describe('when under the rate limit with anonymous user', function () {
-      beforeEach(function (done) {
-        this.req.ip = '1.2.3.4'
-        this.middleware(this.req, this.res, () => {
-          done()
-        })
-      })
-
-      it('should call the rate limiter backend with the ip address', function () {
-        this.rateLimiter.consume.should.have.been.calledWith(
-          `${this.projectId}:${this.docId}:${this.req.ip}`
-        )
-      })
-    })
-
-    describe('when over the rate limit', function () {
-      beforeEach(function (done) {
-        this.userId = 'user-id'
-        this.req.session = {
-          user: { _id: this.userId },
-        }
-        this.res.end.callsFake(() => {
-          done()
-        })
-        this.rateLimiter.consume.rejects({ remainingPoints: 0 })
-        this.middleware(this.req, this.res, this.next)
-      })
-
-      it('should return a 429', function () {
-        this.res.status.should.have.been.calledWith(429)
-      })
-
-      it('should not continue', function () {
-        this.next.should.not.have.been.called
-      })
-    })
-  })
-})

+ 142 - 0
services/web/test/unit/src/Spelling/LearnedWordsManager.test.mjs

@@ -0,0 +1,142 @@
+import { vi, expect } from 'vitest'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+
+const modulePath =
+  '../../../../app/src/Features/Spelling/LearnedWordsManager.mjs'
+
+vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+)
+
+describe('LearnedWordsManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.token = 'a6b3cd919ge'
+    ctx.db = {
+      spellingPreferences: {
+        updateOne: vi.fn(),
+        findOne: vi.fn().mockResolvedValue(['pear']),
+      },
+    }
+
+    vi.doMock('../../../../app/src/infrastructure/mongodb.js', () => ({
+      default: { db: ctx.db },
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: {
+        inc: vi.fn(),
+      },
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
+        maxDictionarySize: 20,
+      },
+    }))
+    ctx.LearnedWordsManager = (await import(modulePath)).default
+  })
+
+  describe('learnWord', function () {
+    describe('under size limit', function () {
+      beforeEach(async function (ctx) {
+        ctx.word = 'instanton'
+        await ctx.LearnedWordsManager.promises.learnWord(ctx.token, ctx.word)
+      })
+
+      it('should insert the word in the word list in the database', function (ctx) {
+        expect(ctx.db.spellingPreferences.updateOne).toHaveBeenCalledWith(
+          {
+            token: ctx.token,
+          },
+          {
+            $addToSet: { learnedWords: ctx.word },
+          },
+          {
+            upsert: true,
+          }
+        )
+      })
+    })
+
+    describe('over size limit', function () {
+      beforeEach(function (ctx) {
+        ctx.word = 'superlongwordthatwillgobeyondthelimit'
+      })
+
+      it('should throw an error and not insert the word in the word list in the database', async function (ctx) {
+        await expect(
+          ctx.LearnedWordsManager.promises.learnWord(ctx.token, ctx.word)
+        ).to.be.rejectedWith(Errors.InvalidError)
+        expect(ctx.db.spellingPreferences.updateOne).not.toHaveBeenCalled()
+      })
+    })
+  })
+
+  describe('unlearnWord', function () {
+    beforeEach(async function (ctx) {
+      ctx.word = 'instanton'
+      await ctx.LearnedWordsManager.promises.unlearnWord(ctx.token, ctx.word)
+    })
+
+    it('should remove the word from the word list in the database', function (ctx) {
+      expect(ctx.db.spellingPreferences.updateOne).toHaveBeenCalledWith(
+        {
+          token: ctx.token,
+        },
+        {
+          $pull: { learnedWords: ctx.word },
+        }
+      )
+    })
+  })
+
+  describe('getLearnedWords', function () {
+    beforeEach(async function (ctx) {
+      ctx.wordList = ['apples', 'bananas', 'pears']
+      ctx.wordListWithDuplicates = ctx.wordList.slice()
+      ctx.wordListWithDuplicates.push('bananas')
+      ctx.db.spellingPreferences.findOne = vi
+        .fn()
+        .mockResolvedValue({ learnedWords: ctx.wordListWithDuplicates })
+      ctx.learnedWords = await ctx.LearnedWordsManager.promises.getLearnedWords(
+        ctx.token
+      )
+    })
+
+    it('should get the word list for the given user', function (ctx) {
+      expect(ctx.db.spellingPreferences.findOne).toHaveBeenCalledWith({
+        token: ctx.token,
+      })
+    })
+
+    it('should return the word list without duplicates', function (ctx) {
+      expect(ctx.learnedWords).to.deep.equal(ctx.wordList)
+    })
+  })
+
+  describe('getLearnedWordsSize', function () {
+    it('should return the word list size in the callback', async function (ctx) {
+      ctx.db.spellingPreferences.findOne = conditions => {
+        return Promise.resolve({
+          learnedWords: ['apples', 'bananas', 'pears', 'bananas'],
+        })
+      }
+      const learnedWordsSize =
+        await ctx.LearnedWordsManager.promises.getLearnedWordsSize(ctx.token)
+      expect(learnedWordsSize).to.equal(38)
+    })
+  })
+
+  describe('deleteUsersLearnedWords', function () {
+    beforeEach(function (ctx) {
+      ctx.db.spellingPreferences.deleteOne = vi.fn()
+    })
+
+    it('should get the word list for the given user', async function (ctx) {
+      await ctx.LearnedWordsManager.promises.deleteUsersLearnedWords(ctx.token)
+      expect(ctx.db.spellingPreferences.deleteOne).toHaveBeenCalledWith({
+        token: ctx.token,
+      })
+    })
+  })
+})

+ 0 - 141
services/web/test/unit/src/Spelling/LearnedWordsManagerTests.js

@@ -1,141 +0,0 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const modulePath = require('path').join(
-  __dirname,
-  '/../../../../app/src/Features/Spelling/LearnedWordsManager'
-)
-const { InvalidError } = require('../../../../app/src/Features/Errors/Errors')
-
-describe('LearnedWordsManager', function () {
-  beforeEach(function () {
-    this.token = 'a6b3cd919ge'
-    this.db = {
-      spellingPreferences: {
-        updateOne: sinon.stub().resolves(),
-        findOne: sinon.stub().resolves(['pear']),
-      },
-    }
-    this.LearnedWordsManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../infrastructure/mongodb': { db: this.db },
-        '@overleaf/metrics': {
-          inc: sinon.stub(),
-        },
-        '@overleaf/settings': {
-          maxDictionarySize: 20,
-        },
-      },
-    })
-  })
-
-  describe('learnWord', function () {
-    describe('under size limit', function () {
-      beforeEach(async function () {
-        this.word = 'instanton'
-        await this.LearnedWordsManager.promises.learnWord(this.token, this.word)
-      })
-
-      it('should insert the word in the word list in the database', function () {
-        expect(
-          this.db.spellingPreferences.updateOne.calledWith(
-            {
-              token: this.token,
-            },
-            {
-              $addToSet: { learnedWords: this.word },
-            },
-            {
-              upsert: true,
-            }
-          )
-        ).to.equal(true)
-      })
-    })
-
-    describe('over size limit', function () {
-      beforeEach(function () {
-        this.word = 'superlongwordthatwillgobeyondthelimit'
-      })
-
-      it('should throw an error and not insert the word in the word list in the database', async function () {
-        await expect(
-          this.LearnedWordsManager.promises.learnWord(this.token, this.word)
-        ).to.be.rejectedWith(InvalidError)
-        expect(this.db.spellingPreferences.updateOne.notCalled).to.equal(true)
-      })
-    })
-  })
-
-  describe('unlearnWord', function () {
-    beforeEach(async function () {
-      this.word = 'instanton'
-      await this.LearnedWordsManager.promises.unlearnWord(this.token, this.word)
-    })
-
-    it('should remove the word from the word list in the database', function () {
-      expect(
-        this.db.spellingPreferences.updateOne.calledWith(
-          {
-            token: this.token,
-          },
-          {
-            $pull: { learnedWords: this.word },
-          }
-        )
-      ).to.equal(true)
-    })
-  })
-
-  describe('getLearnedWords', function () {
-    beforeEach(async function () {
-      this.wordList = ['apples', 'bananas', 'pears']
-      this.wordListWithDuplicates = this.wordList.slice()
-      this.wordListWithDuplicates.push('bananas')
-      this.db.spellingPreferences.findOne = conditions => {
-        return Promise.resolve({ learnedWords: this.wordListWithDuplicates })
-      }
-      sinon.spy(this.db.spellingPreferences, 'findOne')
-      this.learnedWords =
-        await this.LearnedWordsManager.promises.getLearnedWords(this.token)
-    })
-
-    it('should get the word list for the given user', function () {
-      expect(
-        this.db.spellingPreferences.findOne.calledWith({ token: this.token })
-      ).to.equal(true)
-    })
-
-    it('should return the word list without duplicates', function () {
-      expect(this.learnedWords).to.deep.equal(this.wordList)
-    })
-  })
-
-  describe('getLearnedWordsSize', function () {
-    it('should return the word list size in the callback', async function () {
-      this.db.spellingPreferences.findOne = conditions => {
-        return Promise.resolve({
-          learnedWords: ['apples', 'bananas', 'pears', 'bananas'],
-        })
-      }
-      const learnedWordsSize =
-        await this.LearnedWordsManager.promises.getLearnedWordsSize(this.token)
-      expect(learnedWordsSize).to.equal(38)
-    })
-  })
-
-  describe('deleteUsersLearnedWords', function () {
-    beforeEach(function () {
-      this.db.spellingPreferences.deleteOne = sinon.stub().resolves()
-    })
-
-    it('should get the word list for the given user', async function () {
-      await this.LearnedWordsManager.promises.deleteUsersLearnedWords(
-        this.token
-      )
-      expect(
-        this.db.spellingPreferences.deleteOne.calledWith({ token: this.token })
-      ).to.equal(true)
-    })
-  })
-})

+ 185 - 164
services/web/test/unit/src/Subscription/RecurlyEventHandlerTests.js → services/web/test/unit/src/Subscription/RecurlyEventHandler.test.mjs

@@ -1,16 +1,19 @@
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
-const sinon = require('sinon')
+import { vi } from 'vitest'
+import mongodb from 'mongodb-legacy'
+import sinon from 'sinon'
+
+const { ObjectId } = mongodb
+
 const modulePath =
-  '../../../../app/src/Features/Subscription/RecurlyEventHandler'
+  '../../../../app/src/Features/Subscription/RecurlyEventHandler.mjs'
 
 describe('RecurlyEventHandler', function () {
-  beforeEach(function () {
-    this.userId = '123abc234bcd456cde567def'
-    this.planCode = 'collaborator-annual'
-    this.eventData = {
+  beforeEach(async function (ctx) {
+    ctx.userId = '123abc234bcd456cde567def'
+    ctx.planCode = 'collaborator-annual'
+    ctx.eventData = {
       account: {
-        account_code: this.userId,
+        account_code: ctx.userId,
       },
       subscription: {
         uuid: '8435ad98c1ce45da99b07f6a6a2e780f',
@@ -26,17 +29,33 @@ describe('RecurlyEventHandler', function () {
       },
     }
 
-    this.RecurlyEventHandler = SandboxedModule.require(modulePath, {
-      requires: {
-        'mongodb-legacy': { ObjectId },
-        './SubscriptionEmailHandler': (this.SubscriptionEmailHandler = {
+    vi.doMock('mongodb-legacy', () => ({
+      default: { ObjectId },
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionEmailHandler',
+      () => ({
+        default: (ctx.SubscriptionEmailHandler = {
           sendTrialOnboardingEmail: sinon.stub(),
         }),
-        '../Analytics/AnalyticsManager': (this.AnalyticsManager = {
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager',
+      () => ({
+        default: (ctx.AnalyticsManager = {
           recordEventForUserInBackground: sinon.stub(),
           setUserPropertyForUserInBackground: sinon.stub(),
         }),
-        '../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: (ctx.SplitTestHandler = {
           promises: {
             getAssignmentForUser: sinon.stub().resolves({
               variant: 'default',
@@ -44,340 +63,342 @@ describe('RecurlyEventHandler', function () {
             hasUserBeenAssignedToVariant: sinon.stub().resolves(false),
           },
         }),
-      },
-    })
+      })
+    )
+
+    ctx.RecurlyEventHandler = (await import(modulePath)).default
   })
 
-  it('with new_subscription_notification - free trial', async function () {
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+  it('with new_subscription_notification - free trial', async function (ctx) {
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'new_subscription_notification',
-      this.eventData
+      ctx.eventData
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-started',
       {
-        plan_code: this.planCode,
+        plan_code: ctx.planCode,
         quantity: 1,
         is_trial: true,
         has_ai_add_on: false,
-        subscriptionId: this.eventData.subscription.uuid,
+        subscriptionId: ctx.eventData.subscription.uuid,
         payment_provider: 'recurly',
         'customerio-integration': false,
       }
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-plan-code',
-      this.planCode
+      ctx.planCode
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-state',
       'active'
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-is-trial',
       true
     )
   })
 
-  it('with new_subscription_notification - free trial with customerio integration enabled', async function () {
-    this.SplitTestHandler.promises.hasUserBeenAssignedToVariant.resolves(true)
+  it('with new_subscription_notification - free trial with customerio integration enabled', async function (ctx) {
+    ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant.resolves(true)
 
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'new_subscription_notification',
-      this.eventData
+      ctx.eventData
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-started',
       {
-        plan_code: this.planCode,
+        plan_code: ctx.planCode,
         quantity: 1,
         is_trial: true,
         has_ai_add_on: false,
-        subscriptionId: this.eventData.subscription.uuid,
+        subscriptionId: ctx.eventData.subscription.uuid,
         payment_provider: 'recurly',
         'customerio-integration': true,
       }
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-plan-code',
-      this.planCode
+      ctx.planCode
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-state',
       'active'
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-is-trial',
       true
     )
   })
 
-  it('sends free trial onboarding email if user starting a trial', async function () {
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+  it('sends free trial onboarding email if user starting a trial', async function (ctx) {
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'new_subscription_notification',
-      this.eventData
+      ctx.eventData
     )
 
-    sinon.assert.called(this.SubscriptionEmailHandler.sendTrialOnboardingEmail)
+    sinon.assert.called(ctx.SubscriptionEmailHandler.sendTrialOnboardingEmail)
   })
 
-  it('with new_subscription_notification - no free trial', async function () {
-    this.eventData.subscription.current_period_started_at = new Date(
+  it('with new_subscription_notification - no free trial', async function (ctx) {
+    ctx.eventData.subscription.current_period_started_at = new Date(
       '2021-02-10 12:34:56'
     )
-    this.eventData.subscription.current_period_ends_at = new Date(
+    ctx.eventData.subscription.current_period_ends_at = new Date(
       '2021-02-17 12:34:56'
     )
-    this.eventData.subscription.quantity = 3
+    ctx.eventData.subscription.quantity = 3
 
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'new_subscription_notification',
-      this.eventData
+      ctx.eventData
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-started',
       {
-        plan_code: this.planCode,
+        plan_code: ctx.planCode,
         quantity: 3,
         is_trial: false,
         has_ai_add_on: false,
-        subscriptionId: this.eventData.subscription.uuid,
+        subscriptionId: ctx.eventData.subscription.uuid,
         payment_provider: 'recurly',
         'customerio-integration': false,
       }
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-state',
       'active'
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-is-trial',
       false
     )
   })
 
-  it('with updated_subscription_notification', async function () {
-    this.planCode = 'new-plan-code'
-    this.eventData.subscription.plan.plan_code = this.planCode
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+  it('with updated_subscription_notification', async function (ctx) {
+    ctx.planCode = 'new-plan-code'
+    ctx.eventData.subscription.plan.plan_code = ctx.planCode
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'updated_subscription_notification',
-      this.eventData
+      ctx.eventData
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-updated',
       {
-        plan_code: this.planCode,
+        plan_code: ctx.planCode,
         quantity: 1,
         is_trial: true,
         has_ai_add_on: false,
-        subscriptionId: this.eventData.subscription.uuid,
+        subscriptionId: ctx.eventData.subscription.uuid,
         payment_provider: 'recurly',
         'customerio-integration': false,
       }
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-plan-code',
-      this.planCode
+      ctx.planCode
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-state',
       'active'
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-is-trial',
       true
     )
   })
 
-  it('with updated_subscription_notification with customerio integration enabled', async function () {
-    this.SplitTestHandler.promises.hasUserBeenAssignedToVariant.resolves(true)
-    this.planCode = 'new-plan-code'
-    this.eventData.subscription.plan.plan_code = this.planCode
+  it('with updated_subscription_notification with customerio integration enabled', async function (ctx) {
+    ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant.resolves(true)
+    ctx.planCode = 'new-plan-code'
+    ctx.eventData.subscription.plan.plan_code = ctx.planCode
 
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'updated_subscription_notification',
-      this.eventData
+      ctx.eventData
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-updated',
       {
-        plan_code: this.planCode,
+        plan_code: ctx.planCode,
         quantity: 1,
         is_trial: true,
         has_ai_add_on: false,
-        subscriptionId: this.eventData.subscription.uuid,
+        subscriptionId: ctx.eventData.subscription.uuid,
         payment_provider: 'recurly',
         'customerio-integration': true,
       }
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-plan-code',
-      this.planCode
+      ctx.planCode
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-state',
       'active'
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-is-trial',
       true
     )
   })
 
-  it('with canceled_subscription_notification', async function () {
-    this.eventData.subscription.state = 'cancelled'
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+  it('with canceled_subscription_notification', async function (ctx) {
+    ctx.eventData.subscription.state = 'cancelled'
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'canceled_subscription_notification',
-      this.eventData
+      ctx.eventData
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-cancelled',
       {
-        plan_code: this.planCode,
+        plan_code: ctx.planCode,
         quantity: 1,
         is_trial: true,
         has_ai_add_on: false,
-        subscriptionId: this.eventData.subscription.uuid,
+        subscriptionId: ctx.eventData.subscription.uuid,
         payment_provider: 'recurly',
         'customerio-integration': false,
       }
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-state',
       'cancelled'
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-is-trial',
       true
     )
   })
 
-  it('with expired_subscription_notification', async function () {
-    this.eventData.subscription.state = 'expired'
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+  it('with expired_subscription_notification', async function (ctx) {
+    ctx.eventData.subscription.state = 'expired'
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'expired_subscription_notification',
-      this.eventData
+      ctx.eventData
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-expired',
       {
-        plan_code: this.planCode,
+        plan_code: ctx.planCode,
         quantity: 1,
         is_trial: true,
         has_ai_add_on: false,
-        subscriptionId: this.eventData.subscription.uuid,
+        subscriptionId: ctx.eventData.subscription.uuid,
         payment_provider: 'recurly',
         'customerio-integration': false,
       }
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-plan-code',
-      this.planCode
+      ctx.planCode
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-state',
       'expired'
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.setUserPropertyForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+      ctx.userId,
       'subscription-is-trial',
       true
     )
   })
 
-  it('with renewed_subscription_notification', async function () {
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+  it('with renewed_subscription_notification', async function (ctx) {
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'renewed_subscription_notification',
-      this.eventData
+      ctx.eventData
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-renewed',
       {
-        plan_code: this.planCode,
+        plan_code: ctx.planCode,
         quantity: 1,
         is_trial: true,
         has_ai_add_on: false,
-        subscriptionId: this.eventData.subscription.uuid,
+        subscriptionId: ctx.eventData.subscription.uuid,
         payment_provider: 'recurly',
         'customerio-integration': false,
       }
     )
   })
 
-  it('with reactivated_account_notification', async function () {
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+  it('with reactivated_account_notification', async function (ctx) {
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'reactivated_account_notification',
-      this.eventData
+      ctx.eventData
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-reactivated',
       {
-        plan_code: this.planCode,
+        plan_code: ctx.planCode,
         quantity: 1,
         has_ai_add_on: false,
-        subscriptionId: this.eventData.subscription.uuid,
+        subscriptionId: ctx.eventData.subscription.uuid,
         payment_provider: 'recurly',
         'customerio-integration': false,
       }
     )
   })
 
-  it('with paid_charge_invoice_notification', async function () {
+  it('with paid_charge_invoice_notification', async function (ctx) {
     const invoice = {
       invoice_number: 1234,
       currency: 'USD',
@@ -390,18 +411,18 @@ describe('RecurlyEventHandler', function () {
       collection_method: 'automatic',
       subscription_ids: ['abcd1234', 'defa3214'],
     }
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'paid_charge_invoice_notification',
       {
         account: {
-          account_code: this.userId,
+          account_code: ctx.userId,
         },
         invoice,
       }
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-invoice-collected',
       {
         invoiceNumber: invoice.invoice_number,
@@ -417,12 +438,12 @@ describe('RecurlyEventHandler', function () {
     )
   })
 
-  it('with paid_charge_invoice_notification and total_in_cents 0', async function () {
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+  it('with paid_charge_invoice_notification and total_in_cents 0', async function (ctx) {
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'paid_charge_invoice_notification',
       {
         account: {
-          account_code: this.userId,
+          account_code: ctx.userId,
         },
         invoice: {
           state: 'paid',
@@ -430,15 +451,15 @@ describe('RecurlyEventHandler', function () {
         },
       }
     )
-    sinon.assert.notCalled(this.AnalyticsManager.recordEventForUserInBackground)
+    sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForUserInBackground)
   })
 
-  it('with closed_invoice_notification', async function () {
-    await this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+  it('with closed_invoice_notification', async function (ctx) {
+    await ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'closed_invoice_notification',
       {
         account: {
-          account_code: this.userId,
+          account_code: ctx.userId,
         },
         invoice: {
           state: 'collected',
@@ -447,18 +468,18 @@ describe('RecurlyEventHandler', function () {
       }
     )
     sinon.assert.calledWith(
-      this.AnalyticsManager.recordEventForUserInBackground,
-      this.userId,
+      ctx.AnalyticsManager.recordEventForUserInBackground,
+      ctx.userId,
       'subscription-invoice-collected'
     )
   })
 
-  it('with closed_invoice_notification and total_in_cents 0', function () {
-    this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+  it('with closed_invoice_notification and total_in_cents 0', function (ctx) {
+    ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'closed_invoice_notification',
       {
         account: {
-          account_code: this.userId,
+          account_code: ctx.userId,
         },
         invoice: {
           state: 'collected',
@@ -466,25 +487,25 @@ describe('RecurlyEventHandler', function () {
         },
       }
     )
-    sinon.assert.notCalled(this.AnalyticsManager.recordEventForUserInBackground)
+    sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForUserInBackground)
   })
 
-  it('nothing is called with invalid account code', function () {
-    this.eventData.account.account_code = 'foo_bar'
+  it('nothing is called with invalid account code', function (ctx) {
+    ctx.eventData.account.account_code = 'foo_bar'
 
-    this.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
+    ctx.RecurlyEventHandler.sendRecurlyAnalyticsEvent(
       'new_subscription_notification',
-      this.eventData
+      ctx.eventData
     )
-    sinon.assert.notCalled(this.AnalyticsManager.recordEventForUserInBackground)
+    sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForUserInBackground)
     sinon.assert.notCalled(
-      this.AnalyticsManager.setUserPropertyForUserInBackground
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground
     )
     sinon.assert.notCalled(
-      this.AnalyticsManager.setUserPropertyForUserInBackground
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground
     )
     sinon.assert.notCalled(
-      this.AnalyticsManager.setUserPropertyForUserInBackground
+      ctx.AnalyticsManager.setUserPropertyForUserInBackground
     )
   })
 })

+ 1064 - 0
services/web/test/unit/src/Subscription/SubscriptionViewModelBuilder.test.mjs

@@ -0,0 +1,1064 @@
+import { vi, assert } from 'vitest'
+import sinon from 'sinon'
+import {
+  PaymentProviderAccount,
+  PaymentProviderSubscription,
+  PaymentProviderSubscriptionAddOn,
+  PaymentProviderSubscriptionChange,
+} from '../../../../app/src/Features/Subscription/PaymentProviderEntities.js'
+import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.js'
+
+const modulePath =
+  '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder.mjs'
+
+describe('SubscriptionViewModelBuilder', function () {
+  beforeEach(async function (ctx) {
+    ctx.user = { _id: '5208dd34438842e2db333333' }
+    ctx.recurlySubscription_id = '123abc456def'
+    ctx.planCode = 'collaborator_monthly'
+    ctx.planFeatures = {
+      compileGroup: 'priority',
+      collaborators: -1,
+      compileTimeout: 240,
+    }
+    ctx.plan = {
+      planCode: ctx.planCode,
+      features: ctx.planFeatures,
+    }
+    ctx.annualPlanCode = 'collaborator_annual'
+    ctx.annualPlan = {
+      planCode: ctx.annualPlanCode,
+      features: ctx.planFeatures,
+    }
+    ctx.individualSubscription = {
+      planCode: ctx.planCode,
+      plan: ctx.plan,
+      recurlySubscription_id: ctx.recurlySubscription_id,
+      recurlyStatus: {
+        state: 'active',
+      },
+    }
+    ctx.paymentRecord = new PaymentProviderSubscription({
+      id: ctx.recurlySubscription_id,
+      userId: ctx.user._id,
+      currency: 'EUR',
+      planCode: 'plan-code',
+      planName: 'plan-name',
+      planPrice: 13,
+      addOns: [
+        new PaymentProviderSubscriptionAddOn({
+          code: 'addon-code',
+          name: 'addon name',
+          quantity: 1,
+          unitPrice: 2,
+        }),
+      ],
+      subtotal: 15,
+      taxRate: 0.1,
+      taxAmount: 1.5,
+      total: 16.5,
+      periodStart: new Date('2025-01-20T12:00:00.000Z'),
+      periodEnd: new Date('2025-02-20T12:00:00.000Z'),
+      collectionMethod: 'automatic',
+    })
+
+    ctx.individualCustomSubscription = {
+      planCode: ctx.planCode,
+      plan: ctx.plan,
+      recurlySubscription_id: ctx.recurlySubscription_id,
+    }
+
+    ctx.groupPlanCode = 'group_collaborator_monthly'
+    ctx.groupPlanFeatures = {
+      compileGroup: 'priority',
+      collaborators: 10,
+      compileTimeout: 240,
+    }
+    ctx.groupPlan = {
+      planCode: ctx.groupPlanCode,
+      features: ctx.groupPlanFeatures,
+      membersLimit: 4,
+      membersLimitAddOn: 'additional-license',
+      groupPlan: true,
+    }
+    ctx.groupSubscription = {
+      planCode: ctx.groupPlanCode,
+      plan: ctx.plan,
+      recurlyStatus: {
+        state: 'active',
+      },
+    }
+
+    ctx.commonsPlanCode = 'commons_license'
+    ctx.commonsPlanFeatures = {
+      compileGroup: 'priority',
+      collaborators: '-1',
+      compileTimeout: 240,
+    }
+    ctx.commonsPlan = {
+      planCode: ctx.commonsPlanCode,
+      features: ctx.commonsPlanFeatures,
+    }
+    ctx.commonsSubscription = {
+      planCode: ctx.commonsPlanCode,
+      plan: ctx.commonsPlan,
+      name: 'Digital Science',
+    }
+
+    ctx.Settings = {
+      institutionPlanCode: ctx.commonsPlanCode,
+    }
+    ctx.SubscriptionLocator = {
+      promises: {
+        getUsersSubscription: sinon.stub().resolves(),
+        getMemberSubscriptions: sinon.stub().resolves(),
+      },
+      getUsersSubscription: sinon.stub().yields(),
+      getMemberSubscriptions: sinon.stub().yields(null, []),
+      getManagedGroupSubscriptions: sinon.stub().yields(null, []),
+      findLocalPlanInSettings: sinon.stub(),
+    }
+    ctx.InstitutionsGetter = {
+      promises: {
+        getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
+      },
+      getCurrentInstitutionsWithLicence: sinon.stub().yields(null, []),
+      getManagedInstitutions: sinon.stub().yields(null, []),
+    }
+    ctx.InstitutionsManager = {
+      promises: {
+        fetchV1Data: sinon.stub().resolves(),
+      },
+    }
+    ctx.PublishersGetter = {
+      promises: {
+        fetchV1Data: sinon.stub().resolves(),
+      },
+      getManagedPublishers: sinon.stub().yields(null, []),
+    }
+    ctx.RecurlyWrapper = {
+      promises: {
+        getSubscription: sinon.stub().resolves(),
+      },
+    }
+    ctx.SubscriptionUpdater = {
+      promises: {
+        updateSubscriptionFromRecurly: sinon.stub().resolves(),
+      },
+    }
+    ctx.PlansLocator = {
+      findLocalPlanInSettings: sinon.stub(),
+    }
+    ctx.SplitTestHandler = {
+      promises: {
+        getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
+      },
+    }
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.Settings,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: ctx.SubscriptionLocator,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Institutions/InstitutionsGetter',
+      () => ({
+        default: ctx.InstitutionsGetter,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Institutions/InstitutionsManager',
+      () => ({
+        default: ctx.InstitutionsManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/RecurlyWrapper',
+      () => ({
+        default: ctx.RecurlyWrapper,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionUpdater',
+      () => ({
+        default: ctx.SubscriptionUpdater,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
+      default: ctx.PlansLocator,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: (ctx.Modules = {
+        promises: { hooks: { fire: sinon.stub().resolves([]) } },
+        hooks: {
+          fire: sinon.stub().yields(null, []),
+        },
+      }),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/V1SubscriptionManager',
+      () => ({
+        default: {},
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Publishers/PublishersGetter',
+      () => ({
+        default: ctx.PublishersGetter,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionHelper',
+      () => ({
+        default: SubscriptionHelper,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+
+    ctx.SubscriptionViewModelBuilder = (await import(modulePath)).default
+
+    ctx.PlansLocator.findLocalPlanInSettings
+      .withArgs(ctx.planCode)
+      .returns(ctx.plan)
+      .withArgs(ctx.annualPlanCode)
+      .returns(ctx.annualPlan)
+      .withArgs(ctx.groupPlanCode)
+      .returns(ctx.groupPlan)
+      .withArgs(ctx.commonsPlanCode)
+      .returns(ctx.commonsPlan)
+  })
+
+  describe('getUsersSubscriptionDetails', function () {
+    it('should return a free plan when user has no subscription or affiliation', async function (ctx) {
+      const { bestSubscription: usersBestSubscription } =
+        await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+          ctx.user
+        )
+      assert.deepEqual(usersBestSubscription, { type: 'free' })
+    })
+
+    describe('with a individual subscription only', function () {
+      it('should return a individual subscription when user has non-Recurly one', async function (ctx) {
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user)
+          .resolves(ctx.individualCustomSubscription)
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: ctx.individualCustomSubscription,
+          plan: ctx.plan,
+          remainingTrialDays: -1,
+        })
+      })
+
+      it('should return a individual subscription when user has an active one', async function (ctx) {
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user)
+          .resolves(ctx.individualSubscription)
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: ctx.individualSubscription,
+          plan: ctx.plan,
+          remainingTrialDays: -1,
+        })
+      })
+
+      it('should return a individual subscription with remaining free trial days', async function (ctx) {
+        const threeDaysLater = new Date()
+        threeDaysLater.setDate(threeDaysLater.getDate() + 3)
+        ctx.individualSubscription.recurlyStatus.trialEndsAt = threeDaysLater
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user)
+          .resolves(ctx.individualSubscription)
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: ctx.individualSubscription,
+          plan: ctx.plan,
+          remainingTrialDays: 3,
+        })
+      })
+
+      it('should return a individual subscription with free trial on last day', async function (ctx) {
+        const threeHoursLater = new Date()
+        threeHoursLater.setTime(threeHoursLater.getTime() + 3 * 60 * 60 * 1000)
+        ctx.individualSubscription.recurlyStatus.trialEndsAt = threeHoursLater
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user)
+          .resolves(ctx.individualSubscription)
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: ctx.individualSubscription,
+          plan: ctx.plan,
+          remainingTrialDays: 1,
+        })
+      })
+
+      it('should update subscription if recurly payment state is missing', async function (ctx) {
+        ctx.individualSubscriptionWithoutPaymentState = {
+          planCode: ctx.planCode,
+          plan: ctx.plan,
+          recurlySubscription_id: ctx.recurlySubscription_id,
+        }
+        ctx.paymentRecord = {
+          state: 'active',
+        }
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user)
+          .onCall(0)
+          .resolves(ctx.individualSubscriptionWithoutPaymentState)
+          .withArgs(ctx.user)
+          .onCall(1)
+          .resolves(ctx.individualSubscription)
+        const payment = {
+          subscription: ctx.paymentRecord,
+          account: new PaymentProviderAccount({}),
+          coupons: [],
+        }
+
+        ctx.Modules.promises.hooks.fire
+          .withArgs(
+            'getPaymentFromRecordPromise',
+            ctx.individualSubscriptionWithoutPaymentState
+          )
+          .resolves([payment])
+        ctx.Modules.promises.hooks.fire
+          .withArgs(
+            'syncSubscription',
+            payment,
+            ctx.individualSubscriptionWithoutPaymentState
+          )
+          .resolves([])
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: ctx.individualSubscription,
+          plan: ctx.plan,
+          remainingTrialDays: -1,
+        })
+        assert.isTrue(
+          ctx.Modules.promises.hooks.fire.withArgs(
+            'getPaymentFromRecordPromise',
+            ctx.individualSubscriptionWithoutPaymentState
+          ).calledOnce
+        )
+      })
+
+      it('should update subscription if stripe payment state is missing', async function (ctx) {
+        ctx.individualSubscriptionWithoutPaymentState = {
+          planCode: ctx.planCode,
+          plan: ctx.plan,
+          paymentProvider: {
+            subscriptionId: ctx.recurlySubscription_id,
+          },
+        }
+        ctx.paymentRecord = {
+          state: 'active',
+        }
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user)
+          .onCall(0)
+          .resolves(ctx.individualSubscriptionWithoutPaymentState)
+          .withArgs(ctx.user)
+          .onCall(1)
+          .resolves(ctx.individualSubscription)
+        const payment = {
+          subscription: ctx.paymentRecord,
+          account: new PaymentProviderAccount({}),
+          coupons: [],
+        }
+
+        ctx.Modules.promises.hooks.fire
+          .withArgs(
+            'getPaymentFromRecordPromise',
+            ctx.individualSubscriptionWithoutPaymentState
+          )
+          .resolves([payment])
+        ctx.Modules.promises.hooks.fire
+          .withArgs(
+            'syncSubscription',
+            payment,
+            ctx.individualSubscriptionWithoutPaymentState
+          )
+          .resolves([])
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: ctx.individualSubscription,
+          plan: ctx.plan,
+          remainingTrialDays: -1,
+        })
+        assert.isTrue(
+          ctx.Modules.promises.hooks.fire.withArgs(
+            'getPaymentFromRecordPromise',
+            ctx.individualSubscriptionWithoutPaymentState
+          ).calledOnce
+        )
+      })
+    })
+
+    it('should return a group subscription when user has one', async function (ctx) {
+      ctx.SubscriptionLocator.promises.getMemberSubscriptions
+        .withArgs(ctx.user)
+        .resolves([ctx.groupSubscription])
+      const { bestSubscription: usersBestSubscription } =
+        await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+          ctx.user
+        )
+      assert.deepEqual(usersBestSubscription, {
+        type: 'group',
+        subscription: {},
+        plan: ctx.groupPlan,
+        remainingTrialDays: -1,
+      })
+    })
+
+    it('should return a group subscription with team name when user has one', async function (ctx) {
+      ctx.SubscriptionLocator.promises.getMemberSubscriptions
+        .withArgs(ctx.user)
+        .resolves([
+          Object.assign({}, ctx.groupSubscription, { teamName: 'test team' }),
+        ])
+      const { bestSubscription: usersBestSubscription } =
+        await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+          ctx.user
+        )
+      assert.deepEqual(usersBestSubscription, {
+        type: 'group',
+        subscription: { teamName: 'test team' },
+        plan: ctx.groupPlan,
+        remainingTrialDays: -1,
+      })
+    })
+
+    it('should return a commons subscription when user has an institution affiliation', async function (ctx) {
+      ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
+        .withArgs(ctx.user._id)
+        .resolves([ctx.commonsSubscription])
+
+      const { bestSubscription: usersBestSubscription } =
+        await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+          ctx.user
+        )
+
+      assert.deepEqual(usersBestSubscription, {
+        type: 'commons',
+        subscription: ctx.commonsSubscription,
+        plan: ctx.commonsPlan,
+      })
+    })
+
+    describe('with multiple subscriptions', function () {
+      beforeEach(function (ctx) {
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user)
+          .resolves(ctx.individualSubscription)
+        ctx.SubscriptionLocator.promises.getMemberSubscriptions
+          .withArgs(ctx.user)
+          .resolves([ctx.groupSubscription])
+        ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
+          .withArgs(ctx.user._id)
+          .resolves([ctx.commonsSubscription])
+      })
+
+      it('should return individual when the individual subscription has the best feature set', async function (ctx) {
+        ctx.commonsPlan.features = {
+          compileGroup: 'standard',
+          collaborators: 1,
+          compileTimeout: 60,
+        }
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: ctx.individualSubscription,
+          plan: ctx.plan,
+          remainingTrialDays: -1,
+        })
+      })
+
+      it('should return group when the group subscription has the best feature set', async function (ctx) {
+        ctx.plan.features = {
+          compileGroup: 'standard',
+          collaborators: 1,
+          compileTimeout: 60,
+        }
+        ctx.commonsPlan.features = {
+          compileGroup: 'standard',
+          collaborators: 1,
+          compileTimeout: 60,
+        }
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'group',
+          subscription: {},
+          plan: ctx.groupPlan,
+          remainingTrialDays: -1,
+        })
+      })
+
+      it('should return commons when the commons affiliation has the best feature set', async function (ctx) {
+        ctx.plan.features = {
+          compileGroup: 'priority',
+          collaborators: 5,
+          compileTimeout: 240,
+        }
+        ctx.groupPlan.features = {
+          compileGroup: 'standard',
+          collaborators: 1,
+          compileTimeout: 60,
+        }
+        ctx.commonsPlan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'commons',
+          subscription: ctx.commonsSubscription,
+          plan: ctx.commonsPlan,
+        })
+      })
+
+      it('should return individual with equal feature sets', async function (ctx) {
+        ctx.plan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
+        ctx.groupPlan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
+        ctx.commonsPlan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'individual',
+          subscription: ctx.individualSubscription,
+          plan: ctx.plan,
+          remainingTrialDays: -1,
+        })
+      })
+
+      it('should return group over commons with equal feature sets', async function (ctx) {
+        ctx.plan.features = {
+          compileGroup: 'standard',
+          collaborators: 1,
+          compileTimeout: 60,
+        }
+        ctx.groupPlan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
+        ctx.commonsPlan.features = {
+          compileGroup: 'priority',
+          collaborators: -1,
+          compileTimeout: 240,
+        }
+
+        const { bestSubscription: usersBestSubscription } =
+          await ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
+            ctx.user
+          )
+
+        assert.deepEqual(usersBestSubscription, {
+          type: 'group',
+          subscription: {},
+          plan: ctx.groupPlan,
+          remainingTrialDays: -1,
+        })
+      })
+    })
+  })
+
+  describe('buildUsersSubscriptionViewModel', function () {
+    beforeEach(function (ctx) {
+      ctx.SubscriptionLocator.getUsersSubscription.yields(
+        null,
+        ctx.individualSubscription
+      )
+      ctx.Modules.hooks.fire
+        .withArgs('getPaymentFromRecord', ctx.individualSubscription)
+        .yields(null, [
+          {
+            subscription: ctx.paymentRecord,
+            account: new PaymentProviderAccount({}),
+            coupons: [],
+          },
+        ])
+    })
+
+    describe('with a paid subscription', function () {
+      it('adds payment data to the personal subscription', async function (ctx) {
+        ctx.Modules.hooks.fire
+          .withArgs('getPaymentFromRecord', ctx.individualSubscription)
+          .yields(null, [
+            {
+              subscription: ctx.paymentRecord,
+              account: new PaymentProviderAccount({
+                email: 'example@example.com',
+                hasPastDueInvoice: false,
+              }),
+              coupons: [],
+            },
+          ])
+        const result =
+          await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+            ctx.user
+          )
+        assert.deepEqual(result.personalSubscription.payment, {
+          taxRate: 0.1,
+          billingDetailsLink: '/user/subscription/payment/billing-details',
+          accountManagementLink:
+            '/user/subscription/payment/account-management',
+          additionalLicenses: 0,
+          addOns: [
+            {
+              code: 'addon-code',
+              name: 'addon name',
+              quantity: 1,
+              unitPrice: 2,
+              preTaxTotal: 2,
+            },
+          ],
+          totalLicenses: 0,
+          nextPaymentDueAt: 'February 20th, 2025 12:00 PM UTC',
+          nextPaymentDueDate: 'February 20th, 2025',
+          currency: 'EUR',
+          state: 'active',
+          trialEndsAtFormatted: null,
+          trialEndsAt: null,
+          activeCoupons: [],
+          accountEmail: 'example@example.com',
+          hasPastDueInvoice: false,
+          pausedAt: null,
+          remainingPauseCycles: null,
+          displayPrice: '€16.50',
+          planOnlyDisplayPrice: '€14.30',
+          addOnDisplayPricesWithoutAdditionalLicense: {
+            'addon-code': '€2.20',
+          },
+          isEligibleForGroupPlan: true,
+          isEligibleForPause: false,
+          isEligibleForDowngradeUpsell: true,
+        })
+      })
+
+      describe('isEligibleForGroupPlan', function () {
+        it('is false for Stripe subscriptions', async function (ctx) {
+          ctx.paymentRecord.service = 'stripe-us'
+          ctx.Modules.promises.hooks.fire
+            .withArgs('canUpgradeFromIndividualToGroup')
+            .resolves([false])
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(
+            result.personalSubscription.payment.isEligibleForGroupPlan
+          )
+        })
+
+        it('is false when in trial', async function (ctx) {
+          const msIn24Hours = 24 * 60 * 60 * 1000
+          const tomorrow = new Date(Date.now() + msIn24Hours)
+          ctx.paymentRecord.trialPeriodEnd = tomorrow
+          ctx.paymentRecord.service = 'recurly'
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(
+            result.personalSubscription.payment.isEligibleForGroupPlan
+          )
+        })
+
+        it('is true when not in trial and for a Recurly subscription', async function (ctx) {
+          ctx.paymentRecord.service = 'recurly'
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isTrue(
+            result.personalSubscription.payment.isEligibleForGroupPlan
+          )
+        })
+      })
+
+      describe('isEligibleForPause', function () {
+        beforeEach(function (ctx) {
+          ctx.paymentRecord.service = 'recurly'
+          ctx.paymentRecord.addOns = []
+          ctx.paymentRecord.planCode = 'plan-code'
+          ctx.paymentRecord.trialPeriodEnd = null
+          ctx.individualSubscription.pendingPlan = undefined
+          ctx.individualSubscription.groupPlan = undefined
+        })
+
+        it('is false for Stripe subscriptions when feature flag is disabled', async function (ctx) {
+          ctx.paymentRecord.service = 'stripe-us'
+          ctx.SplitTestHandler.promises.getAssignmentForUser
+            .withArgs(ctx.user._id, 'stripe-pause')
+            .resolves({ variant: 'default' })
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+        })
+
+        it('is true for Stripe subscriptions when feature flag is enabled', async function (ctx) {
+          ctx.paymentRecord.service = 'stripe-us'
+          ctx.SplitTestHandler.promises.getAssignmentForUser
+            .withArgs(ctx.user._id, 'stripe-pause')
+            .resolves({ variant: 'enabled' })
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isTrue(result.personalSubscription.payment.isEligibleForPause)
+        })
+
+        it('is false for Stripe subscriptions with pending plan even when feature flag is enabled', async function (ctx) {
+          ctx.paymentRecord.service = 'stripe-us'
+          ctx.individualSubscription.pendingPlan = {} // anything
+          ctx.SplitTestHandler.promises.getAssignmentForUser
+            .withArgs(ctx.user._id, 'stripe-pause')
+            .resolves({ variant: 'enabled' })
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+        })
+
+        it('is false for Stripe subscriptions with annual plan even when feature flag is enabled', async function (ctx) {
+          ctx.paymentRecord.service = 'stripe-us'
+          ctx.paymentRecord.planCode = 'collaborator-annual'
+          ctx.SplitTestHandler.promises.getAssignmentForUser
+            .withArgs(ctx.user._id, 'stripe-pause')
+            .resolves({ variant: 'enabled' })
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+        })
+
+        it('is false for subscriptions with pending plan', async function (ctx) {
+          ctx.paymentRecord.service = 'recurly'
+          ctx.individualSubscription.pendingPlan = {} // anything
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+        })
+
+        it('is false for a group subscription', async function (ctx) {
+          ctx.paymentRecord.service = 'recurly'
+          ctx.individualSubscription.groupPlan = true
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+        })
+
+        it('is false when in trial', async function (ctx) {
+          ctx.paymentRecord.service = 'recurly'
+          const msIn24Hours = 24 * 60 * 60 * 1000
+          const tomorrow = new Date(Date.now() + msIn24Hours)
+          ctx.paymentRecord.trialPeriodEnd = tomorrow
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+        })
+
+        it('is false for annual subscriptions', async function (ctx) {
+          ctx.paymentRecord.service = 'recurly'
+          ctx.paymentRecord.planCode = 'collaborator-annual'
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+        })
+
+        it('is false for subscriptions with add-ons', async function (ctx) {
+          ctx.paymentRecord.service = 'recurly'
+          ctx.paymentRecord.addOns = [{}] // anything
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
+        })
+
+        it('is true when conditions are met', async function (ctx) {
+          ctx.paymentRecord.service = 'recurly'
+          ctx.paymentRecord.addOns = []
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isTrue(result.personalSubscription.payment.isEligibleForPause)
+        })
+      })
+
+      describe('isEligibleForDowngradeUpsell', function () {
+        it('is true for eligible individual subscriptions', async function (ctx) {
+          ctx.paymentRecord.pausePeriodStart = null
+          ctx.paymentRecord.remainingPauseCycles = null
+          ctx.paymentRecord.trialPeriodEnd = null
+          ctx.paymentRecord.service = 'recurly'
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isTrue(
+            result.personalSubscription.payment.isEligibleForDowngradeUpsell
+          )
+        })
+
+        it('is false for group plans', async function (ctx) {
+          ctx.individualSubscription.planCode = ctx.groupPlanCode
+          ctx.paymentRecord.pausePeriodStart = null
+          ctx.paymentRecord.remainingPauseCycles = null
+          ctx.paymentRecord.trialPeriodEnd = null
+          ctx.paymentRecord.service = 'recurly'
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(
+            result.personalSubscription.payment.isEligibleForDowngradeUpsell
+          )
+        })
+
+        it('is false for annual individual plans', async function (ctx) {
+          ctx.individualSubscription.planCode = ctx.annualPlanCode
+          ctx.paymentRecord.pausePeriodStart = null
+          ctx.paymentRecord.remainingPauseCycles = null
+          ctx.paymentRecord.trialPeriodEnd = null
+          ctx.paymentRecord.service = 'recurly'
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(
+            result.personalSubscription.payment.isEligibleForDowngradeUpsell
+          )
+        })
+
+        it('is false for paused plans', async function (ctx) {
+          ctx.paymentRecord.pausePeriodStart = new Date()
+          ctx.paymentRecord.remainingPauseCycles = 1
+          ctx.paymentRecord.trialPeriodEnd = null
+          ctx.paymentRecord.service = 'recurly'
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(
+            result.personalSubscription.payment.isEligibleForDowngradeUpsell
+          )
+        })
+
+        it('is false for plans in free trial period', async function (ctx) {
+          ctx.paymentRecord.pausePeriodStart = null
+          ctx.paymentRecord.remainingPauseCycles = null
+          ctx.paymentRecord.trialPeriodEnd = new Date(
+            Date.now() + 24 * 60 * 60 * 1000 // tomorrow
+          )
+          ctx.paymentRecord.service = 'recurly'
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(
+            result.personalSubscription.payment.isEligibleForDowngradeUpsell
+          )
+        })
+
+        it('is false for Stripe subscriptions', async function (ctx) {
+          ctx.paymentRecord.pausePeriodStart = null
+          ctx.paymentRecord.remainingPauseCycles = null
+          ctx.paymentRecord.trialPeriodEnd = null
+          ctx.paymentRecord.service = 'stripe-us'
+          const result =
+            await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+              ctx.user
+            )
+          assert.isFalse(
+            result.personalSubscription.payment.isEligibleForDowngradeUpsell
+          )
+        })
+      })
+
+      it('includes pending changes', async function (ctx) {
+        ctx.paymentRecord.pendingChange = new PaymentProviderSubscriptionChange(
+          {
+            subscription: ctx.paymentRecord,
+            nextPlanCode: ctx.groupPlanCode,
+            nextPlanName: 'Group Collaborator (Annual) 4 licenses',
+            nextPlanPrice: 1400,
+            nextAddOns: [
+              new PaymentProviderSubscriptionAddOn({
+                code: 'additional-license',
+                name: 'additional license',
+                quantity: 8,
+                unitPrice: 24.4,
+              }),
+              new PaymentProviderSubscriptionAddOn({
+                code: 'addon-code',
+                name: 'addon name',
+                quantity: 1,
+                unitPrice: 2,
+              }),
+            ],
+          }
+        )
+        ctx.Modules.hooks.fire
+          .withArgs('getPaymentFromRecord', ctx.individualSubscription)
+          .yields(null, [
+            {
+              subscription: ctx.paymentRecord,
+              account: {},
+              coupons: [],
+            },
+          ])
+        const result =
+          await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+            ctx.user
+          )
+        assert.equal(
+          result.personalSubscription.payment.displayPrice,
+          '€1,756.92'
+        )
+        assert.equal(
+          result.personalSubscription.payment.planOnlyDisplayPrice,
+          '€1,754.72'
+        )
+        assert.deepEqual(
+          result.personalSubscription.payment
+            .addOnDisplayPricesWithoutAdditionalLicense,
+          { 'addon-code': '€2.20' }
+        )
+        assert.equal(
+          result.personalSubscription.payment.pendingAdditionalLicenses,
+          8
+        )
+        assert.equal(
+          result.personalSubscription.payment.pendingTotalLicenses,
+          12
+        )
+      })
+
+      it('does not add a billing details link for a Stripe subscription', async function (ctx) {
+        ctx.paymentRecord.service = 'stripe-us'
+        ctx.Modules.hooks.fire
+          .withArgs('getPaymentFromRecord', ctx.individualSubscription)
+          .yields(null, [
+            {
+              subscription: ctx.paymentRecord,
+              account: new PaymentProviderAccount({}),
+              coupons: [],
+            },
+          ])
+        const result =
+          await ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+            ctx.user
+          )
+        assert.equal(
+          result.personalSubscription.payment.billingDetailsLink,
+          undefined
+        )
+        assert.equal(
+          result.personalSubscription.payment.accountManagementLink,
+          '/user/subscription/payment/account-management'
+        )
+      })
+    })
+  })
+})

+ 0 - 1003
services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js

@@ -1,1003 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { assert } = require('chai')
-const {
-  PaymentProviderAccount,
-  PaymentProviderSubscription,
-  PaymentProviderSubscriptionAddOn,
-  PaymentProviderSubscriptionChange,
-} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
-const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper')
-
-const modulePath =
-  '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder'
-
-describe('SubscriptionViewModelBuilder', function () {
-  beforeEach(function () {
-    this.user = { _id: '5208dd34438842e2db333333' }
-    this.recurlySubscription_id = '123abc456def'
-    this.planCode = 'collaborator_monthly'
-    this.planFeatures = {
-      compileGroup: 'priority',
-      collaborators: -1,
-      compileTimeout: 240,
-    }
-    this.plan = {
-      planCode: this.planCode,
-      features: this.planFeatures,
-    }
-    this.annualPlanCode = 'collaborator_annual'
-    this.annualPlan = {
-      planCode: this.annualPlanCode,
-      features: this.planFeatures,
-    }
-    this.individualSubscription = {
-      planCode: this.planCode,
-      plan: this.plan,
-      recurlySubscription_id: this.recurlySubscription_id,
-      recurlyStatus: {
-        state: 'active',
-      },
-    }
-    this.paymentRecord = new PaymentProviderSubscription({
-      id: this.recurlySubscription_id,
-      userId: this.user._id,
-      currency: 'EUR',
-      planCode: 'plan-code',
-      planName: 'plan-name',
-      planPrice: 13,
-      addOns: [
-        new PaymentProviderSubscriptionAddOn({
-          code: 'addon-code',
-          name: 'addon name',
-          quantity: 1,
-          unitPrice: 2,
-        }),
-      ],
-      subtotal: 15,
-      taxRate: 0.1,
-      taxAmount: 1.5,
-      total: 16.5,
-      periodStart: new Date('2025-01-20T12:00:00.000Z'),
-      periodEnd: new Date('2025-02-20T12:00:00.000Z'),
-      collectionMethod: 'automatic',
-    })
-
-    this.individualCustomSubscription = {
-      planCode: this.planCode,
-      plan: this.plan,
-      recurlySubscription_id: this.recurlySubscription_id,
-    }
-
-    this.groupPlanCode = 'group_collaborator_monthly'
-    this.groupPlanFeatures = {
-      compileGroup: 'priority',
-      collaborators: 10,
-      compileTimeout: 240,
-    }
-    this.groupPlan = {
-      planCode: this.groupPlanCode,
-      features: this.groupPlanFeatures,
-      membersLimit: 4,
-      membersLimitAddOn: 'additional-license',
-      groupPlan: true,
-    }
-    this.groupSubscription = {
-      planCode: this.groupPlanCode,
-      plan: this.plan,
-      recurlyStatus: {
-        state: 'active',
-      },
-    }
-
-    this.commonsPlanCode = 'commons_license'
-    this.commonsPlanFeatures = {
-      compileGroup: 'priority',
-      collaborators: '-1',
-      compileTimeout: 240,
-    }
-    this.commonsPlan = {
-      planCode: this.commonsPlanCode,
-      features: this.commonsPlanFeatures,
-    }
-    this.commonsSubscription = {
-      planCode: this.commonsPlanCode,
-      plan: this.commonsPlan,
-      name: 'Digital Science',
-    }
-
-    this.Settings = {
-      institutionPlanCode: this.commonsPlanCode,
-    }
-    this.SubscriptionLocator = {
-      promises: {
-        getUsersSubscription: sinon.stub().resolves(),
-        getMemberSubscriptions: sinon.stub().resolves(),
-      },
-      getUsersSubscription: sinon.stub().yields(),
-      getMemberSubscriptions: sinon.stub().yields(null, []),
-      getManagedGroupSubscriptions: sinon.stub().yields(null, []),
-      findLocalPlanInSettings: sinon.stub(),
-    }
-    this.InstitutionsGetter = {
-      promises: {
-        getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
-      },
-      getCurrentInstitutionsWithLicence: sinon.stub().yields(null, []),
-      getManagedInstitutions: sinon.stub().yields(null, []),
-    }
-    this.InstitutionsManager = {
-      promises: {
-        fetchV1Data: sinon.stub().resolves(),
-      },
-    }
-    this.PublishersGetter = {
-      promises: {
-        fetchV1Data: sinon.stub().resolves(),
-      },
-      getManagedPublishers: sinon.stub().yields(null, []),
-    }
-    this.RecurlyWrapper = {
-      promises: {
-        getSubscription: sinon.stub().resolves(),
-      },
-    }
-    this.SubscriptionUpdater = {
-      promises: {
-        updateSubscriptionFromRecurly: sinon.stub().resolves(),
-      },
-    }
-    this.PlansLocator = {
-      findLocalPlanInSettings: sinon.stub(),
-    }
-    this.SplitTestHandler = {
-      promises: {
-        getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
-      },
-    }
-    this.SubscriptionViewModelBuilder = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': this.Settings,
-        './SubscriptionLocator': this.SubscriptionLocator,
-        '../Institutions/InstitutionsGetter': this.InstitutionsGetter,
-        '../Institutions/InstitutionsManager': this.InstitutionsManager,
-        './RecurlyWrapper': this.RecurlyWrapper,
-        './SubscriptionUpdater': this.SubscriptionUpdater,
-        './PlansLocator': this.PlansLocator,
-        '../../infrastructure/Modules': (this.Modules = {
-          promises: { hooks: { fire: sinon.stub().resolves([]) } },
-          hooks: {
-            fire: sinon.stub().yields(null, []),
-          },
-        }),
-        './V1SubscriptionManager': {},
-        '../Publishers/PublishersGetter': this.PublishersGetter,
-        './SubscriptionHelper': SubscriptionHelper,
-        '../SplitTests/SplitTestHandler': this.SplitTestHandler,
-      },
-    })
-
-    this.PlansLocator.findLocalPlanInSettings
-      .withArgs(this.planCode)
-      .returns(this.plan)
-      .withArgs(this.annualPlanCode)
-      .returns(this.annualPlan)
-      .withArgs(this.groupPlanCode)
-      .returns(this.groupPlan)
-      .withArgs(this.commonsPlanCode)
-      .returns(this.commonsPlan)
-  })
-
-  describe('getUsersSubscriptionDetails', function () {
-    it('should return a free plan when user has no subscription or affiliation', async function () {
-      const { bestSubscription: usersBestSubscription } =
-        await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-          this.user
-        )
-      assert.deepEqual(usersBestSubscription, { type: 'free' })
-    })
-
-    describe('with a individual subscription only', function () {
-      it('should return a individual subscription when user has non-Recurly one', async function () {
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user)
-          .resolves(this.individualCustomSubscription)
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'individual',
-          subscription: this.individualCustomSubscription,
-          plan: this.plan,
-          remainingTrialDays: -1,
-        })
-      })
-
-      it('should return a individual subscription when user has an active one', async function () {
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user)
-          .resolves(this.individualSubscription)
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'individual',
-          subscription: this.individualSubscription,
-          plan: this.plan,
-          remainingTrialDays: -1,
-        })
-      })
-
-      it('should return a individual subscription with remaining free trial days', async function () {
-        const threeDaysLater = new Date()
-        threeDaysLater.setDate(threeDaysLater.getDate() + 3)
-        this.individualSubscription.recurlyStatus.trialEndsAt = threeDaysLater
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user)
-          .resolves(this.individualSubscription)
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'individual',
-          subscription: this.individualSubscription,
-          plan: this.plan,
-          remainingTrialDays: 3,
-        })
-      })
-
-      it('should return a individual subscription with free trial on last day', async function () {
-        const threeHoursLater = new Date()
-        threeHoursLater.setTime(threeHoursLater.getTime() + 3 * 60 * 60 * 1000)
-        this.individualSubscription.recurlyStatus.trialEndsAt = threeHoursLater
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user)
-          .resolves(this.individualSubscription)
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'individual',
-          subscription: this.individualSubscription,
-          plan: this.plan,
-          remainingTrialDays: 1,
-        })
-      })
-
-      it('should update subscription if recurly payment state is missing', async function () {
-        this.individualSubscriptionWithoutPaymentState = {
-          planCode: this.planCode,
-          plan: this.plan,
-          recurlySubscription_id: this.recurlySubscription_id,
-        }
-        this.paymentRecord = {
-          state: 'active',
-        }
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user)
-          .onCall(0)
-          .resolves(this.individualSubscriptionWithoutPaymentState)
-          .withArgs(this.user)
-          .onCall(1)
-          .resolves(this.individualSubscription)
-        const payment = {
-          subscription: this.paymentRecord,
-          account: new PaymentProviderAccount({}),
-          coupons: [],
-        }
-
-        this.Modules.promises.hooks.fire
-          .withArgs(
-            'getPaymentFromRecordPromise',
-            this.individualSubscriptionWithoutPaymentState
-          )
-          .resolves([payment])
-        this.Modules.promises.hooks.fire
-          .withArgs(
-            'syncSubscription',
-            payment,
-            this.individualSubscriptionWithoutPaymentState
-          )
-          .resolves([])
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'individual',
-          subscription: this.individualSubscription,
-          plan: this.plan,
-          remainingTrialDays: -1,
-        })
-        assert.isTrue(
-          this.Modules.promises.hooks.fire.withArgs(
-            'getPaymentFromRecordPromise',
-            this.individualSubscriptionWithoutPaymentState
-          ).calledOnce
-        )
-      })
-
-      it('should update subscription if stripe payment state is missing', async function () {
-        this.individualSubscriptionWithoutPaymentState = {
-          planCode: this.planCode,
-          plan: this.plan,
-          paymentProvider: {
-            subscriptionId: this.recurlySubscription_id,
-          },
-        }
-        this.paymentRecord = {
-          state: 'active',
-        }
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user)
-          .onCall(0)
-          .resolves(this.individualSubscriptionWithoutPaymentState)
-          .withArgs(this.user)
-          .onCall(1)
-          .resolves(this.individualSubscription)
-        const payment = {
-          subscription: this.paymentRecord,
-          account: new PaymentProviderAccount({}),
-          coupons: [],
-        }
-
-        this.Modules.promises.hooks.fire
-          .withArgs(
-            'getPaymentFromRecordPromise',
-            this.individualSubscriptionWithoutPaymentState
-          )
-          .resolves([payment])
-        this.Modules.promises.hooks.fire
-          .withArgs(
-            'syncSubscription',
-            payment,
-            this.individualSubscriptionWithoutPaymentState
-          )
-          .resolves([])
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'individual',
-          subscription: this.individualSubscription,
-          plan: this.plan,
-          remainingTrialDays: -1,
-        })
-        assert.isTrue(
-          this.Modules.promises.hooks.fire.withArgs(
-            'getPaymentFromRecordPromise',
-            this.individualSubscriptionWithoutPaymentState
-          ).calledOnce
-        )
-      })
-    })
-
-    it('should return a group subscription when user has one', async function () {
-      this.SubscriptionLocator.promises.getMemberSubscriptions
-        .withArgs(this.user)
-        .resolves([this.groupSubscription])
-      const { bestSubscription: usersBestSubscription } =
-        await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-          this.user
-        )
-      assert.deepEqual(usersBestSubscription, {
-        type: 'group',
-        subscription: {},
-        plan: this.groupPlan,
-        remainingTrialDays: -1,
-      })
-    })
-
-    it('should return a group subscription with team name when user has one', async function () {
-      this.SubscriptionLocator.promises.getMemberSubscriptions
-        .withArgs(this.user)
-        .resolves([
-          Object.assign({}, this.groupSubscription, { teamName: 'test team' }),
-        ])
-      const { bestSubscription: usersBestSubscription } =
-        await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-          this.user
-        )
-      assert.deepEqual(usersBestSubscription, {
-        type: 'group',
-        subscription: { teamName: 'test team' },
-        plan: this.groupPlan,
-        remainingTrialDays: -1,
-      })
-    })
-
-    it('should return a commons subscription when user has an institution affiliation', async function () {
-      this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
-        .withArgs(this.user._id)
-        .resolves([this.commonsSubscription])
-
-      const { bestSubscription: usersBestSubscription } =
-        await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-          this.user
-        )
-
-      assert.deepEqual(usersBestSubscription, {
-        type: 'commons',
-        subscription: this.commonsSubscription,
-        plan: this.commonsPlan,
-      })
-    })
-
-    describe('with multiple subscriptions', function () {
-      beforeEach(function () {
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user)
-          .resolves(this.individualSubscription)
-        this.SubscriptionLocator.promises.getMemberSubscriptions
-          .withArgs(this.user)
-          .resolves([this.groupSubscription])
-        this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
-          .withArgs(this.user._id)
-          .resolves([this.commonsSubscription])
-      })
-
-      it('should return individual when the individual subscription has the best feature set', async function () {
-        this.commonsPlan.features = {
-          compileGroup: 'standard',
-          collaborators: 1,
-          compileTimeout: 60,
-        }
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'individual',
-          subscription: this.individualSubscription,
-          plan: this.plan,
-          remainingTrialDays: -1,
-        })
-      })
-
-      it('should return group when the group subscription has the best feature set', async function () {
-        this.plan.features = {
-          compileGroup: 'standard',
-          collaborators: 1,
-          compileTimeout: 60,
-        }
-        this.commonsPlan.features = {
-          compileGroup: 'standard',
-          collaborators: 1,
-          compileTimeout: 60,
-        }
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'group',
-          subscription: {},
-          plan: this.groupPlan,
-          remainingTrialDays: -1,
-        })
-      })
-
-      it('should return commons when the commons affiliation has the best feature set', async function () {
-        this.plan.features = {
-          compileGroup: 'priority',
-          collaborators: 5,
-          compileTimeout: 240,
-        }
-        this.groupPlan.features = {
-          compileGroup: 'standard',
-          collaborators: 1,
-          compileTimeout: 60,
-        }
-        this.commonsPlan.features = {
-          compileGroup: 'priority',
-          collaborators: -1,
-          compileTimeout: 240,
-        }
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'commons',
-          subscription: this.commonsSubscription,
-          plan: this.commonsPlan,
-        })
-      })
-
-      it('should return individual with equal feature sets', async function () {
-        this.plan.features = {
-          compileGroup: 'priority',
-          collaborators: -1,
-          compileTimeout: 240,
-        }
-        this.groupPlan.features = {
-          compileGroup: 'priority',
-          collaborators: -1,
-          compileTimeout: 240,
-        }
-        this.commonsPlan.features = {
-          compileGroup: 'priority',
-          collaborators: -1,
-          compileTimeout: 240,
-        }
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'individual',
-          subscription: this.individualSubscription,
-          plan: this.plan,
-          remainingTrialDays: -1,
-        })
-      })
-
-      it('should return group over commons with equal feature sets', async function () {
-        this.plan.features = {
-          compileGroup: 'standard',
-          collaborators: 1,
-          compileTimeout: 60,
-        }
-        this.groupPlan.features = {
-          compileGroup: 'priority',
-          collaborators: -1,
-          compileTimeout: 240,
-        }
-        this.commonsPlan.features = {
-          compileGroup: 'priority',
-          collaborators: -1,
-          compileTimeout: 240,
-        }
-
-        const { bestSubscription: usersBestSubscription } =
-          await this.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
-            this.user
-          )
-
-        assert.deepEqual(usersBestSubscription, {
-          type: 'group',
-          subscription: {},
-          plan: this.groupPlan,
-          remainingTrialDays: -1,
-        })
-      })
-    })
-  })
-
-  describe('buildUsersSubscriptionViewModel', function () {
-    beforeEach(function () {
-      this.SubscriptionLocator.getUsersSubscription.yields(
-        null,
-        this.individualSubscription
-      )
-      this.Modules.hooks.fire
-        .withArgs('getPaymentFromRecord', this.individualSubscription)
-        .yields(null, [
-          {
-            subscription: this.paymentRecord,
-            account: new PaymentProviderAccount({}),
-            coupons: [],
-          },
-        ])
-    })
-
-    describe('with a paid subscription', function () {
-      it('adds payment data to the personal subscription', async function () {
-        this.Modules.hooks.fire
-          .withArgs('getPaymentFromRecord', this.individualSubscription)
-          .yields(null, [
-            {
-              subscription: this.paymentRecord,
-              account: new PaymentProviderAccount({
-                email: 'example@example.com',
-                hasPastDueInvoice: false,
-              }),
-              coupons: [],
-            },
-          ])
-        const result =
-          await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-            this.user
-          )
-        assert.deepEqual(result.personalSubscription.payment, {
-          taxRate: 0.1,
-          billingDetailsLink: '/user/subscription/payment/billing-details',
-          accountManagementLink:
-            '/user/subscription/payment/account-management',
-          additionalLicenses: 0,
-          addOns: [
-            {
-              code: 'addon-code',
-              name: 'addon name',
-              quantity: 1,
-              unitPrice: 2,
-              preTaxTotal: 2,
-            },
-          ],
-          totalLicenses: 0,
-          nextPaymentDueAt: 'February 20th, 2025 12:00 PM UTC',
-          nextPaymentDueDate: 'February 20th, 2025',
-          currency: 'EUR',
-          state: 'active',
-          trialEndsAtFormatted: null,
-          trialEndsAt: null,
-          activeCoupons: [],
-          accountEmail: 'example@example.com',
-          hasPastDueInvoice: false,
-          pausedAt: null,
-          remainingPauseCycles: null,
-          displayPrice: '€16.50',
-          planOnlyDisplayPrice: '€14.30',
-          addOnDisplayPricesWithoutAdditionalLicense: {
-            'addon-code': '€2.20',
-          },
-          isEligibleForGroupPlan: true,
-          isEligibleForPause: false,
-          isEligibleForDowngradeUpsell: true,
-        })
-      })
-
-      describe('isEligibleForGroupPlan', function () {
-        it('is false for Stripe subscriptions', async function () {
-          this.paymentRecord.service = 'stripe-us'
-          this.Modules.promises.hooks.fire
-            .withArgs('canUpgradeFromIndividualToGroup')
-            .resolves([false])
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(
-            result.personalSubscription.payment.isEligibleForGroupPlan
-          )
-        })
-
-        it('is false when in trial', async function () {
-          const msIn24Hours = 24 * 60 * 60 * 1000
-          const tomorrow = new Date(Date.now() + msIn24Hours)
-          this.paymentRecord.trialPeriodEnd = tomorrow
-          this.paymentRecord.service = 'recurly'
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(
-            result.personalSubscription.payment.isEligibleForGroupPlan
-          )
-        })
-
-        it('is true when not in trial and for a Recurly subscription', async function () {
-          this.paymentRecord.service = 'recurly'
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isTrue(
-            result.personalSubscription.payment.isEligibleForGroupPlan
-          )
-        })
-      })
-
-      describe('isEligibleForPause', function () {
-        beforeEach(function () {
-          this.paymentRecord.service = 'recurly'
-          this.paymentRecord.addOns = []
-          this.paymentRecord.planCode = 'plan-code'
-          this.paymentRecord.trialPeriodEnd = null
-          this.individualSubscription.pendingPlan = undefined
-          this.individualSubscription.groupPlan = undefined
-        })
-
-        it('is false for Stripe subscriptions when feature flag is disabled', async function () {
-          this.paymentRecord.service = 'stripe-us'
-          this.SplitTestHandler.promises.getAssignmentForUser
-            .withArgs(this.user._id, 'stripe-pause')
-            .resolves({ variant: 'default' })
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
-        })
-
-        it('is true for Stripe subscriptions when feature flag is enabled', async function () {
-          this.paymentRecord.service = 'stripe-us'
-          this.SplitTestHandler.promises.getAssignmentForUser
-            .withArgs(this.user._id, 'stripe-pause')
-            .resolves({ variant: 'enabled' })
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isTrue(result.personalSubscription.payment.isEligibleForPause)
-        })
-
-        it('is false for Stripe subscriptions with pending plan even when feature flag is enabled', async function () {
-          this.paymentRecord.service = 'stripe-us'
-          this.individualSubscription.pendingPlan = {} // anything
-          this.SplitTestHandler.promises.getAssignmentForUser
-            .withArgs(this.user._id, 'stripe-pause')
-            .resolves({ variant: 'enabled' })
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
-        })
-
-        it('is false for Stripe subscriptions with annual plan even when feature flag is enabled', async function () {
-          this.paymentRecord.service = 'stripe-us'
-          this.paymentRecord.planCode = 'collaborator-annual'
-          this.SplitTestHandler.promises.getAssignmentForUser
-            .withArgs(this.user._id, 'stripe-pause')
-            .resolves({ variant: 'enabled' })
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
-        })
-
-        it('is false for subscriptions with pending plan', async function () {
-          this.paymentRecord.service = 'recurly'
-          this.individualSubscription.pendingPlan = {} // anything
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
-        })
-
-        it('is false for a group subscription', async function () {
-          this.paymentRecord.service = 'recurly'
-          this.individualSubscription.groupPlan = true
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
-        })
-
-        it('is false when in trial', async function () {
-          this.paymentRecord.service = 'recurly'
-          const msIn24Hours = 24 * 60 * 60 * 1000
-          const tomorrow = new Date(Date.now() + msIn24Hours)
-          this.paymentRecord.trialPeriodEnd = tomorrow
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
-        })
-
-        it('is false for annual subscriptions', async function () {
-          this.paymentRecord.service = 'recurly'
-          this.paymentRecord.planCode = 'collaborator-annual'
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
-        })
-
-        it('is false for subscriptions with add-ons', async function () {
-          this.paymentRecord.service = 'recurly'
-          this.paymentRecord.addOns = [{}] // anything
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(result.personalSubscription.payment.isEligibleForPause)
-        })
-
-        it('is true when conditions are met', async function () {
-          this.paymentRecord.service = 'recurly'
-          this.paymentRecord.addOns = []
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isTrue(result.personalSubscription.payment.isEligibleForPause)
-        })
-      })
-
-      describe('isEligibleForDowngradeUpsell', function () {
-        it('is true for eligible individual subscriptions', async function () {
-          this.paymentRecord.pausePeriodStart = null
-          this.paymentRecord.remainingPauseCycles = null
-          this.paymentRecord.trialPeriodEnd = null
-          this.paymentRecord.service = 'recurly'
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isTrue(
-            result.personalSubscription.payment.isEligibleForDowngradeUpsell
-          )
-        })
-
-        it('is false for group plans', async function () {
-          this.individualSubscription.planCode = this.groupPlanCode
-          this.paymentRecord.pausePeriodStart = null
-          this.paymentRecord.remainingPauseCycles = null
-          this.paymentRecord.trialPeriodEnd = null
-          this.paymentRecord.service = 'recurly'
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(
-            result.personalSubscription.payment.isEligibleForDowngradeUpsell
-          )
-        })
-
-        it('is false for annual individual plans', async function () {
-          this.individualSubscription.planCode = this.annualPlanCode
-          this.paymentRecord.pausePeriodStart = null
-          this.paymentRecord.remainingPauseCycles = null
-          this.paymentRecord.trialPeriodEnd = null
-          this.paymentRecord.service = 'recurly'
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(
-            result.personalSubscription.payment.isEligibleForDowngradeUpsell
-          )
-        })
-
-        it('is false for paused plans', async function () {
-          this.paymentRecord.pausePeriodStart = new Date()
-          this.paymentRecord.remainingPauseCycles = 1
-          this.paymentRecord.trialPeriodEnd = null
-          this.paymentRecord.service = 'recurly'
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(
-            result.personalSubscription.payment.isEligibleForDowngradeUpsell
-          )
-        })
-
-        it('is false for plans in free trial period', async function () {
-          this.paymentRecord.pausePeriodStart = null
-          this.paymentRecord.remainingPauseCycles = null
-          this.paymentRecord.trialPeriodEnd = new Date(
-            Date.now() + 24 * 60 * 60 * 1000 // tomorrow
-          )
-          this.paymentRecord.service = 'recurly'
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(
-            result.personalSubscription.payment.isEligibleForDowngradeUpsell
-          )
-        })
-
-        it('is false for Stripe subscriptions', async function () {
-          this.paymentRecord.pausePeriodStart = null
-          this.paymentRecord.remainingPauseCycles = null
-          this.paymentRecord.trialPeriodEnd = null
-          this.paymentRecord.service = 'stripe-us'
-          const result =
-            await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-              this.user
-            )
-          assert.isFalse(
-            result.personalSubscription.payment.isEligibleForDowngradeUpsell
-          )
-        })
-      })
-
-      it('includes pending changes', async function () {
-        this.paymentRecord.pendingChange =
-          new PaymentProviderSubscriptionChange({
-            subscription: this.paymentRecord,
-            nextPlanCode: this.groupPlanCode,
-            nextPlanName: 'Group Collaborator (Annual) 4 licenses',
-            nextPlanPrice: 1400,
-            nextAddOns: [
-              new PaymentProviderSubscriptionAddOn({
-                code: 'additional-license',
-                name: 'additional license',
-                quantity: 8,
-                unitPrice: 24.4,
-              }),
-              new PaymentProviderSubscriptionAddOn({
-                code: 'addon-code',
-                name: 'addon name',
-                quantity: 1,
-                unitPrice: 2,
-              }),
-            ],
-          })
-        this.Modules.hooks.fire
-          .withArgs('getPaymentFromRecord', this.individualSubscription)
-          .yields(null, [
-            {
-              subscription: this.paymentRecord,
-              account: {},
-              coupons: [],
-            },
-          ])
-        const result =
-          await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-            this.user
-          )
-        assert.equal(
-          result.personalSubscription.payment.displayPrice,
-          '€1,756.92'
-        )
-        assert.equal(
-          result.personalSubscription.payment.planOnlyDisplayPrice,
-          '€1,754.72'
-        )
-        assert.deepEqual(
-          result.personalSubscription.payment
-            .addOnDisplayPricesWithoutAdditionalLicense,
-          { 'addon-code': '€2.20' }
-        )
-        assert.equal(
-          result.personalSubscription.payment.pendingAdditionalLicenses,
-          8
-        )
-        assert.equal(
-          result.personalSubscription.payment.pendingTotalLicenses,
-          12
-        )
-      })
-
-      it('does not add a billing details link for a Stripe subscription', async function () {
-        this.paymentRecord.service = 'stripe-us'
-        this.Modules.hooks.fire
-          .withArgs('getPaymentFromRecord', this.individualSubscription)
-          .yields(null, [
-            {
-              subscription: this.paymentRecord,
-              account: new PaymentProviderAccount({}),
-              coupons: [],
-            },
-          ])
-        const result =
-          await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
-            this.user
-          )
-        assert.equal(
-          result.personalSubscription.payment.billingDetailsLink,
-          undefined
-        )
-        assert.equal(
-          result.personalSubscription.payment.accountManagementLink,
-          '/user/subscription/payment/account-management'
-        )
-      })
-    })
-  })
-})

+ 126 - 0
services/web/test/unit/src/Templates/TemplatesController.test.mjs

@@ -0,0 +1,126 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import ProjectHelper from '../../../../app/src/Features/Project/ProjectHelper.js'
+
+const modulePath =
+  '../../../../app/src/Features/Templates/TemplatesController.mjs'
+
+describe('TemplatesController', function () {
+  beforeEach(async function (ctx) {
+    ctx.user_id = 'user-id'
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectHelper', () => ({
+      default: ProjectHelper,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/AuthenticationController',
+      () => ({
+        default: (ctx.AuthenticationController = {
+          getLoggedInUserId: sinon.stub().returns(ctx.user_id),
+        }),
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Templates/TemplatesManager',
+      () => ({
+        default: (ctx.TemplatesManager = {
+          promises: { createProjectFromV1Template: sinon.stub() },
+        }),
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: (ctx.SplitTestHandler = {
+          promises: {
+            getAssignment: sinon.stub().resolves({ variant: 'default' }),
+          },
+        }),
+      })
+    )
+
+    ctx.TemplatesController = (await import(modulePath)).default
+    ctx.next = sinon.stub()
+    ctx.req = {
+      body: {
+        brandVariationId: 'brand-variation-id',
+        compiler: 'compiler',
+        mainFile: 'main-file',
+        templateId: 'template-id',
+        templateName: 'template-name',
+        templateVersionId: 'template-version-id',
+      },
+      session: {
+        templateData: 'template-data',
+        user: {
+          _id: ctx.user_id,
+        },
+      },
+    }
+    return (ctx.res = { redirect: sinon.stub() })
+  })
+
+  describe('createProjectFromV1Template', function () {
+    describe('on success', function () {
+      beforeEach(function (ctx) {
+        ctx.project = { _id: 'project-id' }
+        ctx.TemplatesManager.promises.createProjectFromV1Template.resolves(
+          ctx.project
+        )
+        return ctx.TemplatesController.createProjectFromV1Template(
+          ctx.req,
+          ctx.res,
+          ctx.next
+        )
+      })
+
+      it('should call TemplatesManager', function (ctx) {
+        return ctx.TemplatesManager.promises.createProjectFromV1Template.should.have.been.calledWithMatch(
+          'brand-variation-id',
+          'compiler',
+          'main-file',
+          'template-id',
+          'template-name',
+          'template-version-id',
+          'user-id'
+        )
+      })
+
+      it('should redirect to project', function (ctx) {
+        return ctx.res.redirect.should.have.been.calledWith(
+          '/project/project-id'
+        )
+      })
+
+      it('should delete session', function (ctx) {
+        return expect(ctx.req.session.templateData).to.be.undefined
+      })
+    })
+
+    describe('on error', function () {
+      beforeEach(function (ctx) {
+        ctx.TemplatesManager.promises.createProjectFromV1Template.rejects(
+          'error'
+        )
+        return ctx.TemplatesController.createProjectFromV1Template(
+          ctx.req,
+          ctx.res,
+          ctx.next
+        )
+      })
+
+      it('should call next with error', function (ctx) {
+        return ctx.next.should.have.been.calledWithMatch(
+          sinon.match.instanceOf(Error)
+        )
+      })
+
+      it('should not redirect', function (ctx) {
+        return ctx.res.redirect.called.should.equal(false)
+      })
+    })
+  })
+})

+ 0 - 108
services/web/test/unit/src/Templates/TemplatesControllerTests.js

@@ -1,108 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const sinon = require('sinon')
-const ProjectHelper = require('../../../../app/src/Features/Project/ProjectHelper')
-
-const modulePath = '../../../../app/src/Features/Templates/TemplatesController'
-
-describe('TemplatesController', function () {
-  beforeEach(function () {
-    this.user_id = 'user-id'
-    this.TemplatesController = SandboxedModule.require(modulePath, {
-      requires: {
-        '../Project/ProjectHelper': ProjectHelper,
-        '../Authentication/AuthenticationController':
-          (this.AuthenticationController = {
-            getLoggedInUserId: sinon.stub().returns(this.user_id),
-          }),
-        './TemplatesManager': (this.TemplatesManager = {
-          promises: { createProjectFromV1Template: sinon.stub() },
-        }),
-        '../SplitTests/SplitTestHandler': (this.SplitTestHandler = {
-          promises: {
-            getAssignment: sinon.stub().resolves({ variant: 'default' }),
-          },
-        }),
-      },
-    })
-    this.next = sinon.stub()
-    this.req = {
-      body: {
-        brandVariationId: 'brand-variation-id',
-        compiler: 'compiler',
-        mainFile: 'main-file',
-        templateId: 'template-id',
-        templateName: 'template-name',
-        templateVersionId: 'template-version-id',
-      },
-      session: {
-        templateData: 'template-data',
-        user: {
-          _id: this.user_id,
-        },
-      },
-    }
-    return (this.res = { redirect: sinon.stub() })
-  })
-
-  describe('createProjectFromV1Template', function () {
-    describe('on success', function () {
-      beforeEach(function () {
-        this.project = { _id: 'project-id' }
-        this.TemplatesManager.promises.createProjectFromV1Template.resolves(
-          this.project
-        )
-        return this.TemplatesController.createProjectFromV1Template(
-          this.req,
-          this.res,
-          this.next
-        )
-      })
-
-      it('should call TemplatesManager', function () {
-        return this.TemplatesManager.promises.createProjectFromV1Template.should.have.been.calledWithMatch(
-          'brand-variation-id',
-          'compiler',
-          'main-file',
-          'template-id',
-          'template-name',
-          'template-version-id',
-          'user-id'
-        )
-      })
-
-      it('should redirect to project', function () {
-        return this.res.redirect.should.have.been.calledWith(
-          '/project/project-id'
-        )
-      })
-
-      it('should delete session', function () {
-        return expect(this.req.session.templateData).to.be.undefined
-      })
-    })
-
-    describe('on error', function () {
-      beforeEach(function () {
-        this.TemplatesManager.promises.createProjectFromV1Template.rejects(
-          'error'
-        )
-        return this.TemplatesController.createProjectFromV1Template(
-          this.req,
-          this.res,
-          this.next
-        )
-      })
-
-      it('should call next with error', function () {
-        return this.next.should.have.been.calledWithMatch(
-          sinon.match.instanceOf(Error)
-        )
-      })
-
-      it('should not redirect', function () {
-        return this.res.redirect.called.should.equal(false)
-      })
-    })
-  })
-})

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно