Просмотр исходного кода

Convert tests to ESM

GitOrigin-RevId: 03bd4db8cddc548706439edd7f6db0bc3e7ed9d3
Andrew Rumble 9 месяцев назад
Родитель
Сommit
93b7274ea6
61 измененных файлов с 7852 добавлено и 7468 удалено
  1. 2 2
      services/web/app/src/Features/User/UserSessionsManager.mjs
  2. 124 126
      services/web/test/unit/src/Analytics/AccountMappingHelper.test.mjs
  3. 241 229
      services/web/test/unit/src/Analytics/AnalyticsManager.test.mjs
  4. 17 12
      services/web/test/unit/src/Analytics/EmailChangeHelpers.test.mjs
  5. 309 280
      services/web/test/unit/src/Authentication/AuthenticationManager.test.mjs
  6. 51 52
      services/web/test/unit/src/Authentication/SessionManager.test.mjs
  7. 96 97
      services/web/test/unit/src/Chat/ChatApiHandler.test.mjs
  8. 104 97
      services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs
  9. 4 10
      services/web/test/unit/src/Collaborators/CollaboratorsInviteHelper.test.mjs
  10. 46 45
      services/web/test/unit/src/Contact/ContactManager.test.mjs
  11. 209 212
      services/web/test/unit/src/Docstore/DocstoreManager.test.mjs
  12. 72 92
      services/web/test/unit/src/Documents/DocumentHelper.test.mjs
  13. 71 66
      services/web/test/unit/src/Editor/EditorRealTimeController.test.mjs
  14. 347 338
      services/web/test/unit/src/Email/EmailBuilder.test.mjs
  15. 76 67
      services/web/test/unit/src/Email/EmailHandler.test.mjs
  16. 81 70
      services/web/test/unit/src/Email/EmailSender.test.mjs
  17. 2 7
      services/web/test/unit/src/Email/SpamSafe.test.mjs
  18. 156 222
      services/web/test/unit/src/Errors/HttpErrorHandler.test.mjs
  19. 7 7
      services/web/test/unit/src/HelperFiles/SafeHTMLSubstitute.test.mjs
  20. 22 18
      services/web/test/unit/src/HelperFiles/UrlHelper.test.mjs
  21. 135 118
      services/web/test/unit/src/History/HistoryManager.test.mjs
  22. 43 1
      services/web/test/unit/src/History/RestoreManager.test.mjs
  23. 2 8
      services/web/test/unit/src/Institutions/InstitutionHelper.test.mjs
  24. 181 167
      services/web/test/unit/src/Institutions/InstitutionsAPI.test.mjs
  25. 74 66
      services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs
  26. 81 80
      services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs
  27. 62 58
      services/web/test/unit/src/Notifications/NotificationsBuilder.test.mjs
  28. 69 65
      services/web/test/unit/src/Notifications/NotificationsHandler.test.mjs
  29. 21 18
      services/web/test/unit/src/Project/FolderStructureBuilder.test.mjs
  30. 159 160
      services/web/test/unit/src/Project/ProjectEditorHandler.test.mjs
  31. 61 57
      services/web/test/unit/src/Project/ProjectHelper.test.mjs
  32. 242 309
      services/web/test/unit/src/Project/ProjectListController.test.mjs
  33. 102 110
      services/web/test/unit/src/Project/ProjectOptionsHandler.test.mjs
  34. 46 45
      services/web/test/unit/src/Project/ProjectUpdateHandler.test.mjs
  35. 101 101
      services/web/test/unit/src/Project/SafePath.test.mjs
  36. 33 22
      services/web/test/unit/src/Publishers/PublishersGetter.test.mjs
  37. 42 41
      services/web/test/unit/src/Referal/ReferalFeatures.test.mjs
  38. 212 159
      services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs
  39. 42 29
      services/web/test/unit/src/SplitTests/SplitTestSessionHandler.test.mjs
  40. 33 34
      services/web/test/unit/src/Subscription/FeaturesHelper.test.mjs
  41. 204 174
      services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs
  42. 165 156
      services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs
  43. 65 70
      services/web/test/unit/src/Subscription/PlansLocator.test.mjs
  44. 284 275
      services/web/test/unit/src/Subscription/RecurlyClient.test.mjs
  45. 319 309
      services/web/test/unit/src/Subscription/RecurlyWrapper.test.mjs
  46. 67 62
      services/web/test/unit/src/Subscription/SubscriptionLocator.test.mjs
  47. 379 337
      services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs
  48. 27 26
      services/web/test/unit/src/Subscription/UserFeaturesUpdater.test.mjs
  49. 202 163
      services/web/test/unit/src/Subscription/V1SusbcriptionManager.test.mjs
  50. 153 154
      services/web/test/unit/src/Tags/TagsHandler.test.mjs
  51. 9 11
      services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs
  52. 319 293
      services/web/test/unit/src/TokenAccess/TokenAccessHandler.test.mjs
  53. 386 351
      services/web/test/unit/src/Uploads/ArchiveManager.test.mjs
  54. 129 125
      services/web/test/unit/src/Uploads/FileTypeManager.test.mjs
  55. 130 129
      services/web/test/unit/src/User/ThirdPartyIdentityManager.test.mjs
  56. 91 83
      services/web/test/unit/src/User/UserAuditLogHandler.test.mjs
  57. 306 281
      services/web/test/unit/src/User/UserGetter.test.mjs
  58. 328 338
      services/web/test/unit/src/User/UserSessionsManager.test.mjs
  59. 450 381
      services/web/test/unit/src/User/UserUpdater.test.mjs
  60. 18 0
      services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs
  61. 43 53
      services/web/test/unit/src/UserMembership/UserMembershipsHandler.test.mjs

+ 2 - 2
services/web/app/src/Features/User/UserSessionsManager.mjs

@@ -1,7 +1,7 @@
 import Settings from '@overleaf/settings'
 import logger from '@overleaf/logger'
 import _ from 'lodash'
-import { callbackifyAll } from 'node:util'
+import { callbackifyAll } from '@overleaf/promise-utils'
 import UserSessionsRedis from './UserSessionsRedis.mjs'
 const rclient = UserSessionsRedis.client()
 
@@ -163,5 +163,5 @@ const UserSessionsManager = {
 
 export default {
   ...callbackifyAll(UserSessionsManager),
-  promises: UserSessionsManager
+  promises: UserSessionsManager,
 }

+ 124 - 126
services/web/test/unit/src/Analytics/AccountMappingHelper.test.mjs

@@ -1,121 +1,122 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const { ObjectId } = require('mongodb-legacy')
-const path = require('node:path')
+import { expect } from 'vitest'
+import mongodb from 'mongodb-legacy'
+import path from 'node:path'
+
+const { ObjectId } = mongodb
 
 const MODULE_PATH = path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/Features/Analytics/AccountMappingHelper'
 )
 
 describe('AccountMappingHelper', function () {
-  beforeEach(function () {
-    this.AccountMappingHelper = SandboxedModule.require(MODULE_PATH)
+  beforeEach(async function (ctx) {
+    ctx.AccountMappingHelper = (await import(MODULE_PATH)).default
   })
 
   describe('extractAccountMappingsFromSubscription', function () {
     describe('when the v1 id is the same in the updated subscription and the subscription', function () {
       describe('when the salesforce id is the same in the updated subscription and the subscription', function () {
-        beforeEach(function () {
-          this.subscription = {
+        beforeEach(function (ctx) {
+          ctx.subscription = {
             id: new ObjectId('abc123abc123abc123abc123'),
             salesforce_id: 'def456def456def456',
           }
-          this.updatedSubscription = { salesforce_id: 'def456def456def456' }
-          this.result =
-            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-              this.subscription,
-              this.updatedSubscription
+          ctx.updatedSubscription = { salesforce_id: 'def456def456def456' }
+          ctx.result =
+            ctx.AccountMappingHelper.extractAccountMappingsFromSubscription(
+              ctx.subscription,
+              ctx.updatedSubscription
             )
         })
 
-        it('returns an empty array', function () {
-          expect(this.result).to.be.an('array')
-          expect(this.result).to.have.length(0)
+        it('returns an empty array', function (ctx) {
+          expect(ctx.result).to.be.an('array')
+          expect(ctx.result).to.have.length(0)
         })
       })
       describe('when the salesforce id has changed between the subscription and the updated subscription', function () {
-        beforeEach(function () {
-          this.subscription = {
+        beforeEach(function (ctx) {
+          ctx.subscription = {
             id: new ObjectId('abc123abc123abc123abc123'),
             salesforce_id: 'def456def456def456',
           }
-          this.updatedSubscription = { salesforce_id: 'ghi789ghi789ghi789' }
-          this.result =
-            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-              this.subscription,
-              this.updatedSubscription
+          ctx.updatedSubscription = { salesforce_id: 'ghi789ghi789ghi789' }
+          ctx.result =
+            ctx.AccountMappingHelper.extractAccountMappingsFromSubscription(
+              ctx.subscription,
+              ctx.updatedSubscription
             )
         })
 
-        it('returns an array with a single item', function () {
-          expect(this.result).to.be.an('array')
-          expect(this.result).to.have.length(1)
+        it('returns an array with a single item', function (ctx) {
+          expect(ctx.result).to.be.an('array')
+          expect(ctx.result).to.have.length(1)
         })
 
-        it('uses "account" as sourceEntity', function () {
-          expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'account')
+        it('uses "account" as sourceEntity', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty('sourceEntity', 'account')
         })
 
-        it('uses the salesforceId from the updated subscription as sourceEntityId', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses the salesforceId from the updated subscription as sourceEntityId', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'sourceEntityId',
-            this.updatedSubscription.salesforce_id
+            ctx.updatedSubscription.salesforce_id
           )
         })
 
-        it('uses "subscription" as targetEntity', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses "subscription" as targetEntity', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'targetEntity',
             'subscription'
           )
         })
 
-        it('uses the subscriptionId as targetEntityId', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses the subscriptionId as targetEntityId', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'targetEntityId',
-            this.subscription.id
+            ctx.subscription.id
           )
         })
       })
       describe('when the update subscription has a salesforce id and the subscription has no salesforce_id', function () {
-        beforeEach(function () {
-          this.subscription = { id: new ObjectId('abc123abc123abc123abc123') }
-          this.updatedSubscription = { salesforce_id: 'def456def456def456' }
-          this.result =
-            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-              this.subscription,
-              this.updatedSubscription
+        beforeEach(function (ctx) {
+          ctx.subscription = { id: new ObjectId('abc123abc123abc123abc123') }
+          ctx.updatedSubscription = { salesforce_id: 'def456def456def456' }
+          ctx.result =
+            ctx.AccountMappingHelper.extractAccountMappingsFromSubscription(
+              ctx.subscription,
+              ctx.updatedSubscription
             )
         })
 
-        it('returns an array with a single item', function () {
-          expect(this.result).to.be.an('array')
-          expect(this.result).to.have.length(1)
+        it('returns an array with a single item', function (ctx) {
+          expect(ctx.result).to.be.an('array')
+          expect(ctx.result).to.have.length(1)
         })
 
-        it('uses "account" as sourceEntity', function () {
-          expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'account')
+        it('uses "account" as sourceEntity', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty('sourceEntity', 'account')
         })
 
-        it('uses the salesforceId from the updated subscription as sourceEntityId', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses the salesforceId from the updated subscription as sourceEntityId', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'sourceEntityId',
-            this.updatedSubscription.salesforce_id
+            ctx.updatedSubscription.salesforce_id
           )
         })
 
-        it('uses "subscription" as targetEntity', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses "subscription" as targetEntity', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'targetEntity',
             'subscription'
           )
         })
 
-        it('uses the subscriptionId as targetEntityId', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses the subscriptionId as targetEntityId', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'targetEntityId',
-            this.subscription.id
+            ctx.subscription.id
           )
         })
       })
@@ -123,153 +124,150 @@ describe('AccountMappingHelper', function () {
 
     describe('when the v1 id has changed between the subscription and the updated subscription', function () {
       describe('when the salesforce id has not changed between the subscription and the updated subscription', function () {
-        beforeEach(function () {
-          this.subscription = {
+        beforeEach(function (ctx) {
+          ctx.subscription = {
             id: new ObjectId('abc123abc123abc123abc123'),
             v1_id: '1',
             salesforce_id: '',
           }
-          this.updatedSubscription = { v1_id: '2', salesforce_id: '' }
-          this.result =
-            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-              this.subscription,
-              this.updatedSubscription
+          ctx.updatedSubscription = { v1_id: '2', salesforce_id: '' }
+          ctx.result =
+            ctx.AccountMappingHelper.extractAccountMappingsFromSubscription(
+              ctx.subscription,
+              ctx.updatedSubscription
             )
         })
 
-        it('returns an array with a single item', function () {
-          expect(this.result).to.be.an('array')
-          expect(this.result).to.have.length(1)
+        it('returns an array with a single item', function (ctx) {
+          expect(ctx.result).to.be.an('array')
+          expect(ctx.result).to.have.length(1)
         })
 
-        it('uses "university" as the sourceEntity', function () {
-          expect(this.result[0]).to.haveOwnProperty(
-            'sourceEntity',
-            'university'
-          )
+        it('uses "university" as the sourceEntity', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty('sourceEntity', 'university')
         })
 
-        it('uses the v1_id from the updated subscription as the sourceEntityId', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses the v1_id from the updated subscription as the sourceEntityId', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'sourceEntityId',
-            this.updatedSubscription.v1_id
+            ctx.updatedSubscription.v1_id
           )
         })
 
-        it('uses "subscription" as the targetEntity', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses "subscription" as the targetEntity', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'targetEntity',
             'subscription'
           )
         })
 
-        it('uses the subscription id as the targetEntityId', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses the subscription id as the targetEntityId', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'targetEntityId',
-            this.subscription.id
+            ctx.subscription.id
           )
         })
       })
       describe('when the salesforce id has changed between the subscription and the updated subscription', function () {
-        beforeEach(function () {
-          this.subscription = {
+        beforeEach(function (ctx) {
+          ctx.subscription = {
             id: new ObjectId('abc123abc123abc123abc123'),
             v1_id: '',
             salesforce_id: 'def456def456def456',
           }
-          this.updatedSubscription = {
+          ctx.updatedSubscription = {
             v1_id: '2',
             salesforce_id: '',
           }
-          this.result =
-            this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-              this.subscription,
-              this.updatedSubscription
+          ctx.result =
+            ctx.AccountMappingHelper.extractAccountMappingsFromSubscription(
+              ctx.subscription,
+              ctx.updatedSubscription
             )
         })
 
-        it('returns an array with two items', function () {
-          expect(this.result).to.be.an('array')
-          expect(this.result).to.have.length(2)
+        it('returns an array with two items', function (ctx) {
+          expect(ctx.result).to.be.an('array')
+          expect(ctx.result).to.have.length(2)
         })
 
-        it('uses the salesforce_id from the updated subscription as the sourceEntityId for the first item', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses the salesforce_id from the updated subscription as the sourceEntityId for the first item', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'sourceEntityId',
-            this.updatedSubscription.salesforce_id
+            ctx.updatedSubscription.salesforce_id
           )
         })
 
-        it('uses the subscription id as the targetEntityId for the first item', function () {
-          expect(this.result[0]).to.haveOwnProperty(
+        it('uses the subscription id as the targetEntityId for the first item', function (ctx) {
+          expect(ctx.result[0]).to.haveOwnProperty(
             'targetEntityId',
-            this.subscription.id
+            ctx.subscription.id
           )
         })
 
-        it('uses the v1_id from the updated subscription as the sourceEntityId for the second item', function () {
-          expect(this.result[1]).to.haveOwnProperty(
+        it('uses the v1_id from the updated subscription as the sourceEntityId for the second item', function (ctx) {
+          expect(ctx.result[1]).to.haveOwnProperty(
             'sourceEntityId',
-            this.updatedSubscription.v1_id
+            ctx.updatedSubscription.v1_id
           )
         })
 
-        it('uses the subscription id as the targetEntityId for the second item', function () {
-          expect(this.result[1]).to.haveOwnProperty(
+        it('uses the subscription id as the targetEntityId for the second item', function (ctx) {
+          expect(ctx.result[1]).to.haveOwnProperty(
             'targetEntityId',
-            this.subscription.id
+            ctx.subscription.id
           )
         })
       })
     })
   })
   describe('when the recurlySubscription_id has changed between the subscription and the updated subscription', function () {
-    beforeEach(function () {
-      this.subscription = {
+    beforeEach(function (ctx) {
+      ctx.subscription = {
         id: new ObjectId('abc123abc123abc123abc123'),
         recurlySubscription_id: '',
       }
-      this.updatedSubscription = {
+      ctx.updatedSubscription = {
         recurlySubscription_id: '1234a5678b90123cd4567e8f901a2b34',
       }
-      this.result =
-        this.AccountMappingHelper.extractAccountMappingsFromSubscription(
-          this.subscription,
-          this.updatedSubscription
+      ctx.result =
+        ctx.AccountMappingHelper.extractAccountMappingsFromSubscription(
+          ctx.subscription,
+          ctx.updatedSubscription
         )
     })
-    it('returns an array with one item', function () {
-      expect(this.result).to.be.an('array')
-      expect(this.result).to.have.length(1)
+    it('returns an array with one item', function (ctx) {
+      expect(ctx.result).to.be.an('array')
+      expect(ctx.result).to.have.length(1)
     })
 
-    it('uses "recurly" as the source', function () {
-      expect(this.result[0]).to.haveOwnProperty('source', 'recurly')
+    it('uses "recurly" as the source', function (ctx) {
+      expect(ctx.result[0]).to.haveOwnProperty('source', 'recurly')
     })
 
-    it('uses "subscription" as the sourceEntity', function () {
-      expect(this.result[0]).to.haveOwnProperty('sourceEntity', 'subscription')
+    it('uses "subscription" as the sourceEntity', function (ctx) {
+      expect(ctx.result[0]).to.haveOwnProperty('sourceEntity', 'subscription')
     })
 
-    it('uses the recurlySubscription_id as the sourceEntityId', function () {
-      expect(this.result[0]).to.haveOwnProperty(
+    it('uses the recurlySubscription_id as the sourceEntityId', function (ctx) {
+      expect(ctx.result[0]).to.haveOwnProperty(
         'sourceEntityId',
-        this.updatedSubscription.recurlySubscription_id
+        ctx.updatedSubscription.recurlySubscription_id
       )
     })
 
-    it('uses "v2" as the target', function () {
-      expect(this.result[0]).to.haveOwnProperty('target', 'v2')
+    it('uses "v2" as the target', function (ctx) {
+      expect(ctx.result[0]).to.haveOwnProperty('target', 'v2')
     })
 
-    it('uses "subscription" as the targetEntity', function () {
-      expect(this.result[0]).to.haveOwnProperty('targetEntity', 'subscription')
+    it('uses "subscription" as the targetEntity', function (ctx) {
+      expect(ctx.result[0]).to.haveOwnProperty('targetEntity', 'subscription')
     })
 
-    it('uses the subscription id as the targetEntityId', function () {
-      expect(this.result[0]).to.haveOwnProperty(
+    it('uses the subscription id as the targetEntityId', function (ctx) {
+      expect(ctx.result[0]).to.haveOwnProperty(
         'targetEntityId',
-        this.subscription.id
+        ctx.subscription.id
       )
     })
   })

+ 241 - 229
services/web/test/unit/src/Analytics/AnalyticsManager.test.mjs

@@ -1,186 +1,201 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const MockRequest = require('../helpers/MockRequest')
-const MockResponse = require('../helpers/MockResponse')
-const { assert } = require('chai')
-const { ObjectId } = require('mongodb-legacy')
+import { vi, assert } from 'vitest'
+import path from 'path'
+import sinon from 'sinon'
+import MockRequest from '../helpers/MockRequest.js'
+import MockResponse from '../helpers/MockResponse.js'
+import mongodb from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
 
 const MODULE_PATH = path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/Features/Analytics/AnalyticsManager'
 )
+vi.mock('../../../../app/src/infrastructure/Metrics.js', () => ({
+  default: {
+    analyticsQueue: {
+      inc: vi.fn(),
+    },
+  },
+}))
 
 describe('AnalyticsManager', function () {
-  beforeEach(function () {
-    this.fakeUserId = 'dbfc9438d14996f73dd172fb'
-    this.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55'
-    this.Settings = {
+  beforeEach(async function (ctx) {
+    ctx.fakeUserId = 'dbfc9438d14996f73dd172fb'
+    ctx.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55'
+    ctx.Settings = {
       analytics: { enabled: true, hashedEmailSalt: 'salt' },
     }
-    this.analyticsEventsQueue = {
+    ctx.analyticsEventsQueue = {
       add: sinon.stub().resolves(),
       process: sinon.stub().resolves(),
     }
-    this.analyticsEditingSessionQueue = {
+    ctx.analyticsEditingSessionQueue = {
       add: sinon.stub().resolves(),
       process: sinon.stub().resolves(),
     }
-    this.onboardingEmailsQueue = {
+    ctx.onboardingEmailsQueue = {
       add: sinon.stub().resolves(),
       process: sinon.stub().resolves(),
     }
-    this.analyticsUserPropertiesQueue = {
+    ctx.analyticsUserPropertiesQueue = {
       add: sinon.stub().resolves(),
       process: sinon.stub().resolves(),
     }
-    this.analyticsAccountMappingQueue = {
+    ctx.analyticsAccountMappingQueue = {
       add: sinon.stub().resolves(),
       process: sinon.stub().resolves(),
     }
-    this.analyticsEmailChangeQueue = {
+    ctx.analyticsEmailChangeQueue = {
       add: sinon.stub().resolves(),
       process: sinon.stub().resolves(),
     }
-    this.Queues = {
+    ctx.Queues = {
       getQueue: queueName => {
         switch (queueName) {
           case 'analytics-events':
-            return this.analyticsEventsQueue
+            return ctx.analyticsEventsQueue
           case 'analytics-editing-sessions':
-            return this.analyticsEditingSessionQueue
+            return ctx.analyticsEditingSessionQueue
           case 'emails-onboarding':
-            return this.onboardingEmailsQueue
+            return ctx.onboardingEmailsQueue
           case 'analytics-user-properties':
-            return this.analyticsUserPropertiesQueue
+            return ctx.analyticsUserPropertiesQueue
           case 'analytics-account-mapping':
-            return this.analyticsAccountMappingQueue
+            return ctx.analyticsAccountMappingQueue
           case 'analytics-email-change':
-            return this.analyticsEmailChangeQueue
+            return ctx.analyticsEmailChangeQueue
           default:
             throw new Error('Unexpected queue name')
         }
       },
       createScheduledJob: sinon.stub().resolves(),
     }
-    this.backgroundRequest = sinon.stub().yields()
-    this.request = sinon.stub().yields()
-    this.AnalyticsManager = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '@overleaf/settings': this.Settings,
-        '../../infrastructure/Queues': this.Queues,
-        './UserAnalyticsIdCache': (this.UserAnalyticsIdCache = {
-          get: sinon.stub().resolves(this.analyticsId),
+    ctx.backgroundRequest = sinon.stub().yields()
+    ctx.request = sinon.stub().yields()
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.Settings,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
+      default: ctx.Queues,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/UserAnalyticsIdCache',
+      () => ({
+        default: (ctx.UserAnalyticsIdCache = {
+          get: sinon.stub().resolves(ctx.analyticsId),
         }),
-      },
-    })
+      })
+    )
+
+    ctx.AnalyticsManager = (await import(MODULE_PATH)).default
   })
 
   describe('ignores when', function () {
-    it('user is smoke test user', function () {
-      this.Settings.smokeTest = { userId: this.fakeUserId }
-      this.AnalyticsManager.identifyUser(this.fakeUserId, '')
-      sinon.assert.notCalled(this.Queues.createScheduledJob)
+    it('user is smoke test user', function (ctx) {
+      ctx.Settings.smokeTest = { userId: ctx.fakeUserId }
+      ctx.AnalyticsManager.identifyUser(ctx.fakeUserId, '')
+      sinon.assert.notCalled(ctx.Queues.createScheduledJob)
     })
 
-    it('analytics service is disabled', function () {
-      this.Settings.analytics.enabled = false
-      this.AnalyticsManager.identifyUser(this.fakeUserId, '')
-      sinon.assert.notCalled(this.Queues.createScheduledJob)
+    it('analytics service is disabled', function (ctx) {
+      ctx.Settings.analytics.enabled = false
+      ctx.AnalyticsManager.identifyUser(ctx.fakeUserId, '')
+      sinon.assert.notCalled(ctx.Queues.createScheduledJob)
     })
 
-    it('userId is missing', function () {
-      this.AnalyticsManager.identifyUser(undefined, this.analyticsId)
-      sinon.assert.notCalled(this.Queues.createScheduledJob)
+    it('userId is missing', function (ctx) {
+      ctx.AnalyticsManager.identifyUser(undefined, ctx.analyticsId)
+      sinon.assert.notCalled(ctx.Queues.createScheduledJob)
     })
 
-    it('analyticsId is missing', function () {
-      this.AnalyticsManager.identifyUser(
-        new ObjectId(this.fakeUserId),
-        undefined
-      )
-      sinon.assert.notCalled(this.Queues.createScheduledJob)
+    it('analyticsId is missing', function (ctx) {
+      ctx.AnalyticsManager.identifyUser(new ObjectId(ctx.fakeUserId), undefined)
+      sinon.assert.notCalled(ctx.Queues.createScheduledJob)
     })
 
-    it('analyticsId is not a valid UUID', function () {
-      this.AnalyticsManager.identifyUser(
-        new ObjectId(this.fakeUserId),
-        this.fakeUserId
+    it('analyticsId is not a valid UUID', function (ctx) {
+      ctx.AnalyticsManager.identifyUser(
+        new ObjectId(ctx.fakeUserId),
+        ctx.fakeUserId
       )
-      sinon.assert.notCalled(this.Queues.createScheduledJob)
+      sinon.assert.notCalled(ctx.Queues.createScheduledJob)
     })
 
-    it('userId and analyticsId are the same Mongo ID', function () {
-      this.AnalyticsManager.identifyUser(
-        new ObjectId(this.fakeUserId),
-        new ObjectId(this.fakeUserId)
+    it('userId and analyticsId are the same Mongo ID', function (ctx) {
+      ctx.AnalyticsManager.identifyUser(
+        new ObjectId(ctx.fakeUserId),
+        new ObjectId(ctx.fakeUserId)
       )
-      sinon.assert.notCalled(this.Queues.createScheduledJob)
+      sinon.assert.notCalled(ctx.Queues.createScheduledJob)
     })
 
-    it('editing session segmentation is not valid', function () {
-      this.AnalyticsManager.updateEditingSession(
-        this.fakeUserId,
+    it('editing session segmentation is not valid', function (ctx) {
+      ctx.AnalyticsManager.updateEditingSession(
+        ctx.fakeUserId,
         '789ghi',
         'fr',
         { '<alert>': 'foo' }
       )
-      sinon.assert.called(this.logger.info)
-      sinon.assert.notCalled(this.analyticsEditingSessionQueue.add)
+      expect(ctx.logger.info).toHaveBeenCalled()
+      sinon.assert.notCalled(ctx.analyticsEditingSessionQueue.add)
     })
 
-    it('event is not valid', async function () {
-      await this.AnalyticsManager.recordEventForUser(
-        this.fakeUserId,
+    it('event is not valid', async function (ctx) {
+      await ctx.AnalyticsManager.recordEventForUser(
+        ctx.fakeUserId,
         'not an event!'
       )
-      sinon.assert.called(this.logger.info)
-      sinon.assert.notCalled(this.analyticsEventsQueue.add)
+      expect(ctx.logger.info).toHaveBeenCalled()
+      sinon.assert.notCalled(ctx.analyticsEventsQueue.add)
     })
 
-    it('event segmentation is not valid', async function () {
-      await this.AnalyticsManager.recordEventForUser(
-        this.fakeUserId,
+    it('event segmentation is not valid', async function (ctx) {
+      await ctx.AnalyticsManager.recordEventForUser(
+        ctx.fakeUserId,
         'an_event',
         { 'not_a!': 'Valid Segmentation' }
       )
-      sinon.assert.called(this.logger.info)
-      sinon.assert.notCalled(this.analyticsEventsQueue.add)
+      expect(ctx.logger.info).toHaveBeenCalled()
+      sinon.assert.notCalled(ctx.analyticsEventsQueue.add)
     })
 
-    it('user property name is not valid', async function () {
-      await this.AnalyticsManager.setUserPropertyForUser(
-        this.fakeUserId,
+    it('user property name is not valid', async function (ctx) {
+      await ctx.AnalyticsManager.setUserPropertyForUser(
+        ctx.fakeUserId,
         'an invalid property',
         'a_value'
       )
-      sinon.assert.called(this.logger.info)
-      sinon.assert.notCalled(this.analyticsUserPropertiesQueue.add)
+      expect(ctx.logger.info).toHaveBeenCalled()
+      sinon.assert.notCalled(ctx.analyticsUserPropertiesQueue.add)
     })
 
-    it('user property value is not valid', async function () {
-      await this.AnalyticsManager.setUserPropertyForUser(
-        this.fakeUserId,
+    it('user property value is not valid', async function (ctx) {
+      await ctx.AnalyticsManager.setUserPropertyForUser(
+        ctx.fakeUserId,
         'a_property',
         'an invalid value'
       )
-      sinon.assert.called(this.logger.info)
-      sinon.assert.notCalled(this.analyticsUserPropertiesQueue.add)
+      expect(ctx.logger.info).toHaveBeenCalled()
+      sinon.assert.notCalled(ctx.analyticsUserPropertiesQueue.add)
     })
   })
 
   describe('queues the appropriate message for', function () {
-    it('identifyUser', function () {
+    it('identifyUser', function (ctx) {
       const analyticsId = 'bd101c4c-722f-4204-9e2d-8303e5d9c120'
-      this.AnalyticsManager.identifyUser(this.fakeUserId, analyticsId, true)
-      sinon.assert.notCalled(this.logger.info)
+      ctx.AnalyticsManager.identifyUser(ctx.fakeUserId, analyticsId, true)
+      expect(ctx.logger.info).not.toHaveBeenCalled()
       sinon.assert.calledWithMatch(
-        this.Queues.createScheduledJob,
+        ctx.Queues.createScheduledJob,
         'analytics-events',
         {
           name: 'identify',
           data: {
-            userId: this.fakeUserId,
+            userId: ctx.fakeUserId,
             analyticsId,
             isNewUser: true,
             createdAt: sinon.match.date,
@@ -190,38 +205,34 @@ describe('AnalyticsManager', function () {
       )
     })
 
-    it('recordEventForUser', async function () {
+    it('recordEventForUser', async function (ctx) {
       const event = 'fake-event'
-      await this.AnalyticsManager.recordEventForUser(
-        this.fakeUserId,
-        event,
-        null
-      )
-      sinon.assert.notCalled(this.logger.info)
-      sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
-        analyticsId: this.analyticsId,
+      await ctx.AnalyticsManager.recordEventForUser(ctx.fakeUserId, event, null)
+      expect(ctx.logger.info).not.toHaveBeenCalled()
+      sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', {
+        analyticsId: ctx.analyticsId,
         event,
         segmentation: null,
         isLoggedIn: true,
       })
     })
 
-    it('updateEditingSession', function () {
+    it('updateEditingSession', function (ctx) {
       const projectId = '789ghi'
       const countryCode = 'fr'
       const segmentation = { editorType: 'abc' }
-      this.AnalyticsManager.updateEditingSession(
-        this.fakeUserId,
+      ctx.AnalyticsManager.updateEditingSession(
+        ctx.fakeUserId,
         projectId,
         countryCode,
         segmentation
       )
-      sinon.assert.notCalled(this.logger.info)
+      expect(ctx.logger.info).not.toHaveBeenCalled()
       sinon.assert.calledWithMatch(
-        this.analyticsEditingSessionQueue.add,
+        ctx.analyticsEditingSessionQueue.add,
         'editing-session',
         {
-          userId: this.fakeUserId,
+          userId: ctx.fakeUserId,
           projectId,
           countryCode,
           segmentation,
@@ -229,68 +240,68 @@ describe('AnalyticsManager', function () {
       )
     })
 
-    it('empty field in event segmentation', async function () {
+    it('empty field in event segmentation', async function (ctx) {
       const timings = null
-      await this.AnalyticsManager.recordEventForUser(
-        this.fakeUserId,
+      await ctx.AnalyticsManager.recordEventForUser(
+        ctx.fakeUserId,
         'an_event',
         { compileTime: timings?.compileE2E }
       )
-      sinon.assert.notCalled(this.logger.info)
-      sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
-        analyticsId: this.analyticsId,
+      expect(ctx.logger.info).not.toHaveBeenCalled()
+      sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', {
+        analyticsId: ctx.analyticsId,
         event: 'an_event',
         segmentation: { compileTime: undefined },
         isLoggedIn: true,
       })
     })
 
-    it('empty space in event segmentation value', async function () {
-      await this.AnalyticsManager.recordEventForUser(
-        this.fakeUserId,
+    it('empty space in event segmentation value', async function (ctx) {
+      await ctx.AnalyticsManager.recordEventForUser(
+        ctx.fakeUserId,
         'an_event',
         { segment: 'a value with spaces' }
       )
-      sinon.assert.notCalled(this.logger.info)
-      sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
-        analyticsId: this.analyticsId,
+      expect(ctx.logger.info).not.toHaveBeenCalled()
+      sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', {
+        analyticsId: ctx.analyticsId,
         event: 'an_event',
         segmentation: { segment: 'a value with spaces' },
         isLoggedIn: true,
       })
     })
 
-    it('percent sign in event segmentation value', async function () {
-      await this.AnalyticsManager.recordEventForUser(
-        this.fakeUserId,
+    it('percent sign in event segmentation value', async function (ctx) {
+      await ctx.AnalyticsManager.recordEventForUser(
+        ctx.fakeUserId,
         'an_event',
         { segment: 'a value with escaped comma %2C' }
       )
-      sinon.assert.notCalled(this.logger.info)
-      sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
-        analyticsId: this.analyticsId,
+      expect(ctx.logger.info).not.toHaveBeenCalled()
+      sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', {
+        analyticsId: ctx.analyticsId,
         event: 'an_event',
         segmentation: { segment: 'a value with escaped comma %2C' },
         isLoggedIn: true,
       })
     })
 
-    it('boolean field in event segmentation', async function () {
-      await this.AnalyticsManager.recordEventForUser(
-        this.fakeUserId,
+    it('boolean field in event segmentation', async function (ctx) {
+      await ctx.AnalyticsManager.recordEventForUser(
+        ctx.fakeUserId,
         'an_event',
         { isAutoCompile: false }
       )
-      sinon.assert.notCalled(this.logger.info)
-      sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
-        analyticsId: this.analyticsId,
+      expect(ctx.logger.info).not.toHaveBeenCalled()
+      sinon.assert.calledWithMatch(ctx.analyticsEventsQueue.add, 'event', {
+        analyticsId: ctx.analyticsId,
         event: 'an_event',
         segmentation: { isAutoCompile: false },
         isLoggedIn: true,
       })
     })
 
-    it('account mapping', async function () {
+    it('account mapping', async function (ctx) {
       const message = {
         source: 'salesforce',
         sourceEntity: 'account',
@@ -300,24 +311,24 @@ describe('AnalyticsManager', function () {
         targetEntityId: 1,
         createdAt: '2021-01-01T00:00:00Z',
       }
-      await this.AnalyticsManager.registerAccountMapping(message)
+      await ctx.AnalyticsManager.registerAccountMapping(message)
       sinon.assert.calledWithMatch(
-        this.analyticsAccountMappingQueue.add,
+        ctx.analyticsAccountMappingQueue.add,
         'account-mapping',
         message
       )
     })
 
-    it('email change', async function () {
+    it('email change', async function (ctx) {
       const message = {
-        userId: this.fakeUserId,
+        userId: ctx.fakeUserId,
         email: 'test@example.com',
         createdAt: '2021-01-01T00:00:00Z',
         action: 'created',
         emailCreatedAt: '2021-01-01T00:00:00Z',
         isPrimary: false,
       }
-      this.AnalyticsManager.registerEmailChange(message)
+      ctx.AnalyticsManager.registerEmailChange(message)
       const convertedMessage = {
         ...message,
         emailConfirmedAt: undefined,
@@ -326,7 +337,7 @@ describe('AnalyticsManager', function () {
           '1778d425d64c5259ef7b574a2488647eb51ca739a0b16bfa0e2e3e16fff362db', // sha256 hash of email + salt
       }
       sinon.assert.calledWithMatch(
-        this.analyticsEmailChangeQueue.add,
+        ctx.analyticsEmailChangeQueue.add,
         'email-change',
         convertedMessage
       )
@@ -334,123 +345,124 @@ describe('AnalyticsManager', function () {
   })
 
   describe('AnalyticsIdMiddleware', function () {
-    beforeEach(function () {
-      this.userId = '123abc'
-      this.analyticsId = 'bccd308c-5d72-426e-a106-662e88557795'
-      this.AnalyticsManager = SandboxedModule.require(MODULE_PATH, {
-        requires: {
-          '@overleaf/settings': {
-            analytics: { hashedEmailSalt: 'test-salt' },
-          },
-          '../../infrastructure/Queues': {
-            getQueue: queueName => {
-              switch (queueName) {
-                case 'analytics-events':
-                  return this.analyticsEventsQueue
-                case 'analytics-editing-sessions':
-                  return this.analyticsEditingSessionQueue
-                case 'emails-onboarding':
-                  return this.onboardingEmailsQueue
-                case 'analytics-user-properties':
-                  return this.analyticsUserPropertiesQueue
-                case 'analytics-account-mapping':
-                  return this.analyticsAccountMappingQueue
-                case 'analytics-email-change':
-                  return this.analyticsEmailChangeQueue
-                default:
-                  throw new Error('Unexpected queue name')
-              }
-            },
+    beforeEach(async function (ctx) {
+      vi.resetModules()
+      ctx.userId = '123abc'
+      ctx.analyticsId = 'bccd308c-5d72-426e-a106-662e88557795'
+
+      vi.doMock('@overleaf/settings', () => ({
+        default: {
+          analytics: { hashedEmailSalt: 'test-salt' },
+        },
+      }))
+
+      vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
+        default: {
+          getQueue: queueName => {
+            switch (queueName) {
+              case 'analytics-events':
+                return ctx.analyticsEventsQueue
+              case 'analytics-editing-sessions':
+                return ctx.analyticsEditingSessionQueue
+              case 'emails-onboarding':
+                return ctx.onboardingEmailsQueue
+              case 'analytics-user-properties':
+                return ctx.analyticsUserPropertiesQueue
+              case 'analytics-account-mapping':
+                return ctx.analyticsAccountMappingQueue
+              case 'analytics-email-change':
+                return ctx.analyticsEmailChangeQueue
+              default:
+                throw new Error('Unexpected queue name')
+            }
           },
+        },
+      }))
 
-          './UserAnalyticsIdCache': (this.UserAnalyticsIdCache = {
-            get: sinon.stub().resolves(this.analyticsId),
+      vi.doMock(
+        '../../../../app/src/Features/Analytics/UserAnalyticsIdCache',
+        () => ({
+          default: (ctx.UserAnalyticsIdCache = {
+            get: sinon.stub().resolves(ctx.analyticsId),
           }),
-          crypto: {
-            randomUUID: () => this.analyticsId,
-          },
+        })
+      )
+
+      vi.doMock('node:crypto', () => ({
+        default: {
+          randomUUID: () => ctx.analyticsId,
         },
-      })
-      this.req = new MockRequest()
-      this.req.session = {}
-      this.res = new MockResponse()
-      this.next = () => {}
+      }))
+
+      ctx.AnalyticsManager = (await import(MODULE_PATH)).default
+      ctx.req = new MockRequest()
+      ctx.req.session = {}
+      ctx.res = new MockResponse()
+      ctx.next = () => {}
     })
 
-    it('sets session.analyticsId with no user in session', async function () {
-      await this.AnalyticsManager.analyticsIdMiddleware(
-        this.req,
-        this.res,
-        this.next
+    it('sets session.analyticsId with no user in session', async function (ctx) {
+      await ctx.AnalyticsManager.analyticsIdMiddleware(
+        ctx.req,
+        ctx.res,
+        ctx.next
       )
-      assert.equal(this.analyticsId, this.req.session.analyticsId)
+      assert.equal(ctx.analyticsId, ctx.req.session.analyticsId)
     })
 
-    it('does not update analyticsId when existing, with no user in session', async function () {
-      this.req.session.analyticsId = 'foo'
-      await this.AnalyticsManager.analyticsIdMiddleware(
-        this.req,
-        this.res,
-        this.next
+    it('does not update analyticsId when existing, with no user in session', async function (ctx) {
+      ctx.req.session.analyticsId = 'foo'
+      await ctx.AnalyticsManager.analyticsIdMiddleware(
+        ctx.req,
+        ctx.res,
+        ctx.next
       )
-      assert.equal('foo', this.req.session.analyticsId)
+      assert.equal('foo', ctx.req.session.analyticsId)
     })
 
-    it('sets session.analyticsId with a logged in user in session having an analyticsId', async function () {
-      this.req.session.user = {
-        _id: this.userId,
-        analyticsId: this.analyticsId,
+    it('sets session.analyticsId with a logged in user in session having an analyticsId', async function (ctx) {
+      ctx.req.session.user = {
+        _id: ctx.userId,
+        analyticsId: ctx.analyticsId,
       }
-      await this.AnalyticsManager.analyticsIdMiddleware(
-        this.req,
-        this.res,
-        () => {
-          assert.equal(this.analyticsId, this.req.session.analyticsId)
-        }
-      )
+      await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
+        assert.equal(ctx.analyticsId, ctx.req.session.analyticsId)
+      })
     })
 
-    it('sets session.analyticsId with a legacy user session without an analyticsId', async function () {
-      this.UserAnalyticsIdCache.get.resolves(this.userId)
-      this.req.session.user = {
-        _id: this.userId,
+    it('sets session.analyticsId with a legacy user session without an analyticsId', async function (ctx) {
+      ctx.UserAnalyticsIdCache.get.resolves(ctx.userId)
+      ctx.req.session.user = {
+        _id: ctx.userId,
         analyticsId: undefined,
       }
-      await this.AnalyticsManager.analyticsIdMiddleware(
-        this.req,
-        this.res,
-        () => {
-          assert.equal(this.userId, this.req.session.analyticsId)
-        }
-      )
+      await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
+        assert.equal(ctx.userId, ctx.req.session.analyticsId)
+      })
     })
 
-    it('updates session.analyticsId with a legacy user session without an analyticsId if different', async function () {
-      this.UserAnalyticsIdCache.get.resolves(this.userId)
-      this.req.session.user = {
-        _id: this.userId,
+    it('updates session.analyticsId with a legacy user session without an analyticsId if different', async function (ctx) {
+      ctx.UserAnalyticsIdCache.get.resolves(ctx.userId)
+      ctx.req.session.user = {
+        _id: ctx.userId,
         analyticsId: undefined,
       }
-      this.req.analyticsId = 'foo'
-      this.AnalyticsManager.analyticsIdMiddleware(this.req, this.res, () => {
-        assert.equal(this.userId, this.req.session.analyticsId)
+      ctx.req.analyticsId = 'foo'
+      ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
+        assert.equal(ctx.userId, ctx.req.session.analyticsId)
       })
     })
 
-    it('does not update session.analyticsId with a legacy user session without an analyticsId if same', async function () {
-      this.UserAnalyticsIdCache.get.resolves(this.userId)
-      this.req.session.user = {
-        _id: this.userId,
+    it('does not update session.analyticsId with a legacy user session without an analyticsId if same', async function (ctx) {
+      ctx.UserAnalyticsIdCache.get.resolves(ctx.userId)
+      ctx.req.session.user = {
+        _id: ctx.userId,
         analyticsId: undefined,
       }
-      this.req.analyticsId = this.userId
-      await this.AnalyticsManager.analyticsIdMiddleware(
-        this.req,
-        this.res,
-        () => {
-          assert.equal(this.userId, this.req.session.analyticsId)
-        }
-      )
+      ctx.req.analyticsId = ctx.userId
+      await ctx.AnalyticsManager.analyticsIdMiddleware(ctx.req, ctx.res, () => {
+        assert.equal(ctx.userId, ctx.req.session.analyticsId)
+      })
     })
   })
 })

+ 17 - 12
services/web/test/unit/src/Analytics/EmailChangeHelpers.test.mjs

@@ -1,6 +1,5 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
 
 describe('EmailChangeHelper', function () {
   let AnalyticsManager
@@ -8,7 +7,7 @@ describe('EmailChangeHelper', function () {
   let EmailChangeHelpers
   const email = 'test@example.com'
   const userId = '507f1f77bcf86cd799439011'
-  beforeEach(function () {
+  beforeEach(async function () {
     UserGetter = {
       promises: {
         getUserFullEmails: sinon.stub().resolves([]),
@@ -17,15 +16,21 @@ describe('EmailChangeHelper', function () {
     AnalyticsManager = {
       registerEmailChange: sinon.stub(),
     }
-    EmailChangeHelpers = SandboxedModule.require(
-      '../../../../app/src/Features/Analytics/EmailChangeHelper',
-      {
-        requires: {
-          '../User/UserGetter': UserGetter,
-          './AnalyticsManager': AnalyticsManager,
-        },
-      }
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager',
+      () => ({
+        default: AnalyticsManager,
+      })
     )
+
+    EmailChangeHelpers = (
+      await import('../../../../app/src/Features/Analytics/EmailChangeHelper')
+    ).default
   })
 
   describe('registerEmailUpdate', function () {

Разница между файлами не показана из-за своего большого размера
+ 309 - 280
services/web/test/unit/src/Authentication/AuthenticationManager.test.mjs


+ 51 - 52
services/web/test/unit/src/Authentication/SessionManager.test.mjs

@@ -1,26 +1,25 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
+import { expect } from 'vitest'
+import sinon from 'sinon'
+import tk from 'timekeeper'
+import mongodb from 'mongodb-legacy'
 const modulePath =
-  '../../../../app/src/Features/Authentication/SessionManager.js'
-const SandboxedModule = require('sandboxed-module')
-const tk = require('timekeeper')
-const { ObjectId } = require('mongodb-legacy')
+  '../../../../app/src/Features/Authentication/SessionManager.mjs'
+
+const { ObjectId } = mongodb
 
 describe('SessionManager', function () {
-  beforeEach(function () {
-    this.UserModel = { findOne: sinon.stub() }
-    this.SessionManager = SandboxedModule.require(modulePath, {
-      requires: {},
-    })
-    this.user = {
+  beforeEach(async function (ctx) {
+    ctx.UserModel = { findOne: sinon.stub() }
+    ctx.SessionManager = (await import(modulePath)).default
+    ctx.user = {
       _id: new ObjectId(),
-      email: (this.email = 'USER@example.com'),
+      email: (ctx.email = 'USER@example.com'),
       first_name: 'bob',
       last_name: 'brown',
       referal_id: 1234,
       isAdmin: false,
     }
-    this.session = sinon.stub()
+    ctx.session = sinon.stub()
   })
 
   afterEach(function () {
@@ -28,39 +27,39 @@ describe('SessionManager', function () {
   })
 
   describe('isUserLoggedIn', function () {
-    beforeEach(function () {
-      this.stub = sinon.stub(this.SessionManager, 'getLoggedInUserId')
+    beforeEach(function (ctx) {
+      ctx.stub = sinon.stub(ctx.SessionManager, 'getLoggedInUserId')
     })
 
-    afterEach(function () {
-      this.stub.restore()
+    afterEach(function (ctx) {
+      ctx.stub.restore()
     })
 
-    it('should do the right thing in all cases', function () {
-      this.SessionManager.getLoggedInUserId.returns('some_id')
-      expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(true)
-      this.SessionManager.getLoggedInUserId.returns(null)
-      expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(false)
-      this.SessionManager.getLoggedInUserId.returns(false)
-      expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(false)
-      this.SessionManager.getLoggedInUserId.returns(undefined)
-      expect(this.SessionManager.isUserLoggedIn(this.session)).to.equal(false)
+    it('should do the right thing in all cases', function (ctx) {
+      ctx.SessionManager.getLoggedInUserId.returns('some_id')
+      expect(ctx.SessionManager.isUserLoggedIn(ctx.session)).to.equal(true)
+      ctx.SessionManager.getLoggedInUserId.returns(null)
+      expect(ctx.SessionManager.isUserLoggedIn(ctx.session)).to.equal(false)
+      ctx.SessionManager.getLoggedInUserId.returns(false)
+      expect(ctx.SessionManager.isUserLoggedIn(ctx.session)).to.equal(false)
+      ctx.SessionManager.getLoggedInUserId.returns(undefined)
+      expect(ctx.SessionManager.isUserLoggedIn(ctx.session)).to.equal(false)
     })
   })
 
   describe('setInSessionUser', function () {
-    beforeEach(function () {
-      this.user = {
+    beforeEach(function (ctx) {
+      ctx.user = {
         _id: 'id',
         first_name: 'a',
         last_name: 'b',
         email: 'c',
       }
-      this.SessionManager.getSessionUser = sinon.stub().returns(this.user)
+      ctx.SessionManager.getSessionUser = sinon.stub().returns(ctx.user)
     })
 
-    it('should update the right properties', function () {
-      this.SessionManager.setInSessionUser(this.session, {
+    it('should update the right properties', function (ctx) {
+      ctx.SessionManager.setInSessionUser(ctx.session, {
         first_name: 'new_first_name',
         email: 'new_email',
       })
@@ -70,44 +69,44 @@ describe('SessionManager', function () {
         last_name: 'b',
         email: 'new_email',
       }
-      expect(this.user).to.deep.equal(expectedUser)
-      expect(this.user).to.deep.equal(expectedUser)
+      expect(ctx.user).to.deep.equal(expectedUser)
+      expect(ctx.user).to.deep.equal(expectedUser)
     })
   })
 
   describe('getLoggedInUserId', function () {
-    beforeEach(function () {
-      this.req = { session: {} }
+    beforeEach(function (ctx) {
+      ctx.req = { session: {} }
     })
 
-    it('should return the user id from the session', function () {
-      this.user_id = '2134'
-      this.session.user = { _id: this.user_id }
-      const result = this.SessionManager.getLoggedInUserId(this.session)
-      expect(result).to.equal(this.user_id)
+    it('should return the user id from the session', function (ctx) {
+      ctx.user_id = '2134'
+      ctx.session.user = { _id: ctx.user_id }
+      const result = ctx.SessionManager.getLoggedInUserId(ctx.session)
+      expect(result).to.equal(ctx.user_id)
     })
 
-    it('should return user for passport session', function () {
-      this.user_id = '2134'
-      this.session = {
+    it('should return user for passport session', function (ctx) {
+      ctx.user_id = '2134'
+      ctx.session = {
         passport: {
           user: {
-            _id: this.user_id,
+            _id: ctx.user_id,
           },
         },
       }
-      const result = this.SessionManager.getLoggedInUserId(this.session)
-      expect(result).to.equal(this.user_id)
+      const result = ctx.SessionManager.getLoggedInUserId(ctx.session)
+      expect(result).to.equal(ctx.user_id)
     })
 
-    it('should return null if there is no user on the session', function () {
-      this.session = {}
-      const result = this.SessionManager.getLoggedInUserId(this.session)
+    it('should return null if there is no user on the session', function (ctx) {
+      ctx.session = {}
+      const result = ctx.SessionManager.getLoggedInUserId(ctx.session)
       expect(result).to.equal(null)
     })
 
-    it('should return null if there is no session', function () {
-      const result = this.SessionManager.getLoggedInUserId(undefined)
+    it('should return null if there is no session', function (ctx) {
+      const result = ctx.SessionManager.getLoggedInUserId(undefined)
       expect(result).to.equal(null)
     })
   })

+ 96 - 97
services/web/test/unit/src/Chat/ChatApiHandler.test.mjs

@@ -1,179 +1,182 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const { RequestFailedError } = require('@overleaf/fetch-utils')
+import { vi, expect } from 'vitest'
+import path from 'path'
+import sinon from 'sinon'
+import { RequestFailedError } from '@overleaf/fetch-utils'
 
 const MODULE_PATH = path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/Features/Chat/ChatApiHandler'
 )
 
 describe('ChatApiHandler', function () {
-  beforeEach(function () {
-    this.settings = {
+  beforeEach(async function (ctx) {
+    ctx.settings = {
       apis: {
         chat: {
           internal_url: 'http://chat.overleaf.env',
         },
       },
     }
-    this.FetchUtils = {
+    ctx.FetchUtils = {
       fetchJson: sinon.stub(),
       fetchNothing: sinon.stub().resolves(),
     }
-    this.ChatApiHandler = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '@overleaf/settings': this.settings,
-        '@overleaf/fetch-utils': this.FetchUtils,
-      },
-    })
-    this.project_id = '3213213kl12j'
-    this.user_id = '2k3jlkjs9'
-    this.content = 'my message here'
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('@overleaf/fetch-utils', () => ctx.FetchUtils)
+
+    ctx.ChatApiHandler = (await import(MODULE_PATH)).default
+    ctx.project_id = '3213213kl12j'
+    ctx.user_id = '2k3jlkjs9'
+    ctx.content = 'my message here'
   })
 
   describe('sendGlobalMessage', function () {
     describe('successfully', function () {
-      beforeEach(async function () {
-        this.message = { mock: 'message' }
-        this.FetchUtils.fetchJson.resolves(this.message)
-        this.result = await this.ChatApiHandler.promises.sendGlobalMessage(
-          this.project_id,
-          this.user_id,
-          this.content
+      beforeEach(async function (ctx) {
+        ctx.message = { mock: 'message' }
+        ctx.FetchUtils.fetchJson.resolves(ctx.message)
+        ctx.result = await ctx.ChatApiHandler.promises.sendGlobalMessage(
+          ctx.project_id,
+          ctx.user_id,
+          ctx.content
         )
       })
 
-      it('should post the data to the chat api', function () {
-        this.FetchUtils.fetchJson.should.have.been.calledWith(
+      it('should post the data to the chat api', function (ctx) {
+        ctx.FetchUtils.fetchJson.should.have.been.calledWith(
           sinon.match(
             url =>
               url.toString() ===
-              `${this.settings.apis.chat.internal_url}/project/${this.project_id}/messages`
+              `${ctx.settings.apis.chat.internal_url}/project/${ctx.project_id}/messages`
           ),
           {
             method: 'POST',
             json: {
-              content: this.content,
-              user_id: this.user_id,
+              content: ctx.content,
+              user_id: ctx.user_id,
             },
           }
         )
       })
 
-      it('should return the message from the post', function () {
-        expect(this.result).to.deep.equal(this.message)
+      it('should return the message from the post', function (ctx) {
+        expect(ctx.result).to.deep.equal(ctx.message)
       })
     })
 
     describe('with a non-success status code', function () {
-      beforeEach(async function () {
-        this.error = new RequestFailedError('some-url', {}, { status: 500 })
-        this.FetchUtils.fetchJson.rejects(this.error)
+      beforeEach(async function (ctx) {
+        ctx.error = new RequestFailedError('some-url', {}, { status: 500 })
+        ctx.FetchUtils.fetchJson.rejects(ctx.error)
+      })
+
+      it('should throw the error', async function (ctx) {
         await expect(
-          this.ChatApiHandler.promises.sendGlobalMessage(
-            this.project_id,
-            this.user_id,
-            this.content
+          ctx.ChatApiHandler.promises.sendGlobalMessage(
+            ctx.project_id,
+            ctx.user_id,
+            ctx.content
           )
-        ).to.be.rejectedWith(this.error)
+        ).to.be.rejectedWith(ctx.error)
       })
     })
   })
 
   describe('getGlobalMessages', function () {
-    beforeEach(function () {
-      this.messages = [{ mock: 'message' }]
-      this.limit = 30
-      this.before = '1234'
+    beforeEach(function (ctx) {
+      ctx.messages = [{ mock: 'message' }]
+      ctx.limit = 30
+      ctx.before = '1234'
     })
 
     describe('successfully', function () {
-      beforeEach(async function () {
-        this.FetchUtils.fetchJson.resolves(this.messages)
-        this.result = await this.ChatApiHandler.promises.getGlobalMessages(
-          this.project_id,
-          this.limit,
-          this.before
+      beforeEach(async function (ctx) {
+        ctx.FetchUtils.fetchJson.resolves(ctx.messages)
+        ctx.result = await ctx.ChatApiHandler.promises.getGlobalMessages(
+          ctx.project_id,
+          ctx.limit,
+          ctx.before
         )
       })
 
-      it('should make get request for room to chat api', function () {
-        this.FetchUtils.fetchJson.should.have.been.calledWith(
+      it('should make get request for room to chat api', function (ctx) {
+        ctx.FetchUtils.fetchJson.should.have.been.calledWith(
           sinon.match(
             url =>
               url.toString() ===
-              `${this.settings.apis.chat.internal_url}/project/${this.project_id}/messages?limit=${this.limit}&before=${this.before}`
+              `${ctx.settings.apis.chat.internal_url}/project/${ctx.project_id}/messages?limit=${ctx.limit}&before=${ctx.before}`
           )
         )
       })
 
-      it('should return the messages from the request', function () {
-        expect(this.result).to.deep.equal(this.messages)
+      it('should return the messages from the request', function (ctx) {
+        expect(ctx.result).to.deep.equal(ctx.messages)
       })
     })
 
     describe('with failure error code', function () {
-      beforeEach(async function () {
-        this.error = new RequestFailedError('some-url', {}, { status: 500 })
-        this.FetchUtils.fetchJson.rejects(this.error)
+      beforeEach(function (ctx) {
+        ctx.error = new RequestFailedError('some-url', {}, { status: 500 })
+        ctx.FetchUtils.fetchJson.rejects(ctx.error)
+      })
+
+      it('should throw the error', async function (ctx) {
         await expect(
-          this.ChatApiHandler.getGlobalMessages(
-            this.project_id,
-            this.limit,
-            this.before
+          ctx.ChatApiHandler.promises.getGlobalMessages(
+            ctx.project_id,
+            ctx.limit,
+            ctx.before
           )
-        ).to.be.rejectedWith(this.error)
+        ).to.be.rejectedWith(ctx.error)
       })
     })
   })
 
   describe('duplicateCommentThreads', function () {
-    beforeEach(async function () {
-      this.FetchUtils.fetchJson.resolves(
-        (this.mapping = {
+    beforeEach(async function (ctx) {
+      ctx.FetchUtils.fetchJson.resolves(
+        (ctx.mapping = {
           'comment-thread-1': 'comment-thread-1-dup',
           'comment-thread-2': 'comment-thread-2-dup',
           'comment-thread-3': 'comment-thread-3-dup',
         })
       )
-      this.threads = [
-        'comment-thread-1',
-        'comment-thread-2',
-        'comment-thread-3',
-      ]
-      this.result = await this.ChatApiHandler.promises.duplicateCommentThreads(
-        this.project_id,
-        this.threads
+      ctx.threads = ['comment-thread-1', 'comment-thread-2', 'comment-thread-3']
+      ctx.result = await ctx.ChatApiHandler.promises.duplicateCommentThreads(
+        ctx.project_id,
+        ctx.threads
       )
     })
 
-    it('should make a post request to the chat api', function () {
-      expect(this.FetchUtils.fetchJson).to.have.been.calledWith(
+    it('should make a post request to the chat api', function (ctx) {
+      expect(ctx.FetchUtils.fetchJson).to.have.been.calledWith(
         sinon.match(
           url =>
             url.toString() ===
-            `${this.settings.apis.chat.internal_url}/project/${this.project_id}/duplicate-comment-threads`
+            `${ctx.settings.apis.chat.internal_url}/project/${ctx.project_id}/duplicate-comment-threads`
         ),
         {
           method: 'POST',
           json: {
-            threads: this.threads,
+            threads: ctx.threads,
           },
         }
       )
     })
 
-    it('should return the thread mapping', function () {
-      expect(this.result).to.deep.equal(this.mapping)
+    it('should return the thread mapping', function (ctx) {
+      expect(ctx.result).to.deep.equal(ctx.mapping)
     })
   })
 
   describe('generateThreadData', async function () {
-    beforeEach(async function () {
-      this.FetchUtils.fetchJson.resolves(
-        (this.chatResponse = {
+    beforeEach(async function (ctx) {
+      ctx.FetchUtils.fetchJson.resolves(
+        (ctx.chatResponse = {
           'comment-thread-1': {
             messages: [
               {
@@ -196,35 +199,31 @@ describe('ChatApiHandler', function () {
       )
       // Chat won't return threads that couldn't be found, so response can have
       // fewer threads
-      this.threads = [
-        'comment-thread-1',
-        'comment-thread-2',
-        'comment-thread-3',
-      ]
-      this.result = await this.ChatApiHandler.promises.generateThreadData(
-        this.project_id,
-        this.threads
+      ctx.threads = ['comment-thread-1', 'comment-thread-2', 'comment-thread-3']
+      ctx.result = await ctx.ChatApiHandler.promises.generateThreadData(
+        ctx.project_id,
+        ctx.threads
       )
     })
 
-    it('should make a post request to the chat api', function () {
-      expect(this.FetchUtils.fetchJson).to.have.been.calledWith(
+    it('should make a post request to the chat api', function (ctx) {
+      expect(ctx.FetchUtils.fetchJson).to.have.been.calledWith(
         sinon.match(
           url =>
             url.toString() ===
-            `${this.settings.apis.chat.internal_url}/project/${this.project_id}/generate-thread-data`
+            `${ctx.settings.apis.chat.internal_url}/project/${ctx.project_id}/generate-thread-data`
         ),
         {
           method: 'POST',
           json: {
-            threads: this.threads,
+            threads: ctx.threads,
           },
         }
       )
     })
 
-    it('should return the thread data', function () {
-      expect(this.result).to.deep.equal(this.chatResponse)
+    it('should return the thread data', function (ctx) {
+      expect(ctx.result).to.deep.equal(ctx.chatResponse)
     })
   })
 })

+ 104 - 97
services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs

@@ -1,15 +1,16 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
-const Crypto = require('crypto')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+import Crypto from 'crypto'
+
+const { ObjectId } = mongodb
 
 const MODULE_PATH =
-  '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.js'
+  '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs'
 
 describe('CollaboratorsInviteGetter', function () {
-  beforeEach(function () {
-    this.ProjectInvite = class ProjectInvite {
+  beforeEach(async function (ctx) {
+    ctx.ProjectInvite = class ProjectInvite {
       constructor(options) {
         if (options == null) {
           options = {}
@@ -21,95 +22,101 @@ describe('CollaboratorsInviteGetter', function () {
         }
       }
     }
-    this.ProjectInvite.prototype.save = sinon.stub()
-    this.ProjectInvite.findOne = sinon.stub()
-    this.ProjectInvite.find = sinon.stub()
-    this.ProjectInvite.deleteOne = sinon.stub()
-    this.ProjectInvite.findOneAndDelete = sinon.stub()
-    this.ProjectInvite.countDocuments = sinon.stub()
-
-    this.Crypto = {
+    ctx.ProjectInvite.prototype.save = sinon.stub()
+    ctx.ProjectInvite.findOne = sinon.stub()
+    ctx.ProjectInvite.find = sinon.stub()
+    ctx.ProjectInvite.deleteOne = sinon.stub()
+    ctx.ProjectInvite.findOneAndDelete = sinon.stub()
+    ctx.ProjectInvite.countDocuments = sinon.stub()
+
+    ctx.Crypto = {
       randomBytes: sinon.stub().callsFake(Crypto.randomBytes),
     }
 
-    this.CollaboratorsInviteHelper = {
-      generateToken: sinon.stub().returns(this.Crypto.randomBytes(24)),
-      hashInviteToken: sinon.stub().returns(this.tokenHmac),
+    ctx.CollaboratorsInviteHelper = {
+      generateToken: sinon.stub().returns(ctx.Crypto.randomBytes(24)),
+      hashInviteToken: sinon.stub().returns(ctx.tokenHmac),
     }
 
-    this.CollaboratorsInviteGetter = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '../../models/ProjectInvite': { ProjectInvite: this.ProjectInvite },
-        './CollaboratorsInviteHelper': this.CollaboratorsInviteHelper,
-      },
-    })
-
-    this.projectId = new ObjectId()
-    this.sendingUserId = new ObjectId()
-    this.email = 'user@example.com'
-    this.userId = new ObjectId()
-    this.inviteId = new ObjectId()
-    this.token = 'hnhteaosuhtaeosuahs'
-    this.privileges = 'readAndWrite'
-    this.fakeInvite = {
-      _id: this.inviteId,
-      email: this.email,
-      token: this.token,
-      tokenHmac: this.tokenHmac,
-      sendingUserId: this.sendingUserId,
-      projectId: this.projectId,
-      privileges: this.privileges,
+    vi.doMock('../../../../app/src/models/ProjectInvite', () => ({
+      ProjectInvite: ctx.ProjectInvite,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper',
+      () => ({
+        default: ctx.CollaboratorsInviteHelper,
+      })
+    )
+
+    ctx.CollaboratorsInviteGetter = (await import(MODULE_PATH)).default
+
+    ctx.projectId = new ObjectId()
+    ctx.sendingUserId = new ObjectId()
+    ctx.email = 'user@example.com'
+    ctx.userId = new ObjectId()
+    ctx.inviteId = new ObjectId()
+    ctx.token = 'hnhteaosuhtaeosuahs'
+    ctx.privileges = 'readAndWrite'
+    ctx.fakeInvite = {
+      _id: ctx.inviteId,
+      email: ctx.email,
+      token: ctx.token,
+      tokenHmac: ctx.tokenHmac,
+      sendingUserId: ctx.sendingUserId,
+      projectId: ctx.projectId,
+      privileges: ctx.privileges,
       createdAt: new Date(),
     }
   })
 
   describe('getEditInviteCount', function () {
-    beforeEach(function () {
-      this.ProjectInvite.countDocuments.returns({
+    beforeEach(function (ctx) {
+      ctx.ProjectInvite.countDocuments.returns({
         exec: sinon.stub().resolves(2),
       })
-      this.call = async () => {
-        return await this.CollaboratorsInviteGetter.promises.getEditInviteCount(
-          this.projectId
+      ctx.call = async () => {
+        return await ctx.CollaboratorsInviteGetter.promises.getEditInviteCount(
+          ctx.projectId
         )
       }
     })
 
-    it('should produce the count of documents', async function () {
-      const count = await this.call()
-      expect(this.ProjectInvite.countDocuments).to.be.calledWith({
-        projectId: this.projectId,
+    it('should produce the count of documents', async function (ctx) {
+      const count = await ctx.call()
+      expect(ctx.ProjectInvite.countDocuments).to.be.calledWith({
+        projectId: ctx.projectId,
         privileges: { $ne: 'readOnly' },
       })
       expect(count).to.equal(2)
     })
 
     describe('when model.countDocuments produces an error', function () {
-      beforeEach(function () {
-        this.ProjectInvite.countDocuments.returns({
+      beforeEach(function (ctx) {
+        ctx.ProjectInvite.countDocuments.returns({
           exec: sinon.stub().rejects(new Error('woops')),
         })
       })
 
-      it('should produce an error', async function () {
-        await expect(this.call()).to.be.rejectedWith(Error)
+      it('should produce an error', async function (ctx) {
+        await expect(ctx.call()).to.be.rejectedWith(Error)
       })
     })
   })
 
   describe('getAllInvites', function () {
-    beforeEach(function () {
-      this.fakeInvites = [
+    beforeEach(function (ctx) {
+      ctx.fakeInvites = [
         { _id: new ObjectId(), one: 1 },
         { _id: new ObjectId(), two: 2 },
       ]
-      this.ProjectInvite.find.returns({
+      ctx.ProjectInvite.find.returns({
         select: sinon.stub().returnsThis(),
-        exec: sinon.stub().resolves(this.fakeInvites),
+        exec: sinon.stub().resolves(ctx.fakeInvites),
       })
-      this.call = async () => {
-        return await this.CollaboratorsInviteGetter.promises.getAllInvites(
-          this.projectId
+      ctx.call = async () => {
+        return await ctx.CollaboratorsInviteGetter.promises.getAllInvites(
+          ctx.projectId
         )
       }
     })
@@ -117,84 +124,84 @@ describe('CollaboratorsInviteGetter', function () {
     describe('when all goes well', function () {
       beforeEach(function () {})
 
-      it('should produce a list of invite objects', async function () {
-        const invites = await this.call()
+      it('should produce a list of invite objects', async function (ctx) {
+        const invites = await ctx.call()
         expect(invites).to.not.be.oneOf([null, undefined])
-        expect(invites).to.deep.equal(this.fakeInvites)
+        expect(invites).to.deep.equal(ctx.fakeInvites)
       })
 
-      it('should have called ProjectInvite.find', async function () {
-        await this.call()
-        this.ProjectInvite.find.callCount.should.equal(1)
-        this.ProjectInvite.find
-          .calledWith({ projectId: this.projectId })
+      it('should have called ProjectInvite.find', async function (ctx) {
+        await ctx.call()
+        ctx.ProjectInvite.find.callCount.should.equal(1)
+        ctx.ProjectInvite.find
+          .calledWith({ projectId: ctx.projectId })
           .should.equal(true)
       })
     })
 
     describe('when ProjectInvite.find produces an error', function () {
-      beforeEach(function () {
-        this.ProjectInvite.find.returns({
+      beforeEach(function (ctx) {
+        ctx.ProjectInvite.find.returns({
           select: sinon.stub().returnsThis(),
           exec: sinon.stub().rejects(new Error('woops')),
         })
       })
 
-      it('should produce an error', async function () {
-        await expect(this.call()).to.be.rejectedWith(Error)
+      it('should produce an error', async function (ctx) {
+        await expect(ctx.call()).to.be.rejectedWith(Error)
       })
     })
   })
 
   describe('getInviteByToken', function () {
-    beforeEach(function () {
-      this.ProjectInvite.findOne.returns({
-        exec: sinon.stub().resolves(this.fakeInvite),
-      })
-      this.call = async () => {
-        return await this.CollaboratorsInviteGetter.promises.getInviteByToken(
-          this.projectId,
-          this.token
+    beforeEach(function (ctx) {
+      ctx.ProjectInvite.findOne.returns({
+        exec: sinon.stub().resolves(ctx.fakeInvite),
+      })
+      ctx.call = async () => {
+        return await ctx.CollaboratorsInviteGetter.promises.getInviteByToken(
+          ctx.projectId,
+          ctx.token
         )
       }
     })
 
     describe('when all goes well', function () {
-      it('should produce the invite object', async function () {
-        const invite = await this.call()
-        expect(invite).to.deep.equal(this.fakeInvite)
+      it('should produce the invite object', async function (ctx) {
+        const invite = await ctx.call()
+        expect(invite).to.deep.equal(ctx.fakeInvite)
       })
 
-      it('should call ProjectInvite.findOne', async function () {
-        await this.call()
-        this.ProjectInvite.findOne.callCount.should.equal(1)
-        this.ProjectInvite.findOne
-          .calledWith({ projectId: this.projectId, tokenHmac: this.tokenHmac })
+      it('should call ProjectInvite.findOne', async function (ctx) {
+        await ctx.call()
+        ctx.ProjectInvite.findOne.callCount.should.equal(1)
+        ctx.ProjectInvite.findOne
+          .calledWith({ projectId: ctx.projectId, tokenHmac: ctx.tokenHmac })
           .should.equal(true)
       })
     })
 
     describe('when findOne produces an error', function () {
-      beforeEach(function () {
-        this.ProjectInvite.findOne.returns({
+      beforeEach(function (ctx) {
+        ctx.ProjectInvite.findOne.returns({
           exec: sinon.stub().rejects(new Error('woops')),
         })
       })
 
-      it('should produce an error', async function () {
-        await expect(this.call()).to.be.rejectedWith(Error)
+      it('should produce an error', async function (ctx) {
+        await expect(ctx.call()).to.be.rejectedWith(Error)
       })
     })
 
     describe('when findOne does not find an invite', function () {
-      beforeEach(function () {
-        this.ProjectInvite.findOne.returns({
+      beforeEach(function (ctx) {
+        ctx.ProjectInvite.findOne.returns({
           exec: sinon.stub().resolves(null),
         })
       })
 
-      it('should not produce an invite object', async function () {
-        const invite = await this.call()
+      it('should not produce an invite object', async function (ctx) {
+        const invite = await ctx.call()
         expect(invite).to.be.oneOf([null, undefined])
       })
     })

+ 4 - 10
services/web/test/unit/src/Collaborators/CollaboratorsInviteHelper.test.mjs

@@ -1,13 +1,7 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const path = require('path')
-const CollaboratorsInviteHelper = require(
-  path.join(
-    __dirname,
-    '/../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper'
-  )
-)
-const Crypto = require('crypto')
+import sinon from 'sinon'
+import { expect } from 'vitest'
+import CollaboratorsInviteHelper from '../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs'
+import Crypto from 'node:crypto'
 
 describe('CollaboratorsInviteHelper', function () {
   it('should generate a HMAC token', function () {

+ 46 - 45
services/web/test/unit/src/Contact/ContactManager.test.mjs

@@ -1,69 +1,70 @@
-const { expect } = require('chai')
-const sinon = require('sinon')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
 const modulePath = '../../../../app/src/Features/Contacts/ContactManager'
-const SandboxedModule = require('sandboxed-module')
 
 describe('ContactManager', function () {
-  beforeEach(function () {
-    this.user_id = 'user-id-123'
-    this.contact_id = 'contact-id-123'
-    this.contact_ids = ['mock', 'contact_ids']
-    this.FetchUtils = {
+  beforeEach(async function (ctx) {
+    ctx.user_id = 'user-id-123'
+    ctx.contact_id = 'contact-id-123'
+    ctx.contact_ids = ['mock', 'contact_ids']
+    ctx.FetchUtils = {
       fetchJson: sinon.stub(),
     }
-    this.ContactManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/fetch-utils': this.FetchUtils,
-        '@overleaf/settings': (this.settings = {
-          apis: {
-            contacts: {
-              url: 'http://contacts.overleaf.com',
-            },
+
+    vi.doMock('@overleaf/fetch-utils', () => ctx.FetchUtils)
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {
+        apis: {
+          contacts: {
+            url: 'http://contacts.overleaf.com',
           },
-        }),
-      },
-    })
+        },
+      }),
+    }))
+
+    ctx.ContactManager = (await import(modulePath)).default
   })
 
   describe('getContacts', function () {
     describe('with a successful response code', function () {
-      beforeEach(async function () {
-        this.FetchUtils.fetchJson.resolves({ contact_ids: this.contact_ids })
+      beforeEach(async function (ctx) {
+        ctx.FetchUtils.fetchJson.resolves({ contact_ids: ctx.contact_ids })
 
-        this.result = await this.ContactManager.promises.getContactIds(
-          this.user_id,
+        ctx.result = await ctx.ContactManager.promises.getContactIds(
+          ctx.user_id,
           { limit: 42 }
         )
       })
 
-      it('should get the contacts from the contacts api', function () {
-        this.FetchUtils.fetchJson.should.have.been.calledWithMatch(
+      it('should get the contacts from the contacts api', function (ctx) {
+        ctx.FetchUtils.fetchJson.should.have.been.calledWithMatch(
           sinon.match(
             url =>
               url.toString() ===
-              `${this.settings.apis.contacts.url}/user/${this.user_id}/contacts?limit=42`
+              `${ctx.settings.apis.contacts.url}/user/${ctx.user_id}/contacts?limit=42`
           )
         )
       })
 
-      it('should return the contacts', function () {
-        this.result.should.equal(this.contact_ids)
+      it('should return the contacts', function (ctx) {
+        ctx.result.should.equal(ctx.contact_ids)
       })
     })
 
     describe('when an error occurs', function () {
-      beforeEach(async function () {
-        this.response = {
+      beforeEach(async function (ctx) {
+        ctx.response = {
           ok: false,
           statusCode: 500,
-          json: sinon.stub().resolves({ contact_ids: this.contact_ids }),
+          json: sinon.stub().resolves({ contact_ids: ctx.contact_ids }),
         }
-        this.FetchUtils.fetchJson.rejects(new Error('request error'))
+        ctx.FetchUtils.fetchJson.rejects(new Error('request error'))
       })
 
-      it('should reject the promise', async function () {
+      it('should reject the promise', async function (ctx) {
         await expect(
-          this.ContactManager.promises.getContactIds(this.user_id, {
+          ctx.ContactManager.promises.getContactIds(ctx.user_id, {
             limit: 42,
           })
         ).to.be.rejected
@@ -73,31 +74,31 @@ describe('ContactManager', function () {
 
   describe('addContact', function () {
     describe('with a successful response code', function () {
-      beforeEach(async function () {
-        this.FetchUtils.fetchJson.resolves({ contact_ids: this.contact_ids })
+      beforeEach(async function (ctx) {
+        ctx.FetchUtils.fetchJson.resolves({ contact_ids: ctx.contact_ids })
 
-        this.result = await this.ContactManager.promises.addContact(
-          this.user_id,
-          this.contact_id
+        ctx.result = await ctx.ContactManager.promises.addContact(
+          ctx.user_id,
+          ctx.contact_id
         )
       })
 
-      it('should add the contacts for the user in the contacts api', function () {
-        this.FetchUtils.fetchJson.should.have.been.calledWithMatch(
+      it('should add the contacts for the user in the contacts api', function (ctx) {
+        ctx.FetchUtils.fetchJson.should.have.been.calledWithMatch(
           sinon.match(
             url =>
               url.toString() ===
-              `${this.settings.apis.contacts.url}/user/${this.user_id}/contacts`
+              `${ctx.settings.apis.contacts.url}/user/${ctx.user_id}/contacts`
           ),
           sinon.match({
             method: 'POST',
-            json: { contact_id: this.contact_id },
+            json: { contact_id: ctx.contact_id },
           })
         )
       })
 
-      it('should call the callback', function () {
-        this.result.should.equal(this.contact_ids)
+      it('should call the callback', function (ctx) {
+        ctx.result.should.equal(ctx.contact_ids)
       })
     })
   })

+ 209 - 212
services/web/test/unit/src/Docstore/DocstoreManager.test.mjs

@@ -1,60 +1,67 @@
-const sinon = require('sinon')
+import { beforeAll, beforeEach, describe, it, vi, expect } from 'vitest'
+import sinon from 'sinon'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+import tk from 'timekeeper'
 const modulePath = '../../../../app/src/Features/Docstore/DocstoreManager'
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const Errors = require('../../../../app/src/Features/Errors/Errors')
-const tk = require('timekeeper')
+
+vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+)
 
 describe('DocstoreManager', function () {
-  beforeEach(function () {
-    this.requestDefaults = sinon.stub().returns((this.request = sinon.stub()))
-    this.DocstoreManager = SandboxedModule.require(modulePath, {
-      requires: {
-        request: {
-          defaults: this.requestDefaults,
-        },
-        '@overleaf/settings': (this.settings = {
-          apis: {
-            docstore: {
-              url: 'docstore.overleaf.com',
-            },
-          },
-        }),
+  beforeEach(async function (ctx) {
+    ctx.requestDefaults = sinon.stub().returns((ctx.request = sinon.stub()))
+
+    vi.doMock('request', () => ({
+      default: {
+        defaults: ctx.requestDefaults,
       },
-    })
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {
+        apis: {
+          docstore: {
+            url: 'docstore.overleaf.com',
+          },
+        },
+      }),
+    }))
 
-    this.requestDefaults.calledWith({ jar: false }).should.equal(true)
+    ctx.DocstoreManager = (await import(modulePath)).default
 
-    this.project_id = 'project-id-123'
-    this.doc_id = 'doc-id-123'
+    ctx.requestDefaults.calledWith({ jar: false }).should.equal(true)
+
+    ctx.project_id = 'project-id-123'
+    ctx.doc_id = 'doc-id-123'
   })
 
   describe('deleteDoc', function () {
     describe('with a successful response code', function () {
       // for assertions on the deletedAt timestamp, we need to freeze the clock.
-      before(function () {
+      beforeAll(function () {
         tk.freeze(Date.now())
       })
-      after(function () {
+      afterAll(function () {
         tk.reset()
       })
 
-      beforeEach(async function () {
-        this.request.patch = sinon
+      beforeEach(async function (ctx) {
+        ctx.request.patch = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 204 }, '')
-        await this.DocstoreManager.promises.deleteDoc(
-          this.project_id,
-          this.doc_id,
+        await ctx.DocstoreManager.promises.deleteDoc(
+          ctx.project_id,
+          ctx.doc_id,
           'wombat.tex',
           new Date()
         )
       })
 
-      it('should delete the doc in the docstore api', function () {
-        this.request.patch
+      it('should delete the doc in the docstore api', function (ctx) {
+        ctx.request.patch
           .calledWith({
-            url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`,
+            url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`,
             json: { deleted: true, deletedAt: new Date(), name: 'wombat.tex' },
             timeout: 30 * 1000,
           })
@@ -63,19 +70,19 @@ describe('DocstoreManager', function () {
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function () {
-        this.request.patch = sinon
+      beforeEach(function (ctx) {
+        ctx.request.patch = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 500 }, '')
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.deleteDoc(
-            this.project_id,
-            this.doc_id,
+          await ctx.DocstoreManager.promises.deleteDoc(
+            ctx.project_id,
+            ctx.doc_id,
             'main.tex',
             new Date()
           )
@@ -92,18 +99,18 @@ describe('DocstoreManager', function () {
     })
 
     describe('with a missing (404) response code', function () {
-      beforeEach(function () {
-        this.request.patch = sinon
+      beforeEach(function (ctx) {
+        ctx.request.patch = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 404 }, '')
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
         try {
-          await this.DocstoreManager.promises.deleteDoc(
-            this.project_id,
-            this.doc_id,
+          await ctx.DocstoreManager.promises.deleteDoc(
+            ctx.project_id,
+            ctx.doc_id,
             'main.tex',
             new Date()
           )
@@ -121,73 +128,73 @@ describe('DocstoreManager', function () {
   })
 
   describe('updateDoc', function () {
-    beforeEach(function () {
-      this.lines = ['mock', 'doc', 'lines']
-      this.rev = 5
-      this.version = 42
-      this.ranges = { mock: 'ranges' }
-      this.modified = true
+    beforeEach(function (ctx) {
+      ctx.lines = ['mock', 'doc', 'lines']
+      ctx.rev = 5
+      ctx.version = 42
+      ctx.ranges = { mock: 'ranges' }
+      ctx.modified = true
     })
 
     describe('with a successful response code', async function () {
-      beforeEach(async function () {
-        this.request.post = sinon
+      beforeEach(async function (ctx) {
+        ctx.request.post = sinon
           .stub()
           .callsArgWith(
             1,
             null,
             { statusCode: 204 },
-            { modified: this.modified, rev: this.rev }
+            { modified: ctx.modified, rev: ctx.rev }
           )
-        this.updateDocResponse = await this.DocstoreManager.promises.updateDoc(
-          this.project_id,
-          this.doc_id,
-          this.lines,
-          this.version,
-          this.ranges
+        ctx.updateDocResponse = await ctx.DocstoreManager.promises.updateDoc(
+          ctx.project_id,
+          ctx.doc_id,
+          ctx.lines,
+          ctx.version,
+          ctx.ranges
         )
       })
 
-      it('should update the doc in the docstore api', function () {
-        this.request.post
+      it('should update the doc in the docstore api', function (ctx) {
+        ctx.request.post
           .calledWith({
-            url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`,
+            url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`,
             timeout: 30 * 1000,
             json: {
-              lines: this.lines,
-              version: this.version,
-              ranges: this.ranges,
+              lines: ctx.lines,
+              version: ctx.version,
+              ranges: ctx.ranges,
             },
           })
           .should.equal(true)
       })
 
-      it('should return the modified status and revision', function () {
-        expect(this.updateDocResponse).to.haveOwnProperty(
+      it('should return the modified status and revision', function (ctx) {
+        expect(ctx.updateDocResponse).to.haveOwnProperty(
           'modified',
-          this.modified
+          ctx.modified
         )
-        expect(this.updateDocResponse).to.haveOwnProperty('rev', this.rev)
+        expect(ctx.updateDocResponse).to.haveOwnProperty('rev', ctx.rev)
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function () {
-        this.request.post = sinon
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 500 }, '')
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.updateDoc(
-            this.project_id,
-            this.doc_id,
-            this.lines,
-            this.version,
-            this.ranges
+          await ctx.DocstoreManager.promises.updateDoc(
+            ctx.project_id,
+            ctx.doc_id,
+            ctx.lines,
+            ctx.version,
+            ctx.ranges
           )
         } catch (err) {
           error = err
@@ -203,59 +210,56 @@ describe('DocstoreManager', function () {
   })
 
   describe('getDoc', function () {
-    beforeEach(function () {
-      this.doc = {
-        lines: (this.lines = ['mock', 'doc', 'lines']),
-        rev: (this.rev = 5),
-        version: (this.version = 42),
-        ranges: (this.ranges = { mock: 'ranges' }),
+    beforeEach(function (ctx) {
+      ctx.doc = {
+        lines: (ctx.lines = ['mock', 'doc', 'lines']),
+        rev: (ctx.rev = 5),
+        version: (ctx.version = 42),
+        ranges: (ctx.ranges = { mock: 'ranges' }),
       }
     })
 
     describe('with a successful response code', function () {
-      beforeEach(async function () {
-        this.request.get = sinon
+      beforeEach(async function (ctx) {
+        ctx.request.get = sinon
           .stub()
-          .callsArgWith(1, null, { statusCode: 204 }, this.doc)
-        this.getDocResponse = await this.DocstoreManager.promises.getDoc(
-          this.project_id,
-          this.doc_id
+          .callsArgWith(1, null, { statusCode: 204 }, ctx.doc)
+        ctx.getDocResponse = await ctx.DocstoreManager.promises.getDoc(
+          ctx.project_id,
+          ctx.doc_id
         )
       })
 
-      it('should get the doc from the docstore api', function () {
-        this.request.get.should.have.been.calledWith({
-          url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`,
+      it('should get the doc from the docstore api', function (ctx) {
+        ctx.request.get.should.have.been.calledWith({
+          url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`,
           timeout: 30 * 1000,
           json: true,
         })
       })
 
-      it('should resolve with the lines, version and rev', function () {
-        expect(this.getDocResponse).to.eql({
-          lines: this.lines,
-          rev: this.rev,
-          version: this.version,
-          ranges: this.ranges,
+      it('should resolve with the lines, version and rev', function (ctx) {
+        expect(ctx.getDocResponse).to.eql({
+          lines: ctx.lines,
+          rev: ctx.rev,
+          version: ctx.version,
+          ranges: ctx.ranges,
         })
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function () {
-        this.request.get = sinon
+      beforeEach(function (ctx) {
+        ctx.request.get = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 500 }, '')
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.getDoc(
-            this.project_id,
-            this.doc_id
-          )
+          await ctx.DocstoreManager.promises.getDoc(ctx.project_id, ctx.doc_id)
         } catch (err) {
           error = err
         }
@@ -269,53 +273,49 @@ describe('DocstoreManager', function () {
     })
 
     describe('with include_deleted=true', function () {
-      beforeEach(async function () {
-        this.request.get = sinon
+      beforeEach(async function (ctx) {
+        ctx.request.get = sinon
           .stub()
-          .callsArgWith(1, null, { statusCode: 204 }, this.doc)
-        this.getDocResponse = await this.DocstoreManager.promises.getDoc(
-          this.project_id,
-          this.doc_id,
+          .callsArgWith(1, null, { statusCode: 204 }, ctx.doc)
+        ctx.getDocResponse = await ctx.DocstoreManager.promises.getDoc(
+          ctx.project_id,
+          ctx.doc_id,
           { include_deleted: true }
         )
       })
 
-      it('should get the doc from the docstore api (including deleted)', function () {
-        this.request.get.should.have.been.calledWith({
-          url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}`,
+      it('should get the doc from the docstore api (including deleted)', function (ctx) {
+        ctx.request.get.should.have.been.calledWith({
+          url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}`,
           qs: { include_deleted: 'true' },
           timeout: 30 * 1000,
           json: true,
         })
       })
 
-      it('should resolve with the lines, version and rev', function () {
-        expect(this.getDocResponse).to.eql({
-          lines: this.lines,
-          rev: this.rev,
-          version: this.version,
-          ranges: this.ranges,
+      it('should resolve with the lines, version and rev', function (ctx) {
+        expect(ctx.getDocResponse).to.eql({
+          lines: ctx.lines,
+          rev: ctx.rev,
+          version: ctx.version,
+          ranges: ctx.ranges,
         })
       })
     })
 
     describe('with peek=true', function () {
-      beforeEach(async function () {
-        this.request.get = sinon
+      beforeEach(async function (ctx) {
+        ctx.request.get = sinon
           .stub()
-          .callsArgWith(1, null, { statusCode: 204 }, this.doc)
-        await this.DocstoreManager.promises.getDoc(
-          this.project_id,
-          this.doc_id,
-          {
-            peek: true,
-          }
-        )
+          .callsArgWith(1, null, { statusCode: 204 }, ctx.doc)
+        await ctx.DocstoreManager.promises.getDoc(ctx.project_id, ctx.doc_id, {
+          peek: true,
+        })
       })
 
-      it('should call the docstore peek url', function () {
-        this.request.get.should.have.been.calledWith({
-          url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc/${this.doc_id}/peek`,
+      it('should call the docstore peek url', function (ctx) {
+        ctx.request.get.should.have.been.calledWith({
+          url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc/${ctx.doc_id}/peek`,
           timeout: 30 * 1000,
           json: true,
         })
@@ -323,20 +323,17 @@ describe('DocstoreManager', function () {
     })
 
     describe('with a missing (404) response code', function () {
-      beforeEach(function () {
-        this.request.get = sinon
+      beforeEach(function (ctx) {
+        ctx.request.get = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 404 }, '')
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.getDoc(
-            this.project_id,
-            this.doc_id
-          )
+          await ctx.DocstoreManager.promises.getDoc(ctx.project_id, ctx.doc_id)
         } catch (err) {
           error = err
         }
@@ -350,47 +347,47 @@ describe('DocstoreManager', function () {
   describe('getAllDocs', function () {
     describe('with a successful response code', function () {
       let getAllDocsResult
-      beforeEach(async function () {
-        this.request.get = sinon
+      beforeEach(async function (ctx) {
+        ctx.request.get = sinon
           .stub()
           .callsArgWith(
             1,
             null,
             { statusCode: 204 },
-            (this.docs = [{ _id: 'mock-doc-id' }])
+            (ctx.docs = [{ _id: 'mock-doc-id' }])
           )
-        getAllDocsResult = await this.DocstoreManager.promises.getAllDocs(
-          this.project_id
+        getAllDocsResult = await ctx.DocstoreManager.promises.getAllDocs(
+          ctx.project_id
         )
       })
 
-      it('should get all the project docs in the docstore api', function () {
-        this.request.get
+      it('should get all the project docs in the docstore api', function (ctx) {
+        ctx.request.get
           .calledWith({
-            url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc`,
+            url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc`,
             timeout: 30 * 1000,
             json: true,
           })
           .should.equal(true)
       })
 
-      it('should return the docs', function () {
-        expect(getAllDocsResult).to.eql(this.docs)
+      it('should return the docs', function (ctx) {
+        expect(getAllDocsResult).to.eql(ctx.docs)
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function () {
-        this.request.get = sinon
+      beforeEach(function (ctx) {
+        ctx.request.get = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 500 }, '')
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.getAllDocs(this.project_id)
+          await ctx.DocstoreManager.promises.getAllDocs(ctx.project_id)
         } catch (err) {
           error = err
         }
@@ -407,40 +404,40 @@ describe('DocstoreManager', function () {
   describe('getAllDeletedDocs', function () {
     describe('with a successful response code', function () {
       let getAllDeletedDocsResponse
-      beforeEach(async function () {
-        this.docs = [{ _id: 'mock-doc-id', name: 'foo.tex' }]
-        this.request.get = sinon
+      beforeEach(async function (ctx) {
+        ctx.docs = [{ _id: 'mock-doc-id', name: 'foo.tex' }]
+        ctx.request.get = sinon
           .stub()
-          .callsArgWith(1, null, { statusCode: 200 }, this.docs)
+          .callsArgWith(1, null, { statusCode: 200 }, ctx.docs)
         getAllDeletedDocsResponse =
-          await this.DocstoreManager.promises.getAllDeletedDocs(this.project_id)
+          await ctx.DocstoreManager.promises.getAllDeletedDocs(ctx.project_id)
       })
 
-      it('should get all the project docs in the docstore api', function () {
-        this.request.get.should.have.been.calledWith({
-          url: `${this.settings.apis.docstore.url}/project/${this.project_id}/doc-deleted`,
+      it('should get all the project docs in the docstore api', function (ctx) {
+        ctx.request.get.should.have.been.calledWith({
+          url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/doc-deleted`,
           timeout: 30 * 1000,
           json: true,
         })
       })
 
-      it('should resolve with the docs', function () {
-        expect(getAllDeletedDocsResponse).to.eql(this.docs)
+      it('should resolve with the docs', function (ctx) {
+        expect(getAllDeletedDocsResponse).to.eql(ctx.docs)
       })
     })
 
     describe('with an error', function () {
-      beforeEach(async function () {
-        this.request.get = sinon
+      beforeEach(async function (ctx) {
+        ctx.request.get = sinon
           .stub()
           .callsArgWith(1, new Error('connect failed'))
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.getAllDocs(this.project_id)
+          await ctx.DocstoreManager.promises.getAllDocs(ctx.project_id)
         } catch (err) {
           error = err
         }
@@ -451,17 +448,17 @@ describe('DocstoreManager', function () {
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function () {
-        this.request.get = sinon
+      beforeEach(function (ctx) {
+        ctx.request.get = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 500 })
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.getAllDocs(this.project_id)
+          await ctx.DocstoreManager.promises.getAllDocs(ctx.project_id)
         } catch (err) {
           error = err
         }
@@ -478,47 +475,47 @@ describe('DocstoreManager', function () {
   describe('getAllRanges', function () {
     describe('with a successful response code', function () {
       let getAllRangesResult
-      beforeEach(async function () {
-        this.request.get = sinon
+      beforeEach(async function (ctx) {
+        ctx.request.get = sinon
           .stub()
           .callsArgWith(
             1,
             null,
             { statusCode: 204 },
-            (this.docs = [{ _id: 'mock-doc-id', ranges: 'mock-ranges' }])
+            (ctx.docs = [{ _id: 'mock-doc-id', ranges: 'mock-ranges' }])
           )
-        getAllRangesResult = await this.DocstoreManager.promises.getAllRanges(
-          this.project_id
+        getAllRangesResult = await ctx.DocstoreManager.promises.getAllRanges(
+          ctx.project_id
         )
       })
 
-      it('should get all the project doc ranges in the docstore api', function () {
-        this.request.get
+      it('should get all the project doc ranges in the docstore api', function (ctx) {
+        ctx.request.get
           .calledWith({
-            url: `${this.settings.apis.docstore.url}/project/${this.project_id}/ranges`,
+            url: `${ctx.settings.apis.docstore.url}/project/${ctx.project_id}/ranges`,
             timeout: 30 * 1000,
             json: true,
           })
           .should.equal(true)
       })
 
-      it('should return the docs', async function () {
-        expect(getAllRangesResult).to.eql(this.docs)
+      it('should return the docs', async function (ctx) {
+        expect(getAllRangesResult).to.eql(ctx.docs)
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function () {
-        this.request.get = sinon
+      beforeEach(function (ctx) {
+        ctx.request.get = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 500 }, '')
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.getAllRanges(this.project_id)
+          await ctx.DocstoreManager.promises.getAllRanges(ctx.project_id)
         } catch (err) {
           error = err
         }
@@ -534,31 +531,31 @@ describe('DocstoreManager', function () {
 
   describe('archiveProject', function () {
     describe('with a successful response code', function () {
-      beforeEach(function () {
-        this.request.post = sinon
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 204 })
       })
 
-      it('should resolve', async function () {
+      it('should resolve', async function (ctx) {
         await expect(
-          this.DocstoreManager.promises.archiveProject(this.project_id)
+          ctx.DocstoreManager.promises.archiveProject(ctx.project_id)
         ).to.eventually.be.fulfilled
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function () {
-        this.request.post = sinon
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 500 })
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.archiveProject(this.project_id)
+          await ctx.DocstoreManager.promises.archiveProject(ctx.project_id)
         } catch (err) {
           error = err
         }
@@ -574,31 +571,31 @@ describe('DocstoreManager', function () {
 
   describe('unarchiveProject', function () {
     describe('with a successful response code', function () {
-      beforeEach(function () {
-        this.request.post = sinon
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 204 })
       })
 
-      it('should resolve', async function () {
+      it('should resolve', async function (ctx) {
         await expect(
-          this.DocstoreManager.promises.unarchiveProject(this.project_id)
+          ctx.DocstoreManager.promises.unarchiveProject(ctx.project_id)
         ).to.eventually.be.fulfilled
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function () {
-        this.request.post = sinon
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 500 })
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.unarchiveProject(this.project_id)
+          await ctx.DocstoreManager.promises.unarchiveProject(ctx.project_id)
         } catch (err) {
           error = err
         }
@@ -614,31 +611,31 @@ describe('DocstoreManager', function () {
 
   describe('destroyProject', function () {
     describe('with a successful response code', function () {
-      beforeEach(function () {
-        this.request.post = sinon
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 204 })
       })
 
-      it('should resolve', async function () {
+      it('should resolve', async function (ctx) {
         await expect(
-          this.DocstoreManager.promises.destroyProject(this.project_id)
+          ctx.DocstoreManager.promises.destroyProject(ctx.project_id)
         ).to.eventually.be.fulfilled
       })
     })
 
     describe('with a failed response code', function () {
-      beforeEach(function () {
-        this.request.post = sinon
+      beforeEach(function (ctx) {
+        ctx.request.post = sinon
           .stub()
           .callsArgWith(1, null, { statusCode: 500 })
       })
 
-      it('should reject with an error', async function () {
+      it('should reject with an error', async function (ctx) {
         let error
 
         try {
-          await this.DocstoreManager.promises.destroyProject(this.project_id)
+          await ctx.DocstoreManager.promises.destroyProject(ctx.project_id)
         } catch (err) {
           error = err
         }

+ 72 - 92
services/web/test/unit/src/Documents/DocumentHelper.test.mjs

@@ -1,74 +1,58 @@
-/* eslint-disable
-    max-len,
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../../app/src/Features/Documents/DocumentHelper.js'
-const SandboxedModule = require('sandboxed-module')
+import { expect } from 'vitest'
+const modulePath = '../../../../app/src/Features/Documents/DocumentHelper.mjs'
 
 describe('DocumentHelper', function () {
-  beforeEach(function () {
-    return (this.DocumentHelper = SandboxedModule.require(modulePath))
+  beforeEach(async function (ctx) {
+    ctx.DocumentHelper = (await import(modulePath)).default
   })
 
   describe('getTitleFromTexContent', function () {
-    it('should return the title', function () {
+    it('should return the title', function (ctx) {
       const document = '\\begin{document}\n\\title{foo}\n\\end{document}'
-      return expect(
-        this.DocumentHelper.getTitleFromTexContent(document)
-      ).to.equal('foo')
+      expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal(
+        'foo'
+      )
     })
 
-    it('should return the title if surrounded by space', function () {
+    it('should return the title if surrounded by space', function (ctx) {
       const document = '\\begin{document}\n   \\title{foo}   \n\\end{document}'
-      return expect(
-        this.DocumentHelper.getTitleFromTexContent(document)
-      ).to.equal('foo')
+      expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal(
+        'foo'
+      )
     })
 
-    it('should return null if there is no title', function () {
+    it('should return null if there is no title', function (ctx) {
       const document = '\\begin{document}\n\\end{document}'
-      return expect(
-        this.DocumentHelper.getTitleFromTexContent(document)
-      ).to.eql(null)
+      expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.eql(null)
     })
 
-    it('should accept an array', function () {
+    it('should accept an array', function (ctx) {
       const document = ['\\begin{document}', '\\title{foo}', '\\end{document}']
-      return expect(
-        this.DocumentHelper.getTitleFromTexContent(document)
-      ).to.equal('foo')
+      expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal(
+        'foo'
+      )
     })
 
-    it('should parse out formatting elements from the title', function () {
+    it('should parse out formatting elements from the title', function (ctx) {
       const document = '\\title{\\textbf{\\large{Second Year LaTeX Exercise}}}'
-      return expect(
-        this.DocumentHelper.getTitleFromTexContent(document)
-      ).to.equal('Second Year LaTeX Exercise')
+      expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal(
+        'Second Year LaTeX Exercise'
+      )
     })
 
-    it('should ignore junk after the title', function () {
+    it('should ignore junk after the title', function (ctx) {
       const document = '\\title{wombat} potato'
-      return expect(
-        this.DocumentHelper.getTitleFromTexContent(document)
-      ).to.equal('wombat')
+      expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal(
+        'wombat'
+      )
     })
 
-    it('should ignore junk before the title', function () {
+    it('should ignore junk before the title', function (ctx) {
       const document =
         '% this is something that v1 relied on, even though it seems odd \\title{wombat}'
-      return expect(
-        this.DocumentHelper.getTitleFromTexContent(document)
-      ).to.equal('wombat')
+      expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal(
+        'wombat'
+      )
     })
 
     // NICETOHAVE: Current implementation doesn't do this
@@ -76,87 +60,83 @@ describe('DocumentHelper', function () {
     //	document = "\\title{Second Year \\large{LaTeX} Exercise}"
     //	expect(@DocumentHelper.getTitleFromTexContent(document)).to.equal "Second Year LaTeX Exercise"
 
-    it('should collapse whitespace', function () {
+    it('should collapse whitespace', function (ctx) {
       const document = '\\title{Second    Year  LaTeX     Exercise}'
-      return expect(
-        this.DocumentHelper.getTitleFromTexContent(document)
-      ).to.equal('Second Year LaTeX Exercise')
+      expect(ctx.DocumentHelper.getTitleFromTexContent(document)).to.equal(
+        'Second Year LaTeX Exercise'
+      )
     })
   })
 
   describe('detex', function () {
     // note, there are a number of tests for getTitleFromTexContent that also test cases here
-    it('leaves a non-TeX string unchanged', function () {
-      expect(this.DocumentHelper.detex('')).to.equal('')
-      expect(this.DocumentHelper.detex('a')).to.equal('a')
-      return expect(this.DocumentHelper.detex('a a')).to.equal('a a')
+    it('leaves a non-TeX string unchanged', function (ctx) {
+      expect(ctx.DocumentHelper.detex('')).to.equal('')
+      expect(ctx.DocumentHelper.detex('a')).to.equal('a')
+      expect(ctx.DocumentHelper.detex('a a')).to.equal('a a')
     })
 
-    it('collapses spaces', function () {
-      expect(this.DocumentHelper.detex('a  a')).to.equal('a a')
-      return expect(this.DocumentHelper.detex('a \n a')).to.equal('a \n a')
+    it('collapses spaces', function (ctx) {
+      expect(ctx.DocumentHelper.detex('a  a')).to.equal('a a')
+      expect(ctx.DocumentHelper.detex('a \n a')).to.equal('a \n a')
     })
 
-    it('replaces named commands', function () {
-      expect(this.DocumentHelper.detex('\\LaTeX')).to.equal('LaTeX')
-      expect(this.DocumentHelper.detex('\\TikZ')).to.equal('TikZ')
-      expect(this.DocumentHelper.detex('\\TeX')).to.equal('TeX')
-      return expect(this.DocumentHelper.detex('\\BibTeX')).to.equal('BibTeX')
+    it('replaces named commands', function (ctx) {
+      expect(ctx.DocumentHelper.detex('\\LaTeX')).to.equal('LaTeX')
+      expect(ctx.DocumentHelper.detex('\\TikZ')).to.equal('TikZ')
+      expect(ctx.DocumentHelper.detex('\\TeX')).to.equal('TeX')
+      expect(ctx.DocumentHelper.detex('\\BibTeX')).to.equal('BibTeX')
     })
 
-    it('removes general commands', function () {
-      expect(this.DocumentHelper.detex('\\foo')).to.equal('')
-      expect(this.DocumentHelper.detex('\\foo{}')).to.equal('')
-      expect(this.DocumentHelper.detex('\\foo~Test')).to.equal('Test')
-      expect(this.DocumentHelper.detex('\\"e')).to.equal('e')
-      return expect(this.DocumentHelper.detex('\\textit{e}')).to.equal('e')
+    it('removes general commands', function (ctx) {
+      expect(ctx.DocumentHelper.detex('\\foo')).to.equal('')
+      expect(ctx.DocumentHelper.detex('\\foo{}')).to.equal('')
+      expect(ctx.DocumentHelper.detex('\\foo~Test')).to.equal('Test')
+      expect(ctx.DocumentHelper.detex('\\"e')).to.equal('e')
+      expect(ctx.DocumentHelper.detex('\\textit{e}')).to.equal('e')
     })
 
-    it('leaves basic math', function () {
-      return expect(this.DocumentHelper.detex('$\\cal{O}(n^2)$')).to.equal(
-        'O(n^2)'
-      )
+    it('leaves basic math', function (ctx) {
+      expect(ctx.DocumentHelper.detex('$\\cal{O}(n^2)$')).to.equal('O(n^2)')
     })
 
-    it('removes line spacing commands', function () {
-      return expect(this.DocumentHelper.detex('a \\\\[1.50cm] b')).to.equal(
-        'a b'
-      )
+    it('removes line spacing commands', function (ctx) {
+      expect(ctx.DocumentHelper.detex('a \\\\[1.50cm] b')).to.equal('a b')
     })
   })
 
   describe('contentHasDocumentclass', function () {
-    it('should return true if the content has a documentclass', function () {
+    it('should return true if the content has a documentclass', function (ctx) {
       const document = ['% line', '% line', '% line', '\\documentclass']
-      return expect(
-        this.DocumentHelper.contentHasDocumentclass(document)
-      ).to.equal(true)
+      expect(ctx.DocumentHelper.contentHasDocumentclass(document)).to.equal(
+        true
+      )
     })
 
-    it('should allow whitespace before the documentclass', function () {
+    it('should allow whitespace before the documentclass', function (ctx) {
       const document = ['% line', '% line', '% line', '        \\documentclass']
-      return expect(
-        this.DocumentHelper.contentHasDocumentclass(document)
-      ).to.equal(true)
+      expect(ctx.DocumentHelper.contentHasDocumentclass(document)).to.equal(
+        true
+      )
     })
 
-    it('should not allow non-whitespace before the documentclass', function () {
+    it('should not allow non-whitespace before the documentclass', function (ctx) {
       const document = [
         '% line',
         '% line',
         '% line',
         '    asdf \\documentclass',
       ]
-      return expect(
-        this.DocumentHelper.contentHasDocumentclass(document)
-      ).to.equal(false)
+      expect(ctx.DocumentHelper.contentHasDocumentclass(document)).to.equal(
+        false
+      )
     })
 
-    it('should return false when there is no documentclass', function () {
+    it('should return false when there is no documentclass', function (ctx) {
       const document = ['% line', '% line', '% line']
-      return expect(
-        this.DocumentHelper.contentHasDocumentclass(document)
-      ).to.equal(false)
+      expect(ctx.DocumentHelper.contentHasDocumentclass(document)).to.equal(
+        false
+      )
     })
   })
 })

+ 71 - 66
services/web/test/unit/src/Editor/EditorRealTimeController.test.mjs

@@ -1,84 +1,89 @@
-/* eslint-disable
-    max-len,
-    no-return-assign,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS101: Remove unnecessary use of Array.from
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const modulePath = require('path').join(
-  __dirname,
+import { vi } from 'vitest'
+import path from 'path'
+import sinon from 'sinon'
+const modulePath = path.join(
+  import.meta.dirname,
   '../../../../app/src/Features/Editor/EditorRealTimeController'
 )
 
 describe('EditorRealTimeController', function () {
-  beforeEach(function () {
-    this.rclient = { publish: sinon.stub() }
-    this.Metrics = { summary: sinon.stub() }
-    this.EditorRealTimeController = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../infrastructure/RedisWrapper': {
-          client: () => this.rclient,
-        },
-        '../../infrastructure/Server': {
-          io: (this.io = {}),
-        },
-        '@overleaf/settings': { redis: {} },
-        '@overleaf/metrics': this.Metrics,
-        crypto: (this.crypto = {
-          randomBytes: sinon
-            .stub()
-            .withArgs(4)
-            .returns(Buffer.from([0x1, 0x2, 0x3, 0x4])),
-        }),
-        os: (this.os = { hostname: sinon.stub().returns('somehost') }),
+  beforeEach(async function (ctx) {
+    ctx.rclient = { publish: sinon.stub() }
+    ctx.Metrics = { summary: sinon.stub() }
+
+    vi.doMock('../../../../app/src/infrastructure/RedisWrapper', () => ({
+      default: {
+        client: () => ctx.rclient,
       },
-    })
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Server', () => ({
+      default: {
+        io: (ctx.io = {}),
+      },
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: { redis: {} },
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: ctx.Metrics,
+    }))
+
+    vi.doMock('node:crypto', () => ({
+      default: (ctx.crypto = {
+        randomBytes: sinon
+          .stub()
+          .withArgs(4)
+          .returns(Buffer.from([0x1, 0x2, 0x3, 0x4])),
+      }),
+    }))
+
+    vi.doMock('node:os', () => ({
+      default: (ctx.os = { hostname: sinon.stub().returns('somehost') }),
+    }))
+
+    ctx.EditorRealTimeController = (await import(modulePath)).default
 
-    this.room_id = 'room-id'
-    this.message = 'message-to-editor'
-    return (this.payload = ['argument one', 42])
+    ctx.room_id = 'room-id'
+    ctx.message = 'message-to-editor'
+    return (ctx.payload = ['argument one', 42])
   })
 
   describe('emitToRoom', function () {
-    beforeEach(function () {
-      this.message_id = 'web:somehost:01020304-0'
-      return this.EditorRealTimeController.emitToRoom(
-        this.room_id,
-        this.message,
-        ...Array.from(this.payload)
+    beforeEach(function (ctx) {
+      ctx.message_id = 'web:somehost:01020304-0'
+      return ctx.EditorRealTimeController.emitToRoom(
+        ctx.room_id,
+        ctx.message,
+        ...Array.from(ctx.payload)
       )
     })
 
-    it('should publish the message to redis', function () {
-      return this.rclient.publish
+    it('should publish the message to redis', function (ctx) {
+      return ctx.rclient.publish
         .calledWith(
           'editor-events',
           JSON.stringify({
-            room_id: this.room_id,
-            message: this.message,
-            payload: this.payload,
-            _id: this.message_id,
+            room_id: ctx.room_id,
+            message: ctx.message,
+            payload: ctx.payload,
+            _id: ctx.message_id,
           })
         )
         .should.equal(true)
     })
 
-    it('should track the payload size', function () {
-      this.Metrics.summary
+    it('should track the payload size', function (ctx) {
+      ctx.Metrics.summary
         .calledWith(
           'redis.publish.editor-events',
           JSON.stringify({
-            room_id: this.room_id,
-            message: this.message,
-            payload: this.payload,
-            _id: this.message_id,
+            room_id: ctx.room_id,
+            message: ctx.message,
+            payload: ctx.payload,
+            _id: ctx.message_id,
           }).length
         )
         .should.equal(true)
@@ -86,17 +91,17 @@ describe('EditorRealTimeController', function () {
   })
 
   describe('emitToAll', function () {
-    beforeEach(function () {
-      this.EditorRealTimeController.emitToRoom = sinon.stub()
-      return this.EditorRealTimeController.emitToAll(
-        this.message,
-        ...Array.from(this.payload)
+    beforeEach(function (ctx) {
+      ctx.EditorRealTimeController.emitToRoom = sinon.stub()
+      return ctx.EditorRealTimeController.emitToAll(
+        ctx.message,
+        ...Array.from(ctx.payload)
       )
     })
 
-    it("should emit to the room 'all'", function () {
-      return this.EditorRealTimeController.emitToRoom
-        .calledWith('all', this.message, ...Array.from(this.payload))
+    it("should emit to the room 'all'", function (ctx) {
+      return ctx.EditorRealTimeController.emitToRoom
+        .calledWith('all', ctx.message, ...Array.from(ctx.payload))
         .should.equal(true)
     })
   })

Разница между файлами не показана из-за своего большого размера
+ 347 - 338
services/web/test/unit/src/Email/EmailBuilder.test.mjs


+ 76 - 67
services/web/test/unit/src/Email/EmailHandler.test.mjs

@@ -1,121 +1,130 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const { expect } = require('chai')
+import { vi, expect } from 'vitest'
+import path from 'path'
+import sinon from 'sinon'
 
 const MODULE_PATH = path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/Features/Email/EmailHandler'
 )
 
 describe('EmailHandler', function () {
-  beforeEach(function () {
-    this.html = '<html>hello</html>'
-    this.Settings = { email: {} }
-    this.EmailBuilder = {
-      buildEmail: sinon.stub().returns({ html: this.html }),
+  beforeEach(async function (ctx) {
+    ctx.html = '<html>hello</html>'
+    ctx.Settings = { email: {} }
+    ctx.EmailBuilder = {
+      buildEmail: sinon.stub().returns({ html: ctx.html }),
     }
-    this.EmailSender = {
+    ctx.EmailSender = {
       promises: {
         sendEmail: sinon.stub().resolves(),
       },
     }
-    this.Queues = {
+    ctx.Queues = {
       createScheduledJob: sinon.stub().resolves(),
     }
-    this.EmailHandler = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        './EmailBuilder': this.EmailBuilder,
-        './EmailSender': this.EmailSender,
-        '@overleaf/settings': this.Settings,
-        '../../infrastructure/Queues': this.Queues,
-      },
-    })
+
+    vi.doMock('../../../../app/src/Features/Email/EmailBuilder', () => ({
+      default: ctx.EmailBuilder,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Email/EmailSender', () => ({
+      default: ctx.EmailSender,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.Settings,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
+      default: ctx.Queues,
+    }))
+
+    ctx.EmailHandler = (await import(MODULE_PATH)).default
   })
 
   describe('send email', function () {
-    it('should use the correct options', async function () {
+    it('should use the correct options', async function (ctx) {
       const opts = { to: 'bob@bob.com' }
-      await this.EmailHandler.promises.sendEmail('welcome', opts)
-      expect(this.EmailSender.promises.sendEmail).to.have.been.calledWithMatch({
-        html: this.html,
+      await ctx.EmailHandler.promises.sendEmail('welcome', opts)
+      expect(ctx.EmailSender.promises.sendEmail).to.have.been.calledWithMatch({
+        html: ctx.html,
       })
     })
 
-    it('should return the error', async function () {
-      this.EmailSender.promises.sendEmail.rejects(new Error('boom'))
+    it('should return the error', async function (ctx) {
+      ctx.EmailSender.promises.sendEmail.rejects(new Error('boom'))
       const opts = {
         to: 'bob@bob.com',
         subject: 'hello bob',
       }
-      await expect(this.EmailHandler.promises.sendEmail('welcome', opts)).to.be
+      await expect(ctx.EmailHandler.promises.sendEmail('welcome', opts)).to.be
         .rejected
     })
 
-    it('should not send an email if lifecycle is not enabled', async function () {
-      this.Settings.email.lifecycle = false
-      this.EmailBuilder.buildEmail.returns({ type: 'lifecycle' })
-      await this.EmailHandler.promises.sendEmail('welcome', {})
-      expect(this.EmailSender.promises.sendEmail).not.to.have.been.called
+    it('should not send an email if lifecycle is not enabled', async function (ctx) {
+      ctx.Settings.email.lifecycle = false
+      ctx.EmailBuilder.buildEmail.returns({ type: 'lifecycle' })
+      await ctx.EmailHandler.promises.sendEmail('welcome', {})
+      expect(ctx.EmailSender.promises.sendEmail).not.to.have.been.called
     })
 
-    it('should send an email if lifecycle is not enabled but the type is notification', async function () {
-      this.Settings.email.lifecycle = false
-      this.EmailBuilder.buildEmail.returns({ type: 'notification' })
+    it('should send an email if lifecycle is not enabled but the type is notification', async function (ctx) {
+      ctx.Settings.email.lifecycle = false
+      ctx.EmailBuilder.buildEmail.returns({ type: 'notification' })
       const opts = { to: 'bob@bob.com' }
-      await this.EmailHandler.promises.sendEmail('welcome', opts)
-      expect(this.EmailSender.promises.sendEmail).to.have.been.called
+      await ctx.EmailHandler.promises.sendEmail('welcome', opts)
+      expect(ctx.EmailSender.promises.sendEmail).to.have.been.called
     })
 
-    it('should send lifecycle email if it is enabled', async function () {
-      this.Settings.email.lifecycle = true
-      this.EmailBuilder.buildEmail.returns({ type: 'lifecycle' })
+    it('should send lifecycle email if it is enabled', async function (ctx) {
+      ctx.Settings.email.lifecycle = true
+      ctx.EmailBuilder.buildEmail.returns({ type: 'lifecycle' })
       const opts = { to: 'bob@bob.com' }
-      await this.EmailHandler.promises.sendEmail('welcome', opts)
-      expect(this.EmailSender.promises.sendEmail).to.have.been.called
+      await ctx.EmailHandler.promises.sendEmail('welcome', opts)
+      expect(ctx.EmailSender.promises.sendEmail).to.have.been.called
     })
 
     describe('with plain-text email content', function () {
-      beforeEach(function () {
-        this.text = 'hello there'
+      beforeEach(function (ctx) {
+        ctx.text = 'hello there'
       })
 
-      it('should pass along the text field', async function () {
-        this.EmailBuilder.buildEmail.returns({
-          html: this.html,
-          text: this.text,
+      it('should pass along the text field', async function (ctx) {
+        ctx.EmailBuilder.buildEmail.returns({
+          html: ctx.html,
+          text: ctx.text,
         })
         const opts = { to: 'bob@bob.com' }
-        await this.EmailHandler.promises.sendEmail('welcome', opts)
-        expect(
-          this.EmailSender.promises.sendEmail
-        ).to.have.been.calledWithMatch({
-          html: this.html,
-          text: this.text,
-        })
+        await ctx.EmailHandler.promises.sendEmail('welcome', opts)
+        expect(ctx.EmailSender.promises.sendEmail).to.have.been.calledWithMatch(
+          {
+            html: ctx.html,
+            text: ctx.text,
+          }
+        )
       })
     })
   })
 
   describe('send deferred email', function () {
-    beforeEach(function () {
-      this.opts = {
+    beforeEach(function (ctx) {
+      ctx.opts = {
         to: 'bob@bob.com',
         first_name: 'hello bob',
       }
-      this.emailType = 'canceledSubscription'
-      this.ONE_HOUR_IN_MS = 1000 * 60 * 60
-      this.EmailHandler.sendDeferredEmail(
-        this.emailType,
-        this.opts,
-        this.ONE_HOUR_IN_MS
+      ctx.emailType = 'canceledSubscription'
+      ctx.ONE_HOUR_IN_MS = 1000 * 60 * 60
+      ctx.EmailHandler.sendDeferredEmail(
+        ctx.emailType,
+        ctx.opts,
+        ctx.ONE_HOUR_IN_MS
       )
     })
-    it('should add a email job to the queue', function () {
-      expect(this.Queues.createScheduledJob).to.have.been.calledWith(
+    it('should add a email job to the queue', function (ctx) {
+      expect(ctx.Queues.createScheduledJob).to.have.been.calledWith(
         'deferred-emails',
-        { data: { emailType: this.emailType, opts: this.opts } },
-        this.ONE_HOUR_IN_MS
+        { data: { emailType: ctx.emailType, opts: ctx.opts } },
+        ctx.ONE_HOUR_IN_MS
       )
     })
   })

+ 81 - 70
services/web/test/unit/src/Email/EmailSender.test.mjs

@@ -1,23 +1,22 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const { expect } = require('chai')
+import { vi, expect } from 'vitest'
+import path from 'path'
+import sinon from 'sinon'
 
 const MODULE_PATH = path.join(
-  __dirname,
-  '../../../../app/src/Features/Email/EmailSender.js'
+  import.meta.dirname,
+  '../../../../app/src/Features/Email/EmailSender.mjs'
 )
 
 describe('EmailSender', function () {
-  beforeEach(function () {
-    this.rateLimiter = {
+  beforeEach(async function (ctx) {
+    ctx.rateLimiter = {
       consume: sinon.stub().resolves(),
     }
-    this.RateLimiter = {
-      RateLimiter: sinon.stub().returns(this.rateLimiter),
+    ctx.RateLimiter = {
+      RateLimiter: sinon.stub().returns(ctx.rateLimiter),
     }
 
-    this.Settings = {
+    ctx.Settings = {
       email: {
         transport: 'ses',
         parameters: {
@@ -29,25 +28,38 @@ describe('EmailSender', function () {
       },
     }
 
-    this.sesClient = { sendMail: sinon.stub().resolves() }
+    ctx.sesClient = { sendMail: sinon.stub().resolves() }
 
-    this.ses = { createTransport: () => this.sesClient }
+    ctx.ses = { createTransport: () => ctx.sesClient }
 
-    this.SESClient = sinon.stub()
+    ctx.SESClient = sinon.stub()
 
-    this.EmailSender = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        nodemailer: this.ses,
-        '@aws-sdk/client-ses': { SESClient: this.SESClient },
-        '@overleaf/settings': this.Settings,
-        '../../infrastructure/RateLimiter': this.RateLimiter,
-        '@overleaf/metrics': {
-          inc() {},
-        },
+    vi.doMock('nodemailer', () => ({
+      default: ctx.ses,
+    }))
+
+    vi.doMock('@aws-sdk/client-ses', () => ({
+      default: { SESClient: ctx.SESClient },
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.Settings,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/infrastructure/RateLimiter',
+      () => ctx.RateLimiter
+    )
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: {
+        inc() {},
       },
-    })
+    }))
+
+    ctx.EmailSender = (await import(MODULE_PATH)).default
 
-    this.opts = {
+    ctx.opts = {
       to: 'bob@bob.com',
       subject: 'new email',
       html: '<hello></hello>',
@@ -55,77 +67,76 @@ describe('EmailSender', function () {
   })
 
   describe('sendEmail', function () {
-    it('should set the properties on the email to send', async function () {
-      await this.EmailSender.promises.sendEmail(this.opts)
-      expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
-        html: this.opts.html,
-        to: this.opts.to,
-        subject: this.opts.subject,
+    it('should set the properties on the email to send', async function (ctx) {
+      await ctx.EmailSender.promises.sendEmail(ctx.opts)
+      expect(ctx.sesClient.sendMail).to.have.been.calledWithMatch({
+        html: ctx.opts.html,
+        to: ctx.opts.to,
+        subject: ctx.opts.subject,
       })
     })
 
-    it('should return a non-specific error', async function () {
-      this.sesClient.sendMail.rejects(new Error('boom'))
-      await expect(this.EmailSender.promises.sendEmail({})).to.be.rejectedWith(
+    it('should return a non-specific error', async function (ctx) {
+      ctx.sesClient.sendMail.rejects(new Error('boom'))
+      await expect(ctx.EmailSender.promises.sendEmail({})).to.be.rejectedWith(
         'error sending message'
       )
     })
 
-    it('should use the from address from settings', async function () {
-      await this.EmailSender.promises.sendEmail(this.opts)
-      expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
-        from: this.Settings.email.fromAddress,
+    it('should use the from address from settings', async function (ctx) {
+      await ctx.EmailSender.promises.sendEmail(ctx.opts)
+      expect(ctx.sesClient.sendMail).to.have.been.calledWithMatch({
+        from: ctx.Settings.email.fromAddress,
       })
     })
 
-    it('should use the reply to address from settings', async function () {
-      await this.EmailSender.promises.sendEmail(this.opts)
-      expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
-        replyTo: this.Settings.email.replyToAddress,
+    it('should use the reply to address from settings', async function (ctx) {
+      await ctx.EmailSender.promises.sendEmail(ctx.opts)
+      expect(ctx.sesClient.sendMail).to.have.been.calledWithMatch({
+        replyTo: ctx.Settings.email.replyToAddress,
       })
     })
 
-    it('should use the reply to address in options as an override', async function () {
-      this.opts.replyTo = 'someone@else.com'
-      await this.EmailSender.promises.sendEmail(this.opts)
-      expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
-        replyTo: this.opts.replyTo,
+    it('should use the reply to address in options as an override', async function (ctx) {
+      ctx.opts.replyTo = 'someone@else.com'
+      await ctx.EmailSender.promises.sendEmail(ctx.opts)
+      expect(ctx.sesClient.sendMail).to.have.been.calledWithMatch({
+        replyTo: ctx.opts.replyTo,
       })
     })
 
-    it('should not send an email when the rate limiter says no', async function () {
-      this.opts.sendingUser_id = '12321312321'
-      this.rateLimiter.consume.rejects({ remainingPoints: 0 })
-      await expect(this.EmailSender.promises.sendEmail(this.opts)).to.be
-        .rejected
-      expect(this.sesClient.sendMail).not.to.have.been.called
+    it('should not send an email when the rate limiter says no', async function (ctx) {
+      ctx.opts.sendingUser_id = '12321312321'
+      ctx.rateLimiter.consume.rejects({ remainingPoints: 0 })
+      await expect(ctx.EmailSender.promises.sendEmail(ctx.opts)).to.be.rejected
+      expect(ctx.sesClient.sendMail).not.to.have.been.called
     })
 
-    it('should send the email when the rate limtier says continue', async function () {
-      this.opts.sendingUser_id = '12321312321'
-      await this.EmailSender.promises.sendEmail(this.opts)
-      expect(this.sesClient.sendMail).to.have.been.called
+    it('should send the email when the rate limtier says continue', async function (ctx) {
+      ctx.opts.sendingUser_id = '12321312321'
+      await ctx.EmailSender.promises.sendEmail(ctx.opts)
+      expect(ctx.sesClient.sendMail).to.have.been.called
     })
 
-    it('should not check the rate limiter when there is no sendingUser_id', async function () {
-      this.EmailSender.sendEmail(this.opts, () => {
-        expect(this.sesClient.sendMail).to.have.been.called
-        expect(this.rateLimiter.consume).not.to.have.been.called
+    it('should not check the rate limiter when there is no sendingUser_id', async function (ctx) {
+      ctx.EmailSender.sendEmail(ctx.opts, () => {
+        expect(ctx.sesClient.sendMail).to.have.been.called
+        expect(ctx.rateLimiter.consume).not.to.have.been.called
       })
     })
 
     describe('with plain-text email content', function () {
-      beforeEach(function () {
-        this.opts.text = 'hello there'
+      beforeEach(function (ctx) {
+        ctx.opts.text = 'hello there'
       })
 
-      it('should set the text property on the email to send', async function () {
-        await this.EmailSender.promises.sendEmail(this.opts)
-        expect(this.sesClient.sendMail).to.have.been.calledWithMatch({
-          html: this.opts.html,
-          text: this.opts.text,
-          to: this.opts.to,
-          subject: this.opts.subject,
+      it('should set the text property on the email to send', async function (ctx) {
+        await ctx.EmailSender.promises.sendEmail(ctx.opts)
+        expect(ctx.sesClient.sendMail).to.have.been.calledWithMatch({
+          html: ctx.opts.html,
+          text: ctx.opts.text,
+          to: ctx.opts.to,
+          subject: ctx.opts.subject,
         })
       })
     })

+ 2 - 7
services/web/test/unit/src/Email/SpamSafe.test.mjs

@@ -1,10 +1,5 @@
-const path = require('path')
-const modulePath = path.join(
-  __dirname,
-  '../../../../app/src/Features/Email/SpamSafe'
-)
-const SpamSafe = require(modulePath)
-const { expect } = require('chai')
+import SpamSafe from '../../../../app/src/Features/Email/SpamSafe.mjs'
+import { expect } from 'vitest'
 
 describe('SpamSafe', function () {
   it('should reject spammy names', function () {

+ 156 - 222
services/web/test/unit/src/Errors/HttpErrorHandler.test.mjs

@@ -1,224 +1,168 @@
-const { expect } = require('chai')
-const MockResponse = require('../helpers/MockResponse')
-const MockRequest = require('../helpers/MockRequest')
-const SandboxedModule = require('sandboxed-module')
+import { vi, expect } from 'vitest'
+import MockResponse from '../helpers/MockResponse.js'
+import MockRequest from '../helpers/MockRequest.js'
 const modulePath = '../../../../app/src/Features/Errors/HttpErrorHandler.js'
 
 describe('HttpErrorHandler', function () {
-  beforeEach(function () {
-    this.req = new MockRequest()
-    this.res = new MockResponse()
-
-    this.HttpErrorHandler = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': {
-          appName: 'Overleaf',
-          statusPageUrl: 'https://status.overlaf.com',
-        },
+  beforeEach(async function (ctx) {
+    ctx.req = new MockRequest()
+    ctx.res = new MockResponse()
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
+        appName: 'Overleaf',
+        statusPageUrl: 'https://status.overlaf.com',
       },
-    })
+    }))
+
+    ctx.HttpErrorHandler = (await import(modulePath)).default
   })
 
   describe('handleErrorByStatusCode', function () {
-    it('returns the http status code of 400 errors', function () {
+    it('returns the http status code of 400 errors', function (ctx) {
       const err = new Error()
-      this.HttpErrorHandler.handleErrorByStatusCode(
-        this.req,
-        this.res,
-        err,
-        400
-      )
-      expect(this.res.statusCode).to.equal(400)
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 400)
+      expect(ctx.res.statusCode).to.equal(400)
     })
 
-    it('returns the http status code of 500 errors', function () {
+    it('returns the http status code of 500 errors', function (ctx) {
       const err = new Error()
-      this.HttpErrorHandler.handleErrorByStatusCode(
-        this.req,
-        this.res,
-        err,
-        500
-      )
-      expect(this.res.statusCode).to.equal(500)
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 500)
+      expect(ctx.res.statusCode).to.equal(500)
     })
 
-    it('returns the http status code of any 5xx error', function () {
+    it('returns the http status code of any 5xx error', function (ctx) {
       const err = new Error()
-      this.HttpErrorHandler.handleErrorByStatusCode(
-        this.req,
-        this.res,
-        err,
-        588
-      )
-      expect(this.res.statusCode).to.equal(588)
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 588)
+      expect(ctx.res.statusCode).to.equal(588)
     })
 
-    it('returns the http status code of any 4xx error', function () {
+    it('returns the http status code of any 4xx error', function (ctx) {
       const err = new Error()
-      this.HttpErrorHandler.handleErrorByStatusCode(
-        this.req,
-        this.res,
-        err,
-        488
-      )
-      expect(this.res.statusCode).to.equal(488)
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 488)
+      expect(ctx.res.statusCode).to.equal(488)
     })
 
-    it('returns 500 for http status codes smaller than 400', function () {
+    it('returns 500 for http status codes smaller than 400', function (ctx) {
       const err = new Error()
-      this.HttpErrorHandler.handleErrorByStatusCode(
-        this.req,
-        this.res,
-        err,
-        302
-      )
-      expect(this.res.statusCode).to.equal(500)
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 302)
+      expect(ctx.res.statusCode).to.equal(500)
     })
 
-    it('returns 500 for http status codes larger than 600', function () {
+    it('returns 500 for http status codes larger than 600', function (ctx) {
       const err = new Error()
-      this.HttpErrorHandler.handleErrorByStatusCode(
-        this.req,
-        this.res,
-        err,
-        302
-      )
-      expect(this.res.statusCode).to.equal(500)
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 302)
+      expect(ctx.res.statusCode).to.equal(500)
     })
 
-    it('returns 500 when the error has no http status code', function () {
+    it('returns 500 when the error has no http status code', function (ctx) {
       const err = new Error()
-      this.HttpErrorHandler.handleErrorByStatusCode(this.req, this.res, err)
-      expect(this.res.statusCode).to.equal(500)
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err)
+      expect(ctx.res.statusCode).to.equal(500)
     })
 
-    it('uses the conflict() error handler', function () {
+    it('uses the conflict() error handler', function (ctx) {
       const err = new Error()
-      this.HttpErrorHandler.handleErrorByStatusCode(
-        this.req,
-        this.res,
-        err,
-        409
-      )
-      expect(this.res.body).to.equal('conflict')
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 409)
+      expect(ctx.res.body).to.equal('conflict')
     })
 
-    it('uses the forbidden() error handler', function () {
+    it('uses the forbidden() error handler', function (ctx) {
       const err = new Error()
-      this.HttpErrorHandler.handleErrorByStatusCode(
-        this.req,
-        this.res,
-        err,
-        403
-      )
-      expect(this.res.body).to.equal('restricted')
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 403)
+      expect(ctx.res.body).to.equal('restricted')
     })
 
-    it('uses the notFound() error handler', function () {
+    it('uses the notFound() error handler', function (ctx) {
       const err = new Error()
-      this.HttpErrorHandler.handleErrorByStatusCode(
-        this.req,
-        this.res,
-        err,
-        404
-      )
-      expect(this.res.body).to.equal('not found')
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 404)
+      expect(ctx.res.body).to.equal('not found')
     })
 
-    it('uses the unprocessableEntity() error handler', function () {
+    it('uses the unprocessableEntity() error handler', function (ctx) {
       const err = new Error()
       err.httpStatusCode = 422
-      this.HttpErrorHandler.handleErrorByStatusCode(
-        this.req,
-        this.res,
-        err,
-        422
-      )
-      expect(this.res.body).to.equal('unprocessable entity')
+      ctx.HttpErrorHandler.handleErrorByStatusCode(ctx.req, ctx.res, err, 422)
+      expect(ctx.res.body).to.equal('unprocessable entity')
     })
   })
 
   describe('badRequest', function () {
-    it('returns 400', function () {
-      this.HttpErrorHandler.badRequest(this.req, this.res)
-      expect(this.res.statusCode).to.equal(400)
+    it('returns 400', function (ctx) {
+      ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res)
+      expect(ctx.res.statusCode).to.equal(400)
     })
 
-    it('should print a message when no content-type is included', function () {
-      this.HttpErrorHandler.badRequest(this.req, this.res)
-      expect(this.res.body).to.equal('client error')
+    it('should print a message when no content-type is included', function (ctx) {
+      ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res)
+      expect(ctx.res.body).to.equal('client error')
     })
 
-    it("should render a template including the error message when content-type is 'html'", function () {
-      this.req.accepts = () => 'html'
-      this.HttpErrorHandler.badRequest(this.req, this.res, 'an error')
-      expect(this.res.renderedTemplate).to.equal('general/400')
-      expect(this.res.renderedVariables).to.deep.equal({
+    it("should render a template including the error message when content-type is 'html'", function (ctx) {
+      ctx.req.accepts = () => 'html'
+      ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res, 'an error')
+      expect(ctx.res.renderedTemplate).to.equal('general/400')
+      expect(ctx.res.renderedVariables).to.deep.equal({
         title: 'Client Error',
         message: 'an error',
       })
     })
 
-    it("should render a default template when content-type is 'html' and no message is provided", function () {
-      this.req.accepts = () => 'html'
-      this.HttpErrorHandler.badRequest(this.req, this.res)
-      expect(this.res.renderedTemplate).to.equal('general/400')
-      expect(this.res.renderedVariables).to.deep.equal({
+    it("should render a default template when content-type is 'html' and no message is provided", function (ctx) {
+      ctx.req.accepts = () => 'html'
+      ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res)
+      expect(ctx.res.renderedTemplate).to.equal('general/400')
+      expect(ctx.res.renderedVariables).to.deep.equal({
         title: 'Client Error',
         message: undefined,
       })
     })
 
-    it("should return a json object when content-type is 'json'", function () {
-      this.req.accepts = () => 'json'
-      this.HttpErrorHandler.badRequest(this.req, this.res, 'an error', {
+    it("should return a json object when content-type is 'json'", function (ctx) {
+      ctx.req.accepts = () => 'json'
+      ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res, 'an error', {
         foo: 'bar',
       })
-      expect(JSON.parse(this.res.body)).to.deep.equal({
+      expect(JSON.parse(ctx.res.body)).to.deep.equal({
         message: 'an error',
         foo: 'bar',
       })
     })
 
-    it("should return an empty json object when content-type is 'json' and no message and info are provided", function () {
-      this.req.accepts = () => 'json'
-      this.HttpErrorHandler.badRequest(this.req, this.res)
-      expect(JSON.parse(this.res.body)).to.deep.equal({})
+    it("should return an empty json object when content-type is 'json' and no message and info are provided", function (ctx) {
+      ctx.req.accepts = () => 'json'
+      ctx.HttpErrorHandler.badRequest(ctx.req, ctx.res)
+      expect(JSON.parse(ctx.res.body)).to.deep.equal({})
     })
   })
 
   describe('conflict', function () {
-    it('returns 409', function () {
-      this.HttpErrorHandler.conflict(this.req, this.res)
-      expect(this.res.statusCode).to.equal(409)
+    it('returns 409', function (ctx) {
+      ctx.HttpErrorHandler.conflict(ctx.req, ctx.res)
+      expect(ctx.res.statusCode).to.equal(409)
     })
 
-    it('should print a message when no content-type is included', function () {
-      this.HttpErrorHandler.conflict(this.req, this.res)
-      expect(this.res.body).to.equal('conflict')
+    it('should print a message when no content-type is included', function (ctx) {
+      ctx.HttpErrorHandler.conflict(ctx.req, ctx.res)
+      expect(ctx.res.body).to.equal('conflict')
     })
 
-    it("should render a template including the error message when content-type is 'html'", function () {
-      this.req.accepts = () => 'html'
-      this.HttpErrorHandler.unprocessableEntity(this.req, this.res, 'an error')
-      expect(this.res.renderedTemplate).to.equal('general/400')
-      expect(this.res.renderedVariables).to.deep.equal({
+    it("should render a template including the error message when content-type is 'html'", function (ctx) {
+      ctx.req.accepts = () => 'html'
+      ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res, 'an error')
+      expect(ctx.res.renderedTemplate).to.equal('general/400')
+      expect(ctx.res.renderedVariables).to.deep.equal({
         title: 'Client Error',
         message: 'an error',
       })
     })
 
-    it("should return a json object when content-type is 'json'", function () {
-      this.req.accepts = () => 'json'
-      this.HttpErrorHandler.unprocessableEntity(
-        this.req,
-        this.res,
-        'an error',
-        {
-          foo: 'bar',
-        }
-      )
-      expect(JSON.parse(this.res.body)).to.deep.equal({
+    it("should return a json object when content-type is 'json'", function (ctx) {
+      ctx.req.accepts = () => 'json'
+      ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res, 'an error', {
+        foo: 'bar',
+      })
+      expect(JSON.parse(ctx.res.body)).to.deep.equal({
         message: 'an error',
         foo: 'bar',
       })
@@ -226,31 +170,31 @@ describe('HttpErrorHandler', function () {
   })
 
   describe('forbidden', function () {
-    it('returns 403', function () {
-      this.HttpErrorHandler.forbidden(this.req, this.res)
-      expect(this.res.statusCode).to.equal(403)
+    it('returns 403', function (ctx) {
+      ctx.HttpErrorHandler.forbidden(ctx.req, ctx.res)
+      expect(ctx.res.statusCode).to.equal(403)
     })
 
-    it('should print a message when no content-type is included', function () {
-      this.HttpErrorHandler.forbidden(this.req, this.res)
-      expect(this.res.body).to.equal('restricted')
+    it('should print a message when no content-type is included', function (ctx) {
+      ctx.HttpErrorHandler.forbidden(ctx.req, ctx.res)
+      expect(ctx.res.body).to.equal('restricted')
     })
 
-    it("should render a template when content-type is 'html'", function () {
-      this.req.accepts = () => 'html'
-      this.HttpErrorHandler.forbidden(this.req, this.res)
-      expect(this.res.renderedTemplate).to.equal('user/restricted')
-      expect(this.res.renderedVariables).to.deep.equal({
+    it("should render a template when content-type is 'html'", function (ctx) {
+      ctx.req.accepts = () => 'html'
+      ctx.HttpErrorHandler.forbidden(ctx.req, ctx.res)
+      expect(ctx.res.renderedTemplate).to.equal('user/restricted')
+      expect(ctx.res.renderedVariables).to.deep.equal({
         title: 'restricted',
       })
     })
 
-    it("should return a json object when content-type is 'json'", function () {
-      this.req.accepts = () => 'json'
-      this.HttpErrorHandler.forbidden(this.req, this.res, 'an error', {
+    it("should return a json object when content-type is 'json'", function (ctx) {
+      ctx.req.accepts = () => 'json'
+      ctx.HttpErrorHandler.forbidden(ctx.req, ctx.res, 'an error', {
         foo: 'bar',
       })
-      expect(JSON.parse(this.res.body)).to.deep.equal({
+      expect(JSON.parse(ctx.res.body)).to.deep.equal({
         message: 'an error',
         foo: 'bar',
       })
@@ -258,31 +202,31 @@ describe('HttpErrorHandler', function () {
   })
 
   describe('notFound', function () {
-    it('returns 404', function () {
-      this.HttpErrorHandler.notFound(this.req, this.res)
-      expect(this.res.statusCode).to.equal(404)
+    it('returns 404', function (ctx) {
+      ctx.HttpErrorHandler.notFound(ctx.req, ctx.res)
+      expect(ctx.res.statusCode).to.equal(404)
     })
 
-    it('should print a message when no content-type is included', function () {
-      this.HttpErrorHandler.notFound(this.req, this.res)
-      expect(this.res.body).to.equal('not found')
+    it('should print a message when no content-type is included', function (ctx) {
+      ctx.HttpErrorHandler.notFound(ctx.req, ctx.res)
+      expect(ctx.res.body).to.equal('not found')
     })
 
-    it("should render a template when content-type is 'html'", function () {
-      this.req.accepts = () => 'html'
-      this.HttpErrorHandler.notFound(this.req, this.res)
-      expect(this.res.renderedTemplate).to.equal('general/404')
-      expect(this.res.renderedVariables).to.deep.equal({
+    it("should render a template when content-type is 'html'", function (ctx) {
+      ctx.req.accepts = () => 'html'
+      ctx.HttpErrorHandler.notFound(ctx.req, ctx.res)
+      expect(ctx.res.renderedTemplate).to.equal('general/404')
+      expect(ctx.res.renderedVariables).to.deep.equal({
         title: 'page_not_found',
       })
     })
 
-    it("should return a json object when content-type is 'json'", function () {
-      this.req.accepts = () => 'json'
-      this.HttpErrorHandler.notFound(this.req, this.res, 'an error', {
+    it("should return a json object when content-type is 'json'", function (ctx) {
+      ctx.req.accepts = () => 'json'
+      ctx.HttpErrorHandler.notFound(ctx.req, ctx.res, 'an error', {
         foo: 'bar',
       })
-      expect(JSON.parse(this.res.body)).to.deep.equal({
+      expect(JSON.parse(ctx.res.body)).to.deep.equal({
         message: 'an error',
         foo: 'bar',
       })
@@ -290,85 +234,75 @@ describe('HttpErrorHandler', function () {
   })
 
   describe('unprocessableEntity', function () {
-    it('returns 422', function () {
-      this.HttpErrorHandler.unprocessableEntity(this.req, this.res)
-      expect(this.res.statusCode).to.equal(422)
+    it('returns 422', function (ctx) {
+      ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res)
+      expect(ctx.res.statusCode).to.equal(422)
     })
 
-    it('should print a message when no content-type is included', function () {
-      this.HttpErrorHandler.unprocessableEntity(this.req, this.res)
-      expect(this.res.body).to.equal('unprocessable entity')
+    it('should print a message when no content-type is included', function (ctx) {
+      ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res)
+      expect(ctx.res.body).to.equal('unprocessable entity')
     })
 
-    it("should render a template including the error message when content-type is 'html'", function () {
-      this.req.accepts = () => 'html'
-      this.HttpErrorHandler.unprocessableEntity(this.req, this.res, 'an error')
-      expect(this.res.renderedTemplate).to.equal('general/400')
-      expect(this.res.renderedVariables).to.deep.equal({
+    it("should render a template including the error message when content-type is 'html'", function (ctx) {
+      ctx.req.accepts = () => 'html'
+      ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res, 'an error')
+      expect(ctx.res.renderedTemplate).to.equal('general/400')
+      expect(ctx.res.renderedVariables).to.deep.equal({
         title: 'Client Error',
         message: 'an error',
       })
     })
 
-    it("should return a json object when content-type is 'json'", function () {
-      this.req.accepts = () => 'json'
-      this.HttpErrorHandler.unprocessableEntity(
-        this.req,
-        this.res,
-        'an error',
-        {
-          foo: 'bar',
-        }
-      )
-      expect(JSON.parse(this.res.body)).to.deep.equal({
+    it("should return a json object when content-type is 'json'", function (ctx) {
+      ctx.req.accepts = () => 'json'
+      ctx.HttpErrorHandler.unprocessableEntity(ctx.req, ctx.res, 'an error', {
+        foo: 'bar',
+      })
+      expect(JSON.parse(ctx.res.body)).to.deep.equal({
         message: 'an error',
         foo: 'bar',
       })
     })
 
     describe('legacyInternal', function () {
-      it('returns 500', function () {
-        this.HttpErrorHandler.legacyInternal(this.req, this.res, new Error())
-        expect(this.res.statusCode).to.equal(500)
+      it('returns 500', function (ctx) {
+        ctx.HttpErrorHandler.legacyInternal(ctx.req, ctx.res, new Error())
+        expect(ctx.res.statusCode).to.equal(500)
       })
 
-      it('should send the error to the logger', function () {
+      it('should send the error to the logger', function (ctx) {
         const error = new Error('message')
-        this.HttpErrorHandler.legacyInternal(
-          this.req,
-          this.res,
-          'message',
-          error
-        )
-        expect(this.req.logger.setLevel).to.have.been.calledWith('error')
-        expect(this.req.logger.addFields).to.have.been.calledWith({
+        ctx.HttpErrorHandler.legacyInternal(ctx.req, ctx.res, 'message', error)
+        expect(ctx.req.logger.setLevel).to.have.been.calledWith('error')
+        expect(ctx.req.logger.addFields).to.have.been.calledWith({
           err: error,
         })
       })
 
-      it('should print a message when no content-type is included', function () {
-        this.HttpErrorHandler.legacyInternal(this.req, this.res, new Error())
-        expect(this.res.body).to.equal('internal server error')
+      it('should print a message when no content-type is included', function (ctx) {
+        ctx.HttpErrorHandler.legacyInternal(ctx.req, ctx.res, new Error())
+        expect(ctx.res.body).to.equal('internal server error')
       })
 
-      it("should render a template when content-type is 'html'", function () {
-        this.req.accepts = () => 'html'
-        this.HttpErrorHandler.legacyInternal(this.req, this.res, new Error())
-        expect(this.res.renderedTemplate).to.equal('general/500')
-        expect(this.res.renderedVariables).to.deep.equal({
+      it("should render a template when content-type is 'html'", function (ctx) {
+        ctx.req.accepts = () => 'html'
+        ctx.HttpErrorHandler.legacyInternal(ctx.req, ctx.res, new Error())
+        expect(ctx.res.renderedTemplate).to.equal('general/500')
+        expect(ctx.res.renderedVariables).to.deep.equal({
           title: 'Server Error',
         })
       })
 
-      it("should return a json object with a static message when content-type is 'json'", function () {
-        this.req.accepts = () => 'json'
-        this.HttpErrorHandler.legacyInternal(
-          this.req,
-          this.res,
+      it("should return a json object with a static message when content-type is 'json'", function (ctx) {
+        ctx.req.accepts = () => 'json'
+        ctx.HttpErrorHandler.legacyInternal(
+          ctx.req,
+          ctx.res,
           'a message',
           new Error()
         )
-        expect(JSON.parse(this.res.body)).to.deep.equal({
+        expect(JSON.parse(ctx.res.body)).to.deep.equal({
           message: 'a message',
         })
       })

+ 7 - 7
services/web/test/unit/src/HelperFiles/SafeHTMLSubstitute.test.mjs

@@ -1,14 +1,14 @@
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const MODULE_PATH = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/Helpers/SafeHTMLSubstitution.js'
+import { beforeAll, describe, expect } from 'vitest'
+import path from 'node:path'
+const MODULE_PATH = path.join(
+  import.meta.dirname,
+  '../../../../app/src/Features/Helpers/SafeHTMLSubstitution.mjs'
 )
 
 describe('SafeHTMLSubstitution', function () {
   let SafeHTMLSubstitution
-  before(function () {
-    SafeHTMLSubstitution = SandboxedModule.require(MODULE_PATH)
+  beforeAll(async function () {
+    SafeHTMLSubstitution = (await import(MODULE_PATH)).default
   })
 
   describe('SPLIT_REGEX', function () {

+ 22 - 18
services/web/test/unit/src/HelperFiles/UrlHelper.test.mjs

@@ -1,42 +1,46 @@
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/Helpers/UrlHelper.js'
+import { vi, expect } from 'vitest'
+import path from 'node:path'
+
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../../app/src/Features/Helpers/UrlHelper.mjs'
 )
 
 describe('UrlHelper', function () {
-  beforeEach(function () {
-    this.settings = {
+  beforeEach(async function (ctx) {
+    ctx.settings = {
       apis: { linkedUrlProxy: { url: undefined } },
       siteUrl: 'http://127.0.0.1:3000',
     }
-    this.UrlHelper = SandboxedModule.require(modulePath, {
-      requires: { '@overleaf/settings': this.settings },
-    })
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    ctx.UrlHelper = (await import(modulePath)).default
   })
   describe('getSafeRedirectPath', function () {
-    it('sanitize redirect path to prevent open redirects', function () {
-      expect(this.UrlHelper.getSafeRedirectPath('https://evil.com')).to.be
+    it('sanitize redirect path to prevent open redirects', function (ctx) {
+      expect(ctx.UrlHelper.getSafeRedirectPath('https://evil.com')).to.be
         .undefined
 
-      expect(this.UrlHelper.getSafeRedirectPath('//evil.com')).to.be.undefined
+      expect(ctx.UrlHelper.getSafeRedirectPath('//evil.com')).to.be.undefined
 
-      expect(this.UrlHelper.getSafeRedirectPath('//ol.com/evil')).to.equal(
+      expect(ctx.UrlHelper.getSafeRedirectPath('//ol.com/evil')).to.equal(
         '/evil'
       )
 
-      expect(this.UrlHelper.getSafeRedirectPath('////evil.com')).to.be.undefined
+      expect(ctx.UrlHelper.getSafeRedirectPath('////evil.com')).to.be.undefined
 
-      expect(this.UrlHelper.getSafeRedirectPath('%2F%2Fevil.com')).to.equal(
+      expect(ctx.UrlHelper.getSafeRedirectPath('%2F%2Fevil.com')).to.equal(
         '/%2F%2Fevil.com'
       )
 
       expect(
-        this.UrlHelper.getSafeRedirectPath('http://foo.com//evil.com/bad')
+        ctx.UrlHelper.getSafeRedirectPath('http://foo.com//evil.com/bad')
       ).to.equal('/evil.com/bad')
 
-      return expect(this.UrlHelper.getSafeRedirectPath('.evil.com')).to.equal(
+      return expect(ctx.UrlHelper.getSafeRedirectPath('.evil.com')).to.equal(
         '/.evil.com'
       )
     })

+ 135 - 118
services/web/test/unit/src/History/HistoryManager.test.mjs

@@ -1,6 +1,5 @@
-import { expect } from 'chai'
+import { beforeAll, beforeEach, describe, it, vi, expect } from 'vitest'
 import sinon from 'sinon'
-import SandboxedModule from 'sandboxed-module'
 import mongodb from 'mongodb-legacy'
 import {
   cleanupTestDatabase,
@@ -18,11 +17,11 @@ const GLOBAL_BLOBS = [
 ]
 
 describe('HistoryManager', function () {
-  before(async function () {
+  beforeAll(async function () {
     await waitForDb()
   })
-  before(cleanupTestDatabase)
-  before(async function () {
+  beforeAll(cleanupTestDatabase)
+  beforeAll(async function () {
     await db.projectHistoryGlobalBlobs.insertMany(
       GLOBAL_BLOBS.map(sha => ({
         _id: sha,
@@ -32,32 +31,32 @@ describe('HistoryManager', function () {
     )
   })
 
-  beforeEach(function () {
-    this.user_id = 'user-id-123'
-    this.historyId = new ObjectId().toString()
-    this.AuthenticationController = {
-      getLoggedInUserId: sinon.stub().returns(this.user_id),
+  beforeEach(async function (ctx) {
+    ctx.user_id = 'user-id-123'
+    ctx.historyId = new ObjectId().toString()
+    ctx.AuthenticationController = {
+      getLoggedInUserId: sinon.stub().returns(ctx.user_id),
     }
-    this.FetchUtils = {
+    ctx.FetchUtils = {
       fetchJson: sinon.stub(),
       fetchNothing: sinon.stub().resolves(),
     }
-    this.projectHistoryUrl = 'http://project_history.example.com'
-    this.v1HistoryUrl = 'http://v1_history.example.com'
-    this.v1HistoryUser = 'system'
-    this.v1HistoryPassword = 'verysecret'
-    this.settings = {
+    ctx.projectHistoryUrl = 'http://project_history.example.com'
+    ctx.v1HistoryUrl = 'http://v1_history.example.com'
+    ctx.v1HistoryUser = 'system'
+    ctx.v1HistoryPassword = 'verysecret'
+    ctx.settings = {
       apis: {
         filestore: {
           url: 'http://filestore.example.com',
         },
         project_history: {
-          url: this.projectHistoryUrl,
+          url: ctx.projectHistoryUrl,
         },
         v1_history: {
-          url: this.v1HistoryUrl,
-          user: this.v1HistoryUser,
-          pass: this.v1HistoryPassword,
+          url: ctx.v1HistoryUrl,
+          user: ctx.v1HistoryUser,
+          pass: ctx.v1HistoryPassword,
           buckets: {
             globalBlobs: 'globalBlobs',
             projectBlobs: 'projectBlobs',
@@ -66,169 +65,187 @@ describe('HistoryManager', function () {
       },
     }
 
-    this.UserGetter = {
+    ctx.UserGetter = {
       promises: {
         getUsersByV1Ids: sinon.stub(),
         getUsers: sinon.stub(),
       },
     }
 
-    this.project = {
+    ctx.project = {
       overleaf: {
         history: {
-          id: this.historyId,
+          id: ctx.historyId,
         },
       },
     }
 
-    this.ProjectGetter = {
+    ctx.ProjectGetter = {
       promises: {
-        getProject: sinon.stub().resolves(this.project),
+        getProject: sinon.stub().resolves(ctx.project),
       },
     }
 
-    this.HistoryBackupDeletionHandler = {
+    ctx.HistoryBackupDeletionHandler = {
       deleteProject: sinon.stub().resolves(),
     }
 
-    this.HistoryManager = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '../../infrastructure/mongodb': { ObjectId, db, waitForDb },
-        '@overleaf/fetch-utils': this.FetchUtils,
-        '@overleaf/settings': this.settings,
-        '../User/UserGetter': this.UserGetter,
-        '../Project/ProjectGetter': this.ProjectGetter,
-        './HistoryBackupDeletionHandler': this.HistoryBackupDeletionHandler,
-      },
-    })
+    vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({
+      ObjectId,
+      db,
+      waitForDb,
+    }))
+
+    vi.doMock('@overleaf/fetch-utils', () => ctx.FetchUtils)
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: ctx.ProjectGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/History/HistoryBackupDeletionHandler',
+      () => ({
+        default: ctx.HistoryBackupDeletionHandler,
+      })
+    )
+
+    ctx.HistoryManager = (await import(MODULE_PATH)).default
   })
 
   describe('getFilestoreBlobURL', function () {
-    beforeEach(async function () {
-      await this.HistoryManager.loadGlobalBlobsPromise
+    beforeEach(async function (ctx) {
+      await ctx.HistoryManager.loadGlobalBlobsPromise
     })
-    it('should return a global blob location', function () {
+    it('should return a global blob location', function (ctx) {
       for (const sha of GLOBAL_BLOBS) {
-        expect(this.HistoryManager.getFilestoreBlobURL('42', sha)).to.equal(
-          `${this.settings.apis.filestore.url}/history/global/hash/${sha}`
+        expect(ctx.HistoryManager.getFilestoreBlobURL('42', sha)).to.equal(
+          `${ctx.settings.apis.filestore.url}/history/global/hash/${sha}`
         )
       }
     })
-    it('should return a project blob location for a v1 project', function () {
+    it('should return a project blob location for a v1 project', function (ctx) {
       const historyId = 42
       const sha = '6ddfa0578a67fe5ad6623a8665ec9aafce1eb5ca'
-      expect(this.HistoryManager.getFilestoreBlobURL(historyId, sha)).to.equal(
-        `${this.settings.apis.filestore.url}/history/project/${historyId}/hash/${sha}`
+      expect(ctx.HistoryManager.getFilestoreBlobURL(historyId, sha)).to.equal(
+        `${ctx.settings.apis.filestore.url}/history/project/${historyId}/hash/${sha}`
       )
     })
-    it('should return a project blob location for a mongo project', function () {
+    it('should return a project blob location for a mongo project', function (ctx) {
       const historyId = '424242424242424242424242'
       const sha = '6ddfa0578a67fe5ad6623a8665ec9aafce1eb5ca'
-      expect(this.HistoryManager.getFilestoreBlobURL(historyId, sha)).to.equal(
-        `${this.settings.apis.filestore.url}/history/project/${historyId}/hash/${sha}`
+      expect(ctx.HistoryManager.getFilestoreBlobURL(historyId, sha)).to.equal(
+        `${ctx.settings.apis.filestore.url}/history/project/${historyId}/hash/${sha}`
       )
     })
   })
 
   describe('initializeProject', function () {
-    beforeEach(function () {
-      this.settings.apis.project_history.initializeHistoryForNewProjects = true
+    beforeEach(function (ctx) {
+      ctx.settings.apis.project_history.initializeHistoryForNewProjects = true
     })
 
     describe('project history returns a successful response', function () {
-      beforeEach(async function () {
-        this.FetchUtils.fetchJson.resolves({ project: { id: this.historyId } })
-        this.result = await this.HistoryManager.promises.initializeProject(
-          this.historyId
+      beforeEach(async function (ctx) {
+        ctx.FetchUtils.fetchJson.resolves({ project: { id: ctx.historyId } })
+        ctx.result = await ctx.HistoryManager.promises.initializeProject(
+          ctx.historyId
         )
       })
 
-      it('should call the project history api', function () {
-        this.FetchUtils.fetchJson.should.have.been.calledWithMatch(
-          `${this.settings.apis.project_history.url}/project`,
+      it('should call the project history api', function (ctx) {
+        ctx.FetchUtils.fetchJson.should.have.been.calledWithMatch(
+          `${ctx.settings.apis.project_history.url}/project`,
           { method: 'POST' }
         )
       })
 
-      it('should return the overleaf id', function () {
-        expect(this.result).to.equal(this.historyId)
+      it('should return the overleaf id', function (ctx) {
+        expect(ctx.result).to.equal(ctx.historyId)
       })
     })
 
     describe('project history returns a response without the project id', function () {
-      it('should throw an error', async function () {
-        this.FetchUtils.fetchJson.resolves({ project: {} })
+      it('should throw an error', async function (ctx) {
+        ctx.FetchUtils.fetchJson.resolves({ project: {} })
         await expect(
-          this.HistoryManager.promises.initializeProject(this.historyId)
+          ctx.HistoryManager.promises.initializeProject(ctx.historyId)
         ).to.be.rejected
       })
     })
 
     describe('project history errors', function () {
-      it('should propagate the error', async function () {
-        this.FetchUtils.fetchJson.rejects(new Error('problem connecting'))
+      it('should propagate the error', async function (ctx) {
+        ctx.FetchUtils.fetchJson.rejects(new Error('problem connecting'))
         await expect(
-          this.HistoryManager.promises.initializeProject(this.historyId)
+          ctx.HistoryManager.promises.initializeProject(ctx.historyId)
         ).to.be.rejected
       })
     })
   })
 
   describe('injectUserDetails', function () {
-    beforeEach(function () {
-      this.user1 = {
-        _id: (this.user_id1 = '123456'),
+    beforeEach(function (ctx) {
+      ctx.user1 = {
+        _id: (ctx.user_id1 = '123456'),
         first_name: 'Jane',
         last_name: 'Doe',
         email: 'jane@example.com',
         overleaf: { id: 5011 },
       }
-      this.user1_view = {
-        id: this.user_id1,
+      ctx.user1_view = {
+        id: ctx.user_id1,
         first_name: 'Jane',
         last_name: 'Doe',
         email: 'jane@example.com',
       }
-      this.user2 = {
-        _id: (this.user_id2 = 'abcdef'),
+      ctx.user2 = {
+        _id: (ctx.user_id2 = 'abcdef'),
         first_name: 'John',
         last_name: 'Doe',
         email: 'john@example.com',
       }
-      this.user2_view = {
-        id: this.user_id2,
+      ctx.user2_view = {
+        id: ctx.user_id2,
         first_name: 'John',
         last_name: 'Doe',
         email: 'john@example.com',
       }
-      this.UserGetter.promises.getUsersByV1Ids.resolves([this.user1])
-      this.UserGetter.promises.getUsers.resolves([this.user1, this.user2])
+      ctx.UserGetter.promises.getUsersByV1Ids.resolves([ctx.user1])
+      ctx.UserGetter.promises.getUsers.resolves([ctx.user1, ctx.user2])
     })
 
     describe('with a diff', function () {
-      it('should turn user_ids into user objects', async function () {
-        const diff = await this.HistoryManager.promises.injectUserDetails({
+      it('should turn user_ids into user objects', async function (ctx) {
+        const diff = await ctx.HistoryManager.promises.injectUserDetails({
           diff: [
             {
               i: 'foo',
               meta: {
-                users: [this.user_id1],
+                users: [ctx.user_id1],
               },
             },
             {
               i: 'bar',
               meta: {
-                users: [this.user_id2],
+                users: [ctx.user_id2],
               },
             },
           ],
         })
-        expect(diff.diff[0].meta.users).to.deep.equal([this.user1_view])
-        expect(diff.diff[1].meta.users).to.deep.equal([this.user2_view])
+        expect(diff.diff[0].meta.users).to.deep.equal([ctx.user1_view])
+        expect(diff.diff[1].meta.users).to.deep.equal([ctx.user2_view])
       })
 
-      it('should handle v1 user ids', async function () {
-        const diff = await this.HistoryManager.promises.injectUserDetails({
+      it('should handle v1 user ids', async function (ctx) {
+        const diff = await ctx.HistoryManager.promises.injectUserDetails({
           diff: [
             {
               i: 'foo',
@@ -239,38 +256,38 @@ describe('HistoryManager', function () {
             {
               i: 'bar',
               meta: {
-                users: [this.user_id2],
+                users: [ctx.user_id2],
               },
             },
           ],
         })
-        expect(diff.diff[0].meta.users).to.deep.equal([this.user1_view])
-        expect(diff.diff[1].meta.users).to.deep.equal([this.user2_view])
+        expect(diff.diff[0].meta.users).to.deep.equal([ctx.user1_view])
+        expect(diff.diff[1].meta.users).to.deep.equal([ctx.user2_view])
       })
 
-      it('should leave user objects', async function () {
-        const diff = await this.HistoryManager.promises.injectUserDetails({
+      it('should leave user objects', async function (ctx) {
+        const diff = await ctx.HistoryManager.promises.injectUserDetails({
           diff: [
             {
               i: 'foo',
               meta: {
-                users: [this.user1_view],
+                users: [ctx.user1_view],
               },
             },
             {
               i: 'bar',
               meta: {
-                users: [this.user_id2],
+                users: [ctx.user_id2],
               },
             },
           ],
         })
-        expect(diff.diff[0].meta.users).to.deep.equal([this.user1_view])
-        expect(diff.diff[1].meta.users).to.deep.equal([this.user2_view])
+        expect(diff.diff[0].meta.users).to.deep.equal([ctx.user1_view])
+        expect(diff.diff[1].meta.users).to.deep.equal([ctx.user2_view])
       })
 
-      it('should handle a binary diff marker', async function () {
-        const diff = await this.HistoryManager.promises.injectUserDetails({
+      it('should handle a binary diff marker', async function (ctx) {
+        const diff = await ctx.HistoryManager.promises.injectUserDetails({
           diff: { binary: true },
         })
         expect(diff.diff.binary).to.be.true
@@ -278,50 +295,50 @@ describe('HistoryManager', function () {
     })
 
     describe('with a list of updates', function () {
-      it('should turn user_ids into user objects', async function () {
-        const updates = await this.HistoryManager.promises.injectUserDetails({
+      it('should turn user_ids into user objects', async function (ctx) {
+        const updates = await ctx.HistoryManager.promises.injectUserDetails({
           updates: [
             {
               fromV: 5,
               toV: 8,
               meta: {
-                users: [this.user_id1],
+                users: [ctx.user_id1],
               },
             },
             {
               fromV: 4,
               toV: 5,
               meta: {
-                users: [this.user_id2],
+                users: [ctx.user_id2],
               },
             },
           ],
         })
-        expect(updates.updates[0].meta.users).to.deep.equal([this.user1_view])
-        expect(updates.updates[1].meta.users).to.deep.equal([this.user2_view])
+        expect(updates.updates[0].meta.users).to.deep.equal([ctx.user1_view])
+        expect(updates.updates[1].meta.users).to.deep.equal([ctx.user2_view])
       })
 
-      it('should leave user objects', async function () {
-        const updates = await this.HistoryManager.promises.injectUserDetails({
+      it('should leave user objects', async function (ctx) {
+        const updates = await ctx.HistoryManager.promises.injectUserDetails({
           updates: [
             {
               fromV: 5,
               toV: 8,
               meta: {
-                users: [this.user1_view],
+                users: [ctx.user1_view],
               },
             },
             {
               fromV: 4,
               toV: 5,
               meta: {
-                users: [this.user_id2],
+                users: [ctx.user_id2],
               },
             },
           ],
         })
-        expect(updates.updates[0].meta.users).to.deep.equal([this.user1_view])
-        expect(updates.updates[1].meta.users).to.deep.equal([this.user2_view])
+        expect(updates.updates[0].meta.users).to.deep.equal([ctx.user1_view])
+        expect(updates.updates[1].meta.users).to.deep.equal([ctx.user2_view])
       })
     })
   })
@@ -330,33 +347,33 @@ describe('HistoryManager', function () {
     const projectId = new ObjectId()
     const historyId = new ObjectId()
 
-    beforeEach(async function () {
-      await this.HistoryManager.promises.deleteProject(projectId, historyId)
+    beforeEach(async function (ctx) {
+      await ctx.HistoryManager.promises.deleteProject(projectId, historyId)
     })
 
-    it('should call the project-history service', async function () {
-      expect(this.FetchUtils.fetchNothing).to.have.been.calledWith(
-        `${this.projectHistoryUrl}/project/${projectId}`,
+    it('should call the project-history service', async function (ctx) {
+      expect(ctx.FetchUtils.fetchNothing).to.have.been.calledWith(
+        `${ctx.projectHistoryUrl}/project/${projectId}`,
         { method: 'DELETE' }
       )
     })
 
-    it('should call the v1-history service', async function () {
-      expect(this.FetchUtils.fetchNothing).to.have.been.calledWith(
-        `${this.v1HistoryUrl}/projects/${historyId}`,
+    it('should call the v1-history service', async function (ctx) {
+      expect(ctx.FetchUtils.fetchNothing).to.have.been.calledWith(
+        `${ctx.v1HistoryUrl}/projects/${historyId}`,
         {
           method: 'DELETE',
           basicAuth: {
-            user: this.v1HistoryUser,
-            password: this.v1HistoryPassword,
+            user: ctx.v1HistoryUser,
+            password: ctx.v1HistoryPassword,
           },
         }
       )
     })
 
-    it('should call the history-backup-deletion service', async function () {
+    it('should call the history-backup-deletion service', async function (ctx) {
       expect(
-        this.HistoryBackupDeletionHandler.deleteProject
+        ctx.HistoryBackupDeletionHandler.deleteProject
       ).to.have.been.calledWith(projectId)
     })
   })

+ 43 - 1
services/web/test/unit/src/History/RestoreManager.test.mjs

@@ -94,7 +94,49 @@ describe('RestoreManager', function () {
     }))
 
     vi.doMock('@overleaf/settings', () => ({
-      default: {},
+      default: {
+        fileIgnorePattern:
+          '**/{{__MACOSX,.git,.texpadtmp,.R}{,/**},.!(latexmkrc),*.{dvi,aux,log,toc,out,pdfsync,synctex,synctex(busy),fdb_latexmk,fls,nlo,ind,glo,gls,glg,bbl,blg,doc,docx,gz,swp}}',
+        textExtensions: [
+          'tex',
+          'latex',
+          'sty',
+          'cls',
+          'bst',
+          'bib',
+          'bibtex',
+          'txt',
+          'tikz',
+          'mtx',
+          'rtex',
+          'md',
+          'asy',
+          'lbx',
+          'bbx',
+          'cbx',
+          'm',
+          'lco',
+          'dtx',
+          'ins',
+          'ist',
+          'def',
+          'clo',
+          'ldf',
+          'rmd',
+          'lua',
+          'gv',
+          'mf',
+          'yml',
+          'yaml',
+          'lhs',
+          'mk',
+          'xmpdata',
+          'cfg',
+          'rnw',
+          'ltx',
+          'inc',
+        ],
+      },
     }))
 
     vi.doMock('../../../../app/src/infrastructure/FileWriter', () => ({

+ 2 - 8
services/web/test/unit/src/Institutions/InstitutionHelper.test.mjs

@@ -1,11 +1,5 @@
-const { expect } = require('chai')
-const path = require('path')
-const InstitutionsHelper = require(
-  path.join(
-    __dirname,
-    '/../../../../app/src/Features/Institutions/InstitutionsHelper'
-  )
-)
+import { expect } from 'chai'
+import InstitutionsHelper from '../../../../app/src/Features/Institutions/InstitutionsHelper.mjs'
 
 describe('InstitutionsHelper', function () {
   describe('emailHasLicence', function () {

+ 181 - 167
services/web/test/unit/src/Institutions/InstitutionsAPI.test.mjs

@@ -1,70 +1,88 @@
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const { ObjectId } = require('mongodb-legacy')
+import { vi, expect } from 'vitest'
+import path from 'path'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+
 const modulePath = path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/Features/Institutions/InstitutionsAPI'
 )
-const Errors = require('../../../../app/src/Features/Errors/Errors')
+const { ObjectId } = mongodb
+vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+)
 
 describe('InstitutionsAPI', function () {
-  beforeEach(function () {
-    this.settings = {
+  beforeEach(async function (ctx) {
+    ctx.settings = {
       apis: { v1: { url: 'v1.url', user: '', pass: '', timeout: 5000 } },
     }
-    this.request = sinon.stub()
-    this.fetchNothing = sinon.stub()
-    this.ipMatcherNotification = {
-      read: (this.markAsReadIpMatcher = sinon.stub().resolves()),
+    ctx.request = sinon.stub()
+    ctx.fetchNothing = sinon.stub()
+    ctx.ipMatcherNotification = {
+      read: (ctx.markAsReadIpMatcher = sinon.stub().resolves()),
     }
-    this.InstitutionsAPI = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': this.settings,
-        requestretry: this.request,
-        '@overleaf/fetch-utils': {
-          fetchNothing: this.fetchNothing,
-          fetchJson: (this.fetchJson = sinon.stub()),
-        },
-        '../Notifications/NotificationsBuilder': {
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('requestretry', () => ({
+      default: ctx.request,
+    }))
+
+    vi.doMock('@overleaf/fetch-utils', () => ({
+      fetchNothing: ctx.fetchNothing,
+      fetchJson: (ctx.fetchJson = sinon.stub()),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Notifications/NotificationsBuilder',
+      () => ({
+        default: {
           promises: {
             ipMatcherAffiliation: sinon
               .stub()
-              .returns(this.ipMatcherNotification),
+              .returns(ctx.ipMatcherNotification),
           },
         },
-        '../../infrastructure/Modules': (this.Modules = {
-          promises: {
-            hooks: {
-              fire: sinon.stub(),
-            },
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: (ctx.Modules = {
+        promises: {
+          hooks: {
+            fire: sinon.stub(),
           },
-        }),
-      },
-    })
+        },
+      }),
+    }))
 
-    this.stubbedUser = {
+    ctx.InstitutionsAPI = (await import(modulePath)).default
+
+    ctx.stubbedUser = {
       _id: '3131231',
       name: 'bob',
       email: 'hello@world.com',
     }
-    this.newEmail = 'bob@bob.com'
+    ctx.newEmail = 'bob@bob.com'
   })
 
   describe('getInstitutionAffiliations', function () {
-    it('get affiliations', async function () {
-      this.institutionId = 123
+    it('get affiliations', async function (ctx) {
+      ctx.institutionId = 123
       const responseBody = ['123abc', '456def']
-      this.request.yields(null, { statusCode: 200 }, responseBody)
+      ctx.request.yields(null, { statusCode: 200 }, responseBody)
       const body =
-        await this.InstitutionsAPI.promises.getInstitutionAffiliations(
-          this.institutionId
+        await ctx.InstitutionsAPI.promises.getInstitutionAffiliations(
+          ctx.institutionId
         )
 
-      this.request.calledOnce.should.equal(true)
-      const requestOptions = this.request.lastCall.args[0]
-      const expectedUrl = `v1.url/api/v2/institutions/${this.institutionId}/affiliations`
+      ctx.request.calledOnce.should.equal(true)
+      const requestOptions = ctx.request.lastCall.args[0]
+      const expectedUrl = `v1.url/api/v2/institutions/${ctx.institutionId}/affiliations`
       requestOptions.url.should.equal(expectedUrl)
       requestOptions.method.should.equal('GET')
       requestOptions.maxAttempts.should.exist
@@ -74,12 +92,13 @@ describe('InstitutionsAPI', function () {
       body.should.equal(responseBody)
     })
 
-    it('handle empty response', async function () {
-      this.settings.apis.v1.url = ''
+    it('handle empty response', async function (ctx) {
+      ctx.institutionId = 123
+      ctx.settings.apis.v1.url = ''
 
       const body =
-        await this.InstitutionsAPI.promises.getInstitutionAffiliations(
-          this.institutionId
+        await ctx.InstitutionsAPI.promises.getInstitutionAffiliations(
+          ctx.institutionId
         )
       expect(body).to.be.a('Array')
       body.length.should.equal(0)
@@ -89,7 +108,7 @@ describe('InstitutionsAPI', function () {
   describe('getLicencesForAnalytics', function () {
     const lag = 'daily'
     const queryDate = '2017-01-07:00:00.000Z'
-    it('should send the request to v1', async function () {
+    it('should send the request to v1', async function (ctx) {
       const v1Result = {
         lag: 'daily',
         date: queryDate,
@@ -98,22 +117,19 @@ describe('InstitutionsAPI', function () {
           max_confirmation_months: [],
         },
       }
-      this.request.callsArgWith(1, null, { statusCode: 201 }, v1Result)
-      await this.InstitutionsAPI.promises.getLicencesForAnalytics(
-        lag,
-        queryDate
-      )
-      const requestOptions = this.request.lastCall.args[0]
+      ctx.request.callsArgWith(1, null, { statusCode: 201 }, v1Result)
+      await ctx.InstitutionsAPI.promises.getLicencesForAnalytics(lag, queryDate)
+      const requestOptions = ctx.request.lastCall.args[0]
       expect(requestOptions.body.query_date).to.equal(queryDate)
       expect(requestOptions.body.lag).to.equal(lag)
       requestOptions.method.should.equal('GET')
     })
-    it('should handle errors', async function () {
-      this.request.callsArgWith(1, null, { statusCode: 500 })
+    it('should handle errors', async function (ctx) {
+      ctx.request.callsArgWith(1, null, { statusCode: 500 })
       let error
 
       try {
-        await this.InstitutionsAPI.promises.getLicencesForAnalytics(
+        await ctx.InstitutionsAPI.promises.getLicencesForAnalytics(
           lag,
           queryDate
         )
@@ -126,7 +142,7 @@ describe('InstitutionsAPI', function () {
   })
 
   describe('getUserAffiliations', function () {
-    it('get affiliations with commons', async function () {
+    it('get affiliations with commons', async function (ctx) {
       const responseBody = [
         {
           foo: 'bar',
@@ -135,22 +151,22 @@ describe('InstitutionsAPI', function () {
           },
         },
       ]
-      this.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
-      const body = await this.InstitutionsAPI.promises.getUserAffiliations(
-        this.stubbedUser._id
+      ctx.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
+      const body = await ctx.InstitutionsAPI.promises.getUserAffiliations(
+        ctx.stubbedUser._id
       )
-      this.request.calledOnce.should.equal(true)
-      const requestOptions = this.request.lastCall.args[0]
-      const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
+      ctx.request.calledOnce.should.equal(true)
+      const requestOptions = ctx.request.lastCall.args[0]
+      const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
       requestOptions.url.should.equal(expectedUrl)
       requestOptions.method.should.equal('GET')
       requestOptions.maxAttempts.should.equal(3)
-      this.Modules.promises.hooks.fire.should.have.been.called
+      ctx.Modules.promises.hooks.fire.should.have.been.called
       expect(requestOptions.body).not.to.exist
       expect(body).to.deep.equal(responseBody)
     })
 
-    it('get affiliations with domain capture for groups', async function () {
+    it('get affiliations with domain capture for groups', async function (ctx) {
       const responseBody = [
         {
           id: '123abc',
@@ -160,28 +176,28 @@ describe('InstitutionsAPI', function () {
           },
         },
       ]
-      this.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
+      ctx.request.callsArgWith(1, null, { statusCode: 201 }, responseBody)
       const groupResponse = {
         _id: new ObjectId(),
         managedUsersEnabled: false,
         domainCaptureEnabled: true,
       }
-      this.Modules.promises.hooks.fire
+      ctx.Modules.promises.hooks.fire
         .withArgs(
           'getGroupWithDomainCaptureByV1Id',
           responseBody[0].institution.id
         )
         .resolves([groupResponse])
-      const body = await this.InstitutionsAPI.promises.getUserAffiliations(
-        this.stubbedUser._id
+      const body = await ctx.InstitutionsAPI.promises.getUserAffiliations(
+        ctx.stubbedUser._id
       )
-      this.request.calledOnce.should.equal(true)
-      const requestOptions = this.request.lastCall.args[0]
-      const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
+      ctx.request.calledOnce.should.equal(true)
+      const requestOptions = ctx.request.lastCall.args[0]
+      const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
       requestOptions.url.should.equal(expectedUrl)
       requestOptions.method.should.equal('GET')
       requestOptions.maxAttempts.should.equal(3)
-      this.Modules.promises.hooks.fire.should.have.been.calledWith(
+      ctx.Modules.promises.hooks.fire.should.have.been.calledWith(
         'getGroupWithDomainCaptureByV1Id',
         responseBody[0].institution.id
       )
@@ -196,14 +212,14 @@ describe('InstitutionsAPI', function () {
       ])
     })
 
-    it('handle error', async function () {
+    it('handle error', async function (ctx) {
       const body = { errors: 'affiliation error message' }
-      this.request.callsArgWith(1, null, { statusCode: 503 }, body)
+      ctx.request.callsArgWith(1, null, { statusCode: 503 }, body)
       let error
 
       try {
-        await this.InstitutionsAPI.promises.getUserAffiliations(
-          this.stubbedUser._id
+        await ctx.InstitutionsAPI.promises.getUserAffiliations(
+          ctx.stubbedUser._id
         )
       } catch (err) {
         error = err
@@ -212,10 +228,10 @@ describe('InstitutionsAPI', function () {
       expect(error).to.be.instanceOf(Errors.V1ConnectionError)
     })
 
-    it('handle empty response', async function () {
-      this.settings.apis.v1.url = ''
-      const body = await this.InstitutionsAPI.promises.getUserAffiliations(
-        this.stubbedUser._id
+    it('handle empty response', async function (ctx) {
+      ctx.settings.apis.v1.url = ''
+      const body = await ctx.InstitutionsAPI.promises.getUserAffiliations(
+        ctx.stubbedUser._id
       )
       expect(body).to.be.a('Array')
       body.length.should.equal(0)
@@ -223,30 +239,30 @@ describe('InstitutionsAPI', function () {
   })
 
   describe('getUsersNeedingReconfirmationsLapsedProcessed', function () {
-    it('get the list of users', async function () {
-      this.fetchJson.resolves({ statusCode: 200 })
-      await this.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
-      this.fetchJson.calledOnce.should.equal(true)
-      const requestOptions = this.fetchJson.lastCall.args[1]
+    it('get the list of users', async function (ctx) {
+      ctx.fetchJson.resolves({ statusCode: 200 })
+      await ctx.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
+      ctx.fetchJson.calledOnce.should.equal(true)
+      const requestOptions = ctx.fetchJson.lastCall.args[1]
       const expectedUrl = `v1.url/api/v2/institutions/need_reconfirmation_lapsed_processed`
-      this.fetchJson.lastCall.args[0].should.equal(expectedUrl)
+      ctx.fetchJson.lastCall.args[0].should.equal(expectedUrl)
       requestOptions.method.should.equal('GET')
     })
 
-    it('handle error', async function () {
-      this.fetchJson.throws({ info: { statusCode: 500 } })
+    it('handle error', async function (ctx) {
+      ctx.fetchJson.throws({ info: { statusCode: 500 } })
       await expect(
-        this.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
+        ctx.InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed()
       ).to.be.rejected
     })
   })
 
   describe('addAffiliation', function () {
-    beforeEach(function () {
-      this.fetchNothing.resolves({ status: 201 })
+    beforeEach(function (ctx) {
+      ctx.fetchNothing.resolves({ status: 201 })
     })
 
-    it('add affiliation', async function () {
+    it('add affiliation', async function (ctx) {
       const affiliationOptions = {
         university: { id: 1 },
         department: 'Math',
@@ -254,38 +270,38 @@ describe('InstitutionsAPI', function () {
         confirmedAt: new Date(),
         entitlement: true,
       }
-      await this.InstitutionsAPI.promises.addAffiliation(
-        this.stubbedUser._id,
-        this.newEmail,
+      await ctx.InstitutionsAPI.promises.addAffiliation(
+        ctx.stubbedUser._id,
+        ctx.newEmail,
         affiliationOptions
       )
-      this.fetchNothing.calledOnce.should.equal(true)
-      const requestOptions = this.fetchNothing.lastCall.args[1]
-      const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
-      expect(this.fetchNothing.lastCall.args[0]).to.equal(expectedUrl)
+      ctx.fetchNothing.calledOnce.should.equal(true)
+      const requestOptions = ctx.fetchNothing.lastCall.args[1]
+      const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
+      expect(ctx.fetchNothing.lastCall.args[0]).to.equal(expectedUrl)
       requestOptions.method.should.equal('POST')
 
       const { json } = requestOptions
       Object.keys(json).length.should.equal(7)
       expect(json).to.deep.equal(
         Object.assign(
-          { email: this.newEmail, rejectIfBlocklisted: undefined },
+          { email: ctx.newEmail, rejectIfBlocklisted: undefined },
           affiliationOptions
         )
       )
-      this.markAsReadIpMatcher.calledOnce.should.equal(true)
+      ctx.markAsReadIpMatcher.calledOnce.should.equal(true)
     })
 
-    it('handles 422 error', async function () {
+    it('handles 422 error', async function (ctx) {
       const messageFromApi = 'affiliation error message'
       const body = JSON.stringify({ errors: messageFromApi })
-      this.fetchNothing.throws({ response: { status: 422 }, body })
+      ctx.fetchNothing.throws({ response: { status: 422 }, body })
       let error
 
       try {
-        await this.InstitutionsAPI.promises.addAffiliation(
-          this.stubbedUser._id,
-          this.newEmail,
+        await ctx.InstitutionsAPI.promises.addAffiliation(
+          ctx.stubbedUser._id,
+          ctx.newEmail,
           {}
         )
       } catch (err) {
@@ -296,15 +312,15 @@ describe('InstitutionsAPI', function () {
       expect(error).to.have.property('message', `422: ${messageFromApi}`)
     })
 
-    it('handles 500 error', async function () {
+    it('handles 500 error', async function (ctx) {
       const body = { errors: 'affiliation error message' }
-      this.fetchNothing.throws({ response: { status: 500 }, body })
+      ctx.fetchNothing.throws({ response: { status: 500 }, body })
       let error
 
       try {
-        await this.InstitutionsAPI.promises.addAffiliation(
-          this.stubbedUser._id,
-          this.newEmail,
+        await ctx.InstitutionsAPI.promises.addAffiliation(
+          ctx.stubbedUser._id,
+          ctx.newEmail,
           {}
         )
       } catch (err) {
@@ -319,14 +335,14 @@ describe('InstitutionsAPI', function () {
       })
     })
 
-    it('uses default error message when no error body in response', async function () {
-      this.fetchNothing.throws({ response: { status: 429 } })
+    it('uses default error message when no error body in response', async function (ctx) {
+      ctx.fetchNothing.throws({ response: { status: 429 } })
       let error
 
       try {
-        await this.InstitutionsAPI.promises.addAffiliation(
-          this.stubbedUser._id,
-          this.newEmail,
+        await ctx.InstitutionsAPI.promises.addAffiliation(
+          ctx.stubbedUser._id,
+          ctx.newEmail,
           {}
         )
       } catch (err) {
@@ -340,47 +356,47 @@ describe('InstitutionsAPI', function () {
       )
     })
 
-    it('does not try to mark IP matcher notifications as read if no university passed', async function () {
+    it('does not try to mark IP matcher notifications as read if no university passed', async function (ctx) {
       const affiliationOptions = {
         confirmedAt: new Date(),
       }
 
-      await this.InstitutionsAPI.promises.addAffiliation(
-        this.stubbedUser._id,
-        this.newEmail,
+      await ctx.InstitutionsAPI.promises.addAffiliation(
+        ctx.stubbedUser._id,
+        ctx.newEmail,
         affiliationOptions
       )
 
-      expect(this.markAsReadIpMatcher.callCount).to.equal(0)
+      expect(ctx.markAsReadIpMatcher.callCount).to.equal(0)
     })
   })
 
   describe('removeAffiliation', function () {
-    beforeEach(function () {
-      this.fetchNothing.throws({ response: { status: 404 } })
+    beforeEach(function (ctx) {
+      ctx.fetchNothing.throws({ response: { status: 404 } })
     })
 
-    it('remove affiliation', async function () {
-      await this.InstitutionsAPI.promises.removeAffiliation(
-        this.stubbedUser._id,
-        this.newEmail
+    it('remove affiliation', async function (ctx) {
+      await ctx.InstitutionsAPI.promises.removeAffiliation(
+        ctx.stubbedUser._id,
+        ctx.newEmail
       )
-      this.fetchNothing.calledOnce.should.equal(true)
-      const requestOptions = this.fetchNothing.lastCall.args[1]
-      const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations/remove`
-      this.fetchNothing.lastCall.args[0].should.equal(expectedUrl)
+      ctx.fetchNothing.calledOnce.should.equal(true)
+      const requestOptions = ctx.fetchNothing.lastCall.args[1]
+      const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations/remove`
+      ctx.fetchNothing.lastCall.args[0].should.equal(expectedUrl)
       requestOptions.method.should.equal('POST')
-      expect(requestOptions.json).to.deep.equal({ email: this.newEmail })
+      expect(requestOptions.json).to.deep.equal({ email: ctx.newEmail })
     })
 
-    it('handle error', async function () {
-      this.fetchNothing.throws({ response: { status: 500 } })
+    it('handle error', async function (ctx) {
+      ctx.fetchNothing.throws({ response: { status: 500 } })
       let error
 
       try {
-        await this.InstitutionsAPI.promises.removeAffiliation(
-          this.stubbedUser._id,
-          this.newEmail
+        await ctx.InstitutionsAPI.promises.removeAffiliation(
+          ctx.stubbedUser._id,
+          ctx.newEmail
         )
       } catch (err) {
         error = err
@@ -392,26 +408,24 @@ describe('InstitutionsAPI', function () {
   })
 
   describe('deleteAffiliations', function () {
-    it('delete affiliations', async function () {
-      this.request.callsArgWith(1, null, { statusCode: 200 })
-      await this.InstitutionsAPI.promises.deleteAffiliations(
-        this.stubbedUser._id
-      )
-      this.request.calledOnce.should.equal(true)
-      const requestOptions = this.request.lastCall.args[0]
-      const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations`
+    it('delete affiliations', async function (ctx) {
+      ctx.request.callsArgWith(1, null, { statusCode: 200 })
+      await ctx.InstitutionsAPI.promises.deleteAffiliations(ctx.stubbedUser._id)
+      ctx.request.calledOnce.should.equal(true)
+      const requestOptions = ctx.request.lastCall.args[0]
+      const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations`
       requestOptions.url.should.equal(expectedUrl)
       requestOptions.method.should.equal('DELETE')
     })
 
-    it('handle error', async function () {
+    it('handle error', async function (ctx) {
       const body = { errors: 'affiliation error message' }
-      this.request.callsArgWith(1, null, { statusCode: 518 }, body)
+      ctx.request.callsArgWith(1, null, { statusCode: 518 }, body)
       let error
 
       try {
-        await this.InstitutionsAPI.promises.deleteAffiliations(
-          this.stubbedUser._id
+        await ctx.InstitutionsAPI.promises.deleteAffiliations(
+          ctx.stubbedUser._id
         )
       } catch (err) {
         error = err
@@ -422,26 +436,26 @@ describe('InstitutionsAPI', function () {
   })
 
   describe('endorseAffiliation', function () {
-    beforeEach(function () {
-      this.request.callsArgWith(1, null, { statusCode: 204 })
+    beforeEach(function (ctx) {
+      ctx.request.callsArgWith(1, null, { statusCode: 204 })
     })
 
-    it('endorse affiliation', async function () {
-      await this.InstitutionsAPI.promises.endorseAffiliation(
-        this.stubbedUser._id,
-        this.newEmail,
+    it('endorse affiliation', async function (ctx) {
+      await ctx.InstitutionsAPI.promises.endorseAffiliation(
+        ctx.stubbedUser._id,
+        ctx.newEmail,
         'Student',
         'Physics'
       )
-      this.request.calledOnce.should.equal(true)
-      const requestOptions = this.request.lastCall.args[0]
-      const expectedUrl = `v1.url/api/v2/users/${this.stubbedUser._id}/affiliations/endorse`
+      ctx.request.calledOnce.should.equal(true)
+      const requestOptions = ctx.request.lastCall.args[0]
+      const expectedUrl = `v1.url/api/v2/users/${ctx.stubbedUser._id}/affiliations/endorse`
       requestOptions.url.should.equal(expectedUrl)
       requestOptions.method.should.equal('POST')
 
       const { body } = requestOptions
       Object.keys(body).length.should.equal(3)
-      body.email.should.equal(this.newEmail)
+      body.email.should.equal(ctx.newEmail)
       body.role.should.equal('Student')
       body.department.should.equal('Physics')
     })
@@ -450,13 +464,13 @@ describe('InstitutionsAPI', function () {
   describe('sendUsersWithReconfirmationsLapsedProcessed', function () {
     const users = ['abc123', 'def456']
 
-    it('sends the list of users', async function () {
-      this.request.callsArgWith(1, null, { statusCode: 200 })
-      await this.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
+    it('sends the list of users', async function (ctx) {
+      ctx.request.callsArgWith(1, null, { statusCode: 200 })
+      await ctx.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
         users
       )
-      this.request.calledOnce.should.equal(true)
-      const requestOptions = this.request.lastCall.args[0]
+      ctx.request.calledOnce.should.equal(true)
+      const requestOptions = ctx.request.lastCall.args[0]
       const expectedUrl =
         'v1.url/api/v2/institutions/reconfirmation_lapsed_processed'
       requestOptions.url.should.equal(expectedUrl)
@@ -464,12 +478,12 @@ describe('InstitutionsAPI', function () {
       expect(requestOptions.body).to.deep.equal({ users })
     })
 
-    it('handle error', async function () {
-      this.request.callsArgWith(1, null, { statusCode: 500 })
+    it('handle error', async function (ctx) {
+      ctx.request.callsArgWith(1, null, { statusCode: 500 })
       let error
 
       try {
-        await this.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
+        await ctx.InstitutionsAPI.promises.sendUsersWithReconfirmationsLapsedProcessed(
           users
         )
       } catch (err) {

+ 74 - 66
services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs

@@ -1,39 +1,47 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const sinon = require('sinon')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/Institutions/InstitutionsFeatures.js'
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import path from 'node:path'
+
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../../app/src/Features/Institutions/InstitutionsFeatures.mjs'
 )
 
 describe('InstitutionsFeatures', function () {
-  beforeEach(function () {
-    this.UserGetter = {
+  beforeEach(async function (ctx) {
+    ctx.UserGetter = {
       promises: { getUserFullEmails: sinon.stub().resolves([]) },
     }
-    this.PlansLocator = { findLocalPlanInSettings: sinon.stub() }
-    this.institutionPlanCode = 'institution_plan_code'
-    this.InstitutionsFeatures = SandboxedModule.require(modulePath, {
-      requires: {
-        '../User/UserGetter': this.UserGetter,
-        '../Subscription/PlansLocator': this.PlansLocator,
-        '@overleaf/settings': {
-          institutionPlanCode: this.institutionPlanCode,
-        },
+    ctx.PlansLocator = { findLocalPlanInSettings: sinon.stub() }
+    ctx.institutionPlanCode = 'institution_plan_code'
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
+      default: ctx.PlansLocator,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
+        institutionPlanCode: ctx.institutionPlanCode,
       },
-    })
-    this.emailDataWithLicense = [{ emailHasInstitutionLicence: true }]
-    this.emailDataWithoutLicense = [{ emailHasInstitutionLicence: false }]
-    return (this.userId = '12345abcde')
+    }))
+
+    ctx.InstitutionsFeatures = (await import(modulePath)).default
+    ctx.emailDataWithLicense = [{ emailHasInstitutionLicence: true }]
+    ctx.emailDataWithoutLicense = [{ emailHasInstitutionLicence: false }]
+    ctx.userId = '12345abcde'
   })
 
   describe('hasLicence', function () {
-    it('should handle error', async function () {
-      this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
+    it('should handle error', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
       let error
 
       try {
-        await this.InstitutionsFeatures.promises.hasLicence(this.userId)
+        await ctx.InstitutionsFeatures.promises.hasLicence(ctx.userId)
       } catch (err) {
         error = err
       }
@@ -41,93 +49,93 @@ describe('InstitutionsFeatures', function () {
       expect(error).to.exist
     })
 
-    it('should return false if user has no paid affiliations', async function () {
-      this.UserGetter.promises.getUserFullEmails.resolves(
-        this.emailDataWithoutLicense
+    it('should return false if user has no paid affiliations', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.resolves(
+        ctx.emailDataWithoutLicense
       )
-      const hasLicence = await this.InstitutionsFeatures.promises.hasLicence(
-        this.userId
+      const hasLicence = await ctx.InstitutionsFeatures.promises.hasLicence(
+        ctx.userId
       )
       expect(hasLicence).to.be.false
     })
 
-    it('should return true if user has confirmed paid affiliation', async function () {
+    it('should return true if user has confirmed paid affiliation', async function (ctx) {
       const emailData = [
         { emailHasInstitutionLicence: true },
         { emailHasInstitutionLicence: false },
       ]
-      this.UserGetter.promises.getUserFullEmails.resolves(emailData)
-      const hasLicence = await this.InstitutionsFeatures.promises.hasLicence(
-        this.userId
+      ctx.UserGetter.promises.getUserFullEmails.resolves(emailData)
+      const hasLicence = await ctx.InstitutionsFeatures.promises.hasLicence(
+        ctx.userId
       )
       expect(hasLicence).to.be.true
     })
   })
 
   describe('getInstitutionsFeatures', function () {
-    beforeEach(function () {
-      this.testFeatures = { features: { institution: 'all' } }
-      return this.PlansLocator.findLocalPlanInSettings
-        .withArgs(this.institutionPlanCode)
-        .returns(this.testFeatures)
+    beforeEach(function (ctx) {
+      ctx.testFeatures = { features: { institution: 'all' } }
+      return ctx.PlansLocator.findLocalPlanInSettings
+        .withArgs(ctx.institutionPlanCode)
+        .returns(ctx.testFeatures)
     })
 
-    it('should handle error', async function () {
-      this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
+    it('should handle error', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
       await expect(
-        this.InstitutionsFeatures.promises.getInstitutionsFeatures(this.userId)
+        ctx.InstitutionsFeatures.promises.getInstitutionsFeatures(ctx.userId)
       ).to.be.rejected
     })
 
-    it('should return no feaures if user has no plan code', async function () {
-      this.UserGetter.promises.getUserFullEmails.resolves(
-        this.emailDataWithoutLicense
+    it('should return no feaures if user has no plan code', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.resolves(
+        ctx.emailDataWithoutLicense
       )
       const features =
-        await this.InstitutionsFeatures.promises.getInstitutionsFeatures(
-          this.userId
+        await ctx.InstitutionsFeatures.promises.getInstitutionsFeatures(
+          ctx.userId
         )
       expect(features).to.deep.equal({})
     })
 
-    it('should return feaures if user has affiliations plan code', async function () {
-      this.UserGetter.promises.getUserFullEmails.resolves(
-        this.emailDataWithLicense
+    it('should return feaures if user has affiliations plan code', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.resolves(
+        ctx.emailDataWithLicense
       )
       const features =
-        await this.InstitutionsFeatures.promises.getInstitutionsFeatures(
-          this.userId
+        await ctx.InstitutionsFeatures.promises.getInstitutionsFeatures(
+          ctx.userId
         )
-      expect(features).to.deep.equal(this.testFeatures.features)
+      expect(features).to.deep.equal(ctx.testFeatures.features)
     })
   })
 
   describe('getInstitutionsPlan', function () {
-    it('should handle error', async function () {
-      this.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
+    it('should handle error', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('Nope'))
       await expect(
-        this.InstitutionsFeatures.promises.getInstitutionsPlan(this.userId)
+        ctx.InstitutionsFeatures.promises.getInstitutionsPlan(ctx.userId)
       ).to.be.rejected
     })
 
-    it('should return no plan if user has no licence', async function () {
-      this.UserGetter.promises.getUserFullEmails.resolves(
-        this.emailDataWithoutLicense
+    it('should return no plan if user has no licence', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.resolves(
+        ctx.emailDataWithoutLicense
       )
-      const plan = await this.InstitutionsFeatures.promises.getInstitutionsPlan(
-        this.userId
+      const plan = await ctx.InstitutionsFeatures.promises.getInstitutionsPlan(
+        ctx.userId
       )
       expect(plan).to.equal(null)
     })
 
-    it('should return plan if user has licence', async function () {
-      this.UserGetter.promises.getUserFullEmails.resolves(
-        this.emailDataWithLicense
+    it('should return plan if user has licence', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.resolves(
+        ctx.emailDataWithLicense
       )
-      const plan = await this.InstitutionsFeatures.promises.getInstitutionsPlan(
-        this.userId
+      const plan = await ctx.InstitutionsFeatures.promises.getInstitutionsPlan(
+        ctx.userId
       )
-      expect(plan).to.equal(this.institutionPlanCode)
+      expect(plan).to.equal(ctx.institutionPlanCode)
     })
   })
 })

+ 81 - 80
services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs

@@ -1,70 +1,72 @@
-const { expect } = require('chai')
-const sinon = require('sinon')
-const { RequestFailedError } = require('@overleaf/fetch-utils')
-const SandboxedModule = require('sandboxed-module')
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import sinon from 'sinon'
+import { RequestFailedError } from '@overleaf/fetch-utils'
 
 const MODULE_PATH = '../../../../app/src/Features/Newsletter/NewsletterManager'
 
 describe('NewsletterManager', function () {
-  beforeEach('setup mocks', function () {
-    this.Settings = {
+  beforeEach(async function (ctx) {
+    ctx.Settings = {
       mailchimp: {
         api_key: 'api_key',
         list_id: 'list_id',
       },
     }
-    this.mailchimp = {
+    ctx.mailchimp = {
       get: sinon.stub(),
       put: sinon.stub(),
       patch: sinon.stub(),
       delete: sinon.stub(),
     }
-    this.Mailchimp = sinon.stub().returns(this.mailchimp)
+    ctx.Mailchimp = sinon.stub().returns(ctx.mailchimp)
 
-    this.mergeFields = {
+    ctx.mergeFields = {
       FNAME: 'Overleaf',
       LNAME: 'Duck',
       MONGO_ID: 'user_id',
     }
 
-    this.NewsletterManager = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        './MailChimpClient': this.Mailchimp,
-        '@overleaf/settings': this.Settings,
-      },
-      globals: { AbortController },
-    }).promises
+    vi.doMock(
+      '../../../../app/src/Features/Newsletter/MailChimpClient',
+      () => ({
+        default: ctx.Mailchimp,
+      })
+    )
+
+    vi.doMock('@overleaf/settings', () => ({ default: ctx.Settings }))
+
+    ctx.NewsletterManager = (await import(MODULE_PATH)).default.promises
 
-    this.NewsletterManager.get = sinon.stub()
-    this.NewsletterManager.delete = sinon.stub()
+    ctx.NewsletterManager.get = sinon.stub()
+    ctx.NewsletterManager.delete = sinon.stub()
 
-    this.user = {
+    ctx.user = {
       _id: 'user_id',
       email: 'overleaf.duck@example.com',
       first_name: 'Overleaf',
       last_name: 'Duck',
     }
     // MD5 sum of the user email
-    this.emailHash = 'c02f60ed0ef51818186274e406c9a48f'
+    ctx.emailHash = 'c02f60ed0ef51818186274e406c9a48f'
   })
 
   describe('subscribed', function () {
-    it('calls Mailchimp to get the user status', async function () {
-      await this.NewsletterManager.subscribed(this.user)
-      expect(this.mailchimp.get).to.have.been.calledWith(
-        `/lists/list_id/members/${this.emailHash}`
+    it('calls Mailchimp to get the user status', async function (ctx) {
+      await ctx.NewsletterManager.subscribed(ctx.user)
+      expect(ctx.mailchimp.get).to.have.been.calledWith(
+        `/lists/list_id/members/${ctx.emailHash}`
       )
     })
 
-    it('returns true when subscribed', async function () {
-      this.mailchimp.get.resolves({ status: 'subscribed' })
+    it('returns true when subscribed', async function (ctx) {
+      ctx.mailchimp.get.resolves({ status: 'subscribed' })
 
-      const subscribed = await this.NewsletterManager.subscribed(this.user)
+      const subscribed = await ctx.NewsletterManager.subscribed(ctx.user)
       expect(subscribed).to.be.true
     })
 
-    it('returns false on 404', async function () {
-      this.mailchimp.get.rejects(
+    it('returns false on 404', async function (ctx) {
+      ctx.mailchimp.get.rejects(
         new RequestFailedError(
           'http://some-url',
           {},
@@ -72,21 +74,21 @@ describe('NewsletterManager', function () {
           'Not found'
         )
       )
-      const subscribed = await this.NewsletterManager.subscribed(this.user)
+      const subscribed = await ctx.NewsletterManager.subscribed(ctx.user)
       expect(subscribed).to.be.false
     })
   })
 
   describe('subscribe', function () {
-    it('calls Mailchimp to subscribe the user', async function () {
-      await this.NewsletterManager.subscribe(this.user)
-      expect(this.mailchimp.put).to.have.been.calledWith(
-        `/lists/list_id/members/${this.emailHash}`,
+    it('calls Mailchimp to subscribe the user', async function (ctx) {
+      await ctx.NewsletterManager.subscribe(ctx.user)
+      expect(ctx.mailchimp.put).to.have.been.calledWith(
+        `/lists/list_id/members/${ctx.emailHash}`,
         {
-          email_address: this.user.email,
+          email_address: ctx.user.email,
           status: 'subscribed',
           status_if_new: 'subscribed',
-          merge_fields: this.mergeFields,
+          merge_fields: ctx.mergeFields,
         }
       )
     })
@@ -94,118 +96,117 @@ describe('NewsletterManager', function () {
 
   describe('unsubscribe', function () {
     describe('when unsubscribing normally', function () {
-      it('calls Mailchimp to unsubscribe the user', async function () {
-        await this.NewsletterManager.unsubscribe(this.user)
-        expect(this.mailchimp.patch).to.have.been.calledWith(
-          `/lists/list_id/members/${this.emailHash}`,
+      it('calls Mailchimp to unsubscribe the user', async function (ctx) {
+        await ctx.NewsletterManager.unsubscribe(ctx.user)
+        expect(ctx.mailchimp.patch).to.have.been.calledWith(
+          `/lists/list_id/members/${ctx.emailHash}`,
           {
             status: 'unsubscribed',
-            merge_fields: this.mergeFields,
+            merge_fields: ctx.mergeFields,
           }
         )
       })
 
-      it('ignores a Mailchimp error about fake emails', async function () {
-        this.mailchimp.patch.rejects(
+      it('ignores a Mailchimp error about fake emails', async function (ctx) {
+        ctx.mailchimp.patch.rejects(
           new Error(
             'overleaf.duck@example.com looks fake or invalid, please enter a real email address'
           )
         )
-        await expect(this.NewsletterManager.unsubscribe(this.user)).to.be
+        await expect(ctx.NewsletterManager.unsubscribe(ctx.user)).to.be
           .fulfilled
       })
 
-      it('rejects on other errors', async function () {
-        this.mailchimp.patch.rejects(
+      it('rejects on other errors', async function (ctx) {
+        ctx.mailchimp.patch.rejects(
           new Error('something really wrong is happening')
         )
-        await expect(this.NewsletterManager.unsubscribe(this.user)).to.be
-          .rejected
+        await expect(ctx.NewsletterManager.unsubscribe(ctx.user)).to.be.rejected
       })
     })
 
     describe('when deleting', function () {
-      it('calls Mailchimp to delete the user', async function () {
-        await this.NewsletterManager.unsubscribe(this.user, { delete: true })
-        expect(this.mailchimp.delete).to.have.been.calledWith(
-          `/lists/list_id/members/${this.emailHash}`
+      it('calls Mailchimp to delete the user', async function (ctx) {
+        await ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true })
+        expect(ctx.mailchimp.delete).to.have.been.calledWith(
+          `/lists/list_id/members/${ctx.emailHash}`
         )
       })
 
-      it('ignores a Mailchimp error about fake emails', async function () {
-        this.mailchimp.delete.rejects(
+      it('ignores a Mailchimp error about fake emails', async function (ctx) {
+        ctx.mailchimp.delete.rejects(
           new Error(
             'overleaf.duck@example.com looks fake or invalid, please enter a real email address'
           )
         )
         await expect(
-          this.NewsletterManager.unsubscribe(this.user, { delete: true })
+          ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true })
         ).to.be.fulfilled
       })
 
-      it('rejects on other errors', async function () {
-        this.mailchimp.delete.rejects(
+      it('rejects on other errors', async function (ctx) {
+        ctx.mailchimp.delete.rejects(
           new Error('something really wrong is happening')
         )
         await expect(
-          this.NewsletterManager.unsubscribe(this.user, { delete: true })
+          ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true })
         ).to.be.rejected
       })
     })
   })
 
   describe('changeEmail', function () {
-    it('calls Mailchimp to change the subscriber email', async function () {
-      await this.NewsletterManager.changeEmail(
-        this.user,
+    it('calls Mailchimp to change the subscriber email', async function (ctx) {
+      await ctx.NewsletterManager.changeEmail(
+        ctx.user,
         'overleaf.squirrel@example.com'
       )
-      expect(this.mailchimp.patch).to.have.been.calledWith(
-        `/lists/list_id/members/${this.emailHash}`,
+      expect(ctx.mailchimp.patch).to.have.been.calledWith(
+        `/lists/list_id/members/${ctx.emailHash}`,
         {
           email_address: 'overleaf.squirrel@example.com',
-          merge_fields: this.mergeFields,
+          merge_fields: ctx.mergeFields,
         }
       )
     })
 
-    it('deletes the old email if changing the address fails', async function () {
-      this.mailchimp.patch
-        .withArgs(`/lists/list_id/members/${this.emailHash}`, {
+    it('deletes the old email if changing the address fails', async function (ctx) {
+      ctx.mailchimp.patch
+        .withArgs(`/lists/list_id/members/${ctx.emailHash}`, {
           email_address: 'overleaf.squirrel@example.com',
-          merge_fields: this.mergeFields,
+          merge_fields: ctx.mergeFields,
         })
         .rejects(new Error('that did not work'))
 
       await expect(
-        this.NewsletterManager.changeEmail(
-          this.user,
+        ctx.NewsletterManager.changeEmail(
+          ctx.user,
           'overleaf.squirrel@example.com'
         )
       ).to.be.rejected
 
-      expect(this.mailchimp.delete).to.have.been.calledWith(
-        `/lists/list_id/members/${this.emailHash}`
+      expect(ctx.mailchimp.delete).to.have.been.calledWith(
+        `/lists/list_id/members/${ctx.emailHash}`
       )
     })
 
-    it('does not reject on non-fatal error ', async function () {
+    it('does not reject on non-fatal error ', async function (ctx) {
       const nonFatalError = new Error('merge fields were invalid')
-      this.mailchimp.patch.rejects(nonFatalError)
+      ctx.mailchimp.patch.rejects(nonFatalError)
       await expect(
-        this.NewsletterManager.changeEmail(
-          this.user,
+        ctx.NewsletterManager.changeEmail(
+          ctx.user,
           'overleaf.squirrel@example.com'
         )
       ).to.be.fulfilled
     })
 
-    it('rejects on any other error', async function () {
+    it('rejects on any other error', async function (ctx) {
       const fatalError = new Error('fatal error')
-      this.mailchimp.patch.rejects(fatalError)
+      ctx.mailchimp.patch.rejects(fatalError)
       await expect(
-        this.NewsletterManager.changeEmail(
-          this.user,
+        ctx.NewsletterManager.changeEmail(
+          ctx.user,
           'overleaf.squirrel@example.com'
         )
       ).to.be.rejected

+ 62 - 58
services/web/test/unit/src/Notifications/NotificationsBuilder.test.mjs

@@ -1,37 +1,45 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const sinon = require('sinon')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/Notifications/NotificationsBuilder.js'
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import path from 'node:path'
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../../app/src/Features/Notifications/NotificationsBuilder.mjs'
 )
 
 describe('NotificationsBuilder', function () {
   const userId = '507f1f77bcf86cd799439011'
 
-  beforeEach(function () {
-    this.handler = { promises: { createNotification: sinon.stub().resolves() } }
-    this.settings = {
+  beforeEach(async function (ctx) {
+    ctx.handler = { promises: { createNotification: sinon.stub().resolves() } }
+    ctx.settings = {
       apis: { v1: { url: 'http://v1.url', user: '', pass: '' } },
     }
-    this.FetchUtils = {
+    ctx.FetchUtils = {
       fetchJson: sinon.stub(),
     }
-    this.controller = SandboxedModule.require(modulePath, {
-      requires: {
-        './NotificationsHandler': this.handler,
-        '@overleaf/settings': this.settings,
-        '@overleaf/fetch-utils': this.FetchUtils,
-      },
-    })
+
+    vi.doMock(
+      '../../../../app/src/Features/Notifications/NotificationsHandler',
+      () => ({
+        default: ctx.handler,
+      })
+    )
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('@overleaf/fetch-utils', () => ctx.FetchUtils)
+
+    ctx.controller = (await import(modulePath)).default
   })
 
   describe('dropboxUnlinkedDueToLapsedReconfirmation', function () {
-    it('should create the notification', async function () {
-      await this.controller.promises
+    it('should create the notification', async function (ctx) {
+      await ctx.controller.promises
         .dropboxUnlinkedDueToLapsedReconfirmation(userId)
         .create()
-      expect(this.handler.promises.createNotification).to.have.been.calledWith(
+      expect(ctx.handler.promises.createNotification).to.have.been.calledWith(
         userId,
         'drobox-unlinked-due-to-lapsed-reconfirmation',
         'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
@@ -42,15 +50,15 @@ describe('NotificationsBuilder', function () {
     })
     describe('NotificationsHandler error', function () {
       let anError
-      beforeEach(function () {
+      beforeEach(function (ctx) {
         anError = new Error('oops')
-        this.handler.promises.createNotification.rejects(anError)
+        ctx.handler.promises.createNotification.rejects(anError)
       })
-      it('should return errors from NotificationsHandler', async function () {
+      it('should return errors from NotificationsHandler', async function (ctx) {
         let error
 
         try {
-          await this.controller.promises
+          await ctx.controller.promises
             .dropboxUnlinkedDueToLapsedReconfirmation(userId)
             .create()
         } catch (err) {
@@ -64,30 +72,26 @@ describe('NotificationsBuilder', function () {
 
   describe('groupInvitation', function () {
     const subscriptionId = '123123bcabca'
-    beforeEach(function () {
-      this.invite = {
+    beforeEach(function (ctx) {
+      ctx.invite = {
         token: '123123abcabc',
         inviterName: 'Mr Overleaf',
         managedUsersEnabled: false,
       }
     })
 
-    it('should create the notification', async function () {
-      await this.controller.promises
-        .groupInvitation(
-          userId,
-          subscriptionId,
-          this.invite.managedUsersEnabled
-        )
-        .create(this.invite)
-      expect(this.handler.promises.createNotification).to.have.been.calledWith(
+    it('should create the notification', async function (ctx) {
+      await ctx.controller.promises
+        .groupInvitation(userId, subscriptionId, ctx.invite.managedUsersEnabled)
+        .create(ctx.invite)
+      expect(ctx.handler.promises.createNotification).to.have.been.calledWith(
         userId,
         `groupInvitation-${subscriptionId}-${userId}`,
         'notification_group_invitation',
         {
-          token: this.invite.token,
-          inviterName: this.invite.inviterName,
-          managedUsersEnabled: this.invite.managedUsersEnabled,
+          token: ctx.invite.token,
+          inviterName: ctx.invite.inviterName,
+          managedUsersEnabled: ctx.invite.managedUsersEnabled,
         },
         null,
         true
@@ -97,31 +101,31 @@ describe('NotificationsBuilder', function () {
 
   describe('ipMatcherAffiliation', function () {
     describe('with portal and with SSO', function () {
-      beforeEach(function () {
-        this.body = {
+      beforeEach(function (ctx) {
+        ctx.body = {
           id: 1,
           name: 'stanford',
           is_university: true,
           portal_slug: null,
           sso_enabled: false,
         }
-        this.FetchUtils.fetchJson.resolves(this.body)
+        ctx.FetchUtils.fetchJson.resolves(ctx.body)
       })
 
-      it('should call v1 and create affiliation notifications', async function () {
+      it('should call v1 and create affiliation notifications', async function (ctx) {
         const ip = '192.168.0.1'
-        await this.controller.promises.ipMatcherAffiliation(userId).create(ip)
-        this.FetchUtils.fetchJson.calledOnce.should.equal(true)
+        await ctx.controller.promises.ipMatcherAffiliation(userId).create(ip)
+        ctx.FetchUtils.fetchJson.calledOnce.should.equal(true)
         const expectedOpts = {
-          institutionId: this.body.id,
-          university_name: this.body.name,
+          institutionId: ctx.body.id,
+          university_name: ctx.body.name,
           ssoEnabled: false,
           portalPath: undefined,
         }
-        this.handler.promises.createNotification
+        ctx.handler.promises.createNotification
           .calledWith(
             userId,
-            `ip-matched-affiliation-${this.body.id}`,
+            `ip-matched-affiliation-${ctx.body.id}`,
             'notification_ip_matched_affiliation',
             expectedOpts
           )
@@ -129,31 +133,31 @@ describe('NotificationsBuilder', function () {
       })
     })
     describe('without portal and without SSO', function () {
-      beforeEach(function () {
-        this.body = {
+      beforeEach(function (ctx) {
+        ctx.body = {
           id: 1,
           name: 'stanford',
           is_university: true,
           portal_slug: 'stanford',
           sso_enabled: true,
         }
-        this.FetchUtils.fetchJson.resolves(this.body)
+        ctx.FetchUtils.fetchJson.resolves(ctx.body)
       })
 
-      it('should call v1 and create affiliation notifications', async function () {
+      it('should call v1 and create affiliation notifications', async function (ctx) {
         const ip = '192.168.0.1'
-        await this.controller.promises.ipMatcherAffiliation(userId).create(ip)
-        this.FetchUtils.fetchJson.calledOnce.should.equal(true)
+        await ctx.controller.promises.ipMatcherAffiliation(userId).create(ip)
+        ctx.FetchUtils.fetchJson.calledOnce.should.equal(true)
         const expectedOpts = {
-          institutionId: this.body.id,
-          university_name: this.body.name,
+          institutionId: ctx.body.id,
+          university_name: ctx.body.name,
           ssoEnabled: true,
           portalPath: '/edu/stanford',
         }
-        this.handler.promises.createNotification
+        ctx.handler.promises.createNotification
           .calledWith(
             userId,
-            `ip-matched-affiliation-${this.body.id}`,
+            `ip-matched-affiliation-${ctx.body.id}`,
             'notification_ip_matched_affiliation',
             expectedOpts
           )

+ 69 - 65
services/web/test/unit/src/Notifications/NotificationsHandler.test.mjs

@@ -1,9 +1,9 @@
-const SandboxedModule = require('sandboxed-module')
-const { assert } = require('chai')
-const sinon = require('sinon')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/Notifications/NotificationsHandler.js'
+import { vi, assert } from 'vitest'
+import sinon from 'sinon'
+import path from 'node:path'
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../../app/src/Features/Notifications/NotificationsHandler.mjs'
 )
 
 describe('NotificationsHandler', function () {
@@ -11,29 +11,33 @@ describe('NotificationsHandler', function () {
   const notificationId = '123njdskj9jlk'
   const notificationUrl = 'notification.overleaf.testing'
 
-  beforeEach(function () {
-    this.request = sinon.stub().callsArgWith(1)
-    this.handler = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': {
-          apis: { notifications: { url: notificationUrl } },
-        },
-        request: this.request,
+  beforeEach(async function (ctx) {
+    ctx.request = sinon.stub().callsArgWith(1)
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
+        apis: { notifications: { url: notificationUrl } },
       },
-    })
+    }))
+
+    vi.doMock('request', () => ({
+      default: ctx.request,
+    }))
+
+    ctx.handler = (await import(modulePath)).default
   })
 
   describe('getUserNotifications', function () {
-    it('should get unread notifications', async function () {
+    it('should get unread notifications', async function (ctx) {
       const stubbedNotifications = [{ _id: notificationId, user_id: userId }]
-      this.request.callsArgWith(
+      ctx.request.callsArgWith(
         1,
         null,
         { statusCode: 200 },
         stubbedNotifications
       )
       const unreadNotifications =
-        await this.handler.promises.getUserNotifications(userId)
+        await ctx.handler.promises.getUserNotifications(userId)
       stubbedNotifications.should.deep.equal(unreadNotifications)
       const getOpts = {
         uri: `${notificationUrl}/user/${userId}`,
@@ -41,89 +45,89 @@ describe('NotificationsHandler', function () {
         timeout: 1000,
         method: 'GET',
       }
-      this.request.calledWith(getOpts).should.equal(true)
+      ctx.request.calledWith(getOpts).should.equal(true)
     })
 
-    it('should return empty arrays if there are no notifications', async function () {
-      this.request.callsArgWith(1, null, { statusCode: 200 }, null)
+    it('should return empty arrays if there are no notifications', async function (ctx) {
+      ctx.request.callsArgWith(1, null, { statusCode: 200 }, null)
       const unreadNotifications =
-        await this.handler.promises.getUserNotifications(userId)
+        await ctx.handler.promises.getUserNotifications(userId)
       unreadNotifications.length.should.equal(0)
     })
   })
 
   describe('markAsRead', function () {
-    beforeEach(function () {
-      this.key = 'some key here'
+    beforeEach(function (ctx) {
+      ctx.key = 'some key here'
     })
 
-    it('should send a delete request when a delete has been received to mark a notification', async function () {
-      await this.handler.promises.markAsReadWithKey(userId, this.key)
+    it('should send a delete request when a delete has been received to mark a notification', async function (ctx) {
+      await ctx.handler.promises.markAsReadWithKey(userId, ctx.key)
       const opts = {
         uri: `${notificationUrl}/user/${userId}`,
         json: {
-          key: this.key,
+          key: ctx.key,
         },
         timeout: 1000,
         method: 'DELETE',
       }
-      this.request.calledWith(opts).should.equal(true)
+      ctx.request.calledWith(opts).should.equal(true)
     })
   })
 
   describe('createNotification', function () {
-    beforeEach(function () {
-      this.key = 'some key here'
-      this.messageOpts = { value: 12344 }
-      this.templateKey = 'renderThisHtml'
-      this.expiry = null
+    beforeEach(function (ctx) {
+      ctx.key = 'some key here'
+      ctx.messageOpts = { value: 12344 }
+      ctx.templateKey = 'renderThisHtml'
+      ctx.expiry = null
     })
 
-    it('should post the message over', async function () {
-      await this.handler.promises.createNotification(
+    it('should post the message over', async function (ctx) {
+      await ctx.handler.promises.createNotification(
         userId,
-        this.key,
-        this.templateKey,
-        this.messageOpts,
-        this.expiry
+        ctx.key,
+        ctx.templateKey,
+        ctx.messageOpts,
+        ctx.expiry
       )
-      const args = this.request.args[0][0]
+      const args = ctx.request.args[0][0]
       args.uri.should.equal(`${notificationUrl}/user/${userId}`)
       args.timeout.should.equal(1000)
       const expectedJson = {
-        key: this.key,
-        templateKey: this.templateKey,
-        messageOpts: this.messageOpts,
+        key: ctx.key,
+        templateKey: ctx.templateKey,
+        messageOpts: ctx.messageOpts,
         forceCreate: true,
       }
       assert.deepEqual(args.json, expectedJson)
     })
 
     describe('when expiry date is supplied', function () {
-      beforeEach(function () {
-        this.key = 'some key here'
-        this.messageOpts = { value: 12344 }
-        this.templateKey = 'renderThisHtml'
-        this.expiry = new Date()
+      beforeEach(function (ctx) {
+        ctx.key = 'some key here'
+        ctx.messageOpts = { value: 12344 }
+        ctx.templateKey = 'renderThisHtml'
+        ctx.expiry = new Date()
       })
 
-      it('should post the message over with expiry field', async function () {
-        await this.handler.promises.createNotification(
+      it('should post the message over with expiry field', async function (ctx) {
+        await ctx.handler.promises.createNotification(
           userId,
-          this.key,
-          this.templateKey,
-          this.messageOpts,
-          this.expiry
+          ctx.key,
+          ctx.templateKey,
+          ctx.messageOpts,
+          ctx.expiry
         )
 
-        const args = this.request.args[0][0]
+        const args = ctx.request.args[0][0]
         args.uri.should.equal(`${notificationUrl}/user/${userId}`)
         args.timeout.should.equal(1000)
         const expectedJson = {
-          key: this.key,
-          templateKey: this.templateKey,
-          messageOpts: this.messageOpts,
-          expires: this.expiry,
+          key: ctx.key,
+          templateKey: ctx.templateKey,
+          messageOpts: ctx.messageOpts,
+          expires: ctx.expiry,
           forceCreate: true,
         }
         assert.deepEqual(args.json, expectedJson)
@@ -132,18 +136,18 @@ describe('NotificationsHandler', function () {
   })
 
   describe('markAsReadByKeyOnly', function () {
-    beforeEach(function () {
-      this.key = 'some key here'
+    beforeEach(function (ctx) {
+      ctx.key = 'some key here'
     })
 
-    it('should send a delete request when a delete has been received to mark a notification', async function () {
-      await this.handler.promises.markAsReadByKeyOnly(this.key)
+    it('should send a delete request when a delete has been received to mark a notification', async function (ctx) {
+      await ctx.handler.promises.markAsReadByKeyOnly(ctx.key)
       const opts = {
-        uri: `${notificationUrl}/key/${this.key}`,
+        uri: `${notificationUrl}/key/${ctx.key}`,
         timeout: 1000,
         method: 'DELETE',
       }
-      this.request.calledWith(opts).should.equal(true)
+      ctx.request.calledWith(opts).should.equal(true)
     })
   })
 })

+ 21 - 18
services/web/test/unit/src/Project/FolderStructureBuilder.test.mjs

@@ -1,26 +1,29 @@
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
-const sinon = require('sinon')
+import { vi, expect } from 'vitest'
+import mongodb from 'mongodb-legacy'
+import sinon from 'sinon'
+
+const { ObjectId } = mongodb
 
 const MODULE_PATH =
   '../../../../app/src/Features/Project/FolderStructureBuilder'
 
 describe('FolderStructureBuilder', function () {
-  beforeEach(function () {
-    this.FolderStructureBuilder = SandboxedModule.require(MODULE_PATH, {
-      requires: { 'mongodb-legacy': { ObjectId } },
-    })
+  beforeEach(async function (ctx) {
+    vi.doMock('mongodb-legacy', () => ({
+      default: { ObjectId },
+    }))
+
+    ctx.FolderStructureBuilder = (await import(MODULE_PATH)).default
   })
 
   describe('buildFolderStructure', function () {
     describe('when given no documents at all', function () {
-      beforeEach(function () {
-        this.result = this.FolderStructureBuilder.buildFolderStructure([], [])
+      beforeEach(function (ctx) {
+        ctx.result = ctx.FolderStructureBuilder.buildFolderStructure([], [])
       })
 
-      it('returns an empty root folder', function () {
-        sinon.assert.match(this.result, {
+      it('returns an empty root folder', function (ctx) {
+        sinon.assert.match(ctx.result, {
           _id: sinon.match.instanceOf(ObjectId),
           name: 'rootFolder',
           folders: [],
@@ -31,7 +34,7 @@ describe('FolderStructureBuilder', function () {
     })
 
     describe('when given documents and files', function () {
-      beforeEach(function () {
+      beforeEach(function (ctx) {
         const docUploads = [
           { path: '/main.tex', doc: { _id: 'doc-1', name: 'main.tex' } },
           { path: '/foo/other.tex', doc: { _id: 'doc-2', name: 'other.tex' } },
@@ -46,14 +49,14 @@ describe('FolderStructureBuilder', function () {
           { path: '/foo/bbb.jpg', file: { _id: 'file-2', name: 'bbb.jpg' } },
           { path: '/bar/ccc.jpg', file: { _id: 'file-3', name: 'ccc.jpg' } },
         ]
-        this.result = this.FolderStructureBuilder.buildFolderStructure(
+        ctx.result = ctx.FolderStructureBuilder.buildFolderStructure(
           docUploads,
           fileUploads
         )
       })
 
-      it('returns a full folder structure', function () {
-        sinon.assert.match(this.result, {
+      it('returns a full folder structure', function (ctx) {
+        sinon.assert.match(ctx.result, {
           _id: sinon.match.instanceOf(ObjectId),
           name: 'rootFolder',
           docs: [{ _id: 'doc-1', name: 'main.tex' }],
@@ -98,13 +101,13 @@ describe('FolderStructureBuilder', function () {
     })
 
     describe('when given duplicate files', function () {
-      it('throws an error', function () {
+      it('throws an error', function (ctx) {
         const docUploads = [
           { path: '/foo/doc.tex', doc: { _id: 'doc-1', name: 'doc.tex' } },
           { path: '/foo/doc.tex', doc: { _id: 'doc-2', name: 'doc.tex' } },
         ]
         expect(() =>
-          this.FolderStructureBuilder.buildFolderStructure(docUploads, [])
+          ctx.FolderStructureBuilder.buildFolderStructure(docUploads, [])
         ).to.throw()
       })
     })

+ 159 - 160
services/web/test/unit/src/Project/ProjectEditorHandler.test.mjs

@@ -1,12 +1,11 @@
-const _ = require('lodash')
-const { expect } = require('chai')
+import _ from 'lodash'
+import { expect } from 'vitest'
 
 const modulePath = '../../../../app/src/Features/Project/ProjectEditorHandler'
-const SandboxedModule = require('sandboxed-module')
 
 describe('ProjectEditorHandler', function () {
-  beforeEach(function () {
-    this.project = {
+  beforeEach(async function (ctx) {
+    ctx.project = {
       _id: 'project-id',
       owner_ref: 'owner-id',
       name: 'Project Name',
@@ -27,14 +26,14 @@ describe('ProjectEditorHandler', function () {
                 {
                   _id: 'doc-id',
                   name: 'main.tex',
-                  lines: (this.lines = ['line 1', 'line 2', 'line 3']),
+                  lines: (ctx.lines = ['line 1', 'line 2', 'line 3']),
                 },
               ],
               fileRefs: [
                 {
                   _id: 'file-id',
                   name: 'image.png',
-                  created: (this.created = new Date()),
+                  created: (ctx.created = new Date()),
                   size: 1234,
                 },
               ],
@@ -44,8 +43,8 @@ describe('ProjectEditorHandler', function () {
         },
       ],
     }
-    this.ownerMember = {
-      user: (this.owner = {
+    ctx.ownerMember = {
+      user: (ctx.owner = {
         _id: 'owner-id',
         first_name: 'Owner',
         last_name: 'Overleaf',
@@ -56,7 +55,7 @@ describe('ProjectEditorHandler', function () {
       }),
       privilegeLevel: 'owner',
     }
-    this.members = [
+    ctx.members = [
       {
         user: {
           _id: 'read-only-id',
@@ -76,69 +75,69 @@ describe('ProjectEditorHandler', function () {
         privilegeLevel: 'readAndWrite',
       },
     ]
-    this.invites = [
+    ctx.invites = [
       {
         _id: 'invite_one',
         email: 'user-one@example.com',
         privileges: 'readOnly',
-        projectId: this.project._id,
+        projectId: ctx.project._id,
         token: 'my-secret-token1',
       },
       {
         _id: 'invite_two',
         email: 'user-two@example.com',
         privileges: 'readOnly',
-        projectId: this.project._id,
+        projectId: ctx.project._id,
         token: 'my-secret-token2',
       },
     ]
-    this.handler = SandboxedModule.require(modulePath)
+    ctx.handler = (await import(modulePath)).default
   })
 
   describe('buildProjectModelView', function () {
     describe('with owner, members and invites included', function () {
-      beforeEach(function () {
-        this.result = this.handler.buildProjectModelView(
-          this.project,
-          this.ownerMember,
-          this.members,
-          this.invites,
+      beforeEach(function (ctx) {
+        ctx.result = ctx.handler.buildProjectModelView(
+          ctx.project,
+          ctx.ownerMember,
+          ctx.members,
+          ctx.invites,
           false
         )
       })
 
-      it('should include the id', function () {
-        expect(this.result._id).to.exist
-        this.result._id.should.equal('project-id')
+      it('should include the id', function (ctx) {
+        expect(ctx.result._id).to.exist
+        ctx.result._id.should.equal('project-id')
       })
 
-      it('should include the name', function () {
-        expect(this.result.name).to.exist
-        this.result.name.should.equal('Project Name')
+      it('should include the name', function (ctx) {
+        expect(ctx.result.name).to.exist
+        ctx.result.name.should.equal('Project Name')
       })
 
-      it('should include the root doc id', function () {
-        expect(this.result.rootDoc_id).to.exist
-        this.result.rootDoc_id.should.equal('file-id')
+      it('should include the root doc id', function (ctx) {
+        expect(ctx.result.rootDoc_id).to.exist
+        ctx.result.rootDoc_id.should.equal('file-id')
       })
 
-      it('should include the public access level', function () {
-        expect(this.result.publicAccesLevel).to.exist
-        this.result.publicAccesLevel.should.equal('private')
+      it('should include the public access level', function (ctx) {
+        expect(ctx.result.publicAccesLevel).to.exist
+        ctx.result.publicAccesLevel.should.equal('private')
       })
 
-      it('should include the owner', function () {
-        expect(this.result.owner).to.exist
-        this.result.owner._id.should.equal('owner-id')
-        this.result.owner.email.should.equal('owner@overleaf.com')
-        this.result.owner.first_name.should.equal('Owner')
-        this.result.owner.last_name.should.equal('Overleaf')
-        this.result.owner.privileges.should.equal('owner')
+      it('should include the owner', function (ctx) {
+        expect(ctx.result.owner).to.exist
+        ctx.result.owner._id.should.equal('owner-id')
+        ctx.result.owner.email.should.equal('owner@overleaf.com')
+        ctx.result.owner.first_name.should.equal('Owner')
+        ctx.result.owner.last_name.should.equal('Overleaf')
+        ctx.result.owner.privileges.should.equal('owner')
       })
 
-      it('should gather readOnly_refs and collaberators_refs into a list of members', function () {
+      it('should gather readOnly_refs and collaberators_refs into a list of members', function (ctx) {
         const findMember = id => {
-          for (const member of this.result.members) {
+          for (const member of ctx.result.members) {
             if (member._id === id) {
               return member
             }
@@ -146,7 +145,7 @@ describe('ProjectEditorHandler', function () {
           return null
         }
 
-        this.result.members.length.should.equal(2)
+        ctx.result.members.length.should.equal(2)
 
         expect(findMember('read-only-id')).to.exist
         findMember('read-only-id').privileges.should.equal('readOnly')
@@ -163,174 +162,174 @@ describe('ProjectEditorHandler', function () {
         )
       })
 
-      it('should include folders in the project', function () {
-        this.result.rootFolder[0]._id.should.equal('root-folder-id')
-        this.result.rootFolder[0].name.should.equal('')
+      it('should include folders in the project', function (ctx) {
+        ctx.result.rootFolder[0]._id.should.equal('root-folder-id')
+        ctx.result.rootFolder[0].name.should.equal('')
 
-        this.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id')
-        this.result.rootFolder[0].folders[0].name.should.equal('folder')
+        ctx.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id')
+        ctx.result.rootFolder[0].folders[0].name.should.equal('folder')
       })
 
-      it('should not duplicate folder contents', function () {
-        this.result.rootFolder[0].docs.length.should.equal(0)
-        this.result.rootFolder[0].fileRefs.length.should.equal(0)
+      it('should not duplicate folder contents', function (ctx) {
+        ctx.result.rootFolder[0].docs.length.should.equal(0)
+        ctx.result.rootFolder[0].fileRefs.length.should.equal(0)
       })
 
-      it('should include files in the project', function () {
-        this.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal(
+      it('should include files in the project', function (ctx) {
+        ctx.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal(
           'file-id'
         )
-        this.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal(
+        ctx.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal(
           'image.png'
         )
-        this.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal(
-          this.created
+        ctx.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal(
+          ctx.created
         )
-        expect(this.result.rootFolder[0].folders[0].fileRefs[0].size).not.to
+        expect(ctx.result.rootFolder[0].folders[0].fileRefs[0].size).not.to
           .exist
       })
 
-      it('should include docs in the project but not the lines', function () {
-        this.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id')
-        this.result.rootFolder[0].folders[0].docs[0].name.should.equal(
+      it('should include docs in the project but not the lines', function (ctx) {
+        ctx.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id')
+        ctx.result.rootFolder[0].folders[0].docs[0].name.should.equal(
           'main.tex'
         )
-        expect(this.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist
+        expect(ctx.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist
       })
 
-      it('should include invites', function () {
-        expect(this.result.invites).to.exist
-        this.result.invites.should.deep.equal(
-          this.invites.map(invite =>
+      it('should include invites', function (ctx) {
+        expect(ctx.result.invites).to.exist
+        ctx.result.invites.should.deep.equal(
+          ctx.invites.map(invite =>
             _.pick(invite, ['_id', 'email', 'privileges'])
           )
         )
       })
 
-      it('invites should not include the token', function () {
-        for (const invite of this.result.invites) {
+      it('invites should not include the token', function (ctx) {
+        for (const invite of ctx.result.invites) {
           expect(invite.token).not.to.exist
         }
       })
 
-      it('should have the correct features', function () {
-        expect(this.result.features.compileTimeout).to.equal(240)
+      it('should have the correct features', function (ctx) {
+        expect(ctx.result.features.compileTimeout).to.equal(240)
       })
     })
 
     describe('with a restricted user', function () {
-      beforeEach(function () {
-        this.result = this.handler.buildProjectModelView(
-          this.project,
-          this.ownerMember,
+      beforeEach(function (ctx) {
+        ctx.result = ctx.handler.buildProjectModelView(
+          ctx.project,
+          ctx.ownerMember,
           [],
           [],
           true
         )
       })
 
-      it('should include the id', function () {
-        expect(this.result._id).to.exist
-        this.result._id.should.equal('project-id')
+      it('should include the id', function (ctx) {
+        expect(ctx.result._id).to.exist
+        ctx.result._id.should.equal('project-id')
       })
 
-      it('should include the name', function () {
-        expect(this.result.name).to.exist
-        this.result.name.should.equal('Project Name')
+      it('should include the name', function (ctx) {
+        expect(ctx.result.name).to.exist
+        ctx.result.name.should.equal('Project Name')
       })
 
-      it('should include the root doc id', function () {
-        expect(this.result.rootDoc_id).to.exist
-        this.result.rootDoc_id.should.equal('file-id')
+      it('should include the root doc id', function (ctx) {
+        expect(ctx.result.rootDoc_id).to.exist
+        ctx.result.rootDoc_id.should.equal('file-id')
       })
 
-      it('should include the public access level', function () {
-        expect(this.result.publicAccesLevel).to.exist
-        this.result.publicAccesLevel.should.equal('private')
+      it('should include the public access level', function (ctx) {
+        expect(ctx.result.publicAccesLevel).to.exist
+        ctx.result.publicAccesLevel.should.equal('private')
       })
 
-      it('should hide the owner', function () {
-        expect(this.result.owner).to.deep.equal({ _id: 'owner-id' })
+      it('should hide the owner', function (ctx) {
+        expect(ctx.result.owner).to.deep.equal({ _id: 'owner-id' })
       })
 
-      it('should hide members', function () {
-        this.result.members.length.should.equal(0)
+      it('should hide members', function (ctx) {
+        ctx.result.members.length.should.equal(0)
       })
 
-      it('should include folders in the project', function () {
-        this.result.rootFolder[0]._id.should.equal('root-folder-id')
-        this.result.rootFolder[0].name.should.equal('')
+      it('should include folders in the project', function (ctx) {
+        ctx.result.rootFolder[0]._id.should.equal('root-folder-id')
+        ctx.result.rootFolder[0].name.should.equal('')
 
-        this.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id')
-        this.result.rootFolder[0].folders[0].name.should.equal('folder')
+        ctx.result.rootFolder[0].folders[0]._id.should.equal('sub-folder-id')
+        ctx.result.rootFolder[0].folders[0].name.should.equal('folder')
       })
 
-      it('should not duplicate folder contents', function () {
-        this.result.rootFolder[0].docs.length.should.equal(0)
-        this.result.rootFolder[0].fileRefs.length.should.equal(0)
+      it('should not duplicate folder contents', function (ctx) {
+        ctx.result.rootFolder[0].docs.length.should.equal(0)
+        ctx.result.rootFolder[0].fileRefs.length.should.equal(0)
       })
 
-      it('should include files in the project', function () {
-        this.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal(
+      it('should include files in the project', function (ctx) {
+        ctx.result.rootFolder[0].folders[0].fileRefs[0]._id.should.equal(
           'file-id'
         )
-        this.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal(
+        ctx.result.rootFolder[0].folders[0].fileRefs[0].name.should.equal(
           'image.png'
         )
-        this.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal(
-          this.created
+        ctx.result.rootFolder[0].folders[0].fileRefs[0].created.should.equal(
+          ctx.created
         )
-        expect(this.result.rootFolder[0].folders[0].fileRefs[0].size).not.to
+        expect(ctx.result.rootFolder[0].folders[0].fileRefs[0].size).not.to
           .exist
       })
 
-      it('should include docs in the project but not the lines', function () {
-        this.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id')
-        this.result.rootFolder[0].folders[0].docs[0].name.should.equal(
+      it('should include docs in the project but not the lines', function (ctx) {
+        ctx.result.rootFolder[0].folders[0].docs[0]._id.should.equal('doc-id')
+        ctx.result.rootFolder[0].folders[0].docs[0].name.should.equal(
           'main.tex'
         )
-        expect(this.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist
+        expect(ctx.result.rootFolder[0].folders[0].docs[0].lines).not.to.exist
       })
 
-      it('should hide invites', function () {
-        expect(this.result.invites).to.have.length(0)
+      it('should hide invites', function (ctx) {
+        expect(ctx.result.invites).to.have.length(0)
       })
 
-      it('should have the correct features', function () {
-        expect(this.result.features.compileTimeout).to.equal(240)
+      it('should have the correct features', function (ctx) {
+        expect(ctx.result.features.compileTimeout).to.equal(240)
       })
     })
 
     describe('deletedByExternalDataSource', function () {
-      it('should set the deletedByExternalDataSource flag to false when it is not there', function () {
-        delete this.project.deletedByExternalDataSource
-        const result = this.handler.buildProjectModelView(
-          this.project,
-          this.ownerMember,
-          this.members,
+      it('should set the deletedByExternalDataSource flag to false when it is not there', function (ctx) {
+        delete ctx.project.deletedByExternalDataSource
+        const result = ctx.handler.buildProjectModelView(
+          ctx.project,
+          ctx.ownerMember,
+          ctx.members,
           [],
           false
         )
         result.deletedByExternalDataSource.should.equal(false)
       })
 
-      it('should set the deletedByExternalDataSource flag to false when it is false', function () {
-        const result = this.handler.buildProjectModelView(
-          this.project,
-          this.ownerMember,
-          this.members,
+      it('should set the deletedByExternalDataSource flag to false when it is false', function (ctx) {
+        const result = ctx.handler.buildProjectModelView(
+          ctx.project,
+          ctx.ownerMember,
+          ctx.members,
           [],
           false
         )
         result.deletedByExternalDataSource.should.equal(false)
       })
 
-      it('should set the deletedByExternalDataSource flag to true when it is true', function () {
-        this.project.deletedByExternalDataSource = true
-        const result = this.handler.buildProjectModelView(
-          this.project,
-          this.ownerMember,
-          this.members,
+      it('should set the deletedByExternalDataSource flag to true when it is true', function (ctx) {
+        ctx.project.deletedByExternalDataSource = true
+        const result = ctx.handler.buildProjectModelView(
+          ctx.project,
+          ctx.ownerMember,
+          ctx.members,
           [],
           false
         )
@@ -339,60 +338,60 @@ describe('ProjectEditorHandler', function () {
     })
 
     describe('features', function () {
-      beforeEach(function () {
-        this.owner.features = {
+      beforeEach(function (ctx) {
+        ctx.owner.features = {
           versioning: true,
           collaborators: 3,
           compileGroup: 'priority',
           compileTimeout: 96,
         }
-        this.result = this.handler.buildProjectModelView(
-          this.project,
-          this.ownerMember,
-          this.members,
+        ctx.result = ctx.handler.buildProjectModelView(
+          ctx.project,
+          ctx.ownerMember,
+          ctx.members,
           [],
           false
         )
       })
 
-      it('should copy the owner features to the project', function () {
-        this.result.features.versioning.should.equal(
-          this.owner.features.versioning
+      it('should copy the owner features to the project', function (ctx) {
+        ctx.result.features.versioning.should.equal(
+          ctx.owner.features.versioning
         )
-        this.result.features.collaborators.should.equal(
-          this.owner.features.collaborators
+        ctx.result.features.collaborators.should.equal(
+          ctx.owner.features.collaborators
         )
-        this.result.features.compileGroup.should.equal(
-          this.owner.features.compileGroup
+        ctx.result.features.compileGroup.should.equal(
+          ctx.owner.features.compileGroup
         )
-        this.result.features.compileTimeout.should.equal(
-          this.owner.features.compileTimeout
+        ctx.result.features.compileTimeout.should.equal(
+          ctx.owner.features.compileTimeout
         )
       })
     })
 
     describe('trackChangesState', function () {
       describe('when the owner does not have the trackChanges feature', function () {
-        beforeEach(function () {
-          this.owner.features = {
+        beforeEach(function (ctx) {
+          ctx.owner.features = {
             trackChanges: false,
           }
-          this.result = this.handler.buildProjectModelView(
-            this.project,
-            this.ownerMember,
-            this.members,
+          ctx.result = ctx.handler.buildProjectModelView(
+            ctx.project,
+            ctx.ownerMember,
+            ctx.members,
             [],
             false
           )
         })
-        it('should not emit trackChangesState', function () {
-          expect(this.result.trackChangesState).to.not.exist
+        it('should not emit trackChangesState', function (ctx) {
+          expect(ctx.result.trackChangesState).to.not.exist
         })
       })
 
       describe('when the owner has got the trackChanges feature', function () {
-        beforeEach(function () {
-          this.owner.features = {
+        beforeEach(function (ctx) {
+          ctx.owner.features = {
             trackChanges: true,
           }
         })
@@ -401,18 +400,18 @@ describe('ProjectEditorHandler', function () {
           describe(`when track_changes is ${JSON.stringify(
             dbEntry
           )}`, function () {
-            beforeEach(function () {
-              this.project.track_changes = dbEntry
-              this.result = this.handler.buildProjectModelView(
-                this.project,
-                this.ownerMember,
-                this.members,
+            beforeEach(function (ctx) {
+              ctx.project.track_changes = dbEntry
+              ctx.result = ctx.handler.buildProjectModelView(
+                ctx.project,
+                ctx.ownerMember,
+                ctx.members,
                 [],
                 false
               )
             })
-            it(`should set trackChangesState=${expected}`, function () {
-              expect(this.result.trackChangesState).to.deep.equal(expected)
+            it(`should set trackChangesState=${expected}`, function (ctx) {
+              expect(ctx.result.trackChangesState).to.deep.equal(expected)
             })
           })
         }

+ 61 - 57
services/web/test/unit/src/Project/ProjectHelper.test.mjs

@@ -1,8 +1,9 @@
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
+import { vi, expect } from 'vitest'
+import mongodb from 'mongodb-legacy'
 
-const MODULE_PATH = '../../../../app/src/Features/Project/ProjectHelper.js'
+const { ObjectId } = mongodb
+
+const MODULE_PATH = '../../../../app/src/Features/Project/ProjectHelper.mjs'
 
 function _mapToAllowed(images) {
   return images.map(image => {
@@ -11,12 +12,12 @@ function _mapToAllowed(images) {
 }
 
 describe('ProjectHelper', function () {
-  beforeEach(function () {
-    this.project = {
+  beforeEach(async function (ctx) {
+    ctx.project = {
       _id: '123213jlkj9kdlsaj',
     }
 
-    this.user = {
+    ctx.user = {
       _id: '588f3ddae8ebc1bac07c9fa4',
       first_name: 'bjkdsjfk',
       features: {},
@@ -24,13 +25,13 @@ describe('ProjectHelper', function () {
       labsExperiments: ['monthly-texlive'],
     }
 
-    this.adminUser = {
+    ctx.adminUser = {
       _id: 'admin-user-id',
       isAdmin: true,
       alphaProgram: true,
     }
 
-    this.Settings = {
+    ctx.Settings = {
       adminPrivilegeAvailable: true,
       allowedImageNames: [
         { imageName: 'texlive-full:2018.1', imageDesc: 'TeX Live 2018' },
@@ -48,101 +49,104 @@ describe('ProjectHelper', function () {
       ],
     }
 
-    this.ProjectHelper = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        'mongodb-legacy': { ObjectId },
-        '@overleaf/settings': this.Settings,
-      },
-    })
+    vi.doMock('mongodb-legacy', () => ({
+      default: { ObjectId },
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.Settings,
+    }))
+
+    ctx.ProjectHelper = (await import(MODULE_PATH)).default
   })
 
   describe('isArchived', function () {
     describe('project.archived being an array', function () {
-      it('returns true if user id is found', function () {
-        this.project.archived = [
+      it('returns true if user id is found', function (ctx) {
+        ctx.project.archived = [
           new ObjectId('588f3ddae8ebc1bac07c9fa4'),
           new ObjectId('5c41deb2b4ca500153340809'),
         ]
         expect(
-          this.ProjectHelper.isArchived(this.project, this.user._id)
+          ctx.ProjectHelper.isArchived(ctx.project, ctx.user._id)
         ).to.equal(true)
       })
 
-      it('returns false if user id is not found', function () {
-        this.project.archived = []
+      it('returns false if user id is not found', function (ctx) {
+        ctx.project.archived = []
         expect(
-          this.ProjectHelper.isArchived(this.project, this.user._id)
+          ctx.ProjectHelper.isArchived(ctx.project, ctx.user._id)
         ).to.equal(false)
       })
     })
 
     describe('project.archived being undefined', function () {
-      it('returns false if archived is undefined', function () {
-        this.project.archived = undefined
+      it('returns false if archived is undefined', function (ctx) {
+        ctx.project.archived = undefined
         expect(
-          this.ProjectHelper.isArchived(this.project, this.user._id)
+          ctx.ProjectHelper.isArchived(ctx.project, ctx.user._id)
         ).to.equal(false)
       })
     })
   })
 
   describe('isTrashed', function () {
-    it('returns true if user id is found', function () {
-      this.project.trashed = [
+    it('returns true if user id is found', function (ctx) {
+      ctx.project.trashed = [
         new ObjectId('588f3ddae8ebc1bac07c9fa4'),
         new ObjectId('5c41deb2b4ca500153340809'),
       ]
-      expect(
-        this.ProjectHelper.isTrashed(this.project, this.user._id)
-      ).to.equal(true)
+      expect(ctx.ProjectHelper.isTrashed(ctx.project, ctx.user._id)).to.equal(
+        true
+      )
     })
 
-    it('returns false if user id is not found', function () {
-      this.project.trashed = []
-      expect(
-        this.ProjectHelper.isTrashed(this.project, this.user._id)
-      ).to.equal(false)
+    it('returns false if user id is not found', function (ctx) {
+      ctx.project.trashed = []
+      expect(ctx.ProjectHelper.isTrashed(ctx.project, ctx.user._id)).to.equal(
+        false
+      )
     })
 
     describe('project.trashed being undefined', function () {
-      it('returns false if trashed is undefined', function () {
-        this.project.trashed = undefined
-        expect(
-          this.ProjectHelper.isTrashed(this.project, this.user._id)
-        ).to.equal(false)
+      it('returns false if trashed is undefined', function (ctx) {
+        ctx.project.trashed = undefined
+        expect(ctx.ProjectHelper.isTrashed(ctx.project, ctx.user._id)).to.equal(
+          false
+        )
       })
     })
   })
 
   describe('compilerFromV1Engine', function () {
-    it('returns the correct engine for latex_dvipdf', function () {
-      expect(this.ProjectHelper.compilerFromV1Engine('latex_dvipdf')).to.equal(
+    it('returns the correct engine for latex_dvipdf', function (ctx) {
+      expect(ctx.ProjectHelper.compilerFromV1Engine('latex_dvipdf')).to.equal(
         'latex'
       )
     })
 
-    it('returns the correct engine for pdflatex', function () {
-      expect(this.ProjectHelper.compilerFromV1Engine('pdflatex')).to.equal(
+    it('returns the correct engine for pdflatex', function (ctx) {
+      expect(ctx.ProjectHelper.compilerFromV1Engine('pdflatex')).to.equal(
         'pdflatex'
       )
     })
 
-    it('returns the correct engine for xelatex', function () {
-      expect(this.ProjectHelper.compilerFromV1Engine('xelatex')).to.equal(
+    it('returns the correct engine for xelatex', function (ctx) {
+      expect(ctx.ProjectHelper.compilerFromV1Engine('xelatex')).to.equal(
         'xelatex'
       )
     })
 
-    it('returns the correct engine for lualatex', function () {
-      expect(this.ProjectHelper.compilerFromV1Engine('lualatex')).to.equal(
+    it('returns the correct engine for lualatex', function (ctx) {
+      expect(ctx.ProjectHelper.compilerFromV1Engine('lualatex')).to.equal(
         'lualatex'
       )
     })
   })
 
   describe('getAllowedImagesForUser', function () {
-    it('marks alpha only images as not allowed when the user is anonymous', function () {
-      const images = this.ProjectHelper.getAllowedImagesForUser(null)
+    it('marks alpha only images as not allowed when the user is anonymous', function (ctx) {
+      const images = ctx.ProjectHelper.getAllowedImagesForUser(null)
       const imageNames = _mapToAllowed(images)
       expect(imageNames).to.deep.equal([
         { imageName: 'texlive-full:2018.1', allowed: true },
@@ -152,8 +156,8 @@ describe('ProjectHelper', function () {
       ])
     })
 
-    it('marks monthly labs images as not allowed when the user is anonymous', function () {
-      const images = this.ProjectHelper.getAllowedImagesForUser(null)
+    it('marks monthly labs images as not allowed when the user is anonymous', function (ctx) {
+      const images = ctx.ProjectHelper.getAllowedImagesForUser(null)
       const imageNames = _mapToAllowed(images)
       expect(imageNames).to.deep.equal([
         { imageName: 'texlive-full:2018.1', allowed: true },
@@ -163,8 +167,8 @@ describe('ProjectHelper', function () {
       ])
     })
 
-    it('marks monthly labs images as allowed when the user is enrolled', function () {
-      const images = this.ProjectHelper.getAllowedImagesForUser(this.user)
+    it('marks monthly labs images as allowed when the user is enrolled', function (ctx) {
+      const images = ctx.ProjectHelper.getAllowedImagesForUser(ctx.user)
       const imageNames = _mapToAllowed(images)
       expect(imageNames).to.deep.equal([
         { imageName: 'texlive-full:2018.1', allowed: true },
@@ -174,8 +178,8 @@ describe('ProjectHelper', function () {
       ])
     })
 
-    it('marks alpha only images as not allowed when when the user is not admin', function () {
-      const images = this.ProjectHelper.getAllowedImagesForUser(this.user)
+    it('marks alpha only images as not allowed when when the user is not admin', function (ctx) {
+      const images = ctx.ProjectHelper.getAllowedImagesForUser(ctx.user)
       const imageNames = _mapToAllowed(images)
       expect(imageNames).to.deep.equal([
         { imageName: 'texlive-full:2018.1', allowed: true },
@@ -185,8 +189,8 @@ describe('ProjectHelper', function () {
       ])
     })
 
-    it('returns all images when the user is admin', function () {
-      const images = this.ProjectHelper.getAllowedImagesForUser(this.adminUser)
+    it('returns all images when the user is admin', function (ctx) {
+      const images = ctx.ProjectHelper.getAllowedImagesForUser(ctx.adminUser)
       const imageNames = _mapToAllowed(images)
       expect(imageNames).to.deep.equal([
         { imageName: 'texlive-full:2018.1', allowed: true },

+ 242 - 309
services/web/test/unit/src/Project/ProjectListController.test.mjs

@@ -1,4 +1,4 @@
-import { expect, vi } from 'vitest'
+import { beforeEach, describe, it, expect, vi } from 'vitest'
 import sinon from 'sinon'
 import mongodb from 'mongodb-legacy'
 import Errors from '../../../../app/src/Features/Errors/Errors.js'
@@ -7,9 +7,8 @@ const ObjectId = mongodb.ObjectId
 
 const MODULE_PATH = `${import.meta.dirname}/../../../../app/src/Features/Project/ProjectListController`
 
-// Mock AnalyticsManager as it isn't used in these tests but causes the User model to be imported
-// TODO: remove this once all models are ESM and this kind of mocking is no longer necessary
-vi.mock('../../../../app/src/Features/Analytics/AnalyticsManager.js', () => {
+// Mock AnalyticsManager as it isn't used in these tests but causes the User model to be imported and redeclares queues
+vi.mock('../../../../app/src/Features/Analytics/AnalyticsManager.mjs', () => {
   return {}
 })
 
@@ -368,190 +367,154 @@ describe('ProjectListController', function () {
     })
 
     it('should render the project/list-react page', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.res.render = (pageName, opts) => {
-          pageName.should.equal('project/list-react')
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.res.render = (pageName, opts) => {
+        pageName.should.equal('project/list-react')
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should invoke the session maintenance', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.Features.hasFeature.withArgs('saas').returns(true)
-        ctx.res.render = () => {
-          ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
-            ctx.req,
-            ctx.user
-          )
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.Features.hasFeature.withArgs('saas').returns(true)
+      ctx.res.render = () => {
+        ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
+          ctx.req,
+          ctx.user
+        )
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should send the tags', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.res.render = (pageName, opts) => {
-          opts.tags.length.should.equal(ctx.tags.length)
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.res.render = (pageName, opts) => {
+        opts.tags.length.should.equal(ctx.tags.length)
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should create trigger ip matcher notifications', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.settings.overleaf = true
-        ctx.req.ip = '111.111.111.111'
-        ctx.res.render = (pageName, opts) => {
-          ctx.NotificationBuilder.promises.ipMatcherAffiliation.called.should.equal(
-            true
-          )
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.settings.overleaf = true
+      ctx.req.ip = '111.111.111.111'
+      ctx.res.render = (pageName, opts) => {
+        ctx.NotificationBuilder.promises.ipMatcherAffiliation.called.should.equal(
+          true
+        )
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should send the projects', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.res.render = (pageName, opts) => {
-          opts.prefetchedProjectsBlob.projects.length.should.equal(
-            ctx.projects.length +
-              ctx.readAndWrite.length +
-              ctx.readOnly.length +
-              ctx.tokenReadAndWrite.length +
-              ctx.tokenReadOnly.length +
-              ctx.review.length
-          )
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.res.render = (pageName, opts) => {
+        opts.prefetchedProjectsBlob.projects.length.should.equal(
+          ctx.projects.length +
+            ctx.readAndWrite.length +
+            ctx.readOnly.length +
+            ctx.tokenReadAndWrite.length +
+            ctx.tokenReadOnly.length +
+            ctx.review.length
+        )
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should send the user', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.res.render = (pageName, opts) => {
-          opts.user.should.deep.equal(ctx.user)
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.res.render = (pageName, opts) => {
+        opts.user.should.deep.equal(ctx.user)
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should inject the users', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.res.render = (pageName, opts) => {
-          const projects = opts.prefetchedProjectsBlob.projects
-
-          projects
-            .filter(p => p.id === '1')[0]
-            .owner.firstName.should.equal(
-              ctx.users[ctx.projects.filter(p => p._id === 1)[0].owner_ref]
-                .first_name
-            )
-          projects
-            .filter(p => p.id === '2')[0]
-            .owner.firstName.should.equal(
-              ctx.users[ctx.projects.filter(p => p._id === 2)[0].owner_ref]
-                .first_name
-            )
-          projects
-            .filter(p => p.id === '2')[0]
-            .lastUpdatedBy.firstName.should.equal(
-              ctx.users[ctx.projects.filter(p => p._id === 2)[0].lastUpdatedBy]
-                .first_name
-            )
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.res.render = (pageName, opts) => {
+        const projects = opts.prefetchedProjectsBlob.projects
+
+        projects
+          .filter(p => p.id === '1')[0]
+          .owner.firstName.should.equal(
+            ctx.users[ctx.projects.filter(p => p._id === 1)[0].owner_ref]
+              .first_name
+          )
+        projects
+          .filter(p => p.id === '2')[0]
+          .owner.firstName.should.equal(
+            ctx.users[ctx.projects.filter(p => p._id === 2)[0].owner_ref]
+              .first_name
+          )
+        projects
+          .filter(p => p.id === '2')[0]
+          .lastUpdatedBy.firstName.should.equal(
+            ctx.users[ctx.projects.filter(p => p._id === 2)[0].lastUpdatedBy]
+              .first_name
+          )
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it("should send the user's best subscription when saas feature present", async function (ctx) {
-      await new Promise(resolve => {
-        ctx.Features.hasFeature.withArgs('saas').returns(true)
-        ctx.res.render = (pageName, opts) => {
-          expect(opts.usersBestSubscription).to.deep.include({ type: 'free' })
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.Features.hasFeature.withArgs('saas').returns(true)
+      ctx.res.render = (pageName, opts) => {
+        expect(opts.usersBestSubscription).to.deep.include({ type: 'free' })
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should not return a best subscription without saas feature', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.Features.hasFeature.withArgs('saas').returns(false)
-        ctx.res.render = (pageName, opts) => {
-          expect(opts.usersBestSubscription).to.be.undefined
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.Features.hasFeature.withArgs('saas').returns(false)
+      ctx.res.render = (pageName, opts) => {
+        expect(opts.usersBestSubscription).to.be.undefined
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should show INR Banner for Indian users with free account', async function (ctx) {
-      await new Promise(resolve => {
-        // usersBestSubscription is only available when saas feature is present
-        ctx.Features.hasFeature.withArgs('saas').returns(true)
-        ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
-          {
-            bestSubscription: {
-              type: 'free',
-            },
-          }
-        )
-        ctx.GeoIpLookup.promises.getCurrencyCode.resolves({
-          countryCode: 'IN',
-        })
-        ctx.res.render = (pageName, opts) => {
-          expect(opts.showInrGeoBanner).to.be.true
-          resolve()
+      // usersBestSubscription is only available when saas feature is present
+      ctx.Features.hasFeature.withArgs('saas').returns(true)
+      ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
+        {
+          bestSubscription: {
+            type: 'free',
+          },
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+      )
+      ctx.GeoIpLookup.promises.getCurrencyCode.resolves({
+        countryCode: 'IN',
       })
+      ctx.res.render = (pageName, opts) => {
+        expect(opts.showInrGeoBanner).to.be.true
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should not show INR Banner for Indian users with premium account', async function (ctx) {
-      await new Promise(resolve => {
-        // usersBestSubscription is only available when saas feature is present
-        ctx.Features.hasFeature.withArgs('saas').returns(true)
-        ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
-          {
-            bestSubscription: {
-              type: 'individual',
-            },
-          }
-        )
-        ctx.GeoIpLookup.promises.getCurrencyCode.resolves({
-          countryCode: 'IN',
-        })
-        ctx.res.render = (pageName, opts) => {
-          expect(opts.showInrGeoBanner).to.be.false
-          resolve()
+      // usersBestSubscription is only available when saas feature is present
+      ctx.Features.hasFeature.withArgs('saas').returns(true)
+      ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
+        {
+          bestSubscription: {
+            type: 'individual',
+          },
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+      )
+      ctx.GeoIpLookup.promises.getCurrencyCode.resolves({
+        countryCode: 'IN',
       })
+      ctx.res.render = (pageName, opts) => {
+        expect(opts.showInrGeoBanner).to.be.false
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should redirect to domain capture page', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.Features.hasFeature.withArgs('saas').returns(true)
-        ctx.SplitTestHandler.promises.getAssignment
-          .withArgs(ctx.req, ctx.res, 'domain-capture-redirect')
-          .resolves({ variant: 'enabled' })
-        ctx.Modules.promises.hooks.fire
-          .withArgs('findDomainCaptureGroupUserCouldBePartOf', ctx.user._id)
-          .resolves([{ _id: new ObjectId(), managedUsersEnabled: true }])
-        ctx.res.redirect = url => {
-          url.should.equal('/domain-capture')
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.Features.hasFeature.withArgs('saas').returns(true)
+      ctx.SplitTestHandler.promises.getAssignment
+        .withArgs(ctx.req, ctx.res, 'domain-capture-redirect')
+        .resolves({ variant: 'enabled' })
+      ctx.Modules.promises.hooks.fire
+        .withArgs('findDomainCaptureGroupUserCouldBePartOf', ctx.user._id)
+        .resolves([{ _id: new ObjectId(), managedUsersEnabled: true }])
+      ctx.res.redirect = url => {
+        url.should.equal('/domain-capture')
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     describe('when user linked to SSO', function () {
@@ -572,95 +535,80 @@ describe('ProjectListController', function () {
       })
 
       it('should render with Commons template when Commons was linked', async function (ctx) {
-        await new Promise(resolve => {
+        ctx.res.render = (pageName, opts) => {
+          expect(opts.notificationsInstitution).to.deep.equal([
+            Object.assign(
+              { templateKey: 'notification_institution_sso_linked' },
+              notificationData
+            ),
+          ])
+        }
+        await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+      })
+
+      describe('when via domain capture', function () {
+        beforeEach(function (ctx) {
+          ctx.req.session.saml.domainCaptureEnabled = true
+        })
+
+        it('should render with group template', async function (ctx) {
           ctx.res.render = (pageName, opts) => {
             expect(opts.notificationsInstitution).to.deep.equal([
               Object.assign(
-                { templateKey: 'notification_institution_sso_linked' },
+                { templateKey: 'notification_group_sso_linked' },
                 notificationData
               ),
             ])
-            resolve()
           }
-          ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-        })
-      })
-
-      describe('when via domain capture', function () {
-        beforeEach(function (ctx) {
-          ctx.req.session.saml.domainCaptureEnabled = true
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
 
-        it('should render with group template', async function (ctx) {
-          await new Promise(resolve => {
+        describe('user created via domain capture and group is managed', function () {
+          beforeEach(function (ctx) {
+            ctx.req.session.saml.userCreatedViaDomainCapture = true
+          })
+          it('should render with notification_group_sso_linked', async function (ctx) {
             ctx.res.render = (pageName, opts) => {
               expect(opts.notificationsInstitution).to.deep.equal([
                 Object.assign(
-                  { templateKey: 'notification_group_sso_linked' },
+                  {
+                    templateKey: 'notification_group_sso_linked',
+                  },
                   notificationData
                 ),
               ])
-              resolve()
             }
-            ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-          })
-        })
-
-        describe('user created via domain capture and group is managed', function () {
-          beforeEach(function (ctx) {
-            ctx.req.session.saml.userCreatedViaDomainCapture = true
-          })
-          it('should render with notification_group_sso_linked', async function (ctx) {
-            await new Promise(resolve => {
-              ctx.res.render = (pageName, opts) => {
-                expect(opts.notificationsInstitution).to.deep.equal([
-                  Object.assign(
-                    {
-                      templateKey: 'notification_group_sso_linked',
-                    },
-                    notificationData
-                  ),
-                ])
-                resolve()
-              }
-              ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-            })
+            await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
           })
 
           it('should render with notification_account_created_via_group_domain_capture_and_managed_users_enabled when managed user is enabled', async function (ctx) {
             ctx.req.session.saml.managedUsersEnabled = true
-            await new Promise(resolve => {
-              ctx.res.render = (pageName, opts) => {
-                expect(opts.notificationsInstitution).to.deep.equal([
-                  Object.assign(
-                    {
-                      templateKey:
-                        'notification_account_created_via_group_domain_capture_and_managed_users_enabled',
-                    },
-                    notificationData
-                  ),
-                ])
-                resolve()
-              }
-              ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-            })
+            ctx.res.render = (pageName, opts) => {
+              expect(opts.notificationsInstitution).to.deep.equal([
+                Object.assign(
+                  {
+                    templateKey:
+                      'notification_account_created_via_group_domain_capture_and_managed_users_enabled',
+                  },
+                  notificationData
+                ),
+              ])
+            }
+            await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
           })
         })
       })
     })
 
     describe('With Institution SSO feature', function () {
-      beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.institutionEmail = 'test@overleaf.com'
-          ctx.institutionName = 'Overleaf'
-          ctx.Features.hasFeature.withArgs('saml').returns(true)
-          ctx.Features.hasFeature.withArgs('affiliations').returns(true)
-          ctx.Features.hasFeature.withArgs('saas').returns(true)
-          resolve()
-        })
+      beforeEach(function (ctx) {
+        ctx.institutionEmail = 'test@overleaf.com'
+        ctx.institutionName = 'Overleaf'
+        ctx.Features.hasFeature.withArgs('saml').returns(true)
+        ctx.Features.hasFeature.withArgs('affiliations').returns(true)
+        ctx.Features.hasFeature.withArgs('saas').returns(true)
       })
-      it('should show institution SSO available notification for confirmed domains', function (ctx) {
+      it('should show institution SSO available notification for confirmed domains', async function (ctx) {
         ctx.UserGetter.promises.getUserFullEmails.resolves([
           {
             email: 'test@overleaf.com',
@@ -683,9 +631,9 @@ describe('ProjectListController', function () {
             templateKey: 'notification_institution_sso_available',
           })
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+        await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
       })
-      it('should show a linked notification', function (ctx) {
+      it('should show a linked notification', async function (ctx) {
         ctx.req.session.saml = {
           institutionEmail: ctx.institutionEmail,
           linked: {
@@ -700,9 +648,9 @@ describe('ProjectListController', function () {
             templateKey: 'notification_institution_sso_linked',
           })
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+        await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
       })
-      it('should show a group linked notification when domain capture enabled', function (ctx) {
+      it('should show a group linked notification when domain capture enabled', async function (ctx) {
         ctx.req.session.saml = {
           institutionEmail: ctx.institutionEmail,
           linked: {
@@ -718,9 +666,9 @@ describe('ProjectListController', function () {
             templateKey: 'notification_group_sso_linked',
           })
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+        await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
       })
-      it('should show a success notification when joining group via domain capture page', function (ctx) {
+      it('should show a success notification when joining group via domain capture page', async function (ctx) {
         ctx.req.session.saml = {
           linkedGroup: true,
           universityName: ctx.institutionName,
@@ -733,9 +681,9 @@ describe('ProjectListController', function () {
             viaDomainCapture: true,
           })
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+        await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
       })
-      it('should show a linked another email notification', function (ctx) {
+      it('should show a linked another email notification', async function (ctx) {
         // when they request to link an email but the institution returns
         // a different email
         ctx.res.render = (pageName, opts) => {
@@ -754,10 +702,10 @@ describe('ProjectListController', function () {
             universityName: ctx.institutionName,
           },
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+        await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
       })
 
-      it('should show a notification when intent was to register via SSO but account existed', function (ctx) {
+      it('should show a notification when intent was to register via SSO but account existed', async function (ctx) {
         ctx.res.render = (pageName, opts) => {
           expect(opts.notificationsInstitution).to.deep.include({
             email: ctx.institutionEmail,
@@ -775,10 +723,10 @@ describe('ProjectListController', function () {
             name: 'Example University',
           },
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+        await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
       })
 
-      it('should not show a register notification if the flow was abandoned', function (ctx) {
+      it('should not show a register notification if the flow was abandoned', async function (ctx) {
         // could initially start to register with an SSO email and then
         // abandon flow and login with an existing non-institution SSO email
         ctx.res.render = (pageName, opts) => {
@@ -793,10 +741,10 @@ describe('ProjectListController', function () {
             name: 'Example University',
           },
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+        await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
       })
 
-      it('should show error notification', function (ctx) {
+      it('should show error notification', async function (ctx) {
         ctx.res.render = (pageName, opts) => {
           expect(opts.notificationsInstitution.length).to.equal(1)
           expect(opts.notificationsInstitution[0].templateKey).to.equal(
@@ -810,38 +758,35 @@ describe('ProjectListController', function () {
           institutionEmail: ctx.institutionEmail,
           error: new Errors.SAMLAlreadyLinkedError(),
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+        await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
       })
 
       describe('for an unconfirmed domain for an SSO institution', function () {
-        beforeEach(async function (ctx) {
-          await new Promise(resolve => {
-            ctx.UserGetter.promises.getUserFullEmails.resolves([
-              {
-                email: 'test@overleaf-uncofirmed.com',
-                affiliation: {
-                  institution: {
-                    id: 1,
-                    confirmed: false,
-                    name: 'Overleaf',
-                    ssoBeta: false,
-                    ssoEnabled: true,
-                  },
+        beforeEach(function (ctx) {
+          ctx.UserGetter.promises.getUserFullEmails.resolves([
+            {
+              email: 'test@overleaf-uncofirmed.com',
+              affiliation: {
+                institution: {
+                  id: 1,
+                  confirmed: false,
+                  name: 'Overleaf',
+                  ssoBeta: false,
+                  ssoEnabled: true,
                 },
               },
-            ])
-            resolve()
-          })
+            },
+          ])
         })
-        it('should not show institution SSO available notification', function (ctx) {
+        it('should not show institution SSO available notification', async function (ctx) {
           ctx.res.render = (pageName, opts) => {
             expect(opts.notificationsInstitution.length).to.equal(0)
           }
-          ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
       })
       describe('when linking/logging in initiated on institution side', function () {
-        it('should not show a linked another email notification', function (ctx) {
+        it('should not show a linked another email notification', async function (ctx) {
           // this is only used when initated on Overleaf,
           // because we keep track of the requested email they tried to link
           ctx.res.render = (pageName, opts) => {
@@ -859,30 +804,27 @@ describe('ProjectListController', function () {
               universityName: ctx.institutionName,
             },
           }
-          ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
       })
       describe('Institution with SSO beta testable', function () {
-        beforeEach(async function (ctx) {
-          await new Promise(resolve => {
-            ctx.UserGetter.promises.getUserFullEmails.resolves([
-              {
-                email: 'beta@beta.com',
-                affiliation: {
-                  institution: {
-                    id: 2,
-                    confirmed: true,
-                    name: 'Beta University',
-                    ssoBeta: true,
-                    ssoEnabled: false,
-                  },
+        beforeEach(function (ctx) {
+          ctx.UserGetter.promises.getUserFullEmails.resolves([
+            {
+              email: 'beta@beta.com',
+              affiliation: {
+                institution: {
+                  id: 2,
+                  confirmed: true,
+                  name: 'Beta University',
+                  ssoBeta: true,
+                  ssoEnabled: false,
                 },
               },
-            ])
-            resolve()
-          })
+            },
+          ])
         })
-        it('should show institution SSO available notification when on a beta testing session', function (ctx) {
+        it('should show institution SSO available notification when on a beta testing session', async function (ctx) {
           ctx.req.session.samlBeta = true
           ctx.res.render = (pageName, opts) => {
             expect(opts.notificationsInstitution).to.deep.include({
@@ -892,9 +834,9 @@ describe('ProjectListController', function () {
               templateKey: 'notification_institution_sso_available',
             })
           }
-          ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
-        it('should not show institution SSO available notification when not on a beta testing session', function (ctx) {
+        it('should not show institution SSO available notification when not on a beta testing session', async function (ctx) {
           ctx.req.session.samlBeta = false
           ctx.res.render = (pageName, opts) => {
             expect(opts.notificationsInstitution).to.deep.not.include({
@@ -904,11 +846,11 @@ describe('ProjectListController', function () {
               templateKey: 'notification_institution_sso_available',
             })
           }
-          ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
       })
       describe('group domain capture enabled for domain', function () {
-        it('does not show institution SSO available notification', function (ctx) {
+        it('does not show institution SSO available notification', async function (ctx) {
           ctx.UserGetter.promises.getUserFullEmails.resolves([
             {
               email: 'test@overleaf.com',
@@ -926,20 +868,17 @@ describe('ProjectListController', function () {
           ])
           ctx.res.render = (pageName, opts) => {
             expect(opts.notificationsInstitution).to.deep.equal([])
-            ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
           }
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
       })
     })
 
     describe('Without Institution SSO feature', function () {
-      beforeEach(async function (ctx) {
-        await new Promise(resolve => {
-          ctx.Features.hasFeature.withArgs('saml').returns(false)
-          resolve()
-        })
+      beforeEach(function (ctx) {
+        ctx.Features.hasFeature.withArgs('saml').returns(false)
       })
-      it('should not show institution sso available notification', function (ctx) {
+      it('should not show institution sso available notification', async function (ctx) {
         ctx.res.render = (pageName, opts) => {
           expect(opts.notificationsInstitution).to.deep.not.include({
             email: 'test@overleaf.com',
@@ -948,7 +887,7 @@ describe('ProjectListController', function () {
             templateKey: 'notification_institution_sso_available',
           })
         }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+        await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
       })
     })
 
@@ -966,14 +905,14 @@ describe('ProjectListController', function () {
       })
 
       describe('normal enterprise banner', function () {
-        it('shows banner', function (ctx) {
+        it('shows banner', async function (ctx) {
           ctx.res.render = (pageName, opts) => {
             expect(opts.showGroupsAndEnterpriseBanner).to.be.true
           }
-          ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
 
-        it('does not show banner if user is part of any affiliation', function (ctx) {
+        it('does not show banner if user is part of any affiliation', async function (ctx) {
           ctx.UserGetter.promises.getUserFullEmails.resolves([
             {
               email: 'test@overleaf.com',
@@ -993,10 +932,10 @@ describe('ProjectListController', function () {
           ctx.res.render = (pageName, opts) => {
             expect(opts.showGroupsAndEnterpriseBanner).to.be.false
           }
-          ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
 
-        it('does not show banner if user is part of any group subscription', function (ctx) {
+        it('does not show banner if user is part of any group subscription', async function (ctx) {
           ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
             { memberGroupSubscriptions: [{}] }
           )
@@ -1004,22 +943,22 @@ describe('ProjectListController', function () {
           ctx.res.render = (pageName, opts) => {
             expect(opts.showGroupsAndEnterpriseBanner).to.be.false
           }
-          ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
 
-        it('have a banner variant of "FOMO" or "on-premise"', function (ctx) {
+        it('have a banner variant of "FOMO" or "on-premise"', async function (ctx) {
           ctx.res.render = (pageName, opts) => {
             expect(opts.groupsAndEnterpriseBannerVariant).to.be.oneOf([
               'FOMO',
               'on-premise',
             ])
           }
-          ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
       })
 
       describe('US government enterprise banner', function () {
-        it('does not show enterprise banner if US government enterprise banner is shown', function (ctx) {
+        it('does not show enterprise banner if US government enterprise banner is shown', async function (ctx) {
           const emails = [
             {
               email: 'test@test.mil',
@@ -1040,7 +979,7 @@ describe('ProjectListController', function () {
             expect(opts.showGroupsAndEnterpriseBanner).to.be.false
             expect(opts.showUSGovBanner).to.be.true
           }
-          ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
+          await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
         })
       })
     })
@@ -1077,31 +1016,25 @@ describe('ProjectListController', function () {
     })
 
     it('should render the project/list-react page', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.res.render = (pageName, opts) => {
-          pageName.should.equal('project/list-react')
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.res.render = (pageName, opts) => {
+        pageName.should.equal('project/list-react')
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
 
     it('should omit one of the projects', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.res.render = (pageName, opts) => {
-          opts.prefetchedProjectsBlob.projects.length.should.equal(
-            ctx.projects.length +
-              ctx.readAndWrite.length +
-              ctx.readOnly.length +
-              ctx.tokenReadAndWrite.length +
-              ctx.tokenReadOnly.length +
-              ctx.review.length -
-              1
-          )
-          resolve()
-        }
-        ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
-      })
+      ctx.res.render = (pageName, opts) => {
+        opts.prefetchedProjectsBlob.projects.length.should.equal(
+          ctx.projects.length +
+            ctx.readAndWrite.length +
+            ctx.readOnly.length +
+            ctx.tokenReadAndWrite.length +
+            ctx.tokenReadOnly.length +
+            ctx.review.length -
+            1
+        )
+      }
+      await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
     })
   })
 })

+ 102 - 110
services/web/test/unit/src/Project/ProjectOptionsHandler.test.mjs

@@ -1,231 +1,223 @@
-/* eslint-disable
-    n/handle-callback-err,
-    max-len,
-    no-return-assign,
-    no-unused-vars,
-    no-useless-constructor,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const sinon = require('sinon')
-const { expect } = require('chai')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+
 const modulePath =
-  '../../../../app/src/Features/Project/ProjectOptionsHandler.js'
-const SandboxedModule = require('sandboxed-module')
-const { ObjectId } = require('mongodb-legacy')
+  '../../../../app/src/Features/Project/ProjectOptionsHandler.mjs'
+
+const { ObjectId } = mongodb
 
 describe('ProjectOptionsHandler', function () {
   const projectId = '4eecaffcbffa66588e000008'
 
-  beforeEach(function () {
-    let Project
-    this.projectModel = Project = class Project {
-      constructor(options) {}
-    }
-    this.projectModel.updateOne = sinon.stub().resolves()
+  beforeEach(async function (ctx) {
+    ctx.projectModel = class Project {}
+    ctx.projectModel.updateOne = sinon.stub().resolves()
 
-    this.db = {
+    ctx.db = {
       projects: {
         updateOne: sinon.stub().resolves(),
       },
     }
 
-    this.handler = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../models/Project': { Project: this.projectModel },
-        '@overleaf/settings': {
-          languages: [
-            { name: 'English', code: 'en' },
-            { name: 'French', code: 'fr' },
-          ],
-          imageRoot: 'docker-repo/subdir',
-          allowedImageNames: [
-            { imageName: 'texlive-0000.0', imageDesc: 'test image 0' },
-            { imageName: 'texlive-1234.5', imageDesc: 'test image 1' },
-          ],
-        },
-        '../../infrastructure/mongodb': { db: this.db, ObjectId },
+    vi.doMock('../../../../app/src/models/Project', () => ({
+      Project: ctx.projectModel,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
+        languages: [
+          { name: 'English', code: 'en' },
+          { name: 'French', code: 'fr' },
+        ],
+        imageRoot: 'docker-repo/subdir',
+        allowedImageNames: [
+          { imageName: 'texlive-0000.0', imageDesc: 'test image 0' },
+          { imageName: 'texlive-1234.5', imageDesc: 'test image 1' },
+        ],
       },
-    })
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({
+      db: ctx.db,
+      ObjectId,
+    }))
+
+    ctx.handler = (await import(modulePath)).default
   })
 
   describe('Setting the compiler', function () {
-    it('should perform and update on mongo', async function () {
-      await this.handler.promises.setCompiler(projectId, 'xeLaTeX')
-      const args = this.projectModel.updateOne.args[0]
+    it('should perform and update on mongo', async function (ctx) {
+      await ctx.handler.promises.setCompiler(projectId, 'xeLaTeX')
+      const args = ctx.projectModel.updateOne.args[0]
       args[0]._id.should.equal(projectId)
       args[1].compiler.should.equal('xelatex')
     })
 
-    it('should not perform and update on mongo if it is not a recognised compiler', async function () {
+    it('should not perform and update on mongo if it is not a recognised compiler', async function (ctx) {
       const fakeComplier = 'something'
       expect(
-        this.handler.promises.setCompiler(projectId, 'something')
+        ctx.handler.promises.setCompiler(projectId, 'something')
       ).to.be.rejectedWith(`invalid compiler: ${fakeComplier}`)
 
-      this.projectModel.updateOne.called.should.equal(false)
+      ctx.projectModel.updateOne.called.should.equal(false)
     })
 
     describe('when called without arg', function () {
-      it('should callback with null', async function () {
-        await this.handler.promises.setCompiler(projectId, null)
-        this.projectModel.updateOne.callCount.should.equal(0)
+      it('should callback with null', async function (ctx) {
+        await ctx.handler.promises.setCompiler(projectId, null)
+        ctx.projectModel.updateOne.callCount.should.equal(0)
       })
     })
 
     describe('when mongo update error occurs', function () {
-      beforeEach(function () {
-        this.projectModel.updateOne = sinon.stub().yields('error')
+      beforeEach(function (ctx) {
+        ctx.projectModel.updateOne = sinon.stub().yields('error')
       })
 
-      it('should be rejected', async function () {
-        expect(this.handler.promises.setCompiler(projectId, 'xeLaTeX')).to.be
+      it('should be rejected', async function (ctx) {
+        expect(ctx.handler.promises.setCompiler(projectId, 'xeLaTeX')).to.be
           .rejected
       })
     })
   })
 
   describe('Setting the imageName', function () {
-    it('should perform and update on mongo', async function () {
-      await this.handler.promises.setImageName(projectId, 'texlive-1234.5')
-      const args = this.projectModel.updateOne.args[0]
+    it('should perform and update on mongo', async function (ctx) {
+      await ctx.handler.promises.setImageName(projectId, 'texlive-1234.5')
+      const args = ctx.projectModel.updateOne.args[0]
       args[0]._id.should.equal(projectId)
       args[1].imageName.should.equal('docker-repo/subdir/texlive-1234.5')
     })
 
-    it('should not perform and update on mongo if it is not a reconised image name', async function () {
+    it('should not perform and update on mongo if it is not a reconised image name', async function (ctx) {
       const fakeImageName = 'something'
       expect(
-        this.handler.promises.setImageName(projectId, fakeImageName)
+        ctx.handler.promises.setImageName(projectId, fakeImageName)
       ).to.be.rejectedWith(`invalid imageName: ${fakeImageName}`)
 
-      this.projectModel.updateOne.called.should.equal(false)
+      ctx.projectModel.updateOne.called.should.equal(false)
     })
 
     describe('when called without arg', function () {
-      it('should callback with null', async function () {
-        await this.handler.promises.setImageName(projectId, null)
-        this.projectModel.updateOne.callCount.should.equal(0)
+      it('should callback with null', async function (ctx) {
+        await ctx.handler.promises.setImageName(projectId, null)
+        ctx.projectModel.updateOne.callCount.should.equal(0)
       })
     })
 
     describe('when mongo update error occurs', function () {
-      beforeEach(function () {
-        this.projectModel.updateOne = sinon.stub().yields('error')
+      beforeEach(function (ctx) {
+        ctx.projectModel.updateOne = sinon.stub().yields('error')
       })
 
-      it('should be rejected', async function () {
-        expect(this.handler.promises.setImageName(projectId, 'texlive-1234.5'))
+      it('should be rejected', async function (ctx) {
+        expect(ctx.handler.promises.setImageName(projectId, 'texlive-1234.5'))
           .to.be.rejected
       })
     })
   })
 
   describe('setting the spellCheckLanguage', function () {
-    it('should perform and update on mongo', async function () {
-      await this.handler.promises.setSpellCheckLanguage(projectId, 'fr')
-      const args = this.projectModel.updateOne.args[0]
+    it('should perform and update on mongo', async function (ctx) {
+      await ctx.handler.promises.setSpellCheckLanguage(projectId, 'fr')
+      const args = ctx.projectModel.updateOne.args[0]
       args[0]._id.should.equal(projectId)
       args[1].spellCheckLanguage.should.equal('fr')
     })
 
-    it('should not perform and update on mongo if it is not a reconised langauge', async function () {
+    it('should not perform and update on mongo if it is not a reconised langauge', async function (ctx) {
       const fakeLanguageCode = 'not a lang'
       expect(
-        this.handler.promises.setSpellCheckLanguage(projectId, fakeLanguageCode)
+        ctx.handler.promises.setSpellCheckLanguage(projectId, fakeLanguageCode)
       ).to.be.rejectedWith(`invalid languageCode: ${fakeLanguageCode}`)
-      this.projectModel.updateOne.called.should.equal(false)
+      ctx.projectModel.updateOne.called.should.equal(false)
     })
 
-    it('should perform and update on mongo if the language is blank (means turn it off)', async function () {
-      await this.handler.promises.setSpellCheckLanguage(projectId, '')
-      this.projectModel.updateOne.called.should.equal(true)
+    it('should perform and update on mongo if the language is blank (means turn it off)', async function (ctx) {
+      await ctx.handler.promises.setSpellCheckLanguage(projectId, '')
+      ctx.projectModel.updateOne.called.should.equal(true)
     })
 
     describe('when mongo update error occurs', function () {
-      beforeEach(function () {
-        this.projectModel.updateOne = sinon.stub().yields('error')
+      beforeEach(function (ctx) {
+        ctx.projectModel.updateOne = sinon.stub().yields('error')
       })
 
-      it('should be rejected', async function () {
-        expect(this.handler.promises.setSpellCheckLanguage(projectId)).to.be
+      it('should be rejected', async function (ctx) {
+        expect(ctx.handler.promises.setSpellCheckLanguage(projectId)).to.be
           .rejected
       })
     })
   })
 
   describe('setting the brandVariationId', function () {
-    it('should perform and update on mongo', async function () {
-      await this.handler.promises.setBrandVariationId(projectId, '123')
-      const args = this.projectModel.updateOne.args[0]
+    it('should perform and update on mongo', async function (ctx) {
+      await ctx.handler.promises.setBrandVariationId(projectId, '123')
+      const args = ctx.projectModel.updateOne.args[0]
       args[0]._id.should.equal(projectId)
       args[1].brandVariationId.should.equal('123')
     })
 
-    it('should not perform and update on mongo if there is no brand variation', async function () {
-      await this.handler.promises.setBrandVariationId(projectId, null)
-      this.projectModel.updateOne.called.should.equal(false)
+    it('should not perform and update on mongo if there is no brand variation', async function (ctx) {
+      await ctx.handler.promises.setBrandVariationId(projectId, null)
+      ctx.projectModel.updateOne.called.should.equal(false)
     })
 
-    it('should not perform and update on mongo if brand variation is an empty string', async function () {
-      await this.handler.promises.setBrandVariationId(projectId, '')
-      this.projectModel.updateOne.called.should.equal(false)
+    it('should not perform and update on mongo if brand variation is an empty string', async function (ctx) {
+      await ctx.handler.promises.setBrandVariationId(projectId, '')
+      ctx.projectModel.updateOne.called.should.equal(false)
     })
 
     describe('when mongo update error occurs', function () {
-      beforeEach(function () {
-        this.projectModel.updateOne = sinon.stub().yields('error')
+      beforeEach(function (ctx) {
+        ctx.projectModel.updateOne = sinon.stub().yields('error')
       })
 
-      it('should be rejected', async function () {
-        expect(this.handler.promises.setBrandVariationId(projectId, '123')).to
-          .be.rejected
+      it('should be rejected', async function (ctx) {
+        expect(ctx.handler.promises.setBrandVariationId(projectId, '123')).to.be
+          .rejected
       })
     })
   })
 
   describe('setting the rangesSupportEnabled', function () {
-    it('should perform and update on mongo', async function () {
-      await this.handler.promises.setHistoryRangesSupport(projectId, true)
+    it('should perform and update on mongo', async function (ctx) {
+      await ctx.handler.promises.setHistoryRangesSupport(projectId, true)
       sinon.assert.calledWith(
-        this.db.projects.updateOne,
+        ctx.db.projects.updateOne,
         { _id: new ObjectId(projectId) },
         { $set: { 'overleaf.history.rangesSupportEnabled': true } }
       )
     })
 
     describe('when mongo update error occurs', function () {
-      beforeEach(function () {
-        this.db.projects.updateOne = sinon.stub().yields('error')
+      beforeEach(function (ctx) {
+        ctx.db.projects.updateOne = sinon.stub().yields('error')
       })
 
-      it('should be rejected', async function () {
-        expect(this.handler.promises.setHistoryRangesSupport(projectId, true))
-          .to.be.rejected
+      it('should be rejected', async function (ctx) {
+        expect(ctx.handler.promises.setHistoryRangesSupport(projectId, true)).to
+          .be.rejected
       })
     })
   })
 
   describe('unsetting the brandVariationId', function () {
-    it('should perform and update on mongo', async function () {
-      await this.handler.promises.unsetBrandVariationId(projectId)
-      const args = this.projectModel.updateOne.args[0]
+    it('should perform and update on mongo', async function (ctx) {
+      await ctx.handler.promises.unsetBrandVariationId(projectId)
+      const args = ctx.projectModel.updateOne.args[0]
       args[0]._id.should.equal(projectId)
       expect(args[1]).to.deep.equal({ $unset: { brandVariationId: 1 } })
     })
 
     describe('when mongo update error occurs', function () {
-      beforeEach(function () {
-        this.projectModel.updateOne = sinon.stub().yields('error')
+      beforeEach(function (ctx) {
+        ctx.projectModel.updateOne = sinon.stub().yields('error')
       })
 
-      it('should be rejected', async function () {
-        expect(this.handler.promises.unsetBrandVariationId(projectId)).to.be
+      it('should be rejected', async function (ctx) {
+        expect(ctx.handler.promises.unsetBrandVariationId(projectId)).to.be
           .rejected
       })
     })

+ 46 - 45
services/web/test/unit/src/Project/ProjectUpdateHandler.test.mjs

@@ -1,67 +1,68 @@
-const sinon = require('sinon')
+import { vi } from 'vitest'
+import sinon from 'sinon'
 const modulePath =
-  '../../../../app/src/Features/Project/ProjectUpdateHandler.js'
-const SandboxedModule = require('sandboxed-module')
+  '../../../../app/src/Features/Project/ProjectUpdateHandler.mjs'
 
 describe('ProjectUpdateHandler', function () {
-  beforeEach(function () {
-    this.fakeTime = new Date()
-    this.clock = sinon.useFakeTimers(this.fakeTime.getTime())
+  beforeEach(function (ctx) {
+    ctx.fakeTime = new Date()
+    ctx.clock = sinon.useFakeTimers(ctx.fakeTime.getTime())
   })
 
-  afterEach(function () {
-    this.clock.restore()
+  afterEach(function (ctx) {
+    ctx.clock.restore()
   })
 
-  beforeEach(function () {
-    this.ProjectModel = class Project {}
-    this.ProjectModel.updateOne = sinon.stub().returns({
+  beforeEach(async function (ctx) {
+    ctx.ProjectModel = class Project {}
+    ctx.ProjectModel.updateOne = sinon.stub().returns({
       exec: sinon.stub(),
     })
-    this.handler = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../models/Project': { Project: this.ProjectModel },
-      },
-    })
+
+    vi.doMock('../../../../app/src/models/Project', () => ({
+      Project: ctx.ProjectModel,
+    }))
+
+    ctx.handler = (await import(modulePath)).default
   })
 
   describe('marking a project as recently updated', function () {
-    beforeEach(function () {
-      this.project_id = 'project_id'
-      this.lastUpdatedAt = 987654321
-      this.lastUpdatedBy = 'fake-last-updater-id'
+    beforeEach(function (ctx) {
+      ctx.project_id = 'project_id'
+      ctx.lastUpdatedAt = 987654321
+      ctx.lastUpdatedBy = 'fake-last-updater-id'
     })
 
-    it('should send an update to mongo', async function () {
-      await this.handler.promises.markAsUpdated(
-        this.project_id,
-        this.lastUpdatedAt,
-        this.lastUpdatedBy
+    it('should send an update to mongo', async function (ctx) {
+      await ctx.handler.promises.markAsUpdated(
+        ctx.project_id,
+        ctx.lastUpdatedAt,
+        ctx.lastUpdatedBy
       )
 
       sinon.assert.calledWith(
-        this.ProjectModel.updateOne,
+        ctx.ProjectModel.updateOne,
         {
-          _id: this.project_id,
-          lastUpdated: { $lt: this.lastUpdatedAt },
+          _id: ctx.project_id,
+          lastUpdated: { $lt: ctx.lastUpdatedAt },
         },
         {
-          lastUpdated: this.lastUpdatedAt,
-          lastUpdatedBy: this.lastUpdatedBy,
+          lastUpdated: ctx.lastUpdatedAt,
+          lastUpdatedBy: ctx.lastUpdatedBy,
         }
       )
     })
 
-    it('should set smart fallbacks', async function () {
-      await this.handler.promises.markAsUpdated(this.project_id, null, null)
+    it('should set smart fallbacks', async function (ctx) {
+      await ctx.handler.promises.markAsUpdated(ctx.project_id, null, null)
       sinon.assert.calledWithMatch(
-        this.ProjectModel.updateOne,
+        ctx.ProjectModel.updateOne,
         {
-          _id: this.project_id,
-          lastUpdated: { $lt: this.fakeTime },
+          _id: ctx.project_id,
+          lastUpdated: { $lt: ctx.fakeTime },
         },
         {
-          lastUpdated: this.fakeTime,
+          lastUpdated: ctx.fakeTime,
           lastUpdatedBy: null,
         }
       )
@@ -69,10 +70,10 @@ describe('ProjectUpdateHandler', function () {
   })
 
   describe('markAsOpened', function () {
-    it('should send an update to mongo', async function () {
+    it('should send an update to mongo', async function (ctx) {
       const projectId = 'project_id'
-      await this.handler.promises.markAsOpened(projectId)
-      const args = this.ProjectModel.updateOne.args[0]
+      await ctx.handler.promises.markAsOpened(projectId)
+      const args = ctx.ProjectModel.updateOne.args[0]
       args[0]._id.should.equal(projectId)
       const date = args[1].lastOpened + ''
       const now = Date.now() + ''
@@ -81,20 +82,20 @@ describe('ProjectUpdateHandler', function () {
   })
 
   describe('markAsInactive', function () {
-    it('should send an update to mongo', async function () {
+    it('should send an update to mongo', async function (ctx) {
       const projectId = 'project_id'
-      await this.handler.promises.markAsInactive(projectId)
-      const args = this.ProjectModel.updateOne.args[0]
+      await ctx.handler.promises.markAsInactive(projectId)
+      const args = ctx.ProjectModel.updateOne.args[0]
       args[0]._id.should.equal(projectId)
       args[1].active.should.equal(false)
     })
   })
 
   describe('markAsActive', function () {
-    it('should send an update to mongo', async function () {
+    it('should send an update to mongo', async function (ctx) {
       const projectId = 'project_id'
-      await this.handler.promises.markAsActive(projectId)
-      const args = this.ProjectModel.updateOne.args[0]
+      await ctx.handler.promises.markAsActive(projectId)
+      const args = ctx.ProjectModel.updateOne.args[0]
       args[0]._id.should.equal(projectId)
       args[1].active.should.equal(true)
     })

+ 101 - 101
services/web/test/unit/src/Project/SafePath.test.mjs

@@ -10,89 +10,89 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const { assert, expect } = require('chai')
-const sinon = require('sinon')
+import { assert, expect } from 'vitest'
+
+import sinon from 'sinon'
 const modulePath = '../../../../app/src/Features/Project/SafePath'
-const SandboxedModule = require('sandboxed-module')
 
 describe('SafePath', function () {
-  beforeEach(function () {
-    return (this.SafePath = SandboxedModule.require(modulePath))
+  beforeEach(async function (ctx) {
+    return (ctx.SafePath = (await import(modulePath)).default)
   })
 
   describe('isCleanFilename', function () {
-    it('should accept a valid filename "main.tex"', function () {
-      const result = this.SafePath.isCleanFilename('main.tex')
+    it('should accept a valid filename "main.tex"', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('main.tex')
       return result.should.equal(true)
     })
 
-    it('should not accept an empty filename', function () {
-      const result = this.SafePath.isCleanFilename('')
+    it('should not accept an empty filename', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('')
       return result.should.equal(false)
     })
 
-    it('should not accept / anywhere', function () {
-      const result = this.SafePath.isCleanFilename('foo/bar')
+    it('should not accept / anywhere', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('foo/bar')
       return result.should.equal(false)
     })
 
-    it('should not accept .', function () {
-      const result = this.SafePath.isCleanFilename('.')
+    it('should not accept .', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('.')
       return result.should.equal(false)
     })
 
-    it('should not accept ..', function () {
-      const result = this.SafePath.isCleanFilename('..')
+    it('should not accept ..', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('..')
       return result.should.equal(false)
     })
 
-    it('should not accept * anywhere', function () {
-      const result = this.SafePath.isCleanFilename('foo*bar')
+    it('should not accept * anywhere', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('foo*bar')
       return result.should.equal(false)
     })
 
-    it('should not accept leading whitespace', function () {
-      const result = this.SafePath.isCleanFilename(' foobar.tex')
+    it('should not accept leading whitespace', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename(' foobar.tex')
       return result.should.equal(false)
     })
 
-    it('should not accept trailing whitespace', function () {
-      const result = this.SafePath.isCleanFilename('foobar.tex ')
+    it('should not accept trailing whitespace', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('foobar.tex ')
       return result.should.equal(false)
     })
 
-    it('should not accept leading and trailing whitespace', function () {
-      const result = this.SafePath.isCleanFilename(' foobar.tex ')
+    it('should not accept leading and trailing whitespace', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename(' foobar.tex ')
       return result.should.equal(false)
     })
 
-    it('should not accept control characters (0-31)', function () {
-      const result = this.SafePath.isCleanFilename('foo\u0010bar')
+    it('should not accept control characters (0-31)', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('foo\u0010bar')
       return result.should.equal(false)
     })
 
-    it('should not accept control characters (127, delete)', function () {
-      const result = this.SafePath.isCleanFilename('foo\u007fbar')
+    it('should not accept control characters (127, delete)', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('foo\u007fbar')
       return result.should.equal(false)
     })
 
-    it('should not accept control characters (128-159)', function () {
-      const result = this.SafePath.isCleanFilename('foo\u0080\u0090bar')
+    it('should not accept control characters (128-159)', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('foo\u0080\u0090bar')
       return result.should.equal(false)
     })
 
-    it('should not accept surrogate characters (128-159)', function () {
-      const result = this.SafePath.isCleanFilename('foo\uD800\uDFFFbar')
+    it('should not accept surrogate characters (128-159)', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('foo\uD800\uDFFFbar')
       return result.should.equal(false)
     })
 
-    it('should accept javascript property names', function () {
-      const result = this.SafePath.isCleanFilename('prototype')
+    it('should accept javascript property names', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('prototype')
       return result.should.equal(true)
     })
 
-    it('should accept javascript property names in the prototype', function () {
-      const result = this.SafePath.isCleanFilename('hasOwnProperty')
+    it('should accept javascript property names in the prototype', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('hasOwnProperty')
       return result.should.equal(true)
     })
 
@@ -105,172 +105,172 @@ describe('SafePath', function () {
     // 	result = @SafePath.isCleanFilename 'hello.'
     // 	result.should.equal false
 
-    it('should not accept \\', function () {
-      const result = this.SafePath.isCleanFilename('foo\\bar')
+    it('should not accept \\', function (ctx) {
+      const result = ctx.SafePath.isCleanFilename('foo\\bar')
       return result.should.equal(false)
     })
 
-    it('should reject filenames regardless of order  (/g) for bad characters', function () {
-      const result1 = this.SafePath.isCleanFilename('foo*bar.tex') // * is not allowed
-      const result2 = this.SafePath.isCleanFilename('*foobar.tex') // bad char location is before previous match
+    it('should reject filenames regardless of order  (/g) for bad characters', function (ctx) {
+      const result1 = ctx.SafePath.isCleanFilename('foo*bar.tex') // * is not allowed
+      const result2 = ctx.SafePath.isCleanFilename('*foobar.tex') // bad char location is before previous match
       return result1.should.equal(false) && result2.should.equal(false)
     })
 
-    it('should reject filenames regardless of order (/g) for bad filenames', function () {
-      const result1 = this.SafePath.isCleanFilename('foo ') // trailing space
-      const result2 = this.SafePath.isCleanFilename(' foobar') // leading space, match location is before previous match
+    it('should reject filenames regardless of order (/g) for bad filenames', function (ctx) {
+      const result1 = ctx.SafePath.isCleanFilename('foo ') // trailing space
+      const result2 = ctx.SafePath.isCleanFilename(' foobar') // leading space, match location is before previous match
       return result1.should.equal(false) && result2.should.equal(false)
     })
   })
 
   describe('isCleanPath', function () {
-    it('should accept a valid filename "main.tex"', function () {
-      const result = this.SafePath.isCleanPath('main.tex')
+    it('should accept a valid filename "main.tex"', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('main.tex')
       return result.should.equal(true)
     })
 
-    it('should accept a valid path "foo/main.tex"', function () {
-      const result = this.SafePath.isCleanPath('foo/main.tex')
+    it('should accept a valid path "foo/main.tex"', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('foo/main.tex')
       return result.should.equal(true)
     })
 
-    it('should accept empty path elements', function () {
-      const result = this.SafePath.isCleanPath('foo//main.tex')
+    it('should accept empty path elements', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('foo//main.tex')
       return result.should.equal(true)
     })
 
-    it('should not accept an empty filename', function () {
-      const result = this.SafePath.isCleanPath('foo/bar/')
+    it('should not accept an empty filename', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('foo/bar/')
       return result.should.equal(false)
     })
 
-    it('should accept a path that starts with a slash', function () {
-      const result = this.SafePath.isCleanPath('/etc/passwd')
+    it('should accept a path that starts with a slash', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('/etc/passwd')
       return result.should.equal(true)
     })
 
-    it('should not accept a path that has an asterisk as the 0th element', function () {
-      const result = this.SafePath.isCleanPath('*/foo/bar')
+    it('should not accept a path that has an asterisk as the 0th element', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('*/foo/bar')
       return result.should.equal(false)
     })
 
-    it('should not accept a path that has an asterisk as a middle element', function () {
-      const result = this.SafePath.isCleanPath('foo/*/bar')
+    it('should not accept a path that has an asterisk as a middle element', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('foo/*/bar')
       return result.should.equal(false)
     })
 
-    it('should not accept a path that has an asterisk as the filename', function () {
-      const result = this.SafePath.isCleanPath('foo/bar/*')
+    it('should not accept a path that has an asterisk as the filename', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('foo/bar/*')
       return result.should.equal(false)
     })
 
-    it('should not accept a path that contains an asterisk in the 0th element', function () {
-      const result = this.SafePath.isCleanPath('f*o/bar/baz')
+    it('should not accept a path that contains an asterisk in the 0th element', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('f*o/bar/baz')
       return result.should.equal(false)
     })
 
-    it('should not accept a path that contains an asterisk in a middle element', function () {
-      const result = this.SafePath.isCleanPath('foo/b*r/baz')
+    it('should not accept a path that contains an asterisk in a middle element', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('foo/b*r/baz')
       return result.should.equal(false)
     })
 
-    it('should not accept a path that contains an asterisk in the filename', function () {
-      const result = this.SafePath.isCleanPath('foo/bar/b*z')
+    it('should not accept a path that contains an asterisk in the filename', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('foo/bar/b*z')
       return result.should.equal(false)
     })
 
-    it('should not accept multiple problematic elements', function () {
-      const result = this.SafePath.isCleanPath('f*o/b*r/b*z')
+    it('should not accept multiple problematic elements', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('f*o/b*r/b*z')
       return result.should.equal(false)
     })
 
-    it('should not accept a problematic path with an empty element', function () {
-      const result = this.SafePath.isCleanPath('foo//*/bar')
+    it('should not accept a problematic path with an empty element', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('foo//*/bar')
       return result.should.equal(false)
     })
 
-    it('should not accept javascript property names', function () {
-      const result = this.SafePath.isCleanPath('prototype')
+    it('should not accept javascript property names', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('prototype')
       return result.should.equal(false)
     })
 
-    it('should not accept javascript property names in the prototype', function () {
-      const result = this.SafePath.isCleanPath('hasOwnProperty')
+    it('should not accept javascript property names in the prototype', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('hasOwnProperty')
       return result.should.equal(false)
     })
 
-    it('should not accept javascript property names resulting from substitutions', function () {
-      const result = this.SafePath.isCleanPath('  proto  ')
+    it('should not accept javascript property names resulting from substitutions', function (ctx) {
+      const result = ctx.SafePath.isCleanPath('  proto  ')
       return result.should.equal(false)
     })
   })
 
   describe('isAllowedLength', function () {
-    it('should accept a valid path "main.tex"', function () {
-      const result = this.SafePath.isAllowedLength('main.tex')
+    it('should accept a valid path "main.tex"', function (ctx) {
+      const result = ctx.SafePath.isAllowedLength('main.tex')
       return result.should.equal(true)
     })
 
-    it('should not accept an extremely long path', function () {
+    it('should not accept an extremely long path', function (ctx) {
       const longPath = new Array(1000).join('/subdir') + '/main.tex'
-      const result = this.SafePath.isAllowedLength(longPath)
+      const result = ctx.SafePath.isAllowedLength(longPath)
       return result.should.equal(false)
     })
 
-    it('should not accept an empty path', function () {
-      const result = this.SafePath.isAllowedLength('')
+    it('should not accept an empty path', function (ctx) {
+      const result = ctx.SafePath.isAllowedLength('')
       return result.should.equal(false)
     })
   })
 
   describe('clean', function () {
-    it('should not modify a valid filename', function () {
-      const result = this.SafePath.clean('main.tex')
+    it('should not modify a valid filename', function (ctx) {
+      const result = ctx.SafePath.clean('main.tex')
       return result.should.equal('main.tex')
     })
 
-    it('should replace invalid characters with _', function () {
-      const result = this.SafePath.clean('foo/bar*/main.tex')
+    it('should replace invalid characters with _', function (ctx) {
+      const result = ctx.SafePath.clean('foo/bar*/main.tex')
       return result.should.equal('foo_bar__main.tex')
     })
 
-    it('should replace "." with "_"', function () {
-      const result = this.SafePath.clean('.')
+    it('should replace "." with "_"', function (ctx) {
+      const result = ctx.SafePath.clean('.')
       return result.should.equal('_')
     })
 
-    it('should replace ".." with "__"', function () {
-      const result = this.SafePath.clean('..')
+    it('should replace ".." with "__"', function (ctx) {
+      const result = ctx.SafePath.clean('..')
       return result.should.equal('__')
     })
 
-    it('should replace a single trailing space with _', function () {
-      const result = this.SafePath.clean('foo ')
+    it('should replace a single trailing space with _', function (ctx) {
+      const result = ctx.SafePath.clean('foo ')
       return result.should.equal('foo_')
     })
 
-    it('should replace a multiple trailing spaces with ___', function () {
-      const result = this.SafePath.clean('foo  ')
+    it('should replace a multiple trailing spaces with ___', function (ctx) {
+      const result = ctx.SafePath.clean('foo  ')
       return result.should.equal('foo__')
     })
 
-    it('should replace a single leading space with _', function () {
-      const result = this.SafePath.clean(' foo')
+    it('should replace a single leading space with _', function (ctx) {
+      const result = ctx.SafePath.clean(' foo')
       return result.should.equal('_foo')
     })
 
-    it('should replace a multiple leading spaces with ___', function () {
-      const result = this.SafePath.clean('  foo')
+    it('should replace a multiple leading spaces with ___', function (ctx) {
+      const result = ctx.SafePath.clean('  foo')
       return result.should.equal('__foo')
     })
 
-    it('should prefix javascript property names with @', function () {
-      const result = this.SafePath.clean('prototype')
+    it('should prefix javascript property names with @', function (ctx) {
+      const result = ctx.SafePath.clean('prototype')
       return result.should.equal('@prototype')
     })
 
-    it('should prefix javascript property names in the prototype with @', function () {
-      const result = this.SafePath.clean('hasOwnProperty')
+    it('should prefix javascript property names in the prototype with @', function (ctx) {
+      const result = ctx.SafePath.clean('hasOwnProperty')
       return result.should.equal('@hasOwnProperty')
     })
   })

+ 33 - 22
services/web/test/unit/src/Publishers/PublishersGetter.test.mjs

@@ -1,25 +1,25 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/Publishers/PublishersGetter.js'
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import path from 'node:path'
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../../app/src/Features/Publishers/PublishersGetter.mjs'
 )
 
 describe('PublishersGetter', function () {
-  beforeEach(function () {
-    this.publisher = {
+  beforeEach(async function (ctx) {
+    ctx.publisher = {
       _id: 'mock-publsiher-id',
       slug: 'ieee',
       fetchV1Data: sinon.stub(),
     }
 
-    this.UserMembershipsHandler = {
+    ctx.UserMembershipsHandler = {
       promises: {
-        getEntitiesByUser: sinon.stub().resolves([this.publisher]),
+        getEntitiesByUser: sinon.stub().resolves([ctx.publisher]),
       },
     }
-    this.UserMembershipEntityConfigs = {
+    ctx.UserMembershipEntityConfigs = {
       publisher: {
         modelName: 'Publisher',
         canCreate: true,
@@ -29,22 +29,33 @@ describe('PublishersGetter', function () {
       },
     }
 
-    this.PublishersGetter = SandboxedModule.require(modulePath, {
-      requires: {
-        '../User/UserGetter': this.UserGetter,
-        '../UserMembership/UserMembershipsHandler': this.UserMembershipsHandler,
-        '../UserMembership/UserMembershipEntityConfigs':
-          this.UserMembershipEntityConfigs,
-      },
-    })
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/UserMembership/UserMembershipsHandler',
+      () => ({
+        default: ctx.UserMembershipsHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs',
+      () => ({
+        default: ctx.UserMembershipEntityConfigs,
+      })
+    )
+
+    ctx.PublishersGetter = (await import(modulePath)).default
 
-    this.userId = '12345abcde'
+    ctx.userId = '12345abcde'
   })
 
   describe('getManagedPublishers', function () {
-    it('fetches v1 data before returning publisher list', async function () {
+    it('fetches v1 data before returning publisher list', async function (ctx) {
       const publishers =
-        await this.PublishersGetter.promises.getManagedPublishers(this.userId)
+        await ctx.PublishersGetter.promises.getManagedPublishers(ctx.userId)
       expect(publishers.length).to.equal(1)
     })
   })

+ 42 - 41
services/web/test/unit/src/Referal/ReferalFeatures.test.mjs

@@ -1,31 +1,32 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/Referal/ReferalFeatures.js'
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import path from 'node:path'
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../../app/src/Features/Referal/ReferalFeatures.mjs'
 )
 
 describe('ReferalFeatures', function () {
-  beforeEach(function () {
-    this.ReferalFeatures = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../models/User': {
-          User: (this.User = {}),
-        },
-        '@overleaf/settings': (this.Settings = {}),
-      },
-    })
-    this.referal_id = 'referal-id-123'
-    this.referal_medium = 'twitter'
-    this.user_id = 'user-id-123'
-    this.new_user_id = 'new-user-id-123'
+  beforeEach(async function (ctx) {
+    vi.doMock('../../../../app/src/models/User', () => ({
+      User: (ctx.User = {}),
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.Settings = {}),
+    }))
+
+    ctx.ReferalFeatures = (await import(modulePath)).default
+    ctx.referal_id = 'referal-id-123'
+    ctx.referal_medium = 'twitter'
+    ctx.user_id = 'user-id-123'
+    ctx.new_user_id = 'new-user-id-123'
   })
 
   describe('getBonusFeatures', function () {
-    beforeEach(async function () {
-      this.refered_user_count = 3
-      this.Settings.bonus_features = {
+    beforeEach(async function (ctx) {
+      ctx.refered_user_count = 3
+      ctx.Settings.bonus_features = {
         3: {
           collaborators: 3,
           dropbox: false,
@@ -33,54 +34,54 @@ describe('ReferalFeatures', function () {
         },
       }
       const stubbedUser = {
-        refered_user_count: this.refered_user_count,
+        refered_user_count: ctx.refered_user_count,
         features: { collaborators: 1, dropbox: false, versioning: false },
       }
 
-      this.User.findOne = sinon.stub().returns({
+      ctx.User.findOne = sinon.stub().returns({
         exec: sinon.stub().resolves(stubbedUser),
       })
-      this.features = await this.ReferalFeatures.promises.getBonusFeatures(
-        this.user_id
+      ctx.features = await ctx.ReferalFeatures.promises.getBonusFeatures(
+        ctx.user_id
       )
     })
 
-    it('should get the users number of refered user', function () {
-      this.User.findOne.calledWith({ _id: this.user_id }).should.equal(true)
+    it('should get the users number of refered user', function (ctx) {
+      ctx.User.findOne.calledWith({ _id: ctx.user_id }).should.equal(true)
     })
 
-    it('should return the features', function () {
-      expect(this.features).to.equal(this.Settings.bonus_features[3])
+    it('should return the features', function (ctx) {
+      expect(ctx.features).to.equal(ctx.Settings.bonus_features[3])
     })
   })
 
   describe('when the user is not at a bonus level', function () {
-    beforeEach(async function () {
-      this.refered_user_count = 0
-      this.Settings.bonus_features = {
+    beforeEach(async function (ctx) {
+      ctx.refered_user_count = 0
+      ctx.Settings.bonus_features = {
         1: {
           collaborators: 3,
           dropbox: false,
           versioning: false,
         },
       }
-      this.User.findOne = sinon.stub().returns({
+      ctx.User.findOne = sinon.stub().returns({
         exec: sinon
           .stub()
-          .resolves({ refered_user_count: this.refered_user_count }),
+          .resolves({ refered_user_count: ctx.refered_user_count }),
       })
 
-      this.features = await this.ReferalFeatures.promises.getBonusFeatures(
-        this.user_id
+      ctx.features = await ctx.ReferalFeatures.promises.getBonusFeatures(
+        ctx.user_id
       )
     })
 
-    it('should get the users number of refered user', function () {
-      this.User.findOne.calledWith({ _id: this.user_id }).should.equal(true)
+    it('should get the users number of refered user', function (ctx) {
+      ctx.User.findOne.calledWith({ _id: ctx.user_id }).should.equal(true)
     })
 
-    it('should return an empty feature set', function () {
-      expect(this.features).to.be.empty
+    it('should return an empty feature set', function (ctx) {
+      expect(ctx.features).to.be.empty
     })
   })
 })

+ 212 - 159
services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs

@@ -1,19 +1,21 @@
-const Path = require('path')
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { ObjectId } = require('mongodb-legacy')
-const { assert, expect } = require('chai')
-const MockRequest = require('../helpers/MockRequest')
-const MockResponse = require('../helpers/MockResponse')
+import { vi, assert, expect } from 'vitest'
+import Path from 'path'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+import MockRequest from '../helpers/MockRequest.js'
+import MockResponse from '../helpers/MockResponse.js'
+
+const { ObjectId } = mongodb
 
 const MODULE_PATH = Path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/Features/SplitTests/SplitTestHandler'
 )
 
 describe('SplitTestHandler', function () {
-  beforeEach(function () {
-    this.splitTests = [
+  let Features
+  beforeEach(async function (ctx) {
+    ctx.splitTests = [
       makeSplitTest('active-test', { versionNumber: 2 }),
       makeSplitTest('not-active-test', { active: false }),
       makeSplitTest('legacy-test'),
@@ -23,72 +25,119 @@ describe('SplitTestHandler', function () {
         versionNumber: 2,
       }),
     ]
-    this.cachedSplitTests = new Map()
-    for (const splitTest of this.splitTests) {
-      this.cachedSplitTests.set(splitTest.name, splitTest)
+    ctx.cachedSplitTests = new Map()
+    for (const splitTest of ctx.splitTests) {
+      ctx.cachedSplitTests.set(splitTest.name, splitTest)
     }
 
-    this.SplitTest = {
+    ctx.SplitTest = {
       find: sinon.stub().returns({
-        exec: sinon.stub().resolves(this.splitTests),
+        exec: sinon.stub().resolves(ctx.splitTests),
       }),
     }
 
-    this.SplitTestCache = {
+    ctx.SplitTestCache = {
       get: sinon.stub().resolves({}),
     }
-    this.SplitTestCache.get.resolves(this.cachedSplitTests)
-    this.Settings = {
+    ctx.SplitTestCache.get.resolves(ctx.cachedSplitTests)
+    ctx.Settings = {
       moduleImportSequence: [],
       overleaf: {},
       devToolbar: {
         enabled: false,
       },
     }
-    this.AnalyticsManager = {
+    ctx.AnalyticsManager = {
       getIdsFromSession: sinon.stub(),
       setUserPropertyForAnalyticsId: sinon.stub().resolves(),
     }
-    this.LocalsHelper = {
+    ctx.LocalsHelper = {
       setSplitTestVariant: sinon.stub(),
       setSplitTestInfo: sinon.stub(),
     }
-    this.SplitTestSessionHandler = {
+    ctx.SplitTestSessionHandler = {
       collectSessionStats: sinon.stub(),
       getCachedVariant: sinon.stub(),
       setVariantInCache: sinon.stub(),
     }
-    this.SplitTestUserGetter = {
+    ctx.SplitTestUserGetter = {
       promises: {
         getUser: sinon.stub().resolves(null),
       },
     }
-    this.SessionManager = {
+    ctx.SessionManager = {
       isUserLoggedIn: sinon.stub().returns(false),
     }
 
-    this.SplitTestHandler = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '../User/UserGetter': this.UserGetter,
-        './SplitTestCache': this.SplitTestCache,
-        '../../models/SplitTest': { SplitTest: this.SplitTest },
-        '../User/UserUpdater': {},
-        '../Analytics/AnalyticsManager': this.AnalyticsManager,
-        './LocalsHelper': this.LocalsHelper,
-        './SplitTestSessionHandler': this.SplitTestSessionHandler,
-        './SplitTestUserGetter': this.SplitTestUserGetter,
-        '../Authentication/SessionManager': this.SessionManager,
-        '@overleaf/settings': this.Settings,
-      },
-    })
+    Features = {
+      hasFeature: vi.fn().mockReturnValue(true),
+    }
+
+    vi.doMock('../../../../app/src/infrastructure/Features', () => ({
+      default: Features,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/SplitTests/SplitTestCache', () => ({
+      default: ctx.SplitTestCache,
+    }))
+
+    vi.doMock('../../../../app/src/models/SplitTest', () => ({
+      SplitTest: ctx.SplitTest,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
+      default: {},
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager',
+      () => ({
+        default: ctx.AnalyticsManager,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/SplitTests/LocalsHelper', () => ({
+      default: ctx.LocalsHelper,
+    }))
 
-    this.req = new MockRequest()
-    this.res = new MockResponse()
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestSessionHandler',
+      () => ({
+        default: ctx.SplitTestSessionHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestUserGetter',
+      () => ({
+        default: ctx.SplitTestUserGetter,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.Settings,
+    }))
+
+    ctx.SplitTestHandler = (await import(MODULE_PATH)).default
+
+    ctx.req = new MockRequest()
+    ctx.res = new MockResponse()
   })
 
   describe('with an existing user', function () {
-    beforeEach(async function () {
-      this.user = {
+    beforeEach(async function (ctx) {
+      ctx.user = {
         _id: new ObjectId(),
         splitTests: {
           'active-test': [
@@ -110,53 +159,53 @@ describe('SplitTestHandler', function () {
           ],
         },
       }
-      this.SplitTestUserGetter.promises.getUser.resolves(this.user)
-      this.SessionManager.isUserLoggedIn.returns(true)
-      this.assignments =
-        await this.SplitTestHandler.promises.getActiveAssignmentsForUser(
-          this.user._id
+      ctx.SplitTestUserGetter.promises.getUser.resolves(ctx.user)
+      ctx.SessionManager.isUserLoggedIn.returns(true)
+      ctx.assignments =
+        await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
+          ctx.user._id
         )
-      this.explicitAssignments =
-        await this.SplitTestHandler.promises.getActiveAssignmentsForUser(
-          this.user._id,
+      ctx.explicitAssignments =
+        await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
+          ctx.user._id,
           false,
           true
         )
-      this.assignedToActiveTest =
-        await this.SplitTestHandler.promises.hasUserBeenAssignedToVariant(
-          this.req,
-          this.user._id,
+      ctx.assignedToActiveTest =
+        await ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant(
+          ctx.req,
+          ctx.user._id,
           'active-test',
           'variant-1'
         )
-      this.assignedToActiveTestAnyVersion =
-        await this.SplitTestHandler.promises.hasUserBeenAssignedToVariant(
-          this.req,
-          this.user._id,
+      ctx.assignedToActiveTestAnyVersion =
+        await ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant(
+          ctx.req,
+          ctx.user._id,
           'active-test',
           'variant-1',
           true
         )
     })
 
-    it('handles the legacy assignment format', function () {
-      expect(this.assignments['legacy-test']).to.deep.equal({
+    it('handles the legacy assignment format', function (ctx) {
+      expect(ctx.assignments['legacy-test']).to.deep.equal({
         variantName: 'variant-1',
         phase: 'release',
         versionNumber: 1,
       })
     })
 
-    it('returns the current assignment for each active test', function () {
-      expect(this.assignments['active-test']).to.deep.equal({
+    it('returns the current assignment for each active test', function (ctx) {
+      expect(ctx.assignments['active-test']).to.deep.equal({
         variantName: 'variant-1',
         phase: 'release',
         versionNumber: 2,
       })
     })
 
-    it('returns the explicit assignment for each active test', function () {
-      expect(this.explicitAssignments['active-test']).to.deep.equal({
+    it('returns the explicit assignment for each active test', function (ctx) {
+      expect(ctx.explicitAssignments['active-test']).to.deep.equal({
         variantName: 'variant-1',
         phase: 'release',
         versionNumber: 2,
@@ -164,74 +213,74 @@ describe('SplitTestHandler', function () {
       })
     })
 
-    it('returns the current assignment for tests with analytics disabled', function () {
-      expect(this.assignments['no-analytics-test-1']).to.deep.equal({
+    it('returns the current assignment for tests with analytics disabled', function (ctx) {
+      expect(ctx.assignments['no-analytics-test-1']).to.deep.equal({
         variantName: 'variant-1',
         phase: 'release',
         versionNumber: 1,
       })
     })
 
-    it('returns the current assignment for tests with analytics disabled that had previous assignments', function () {
-      expect(this.assignments['no-analytics-test-2']).to.deep.equal({
+    it('returns the current assignment for tests with analytics disabled that had previous assignments', function (ctx) {
+      expect(ctx.assignments['no-analytics-test-2']).to.deep.equal({
         variantName: 'variant-1',
         phase: 'release',
         versionNumber: 2,
       })
     })
 
-    it('shows user has been assigned to previous version of variant', function () {
-      expect(this.assignedToActiveTestAnyVersion).to.be.true
+    it('shows user has been assigned to previous version of variant', function (ctx) {
+      expect(ctx.assignedToActiveTestAnyVersion).to.be.true
     })
 
-    it('shows user has not been explicitly assigned to current version of variant', function () {
-      expect(this.assignedToActiveTest).to.be.false
+    it('shows user has not been explicitly assigned to current version of variant', function (ctx) {
+      expect(ctx.assignedToActiveTest).to.be.false
     })
 
-    it('does not return assignments for unknown tests', function () {
-      expect(this.assignments).not.to.have.property('unknown-test')
+    it('does not return assignments for unknown tests', function (ctx) {
+      expect(ctx.assignments).not.to.have.property('unknown-test')
     })
   })
 
   describe('with an non-existent user', function () {
-    beforeEach(async function () {
+    beforeEach(async function (ctx) {
       const unknownUserId = new ObjectId()
-      this.assignments =
-        await this.SplitTestHandler.promises.getActiveAssignmentsForUser(
+      ctx.assignments =
+        await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
           unknownUserId
         )
     })
 
-    it('returns empty assignments', function () {
-      expect(this.assignments).to.deep.equal({})
+    it('returns empty assignments', function (ctx) {
+      expect(ctx.assignments).to.deep.equal({})
     })
   })
 
   describe('with a user without assignments', function () {
-    beforeEach(async function () {
-      this.user = { _id: new ObjectId() }
-      this.SplitTestUserGetter.promises.getUser.resolves(this.user)
-      this.assignments =
-        await this.SplitTestHandler.promises.getActiveAssignmentsForUser(
-          this.user._id
+    beforeEach(async function (ctx) {
+      ctx.user = { _id: new ObjectId() }
+      ctx.SplitTestUserGetter.promises.getUser.resolves(ctx.user)
+      ctx.assignments =
+        await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
+          ctx.user._id
         )
-      this.explicitAssignments =
-        await this.SplitTestHandler.promises.getActiveAssignmentsForUser(
-          this.user._id,
+      ctx.explicitAssignments =
+        await ctx.SplitTestHandler.promises.getActiveAssignmentsForUser(
+          ctx.user._id,
           false,
           true
         )
-      this.assignedToActiveTest =
-        await this.SplitTestHandler.promises.hasUserBeenAssignedToVariant(
-          this.req,
-          this.user._id,
+      ctx.assignedToActiveTest =
+        await ctx.SplitTestHandler.promises.hasUserBeenAssignedToVariant(
+          ctx.req,
+          ctx.user._id,
           'active-test',
           'variant-1'
         )
     })
 
-    it('returns current assignments', function () {
-      expect(this.assignments).to.deep.equal({
+    it('returns current assignments', function (ctx) {
+      expect(ctx.assignments).to.deep.equal({
         'active-test': {
           phase: 'release',
           variantName: 'variant-1',
@@ -260,23 +309,23 @@ describe('SplitTestHandler', function () {
       })
     })
 
-    it('shows user not assigned to variant', function () {
-      expect(this.assignedToActiveTest).to.be.false
+    it('shows user not assigned to variant', function (ctx) {
+      expect(ctx.assignedToActiveTest).to.be.false
     })
   })
 
   describe('with settings overrides', function () {
-    beforeEach(function () {
-      this.Settings.splitTestOverrides = {
+    beforeEach(function (ctx) {
+      ctx.Settings.splitTestOverrides = {
         'my-test-name': 'foo-1',
       }
     })
 
-    it('should not use the override when in SaaS mode', async function () {
-      this.AnalyticsManager.getIdsFromSession.returns({
+    it('should not use the override when in SaaS mode', async function (ctx) {
+      ctx.AnalyticsManager.getIdsFromSession.returns({
         userId: 'abc123abc123',
       })
-      this.SplitTestCache.get.resolves(
+      ctx.SplitTestCache.get.resolves(
         new Map([
           [
             'my-test-name',
@@ -300,37 +349,39 @@ describe('SplitTestHandler', function () {
         ])
       )
 
-      const assignment = await this.SplitTestHandler.promises.getAssignment(
-        this.req,
-        this.res,
+      const assignment = await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
         'my-test-name'
       )
 
       assert.equal('100-percent-variant', assignment.variant)
     })
 
-    it('should use the override when not in SaaS mode', async function () {
-      this.Settings.splitTestOverrides = {
+    it('should use the override when not in SaaS mode', async function (ctx) {
+      ctx.Settings.splitTestOverrides = {
         'my-test-name': 'foo-1',
       }
-      this.Settings.overleaf = undefined
-
-      const assignment = await this.SplitTestHandler.promises.getAssignment(
-        this.req,
-        this.res,
+      ctx.Settings.overleaf = undefined
+      Features.hasFeature.mockImplementation(function (feature) {
+        return feature !== 'saas'
+      })
+      const assignment = await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
         'my-test-name'
       )
 
       assert.equal('foo-1', assignment.variant)
     })
 
-    it('should use default when not in SaaS mode and no override is provided', async function () {
-      this.Settings.splitTestOverrides = {}
-      this.Settings.overleaf = undefined
+    it('should use default when not in SaaS mode and no override is provided', async function (ctx) {
+      ctx.Settings.splitTestOverrides = {}
+      ctx.Settings.overleaf = undefined
 
-      const assignment = await this.SplitTestHandler.promises.getAssignment(
-        this.req,
-        this.res,
+      const assignment = await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
         'my-test-name'
       )
 
@@ -339,34 +390,36 @@ describe('SplitTestHandler', function () {
   })
 
   describe('save assignments to res.locals', function () {
-    beforeEach(function () {
-      this.AnalyticsManager.getIdsFromSession.returns({
+    beforeEach(function (ctx) {
+      ctx.AnalyticsManager.getIdsFromSession.returns({
         userId: 'abc123abc123',
       })
     })
 
-    it('when in SaaS mode it should set the variant', async function () {
-      await this.SplitTestHandler.promises.getAssignment(
-        this.req,
-        this.res,
+    it('when in SaaS mode it should set the variant', async function (ctx) {
+      await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
         'active-test'
       )
-      expect(this.LocalsHelper.setSplitTestVariant).to.have.been.calledWith(
-        this.res.locals,
+      expect(ctx.LocalsHelper.setSplitTestVariant).to.have.been.calledWith(
+        ctx.res.locals,
         'active-test',
         'variant-1'
       )
     })
 
-    it('when not in SaaS mode it should set the default variant', async function () {
-      this.Settings.overleaf = undefined
-      await this.SplitTestHandler.promises.getAssignment(
-        this.req,
-        this.res,
+    it('when not in SaaS mode it should set the default variant', async function (ctx) {
+      Features.hasFeature.mockImplementation(function (feature) {
+        return feature !== 'saas'
+      })
+      await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
         'active-test'
       )
-      expect(this.LocalsHelper.setSplitTestVariant).to.have.been.calledWith(
-        this.res.locals,
+      expect(ctx.LocalsHelper.setSplitTestVariant).to.have.been.calledWith(
+        ctx.res.locals,
         'active-test',
         'default'
       )
@@ -374,62 +427,62 @@ describe('SplitTestHandler', function () {
   })
 
   describe('variant user limits', function () {
-    beforeEach(function () {
-      this.AnalyticsManager.getIdsFromSession.returns({
+    beforeEach(function (ctx) {
+      ctx.AnalyticsManager.getIdsFromSession.returns({
         userId: 'abc123abc123',
       })
-      this.SplitTestUserGetter.promises.getUser.resolves({
+      ctx.SplitTestUserGetter.promises.getUser.resolves({
         _id: new ObjectId('abc123abc123abc123abc123'),
         splitTests: {},
       })
     })
 
-    it('should assign to variant when under limit', async function () {
-      this.cachedSplitTests.set(
+    it('should assign to variant when under limit', async function (ctx) {
+      ctx.cachedSplitTests.set(
         'active-test',
         makeSplitTest('active-test', { userLimit: 100, userCount: 50 })
       )
 
-      const assignment = await this.SplitTestHandler.promises.getAssignment(
-        this.req,
-        this.res,
+      const assignment = await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
         'active-test'
       )
 
       expect(assignment.variant).to.equal('variant-1')
     })
 
-    it('should assign to default when limit reached', async function () {
-      this.cachedSplitTests.set(
+    it('should assign to default when limit reached', async function (ctx) {
+      ctx.cachedSplitTests.set(
         'active-test',
         makeSplitTest('active-test', { userLimit: 100, userCount: 100 })
       )
 
-      const assignment = await this.SplitTestHandler.promises.getAssignment(
-        this.req,
-        this.res,
+      const assignment = await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
         'active-test'
       )
 
       expect(assignment.variant).to.equal('default')
     })
 
-    it('should not apply limits when no limit configured', async function () {
-      const assignment = await this.SplitTestHandler.promises.getAssignment(
-        this.req,
-        this.res,
+    it('should not apply limits when no limit configured', async function (ctx) {
+      const assignment = await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
         'active-test'
       )
 
       expect(assignment.variant).to.equal('variant-1')
     })
 
-    it('should allow already assigned users even when limit reached', async function () {
-      this.cachedSplitTests.set(
+    it('should allow already assigned users even when limit reached', async function (ctx) {
+      ctx.cachedSplitTests.set(
         'active-test',
         makeSplitTest('active-test', { userLimit: 100, userCount: 100 })
       )
-      this.SplitTestUserGetter.promises.getUser.resolves({
+      ctx.SplitTestUserGetter.promises.getUser.resolves({
         _id: new ObjectId('abc123abc123abc123abc123'),
         splitTests: {
           'active-test': [
@@ -443,24 +496,24 @@ describe('SplitTestHandler', function () {
         },
       })
 
-      const assignment = await this.SplitTestHandler.promises.getAssignment(
-        this.req,
-        this.res,
+      const assignment = await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
         'active-test'
       )
 
       expect(assignment.variant).to.equal('variant-1')
     })
 
-    it('should assign to default if userCount is undefined', async function () {
-      this.cachedSplitTests.set(
+    it('should assign to default if userCount is undefined', async function (ctx) {
+      ctx.cachedSplitTests.set(
         'active-test',
         makeSplitTest('active-test', { userLimit: 100, userCount: undefined })
       )
 
-      const assignment = await this.SplitTestHandler.promises.getAssignment(
-        this.req,
-        this.res,
+      const assignment = await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
         'active-test'
       )
 

+ 42 - 29
services/web/test/unit/src/SplitTests/SplitTestSessionHandler.test.mjs

@@ -1,23 +1,24 @@
-const Path = require('path')
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const { ObjectId } = require('mongodb-legacy')
+import { vi, expect } from 'vitest'
+import Path from 'path'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+
+const { ObjectId } = mongodb
 
 const MODULE_PATH = Path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/Features/SplitTests/SplitTestSessionHandler'
 )
 
 describe('SplitTestSessionHandler', function () {
-  beforeEach(function () {
-    this.SplitTestCache = {
+  beforeEach(async function (ctx) {
+    ctx.SplitTestCache = {
       get: sinon.stub().resolves(),
     }
-    this.SplitTestUserGetter = {}
-    this.Metrics = {}
+    ctx.SplitTestUserGetter = {}
+    ctx.Metrics = {}
 
-    this.SplitTestCache.get = sinon.stub().resolves(
+    ctx.SplitTestCache.get = sinon.stub().resolves(
       new Map(
         Object.entries({
           'anon-test-1': {
@@ -55,17 +56,29 @@ describe('SplitTestSessionHandler', function () {
       )
     )
 
-    this.SplitTestSessionHandler = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        './SplitTestCache': this.SplitTestCache,
-        './SplitTestUserGetter': this.SplitTestUserGetter,
-        '@overleaf/metrics': this.Metrics,
-        'mongodb-legacy': { ObjectId },
-      },
-    })
+    vi.doMock('../../../../app/src/Features/SplitTests/SplitTestCache', () => ({
+      default: ctx.SplitTestCache,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestUserGetter',
+      () => ({
+        default: ctx.SplitTestUserGetter,
+      })
+    )
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: ctx.Metrics,
+    }))
+
+    vi.doMock('mongodb-legacy', () => ({
+      default: { ObjectId },
+    }))
+
+    ctx.SplitTestSessionHandler = (await import(MODULE_PATH)).default
   })
 
-  it('should read from the splitTests field', async function () {
+  it('should read from the splitTests field', async function (ctx) {
     const session = {
       splitTests: {
         'anon-test-1': [
@@ -95,7 +108,7 @@ describe('SplitTestSessionHandler', function () {
     }
 
     const assignments =
-      await this.SplitTestSessionHandler.promises.getAssignments(session)
+      await ctx.SplitTestSessionHandler.promises.getAssignments(session)
     expect(assignments).to.deep.equal({
       'anon-test-1': [
         {
@@ -122,8 +135,8 @@ describe('SplitTestSessionHandler', function () {
     })
   })
 
-  it('should read from the sta field', async function () {
-    this.SplitTestCache.get = sinon.stub().resolves(
+  it('should read from the sta field', async function (ctx) {
+    ctx.SplitTestCache.get = sinon.stub().resolves(
       new Map(
         Object.entries({
           'anon-test-1': {
@@ -165,7 +178,7 @@ describe('SplitTestSessionHandler', function () {
     }
 
     const assignments =
-      await this.SplitTestSessionHandler.promises.getAssignments(session)
+      await ctx.SplitTestSessionHandler.promises.getAssignments(session)
     expect(assignments).to.deep.equal({
       'anon-test-1': [
         {
@@ -192,8 +205,8 @@ describe('SplitTestSessionHandler', function () {
     })
   })
 
-  it('should deduplicate entries from the sta field', async function () {
-    this.SplitTestCache.get = sinon.stub().resolves(
+  it('should deduplicate entries from the sta field', async function (ctx) {
+    ctx.SplitTestCache.get = sinon.stub().resolves(
       new Map(
         Object.entries({
           'anon-test-1': {
@@ -235,7 +248,7 @@ describe('SplitTestSessionHandler', function () {
     }
 
     const assignments =
-      await this.SplitTestSessionHandler.promises.getAssignments(session)
+      await ctx.SplitTestSessionHandler.promises.getAssignments(session)
     expect(assignments).to.deep.equal({
       'anon-test-1': [
         {
@@ -262,7 +275,7 @@ describe('SplitTestSessionHandler', function () {
     })
   })
 
-  it('should merge assignments from both splitTests and sta fields', async function () {
+  it('should merge assignments from both splitTests and sta fields', async function (ctx) {
     const session = {
       splitTests: {
         'anon-test-1': [
@@ -286,7 +299,7 @@ describe('SplitTestSessionHandler', function () {
     }
 
     const assignments =
-      await this.SplitTestSessionHandler.promises.getAssignments(session)
+      await ctx.SplitTestSessionHandler.promises.getAssignments(session)
     expect(assignments).to.deep.equal({
       'anon-test-1': [
         {

+ 33 - 34
services/web/test/unit/src/Subscription/FeaturesHelper.test.mjs

@@ -1,112 +1,111 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
+import { expect } from 'vitest'
 
 const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesHelper'
 
 describe('FeaturesHelper', function () {
-  beforeEach(function () {
-    this.FeaturesHelper = SandboxedModule.require(MODULE_PATH)
+  beforeEach(async function (ctx) {
+    ctx.FeaturesHelper = (await import(MODULE_PATH)).default
   })
 
   describe('mergeFeatures', function () {
-    it('should prefer priority over standard for compileGroup', function () {
+    it('should prefer priority over standard for compileGroup', function (ctx) {
       expect(
-        this.FeaturesHelper.mergeFeatures(
+        ctx.FeaturesHelper.mergeFeatures(
           { compileGroup: 'priority' },
           { compileGroup: 'standard' }
         )
       ).to.deep.equal({ compileGroup: 'priority' })
       expect(
-        this.FeaturesHelper.mergeFeatures(
+        ctx.FeaturesHelper.mergeFeatures(
           { compileGroup: 'standard' },
           { compileGroup: 'priority' }
         )
       ).to.deep.equal({ compileGroup: 'priority' })
       expect(
-        this.FeaturesHelper.mergeFeatures(
+        ctx.FeaturesHelper.mergeFeatures(
           { compileGroup: 'priority' },
           { compileGroup: 'priority' }
         )
       ).to.deep.equal({ compileGroup: 'priority' })
       expect(
-        this.FeaturesHelper.mergeFeatures(
+        ctx.FeaturesHelper.mergeFeatures(
           { compileGroup: 'standard' },
           { compileGroup: 'standard' }
         )
       ).to.deep.equal({ compileGroup: 'standard' })
     })
 
-    it('should prefer -1 over any other for collaborators', function () {
+    it('should prefer -1 over any other for collaborators', function (ctx) {
       expect(
-        this.FeaturesHelper.mergeFeatures(
+        ctx.FeaturesHelper.mergeFeatures(
           { collaborators: -1 },
           { collaborators: 10 }
         )
       ).to.deep.equal({ collaborators: -1 })
       expect(
-        this.FeaturesHelper.mergeFeatures(
+        ctx.FeaturesHelper.mergeFeatures(
           { collaborators: 10 },
           { collaborators: -1 }
         )
       ).to.deep.equal({ collaborators: -1 })
       expect(
-        this.FeaturesHelper.mergeFeatures(
+        ctx.FeaturesHelper.mergeFeatures(
           { collaborators: 4 },
           { collaborators: 10 }
         )
       ).to.deep.equal({ collaborators: 10 })
     })
 
-    it('should prefer the higher of compileTimeout', function () {
+    it('should prefer the higher of compileTimeout', function (ctx) {
       expect(
-        this.FeaturesHelper.mergeFeatures(
+        ctx.FeaturesHelper.mergeFeatures(
           { compileTimeout: 20 },
           { compileTimeout: 10 }
         )
       ).to.deep.equal({ compileTimeout: 20 })
       expect(
-        this.FeaturesHelper.mergeFeatures(
+        ctx.FeaturesHelper.mergeFeatures(
           { compileTimeout: 10 },
           { compileTimeout: 20 }
         )
       ).to.deep.equal({ compileTimeout: 20 })
     })
 
-    it('should prefer the true over false for other keys', function () {
+    it('should prefer the true over false for other keys', function (ctx) {
       expect(
-        this.FeaturesHelper.mergeFeatures({ github: true }, { github: false })
+        ctx.FeaturesHelper.mergeFeatures({ github: true }, { github: false })
       ).to.deep.equal({ github: true })
       expect(
-        this.FeaturesHelper.mergeFeatures({ github: false }, { github: true })
+        ctx.FeaturesHelper.mergeFeatures({ github: false }, { github: true })
       ).to.deep.equal({ github: true })
       expect(
-        this.FeaturesHelper.mergeFeatures({ github: true }, { github: true })
+        ctx.FeaturesHelper.mergeFeatures({ github: true }, { github: true })
       ).to.deep.equal({ github: true })
       expect(
-        this.FeaturesHelper.mergeFeatures({ github: false }, { github: false })
+        ctx.FeaturesHelper.mergeFeatures({ github: false }, { github: false })
       ).to.deep.equal({ github: false })
     })
   })
 
   describe('computeFeatureSet', function () {
-    it('should handle only one featureSet', function () {
+    it('should handle only one featureSet', function (ctx) {
       expect(
-        this.FeaturesHelper.computeFeatureSet([
+        ctx.FeaturesHelper.computeFeatureSet([
           { github: true, feat1: true, feat2: false },
         ])
       ).to.deep.equal({ github: true, feat1: true, feat2: false })
     })
-    it('should handle an empty array of featureSets', function () {
-      expect(this.FeaturesHelper.computeFeatureSet([])).to.deep.equal({})
+    it('should handle an empty array of featureSets', function (ctx) {
+      expect(ctx.FeaturesHelper.computeFeatureSet([])).to.deep.equal({})
     })
 
-    it('should handle 3+ featureSets', function () {
+    it('should handle 3+ featureSets', function (ctx) {
       const featureSets = [
         { github: true, feat1: false, feat2: false },
         { github: false, feat1: true, feat2: false, feat3: false },
         { github: false, feat1: false, feat2: true, feat4: true },
       ]
-      expect(this.FeaturesHelper.computeFeatureSet(featureSets)).to.deep.equal({
+      expect(ctx.FeaturesHelper.computeFeatureSet(featureSets)).to.deep.equal({
         github: true,
         feat1: true,
         feat2: true,
@@ -117,34 +116,34 @@ describe('FeaturesHelper', function () {
   })
 
   describe('isFeatureSetBetter', function () {
-    it('simple comparisons', function () {
-      const result1 = this.FeaturesHelper.isFeatureSetBetter(
+    it('simple comparisons', function (ctx) {
+      const result1 = ctx.FeaturesHelper.isFeatureSetBetter(
         { dropbox: true },
         { dropbox: false }
       )
       expect(result1).to.be.true
 
-      const result2 = this.FeaturesHelper.isFeatureSetBetter(
+      const result2 = ctx.FeaturesHelper.isFeatureSetBetter(
         { dropbox: false },
         { dropbox: true }
       )
       expect(result2).to.be.false
     })
 
-    it('compound comparisons with same features', function () {
-      const result1 = this.FeaturesHelper.isFeatureSetBetter(
+    it('compound comparisons with same features', function (ctx) {
+      const result1 = ctx.FeaturesHelper.isFeatureSetBetter(
         { collaborators: 9, dropbox: true },
         { collaborators: 10, dropbox: true }
       )
       expect(result1).to.be.false
 
-      const result2 = this.FeaturesHelper.isFeatureSetBetter(
+      const result2 = ctx.FeaturesHelper.isFeatureSetBetter(
         { collaborators: -1, dropbox: true },
         { collaborators: 10, dropbox: true }
       )
       expect(result2).to.be.true
 
-      const result3 = this.FeaturesHelper.isFeatureSetBetter(
+      const result3 = ctx.FeaturesHelper.isFeatureSetBetter(
         { collaborators: -1, compileTimeout: 60, dropbox: true },
         { collaborators: 10, compileTimeout: 60, dropbox: true }
       )

+ 204 - 174
services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs

@@ -1,39 +1,38 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const sinon = require('sinon')
-const { ObjectId } = require('mongodb-legacy')
-const {
-  AI_ADD_ON_CODE,
-} = require('../../../../app/src/Features/Subscription/AiHelper')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.js'
+
+const { ObjectId } = mongodb
 
 const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesUpdater'
 
 describe('FeaturesUpdater', function () {
-  beforeEach(function () {
-    this.v1UserId = 12345
-    this.user = {
+  beforeEach(async function (ctx) {
+    ctx.v1UserId = 12345
+    ctx.user = {
       _id: new ObjectId(),
       features: {},
-      overleaf: { id: this.v1UserId },
+      overleaf: { id: ctx.v1UserId },
     }
-    this.aiAddOn = { addOnCode: AI_ADD_ON_CODE, quantity: 1 }
-    this.subscriptions = {
+    ctx.aiAddOn = { addOnCode: AI_ADD_ON_CODE, quantity: 1 }
+    ctx.subscriptions = {
       individual: { planCode: 'individual-plan' },
       group1: { planCode: 'group-plan-1', groupPlan: true },
       group2: { planCode: 'group-plan-2', groupPlan: true },
       noDropbox: { planCode: 'no-dropbox' },
       individualPlusAiAddOn: {
         planCode: 'individual-plan',
-        addOns: [this.aiAddOn],
+        addOns: [ctx.aiAddOn],
       },
       groupPlusAiAddOn: {
         planCode: 'group-plan-1',
         groupPlan: true,
-        addOns: [this.aiAddOn],
+        addOns: [ctx.aiAddOn],
       },
     }
 
-    this.UserFeaturesUpdater = {
+    ctx.UserFeaturesUpdater = {
       promises: {
         updateFeatures: sinon
           .stub()
@@ -41,20 +40,20 @@ describe('FeaturesUpdater', function () {
       },
     }
 
-    this.SubscriptionLocator = {
+    ctx.SubscriptionLocator = {
       promises: {
         getUsersSubscription: sinon.stub(),
         getGroupSubscriptionsMemberOf: sinon.stub(),
       },
     }
-    this.SubscriptionLocator.promises.getUsersSubscription
-      .withArgs(this.user._id)
-      .resolves(this.subscriptions.individual)
-    this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
-      .withArgs(this.user._id)
-      .resolves([this.subscriptions.group1, this.subscriptions.group2])
-
-    this.Settings = {
+    ctx.SubscriptionLocator.promises.getUsersSubscription
+      .withArgs(ctx.user._id)
+      .resolves(ctx.subscriptions.individual)
+    ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
+      .withArgs(ctx.user._id)
+      .resolves([ctx.subscriptions.group1, ctx.subscriptions.group2])
+
+    ctx.Settings = {
       defaultFeatures: { default: 'features' },
       plans: [
         { planCode: 'individual-plan', features: { individual: 'features' } },
@@ -75,19 +74,19 @@ describe('FeaturesUpdater', function () {
       },
     }
 
-    this.ReferalFeatures = {
+    ctx.ReferalFeatures = {
       promises: {
         getBonusFeatures: sinon.stub().resolves({ bonus: 'features' }),
       },
     }
-    this.V1SubscriptionManager = {
+    ctx.V1SubscriptionManager = {
       getGrandfatheredFeaturesForV1User: sinon.stub(),
     }
-    this.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
-      .withArgs(this.v1UserId)
+    ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
+      .withArgs(ctx.v1UserId)
       .returns({ grandfathered: 'features' })
 
-    this.InstitutionsFeatures = {
+    ctx.InstitutionsFeatures = {
       promises: {
         getInstitutionsFeatures: sinon
           .stub()
@@ -95,113 +94,156 @@ describe('FeaturesUpdater', function () {
       },
     }
 
-    this.UserGetter = {
+    ctx.UserGetter = {
       promises: {
         getUser: sinon.stub().resolves(null),
       },
     }
-    this.UserGetter.promises.getUser.withArgs(this.user._id).resolves(this.user)
-    this.UserGetter.promises.getUser
-      .withArgs({ 'overleaf.id': this.v1UserId })
-      .resolves(this.user)
+    ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
+    ctx.UserGetter.promises.getUser
+      .withArgs({ 'overleaf.id': ctx.v1UserId })
+      .resolves(ctx.user)
 
-    this.AnalyticsManager = {
+    ctx.AnalyticsManager = {
       setUserPropertyForUserInBackground: sinon.stub(),
     }
-    this.Modules = {
+    ctx.Modules = {
       promises: { hooks: { fire: sinon.stub().resolves() } },
     }
-    this.Queues = {
+    ctx.Queues = {
       getQueue: sinon.stub().returns({
         add: sinon.stub().resolves(),
       }),
     }
 
-    this.FeaturesUpdater = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        './UserFeaturesUpdater': this.UserFeaturesUpdater,
-        './SubscriptionLocator': this.SubscriptionLocator,
-        '@overleaf/settings': this.Settings,
-        '../Referal/ReferalFeatures': this.ReferalFeatures,
-        './V1SubscriptionManager': this.V1SubscriptionManager,
-        '../Institutions/InstitutionsFeatures': this.InstitutionsFeatures,
-        '../User/UserGetter': this.UserGetter,
-        '../Analytics/AnalyticsManager': this.AnalyticsManager,
-        '../../infrastructure/Modules': this.Modules,
-        '../../infrastructure/Queues': this.Queues,
-        '../../models/Subscription': {},
-      },
-    })
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/UserFeaturesUpdater',
+      () => ({
+        default: ctx.UserFeaturesUpdater,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: ctx.SubscriptionLocator,
+      })
+    )
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.Settings,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Referal/ReferalFeatures', () => ({
+      default: ctx.ReferalFeatures,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/V1SubscriptionManager',
+      () => ({
+        default: ctx.V1SubscriptionManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Institutions/InstitutionsFeatures',
+      () => ({
+        default: ctx.InstitutionsFeatures,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager',
+      () => ({
+        default: ctx.AnalyticsManager,
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: ctx.Modules,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
+      default: ctx.Queues,
+    }))
+
+    vi.doMock('../../../../app/src/models/Subscription', () => ({}))
+
+    ctx.FeaturesUpdater = (await import(MODULE_PATH)).default
   })
 
   describe('computeFeatures', function () {
     describe('when userFeaturesDisabled is true for individual plan', function () {
-      beforeEach(function () {
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user._id)
+      beforeEach(function (ctx) {
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user._id)
           .resolves({
             planCode: 'individual-plan',
             userFeaturesDisabled: true,
             groupPlan: false,
-            addOns: [this.aiAddOn],
+            addOns: [ctx.aiAddOn],
           })
       })
 
-      it('removes all individual plan features', async function () {
-        const features = await this.FeaturesUpdater.promises.computeFeatures(
-          this.user._id
+      it('removes all individual plan features', async function (ctx) {
+        const features = await ctx.FeaturesUpdater.promises.computeFeatures(
+          ctx.user._id
         )
         expect(features).to.deep.equal({ default: 'features' })
       })
     })
 
     describe('when userFeaturesDisabled is true for group plan', function () {
-      beforeEach(function () {
+      beforeEach(function (ctx) {
         const groupSubscription = {
           planCode: 'group-plan-1',
           userFeaturesDisabled: true,
           groupPlan: true,
-          addOns: [this.aiAddOn],
+          addOns: [ctx.aiAddOn],
         }
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user._id)
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user._id)
           .resolves(groupSubscription)
-        this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
-          .withArgs(this.user._id)
+        ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
+          .withArgs(ctx.user._id)
           .resolves([groupSubscription])
       })
 
-      it('removes all group plan features', async function () {
-        const features = await this.FeaturesUpdater.promises.computeFeatures(
-          this.user._id
+      it('removes all group plan features', async function (ctx) {
+        const features = await ctx.FeaturesUpdater.promises.computeFeatures(
+          ctx.user._id
         )
         expect(features).to.deep.equal({ default: 'features' })
       })
     })
-    beforeEach(function () {
-      this.SubscriptionLocator.promises.getUsersSubscription
-        .withArgs(this.user._id)
+    beforeEach(function (ctx) {
+      ctx.SubscriptionLocator.promises.getUsersSubscription
+        .withArgs(ctx.user._id)
         .resolves(null)
-      this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
-        .withArgs(this.user._id)
+      ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
+        .withArgs(ctx.user._id)
         .resolves([])
-      this.ReferalFeatures.promises.getBonusFeatures.resolves({})
-      this.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
-        .withArgs(this.v1UserId)
+      ctx.ReferalFeatures.promises.getBonusFeatures.resolves({})
+      ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
+        .withArgs(ctx.v1UserId)
         .returns({})
-      this.InstitutionsFeatures.promises.getInstitutionsFeatures.resolves({})
+      ctx.InstitutionsFeatures.promises.getInstitutionsFeatures.resolves({})
     })
 
     describe('individual subscriber', function () {
-      beforeEach(function () {
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user._id)
-          .resolves(this.subscriptions.individual)
+      beforeEach(function (ctx) {
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user._id)
+          .resolves(ctx.subscriptions.individual)
       })
 
-      it('returns the individual features', async function () {
-        const features = await this.FeaturesUpdater.promises.computeFeatures(
-          this.user._id
+      it('returns the individual features', async function (ctx) {
+        const features = await ctx.FeaturesUpdater.promises.computeFeatures(
+          ctx.user._id
         )
         expect(features).to.deep.equal({
           default: 'features',
@@ -211,15 +253,15 @@ describe('FeaturesUpdater', function () {
     })
 
     describe('group admin', function () {
-      beforeEach(function () {
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user._id)
-          .resolves(this.subscriptions.group1)
+      beforeEach(function (ctx) {
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user._id)
+          .resolves(ctx.subscriptions.group1)
       })
 
-      it("doesn't return the group features", async function () {
-        const features = await this.FeaturesUpdater.promises.computeFeatures(
-          this.user._id
+      it("doesn't return the group features", async function (ctx) {
+        const features = await ctx.FeaturesUpdater.promises.computeFeatures(
+          ctx.user._id
         )
         expect(features).to.deep.equal({
           default: 'features',
@@ -228,15 +270,15 @@ describe('FeaturesUpdater', function () {
     })
 
     describe('group member', function () {
-      beforeEach(function () {
-        this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
-          .withArgs(this.user._id)
-          .resolves([this.subscriptions.group1])
+      beforeEach(function (ctx) {
+        ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
+          .withArgs(ctx.user._id)
+          .resolves([ctx.subscriptions.group1])
       })
 
-      it('returns the group features', async function () {
-        const features = await this.FeaturesUpdater.promises.computeFeatures(
-          this.user._id
+      it('returns the group features', async function (ctx) {
+        const features = await ctx.FeaturesUpdater.promises.computeFeatures(
+          ctx.user._id
         )
         expect(features).to.deep.equal({
           default: 'features',
@@ -246,15 +288,15 @@ describe('FeaturesUpdater', function () {
     })
 
     describe('individual subscription + AI add-on', function () {
-      beforeEach(function () {
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user._id)
-          .resolves(this.subscriptions.individualPlusAiAddOn)
+      beforeEach(function (ctx) {
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user._id)
+          .resolves(ctx.subscriptions.individualPlusAiAddOn)
       })
 
-      it('returns the individual features and the AI error assistant', async function () {
-        const features = await this.FeaturesUpdater.promises.computeFeatures(
-          this.user._id
+      it('returns the individual features and the AI error assistant', async function (ctx) {
+        const features = await ctx.FeaturesUpdater.promises.computeFeatures(
+          ctx.user._id
         )
         expect(features).to.deep.equal({
           default: 'features',
@@ -265,15 +307,15 @@ describe('FeaturesUpdater', function () {
     })
 
     describe('group admin + AI add-on', function () {
-      beforeEach(function () {
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user._id)
-          .resolves(this.subscriptions.groupPlusAiAddOn)
+      beforeEach(function (ctx) {
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user._id)
+          .resolves(ctx.subscriptions.groupPlusAiAddOn)
       })
 
-      it('returns the AI error assistant only', async function () {
-        const features = await this.FeaturesUpdater.promises.computeFeatures(
-          this.user._id
+      it('returns the AI error assistant only', async function (ctx) {
+        const features = await ctx.FeaturesUpdater.promises.computeFeatures(
+          ctx.user._id
         )
         expect(features).to.deep.equal({
           default: 'features',
@@ -283,15 +325,15 @@ describe('FeaturesUpdater', function () {
     })
 
     describe('group member + AI add-on', function () {
-      beforeEach(function () {
-        this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
-          .withArgs(this.user._id)
-          .resolves([this.subscriptions.groupPlusAiAddOn])
+      beforeEach(function (ctx) {
+        ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
+          .withArgs(ctx.user._id)
+          .resolves([ctx.subscriptions.groupPlusAiAddOn])
       })
 
-      it('returns the group features without the AI features', async function () {
-        const features = await this.FeaturesUpdater.promises.computeFeatures(
-          this.user._id
+      it('returns the group features without the AI features', async function (ctx) {
+        const features = await ctx.FeaturesUpdater.promises.computeFeatures(
+          ctx.user._id
         )
         expect(features).to.deep.equal({
           default: 'features',
@@ -302,52 +344,43 @@ describe('FeaturesUpdater', function () {
   })
 
   describe('refreshFeatures', function () {
-    it('should return features and featuresChanged', async function () {
+    it('should return features and featuresChanged', async function (ctx) {
       const { features, featuresChanged } =
-        await this.FeaturesUpdater.promises.refreshFeatures(
-          this.user._id,
-          'test'
-        )
+        await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
       expect(features).to.exist
       expect(featuresChanged).to.exist
     })
 
     describe('normally', function () {
-      beforeEach(async function () {
-        await this.FeaturesUpdater.promises.refreshFeatures(
-          this.user._id,
-          'test'
-        )
+      beforeEach(async function (ctx) {
+        await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
       })
 
-      it('should update the user with the merged features', function () {
+      it('should update the user with the merged features', function (ctx) {
         expect(
-          this.UserFeaturesUpdater.promises.updateFeatures
-        ).to.have.been.calledWith(this.user._id, this.Settings.features.all)
+          ctx.UserFeaturesUpdater.promises.updateFeatures
+        ).to.have.been.calledWith(ctx.user._id, ctx.Settings.features.all)
       })
 
-      it('should send the corresponding feature set user property', function () {
+      it('should send the corresponding feature set user property', function (ctx) {
         expect(
-          this.AnalyticsManager.setUserPropertyForUserInBackground
-        ).to.have.been.calledWith(this.user._id, 'feature-set', 'all')
+          ctx.AnalyticsManager.setUserPropertyForUserInBackground
+        ).to.have.been.calledWith(ctx.user._id, 'feature-set', 'all')
       })
     })
 
     describe('with a non-standard feature set', async function () {
-      beforeEach(async function () {
-        this.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
-          .withArgs(this.user._id)
+      beforeEach(async function (ctx) {
+        ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
+          .withArgs(ctx.user._id)
           .resolves(null)
-        await this.FeaturesUpdater.promises.refreshFeatures(
-          this.user._id,
-          'test'
-        )
+        await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
       })
 
-      it('should send mixed feature set user property', function () {
+      it('should send mixed feature set user property', function (ctx) {
         sinon.assert.calledWith(
-          this.AnalyticsManager.setUserPropertyForUserInBackground,
-          this.user._id,
+          ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+          ctx.user._id,
           'feature-set',
           'mixed'
         )
@@ -355,21 +388,18 @@ describe('FeaturesUpdater', function () {
     })
 
     describe('when losing dropbox feature', async function () {
-      beforeEach(async function () {
-        this.user.features = { dropbox: true }
-        this.SubscriptionLocator.promises.getUsersSubscription
-          .withArgs(this.user._id)
-          .resolves(this.subscriptions.noDropbox)
-        await this.FeaturesUpdater.promises.refreshFeatures(
-          this.user._id,
-          'test'
-        )
+      beforeEach(async function (ctx) {
+        ctx.user.features = { dropbox: true }
+        ctx.SubscriptionLocator.promises.getUsersSubscription
+          .withArgs(ctx.user._id)
+          .resolves(ctx.subscriptions.noDropbox)
+        await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
       })
 
-      it('should fire module hook to unlink dropbox', function () {
-        expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
+      it('should fire module hook to unlink dropbox', function (ctx) {
+        expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
           'removeDropbox',
-          this.user._id,
+          ctx.user._id,
           'test'
         )
       })
@@ -378,40 +408,40 @@ describe('FeaturesUpdater', function () {
 
   describe('doSyncFromV1', function () {
     describe('when all goes well', function () {
-      beforeEach(async function () {
-        await this.FeaturesUpdater.promises.doSyncFromV1(this.v1UserId)
+      beforeEach(async function (ctx) {
+        await ctx.FeaturesUpdater.promises.doSyncFromV1(ctx.v1UserId)
       })
 
-      it('should update the user with the merged features', function () {
+      it('should update the user with the merged features', function (ctx) {
         expect(
-          this.UserFeaturesUpdater.promises.updateFeatures
-        ).to.have.been.calledWith(this.user._id, this.Settings.features.all)
+          ctx.UserFeaturesUpdater.promises.updateFeatures
+        ).to.have.been.calledWith(ctx.user._id, ctx.Settings.features.all)
       })
     })
 
     describe('when getUser produces an error', function () {
-      beforeEach(function () {
-        this.UserGetter.promises.getUser.rejects(new Error('woops'))
+      beforeEach(function (ctx) {
+        ctx.UserGetter.promises.getUser.rejects(new Error('woops'))
       })
 
-      it('should propagate the error', async function () {
+      it('should propagate the error', async function (ctx) {
         const someId = 9090
-        await expect(this.FeaturesUpdater.promises.doSyncFromV1(someId)).to.be
+        await expect(ctx.FeaturesUpdater.promises.doSyncFromV1(someId)).to.be
           .rejected
-        expect(this.UserFeaturesUpdater.promises.updateFeatures).not.to.have
-          .been.called
+        expect(ctx.UserFeaturesUpdater.promises.updateFeatures).not.to.have.been
+          .called
       })
     })
 
     describe('when getUser does not find a user', function () {
-      beforeEach(async function () {
+      beforeEach(async function (ctx) {
         const someOtherId = 987
-        await this.FeaturesUpdater.promises.doSyncFromV1(someOtherId)
+        await ctx.FeaturesUpdater.promises.doSyncFromV1(someOtherId)
       })
 
-      it('should not update the user', function () {
-        expect(this.UserFeaturesUpdater.promises.updateFeatures).not.to.have
-          .been.called
+      it('should not update the user', function (ctx) {
+        expect(ctx.UserFeaturesUpdater.promises.updateFeatures).not.to.have.been
+          .called
       })
     })
   })

+ 165 - 156
services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs

@@ -1,27 +1,26 @@
-// @ts-check
+import { vi, expect } from 'vitest'
+
+import Errors from '../../../../app/src/Features/Subscription/Errors.js'
+
+import PaymentProviderEntities from '../../../../app/src/Features/Subscription/PaymentProviderEntities.mjs'
+import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.js'
+import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.js'
 
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const Errors = require('../../../../app/src/Features/Subscription/Errors')
 const {
   PaymentProviderSubscriptionChangeRequest,
   PaymentProviderSubscriptionUpdateRequest,
   PaymentProviderSubscriptionChange,
   PaymentProviderSubscription,
   PaymentProviderSubscriptionAddOnUpdate,
-} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
-const {
-  AI_ADD_ON_CODE,
-} = require('../../../../app/src/Features/Subscription/AiHelper')
-const SubscriptionHelper = require('../../../../app/src/Features/Subscription/SubscriptionHelper')
+} = PaymentProviderEntities
 
 const MODULE_PATH =
   '../../../../app/src/Features/Subscription/PaymentProviderEntities'
 
 describe('PaymentProviderEntities', function () {
   describe('PaymentProviderSubscription', function () {
-    beforeEach(function () {
-      this.Settings = {
+    beforeEach(async function (ctx) {
+      ctx.Settings = {
         plans: [
           { planCode: 'assistant-annual', price_in_cents: 5900 },
           { planCode: 'cheap-plan', price_in_cents: 500 },
@@ -35,34 +34,44 @@ describe('PaymentProviderEntities', function () {
         features: [],
       }
 
-      this.PaymentProviderEntities = SandboxedModule.require(MODULE_PATH, {
-        requires: {
-          '@overleaf/settings': this.Settings,
-          './Errors': Errors,
-          './SubscriptionHelper': SubscriptionHelper,
-        },
-      })
+      vi.doMock('@overleaf/settings', () => ({
+        default: ctx.Settings,
+      }))
+
+      vi.doMock(
+        '../../../../app/src/Features/Subscription/Errors',
+        () => Errors
+      )
+
+      vi.doMock(
+        '../../../../app/src/Features/Subscription/SubscriptionHelper',
+        () => ({
+          default: SubscriptionHelper,
+        })
+      )
+
+      ctx.PaymentProviderEntities = (await import(MODULE_PATH)).default
     })
 
     describe('with add-ons', function () {
-      beforeEach(function () {
+      beforeEach(function (ctx) {
         const {
           PaymentProviderSubscription,
           PaymentProviderSubscriptionAddOn,
-        } = this.PaymentProviderEntities
-        this.addOn = new PaymentProviderSubscriptionAddOn({
+        } = ctx.PaymentProviderEntities
+        ctx.addOn = new PaymentProviderSubscriptionAddOn({
           code: 'add-on-code',
           name: 'My Add-On',
           quantity: 1,
           unitPrice: 2,
         })
-        this.subscription = new PaymentProviderSubscription({
+        ctx.subscription = new PaymentProviderSubscription({
           id: 'subscription-id',
           userId: 'user-id',
           planCode: 'regular-plan',
           planName: 'My Plan',
           planPrice: 10,
-          addOns: [this.addOn],
+          addOns: [ctx.addOn],
           subtotal: 10.99,
           taxRate: 0.2,
           taxAmount: 2.4,
@@ -72,82 +81,82 @@ describe('PaymentProviderEntities', function () {
       })
 
       describe('hasAddOn()', function () {
-        it('returns true if the subscription has the given add-on', function () {
-          expect(this.subscription.hasAddOn(this.addOn.code)).to.be.true
+        it('returns true if the subscription has the given add-on', function (ctx) {
+          expect(ctx.subscription.hasAddOn(ctx.addOn.code)).to.be.true
         })
 
-        it("returns false if the subscription doesn't have the given add-on", function () {
-          expect(this.subscription.hasAddOn('another-add-on')).to.be.false
+        it("returns false if the subscription doesn't have the given add-on", function (ctx) {
+          expect(ctx.subscription.hasAddOn('another-add-on')).to.be.false
         })
       })
 
       describe('getRequestForPlanChange()', function () {
-        it('returns a change request for upgrades', function () {
+        it('returns a change request for upgrades', function (ctx) {
           const { PaymentProviderSubscriptionChangeRequest } =
-            this.PaymentProviderEntities
-          const changeRequest = this.subscription.getRequestForPlanChange(
+            ctx.PaymentProviderEntities
+          const changeRequest = ctx.subscription.getRequestForPlanChange(
             'premium-plan',
             1,
-            this.subscription.shouldPlanChangeAtTermEnd('premium-plan')
+            ctx.subscription.shouldPlanChangeAtTermEnd('premium-plan')
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               planCode: 'premium-plan',
             })
           )
         })
 
-        it('returns a change request for downgrades', function () {
+        it('returns a change request for downgrades', function (ctx) {
           const { PaymentProviderSubscriptionChangeRequest } =
-            this.PaymentProviderEntities
-          const changeRequest = this.subscription.getRequestForPlanChange(
+            ctx.PaymentProviderEntities
+          const changeRequest = ctx.subscription.getRequestForPlanChange(
             'cheap-plan',
             1,
-            this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
+            ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'term_end',
               planCode: 'cheap-plan',
             })
           )
         })
 
-        it('returns a change request for downgrades while on trial', function () {
+        it('returns a change request for downgrades while on trial', function (ctx) {
           const fiveDaysFromNow = new Date()
           fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5)
-          this.subscription.trialPeriodEnd = fiveDaysFromNow
+          ctx.subscription.trialPeriodEnd = fiveDaysFromNow
           const { PaymentProviderSubscriptionChangeRequest } =
-            this.PaymentProviderEntities
-          const changeRequest = this.subscription.getRequestForPlanChange(
+            ctx.PaymentProviderEntities
+          const changeRequest = ctx.subscription.getRequestForPlanChange(
             'cheap-plan',
             1,
-            this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
+            ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               planCode: 'cheap-plan',
             })
           )
         })
 
-        it('preserves the AI add-on on upgrades', function () {
+        it('preserves the AI add-on on upgrades', function (ctx) {
           const { PaymentProviderSubscriptionChangeRequest } =
-            this.PaymentProviderEntities
-          this.addOn.code = AI_ADD_ON_CODE
-          const changeRequest = this.subscription.getRequestForPlanChange(
+            ctx.PaymentProviderEntities
+          ctx.addOn.code = AI_ADD_ON_CODE
+          const changeRequest = ctx.subscription.getRequestForPlanChange(
             'premium-plan',
             1,
-            this.subscription.shouldPlanChangeAtTermEnd('premium-plan')
+            ctx.subscription.shouldPlanChangeAtTermEnd('premium-plan')
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               planCode: 'premium-plan',
               addOnUpdates: [
@@ -160,18 +169,18 @@ describe('PaymentProviderEntities', function () {
           )
         })
 
-        it('preserves the AI add-on on downgrades', function () {
+        it('preserves the AI add-on on downgrades', function (ctx) {
           const { PaymentProviderSubscriptionChangeRequest } =
-            this.PaymentProviderEntities
-          this.addOn.code = AI_ADD_ON_CODE
-          const changeRequest = this.subscription.getRequestForPlanChange(
+            ctx.PaymentProviderEntities
+          ctx.addOn.code = AI_ADD_ON_CODE
+          const changeRequest = ctx.subscription.getRequestForPlanChange(
             'cheap-plan',
             1,
-            this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
+            ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'term_end',
               planCode: 'cheap-plan',
               addOnUpdates: [
@@ -184,19 +193,19 @@ describe('PaymentProviderEntities', function () {
           )
         })
 
-        it('preserves the AI add-on on upgrades from the standalone AI plan', function () {
+        it('preserves the AI add-on on upgrades from the standalone AI plan', function (ctx) {
           const { PaymentProviderSubscriptionChangeRequest } =
-            this.PaymentProviderEntities
-          this.subscription.planCode = 'assistant-annual'
-          this.subscription.addOns = []
-          const changeRequest = this.subscription.getRequestForPlanChange(
+            ctx.PaymentProviderEntities
+          ctx.subscription.planCode = 'assistant-annual'
+          ctx.subscription.addOns = []
+          const changeRequest = ctx.subscription.getRequestForPlanChange(
             'cheap-plan',
             1,
-            this.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
+            ctx.subscription.shouldPlanChangeAtTermEnd('cheap-plan')
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               planCode: 'cheap-plan',
               addOnUpdates: [
@@ -209,20 +218,20 @@ describe('PaymentProviderEntities', function () {
           )
         })
 
-        it('upgrade from individual to group plan for Stripe subscription', function () {
-          this.subscription.service = 'stripe-uk'
+        it('upgrade from individual to group plan for Stripe subscription', function (ctx) {
+          ctx.subscription.service = 'stripe-uk'
           const { PaymentProviderSubscriptionChangeRequest } =
-            this.PaymentProviderEntities
-          const changeRequest = this.subscription.getRequestForPlanChange(
+            ctx.PaymentProviderEntities
+          const changeRequest = ctx.subscription.getRequestForPlanChange(
             'group_collaborator',
             10,
-            this.subscription.shouldPlanChangeAtTermEnd(
+            ctx.subscription.shouldPlanChangeAtTermEnd(
               'group_collaborator_10_enterprise'
             )
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               planCode: 'group_collaborator',
               addOnUpdates: [
@@ -235,21 +244,21 @@ describe('PaymentProviderEntities', function () {
           )
         })
 
-        it('upgrade from individual to group plan and preserves the AI add-on for Stripe subscription', function () {
-          this.subscription.service = 'stripe-uk'
+        it('upgrade from individual to group plan and preserves the AI add-on for Stripe subscription', function (ctx) {
+          ctx.subscription.service = 'stripe-uk'
           const { PaymentProviderSubscriptionChangeRequest } =
-            this.PaymentProviderEntities
-          this.addOn.code = AI_ADD_ON_CODE
-          const changeRequest = this.subscription.getRequestForPlanChange(
+            ctx.PaymentProviderEntities
+          ctx.addOn.code = AI_ADD_ON_CODE
+          const changeRequest = ctx.subscription.getRequestForPlanChange(
             'group_collaborator',
             10,
-            this.subscription.shouldPlanChangeAtTermEnd(
+            ctx.subscription.shouldPlanChangeAtTermEnd(
               'group_collaborator_10_enterprise'
             )
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               planCode: 'group_collaborator',
               addOnUpdates: [
@@ -268,22 +277,22 @@ describe('PaymentProviderEntities', function () {
       })
 
       describe('getRequestForAddOnPurchase()', function () {
-        it('returns a change request', function () {
+        it('returns a change request', function (ctx) {
           const {
             PaymentProviderSubscriptionChangeRequest,
             PaymentProviderSubscriptionAddOnUpdate,
-          } = this.PaymentProviderEntities
+          } = ctx.PaymentProviderEntities
           const changeRequest =
-            this.subscription.getRequestForAddOnPurchase('another-add-on')
+            ctx.subscription.getRequestForAddOnPurchase('another-add-on')
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               addOnUpdates: [
                 new PaymentProviderSubscriptionAddOnUpdate({
-                  code: this.addOn.code,
-                  quantity: this.addOn.quantity,
-                  unitPrice: this.addOn.unitPrice,
+                  code: ctx.addOn.code,
+                  quantity: ctx.addOn.quantity,
+                  unitPrice: ctx.addOn.unitPrice,
                 }),
                 new PaymentProviderSubscriptionAddOnUpdate({
                   code: 'another-add-on',
@@ -294,27 +303,27 @@ describe('PaymentProviderEntities', function () {
           )
         })
 
-        it('returns a change request with quantity and unit price specified', function () {
+        it('returns a change request with quantity and unit price specified', function (ctx) {
           const {
             PaymentProviderSubscriptionChangeRequest,
             PaymentProviderSubscriptionAddOnUpdate,
-          } = this.PaymentProviderEntities
+          } = ctx.PaymentProviderEntities
           const quantity = 5
           const unitPrice = 10
-          const changeRequest = this.subscription.getRequestForAddOnPurchase(
+          const changeRequest = ctx.subscription.getRequestForAddOnPurchase(
             'another-add-on',
             quantity,
             unitPrice
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               addOnUpdates: [
                 new PaymentProviderSubscriptionAddOnUpdate({
-                  code: this.addOn.code,
-                  quantity: this.addOn.quantity,
-                  unitPrice: this.addOn.unitPrice,
+                  code: ctx.addOn.code,
+                  quantity: ctx.addOn.quantity,
+                  unitPrice: ctx.addOn.unitPrice,
                 }),
                 new PaymentProviderSubscriptionAddOnUpdate({
                   code: 'another-add-on',
@@ -326,95 +335,95 @@ describe('PaymentProviderEntities', function () {
           )
         })
 
-        it('throws a DuplicateAddOnError if the subscription already has the add-on', function () {
+        it('throws a DuplicateAddOnError if the subscription already has the add-on', function (ctx) {
           expect(() =>
-            this.subscription.getRequestForAddOnPurchase(this.addOn.code)
+            ctx.subscription.getRequestForAddOnPurchase(ctx.addOn.code)
           ).to.throw(Errors.DuplicateAddOnError)
         })
       })
 
       describe('getRequestForAddOnUpdate()', function () {
-        it('returns a change request', function () {
+        it('returns a change request', function (ctx) {
           const {
             PaymentProviderSubscriptionChangeRequest,
             PaymentProviderSubscriptionAddOnUpdate,
-          } = this.PaymentProviderEntities
+          } = ctx.PaymentProviderEntities
           const newQuantity = 2
-          const changeRequest = this.subscription.getRequestForAddOnUpdate(
+          const changeRequest = ctx.subscription.getRequestForAddOnUpdate(
             'add-on-code',
             newQuantity
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               addOnUpdates: [
                 new PaymentProviderSubscriptionAddOnUpdate({
-                  code: this.addOn.code,
+                  code: ctx.addOn.code,
                   quantity: newQuantity,
-                  unitPrice: this.addOn.unitPrice,
+                  unitPrice: ctx.addOn.unitPrice,
                 }),
               ],
             })
           )
         })
 
-        it("throws a AddOnNotPresentError if the subscription doesn't have the add-on", function () {
+        it("throws a AddOnNotPresentError if the subscription doesn't have the add-on", function (ctx) {
           expect(() =>
-            this.subscription.getRequestForAddOnUpdate('another-add-on', 2)
+            ctx.subscription.getRequestForAddOnUpdate('another-add-on', 2)
           ).to.throw(Errors.AddOnNotPresentError)
         })
       })
 
       describe('getRequestForAddOnRemoval()', function () {
-        it('returns a change request', function () {
-          const changeRequest = this.subscription.getRequestForAddOnRemoval(
-            this.addOn.code
+        it('returns a change request', function (ctx) {
+          const changeRequest = ctx.subscription.getRequestForAddOnRemoval(
+            ctx.addOn.code
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'term_end',
               addOnUpdates: [],
             })
           )
         })
 
-        it('returns a change request when in trial', function () {
+        it('returns a change request when in trial', function (ctx) {
           const fiveDaysFromNow = new Date()
           fiveDaysFromNow.setDate(fiveDaysFromNow.getDate() + 5)
-          this.subscription.trialPeriodEnd = fiveDaysFromNow
-          const changeRequest = this.subscription.getRequestForAddOnRemoval(
-            this.addOn.code
+          ctx.subscription.trialPeriodEnd = fiveDaysFromNow
+          const changeRequest = ctx.subscription.getRequestForAddOnRemoval(
+            ctx.addOn.code
           )
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               addOnUpdates: [],
             })
           )
         })
 
-        it("throws an AddOnNotPresentError if the subscription doesn't have the add-on", function () {
+        it("throws an AddOnNotPresentError if the subscription doesn't have the add-on", function (ctx) {
           expect(() =>
-            this.subscription.getRequestForAddOnRemoval('another-add-on')
+            ctx.subscription.getRequestForAddOnRemoval('another-add-on')
           ).to.throw(Errors.AddOnNotPresentError)
         })
       })
 
       describe('getRequestForAddOnReactivation()', function () {
-        it('throws an AddOnNotPresentError', function () {
+        it('throws an AddOnNotPresentError', function (ctx) {
           expect(() =>
-            this.subscription.getRequestForAddOnReactivation(this.addOn.code)
+            ctx.subscription.getRequestForAddOnReactivation(ctx.addOn.code)
           ).to.throw(Errors.AddOnNotPresentError)
         })
       })
 
       describe('getRequestForGroupPlanUpgrade()', function () {
-        it('returns a correct change request', function () {
+        it('returns a correct change request', function (ctx) {
           const changeRequest =
-            this.subscription.getRequestForGroupPlanUpgrade('test_plan_code')
+            ctx.subscription.getRequestForGroupPlanUpgrade('test_plan_code')
           const addOns = [
             new PaymentProviderSubscriptionAddOnUpdate({
               code: 'add-on-code',
@@ -423,7 +432,7 @@ describe('PaymentProviderEntities', function () {
           ]
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               addOnUpdates: addOns,
               planCode: 'test_plan_code',
@@ -433,15 +442,15 @@ describe('PaymentProviderEntities', function () {
       })
 
       describe('getRequestForPoNumberAndTermsAndConditionsUpdate()', function () {
-        it('returns a correct update request', function () {
+        it('returns a correct update request', function (ctx) {
           const updateRequest =
-            this.subscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
+            ctx.subscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
               'O12345',
               'T&C copy'
             )
           expect(updateRequest).to.deep.equal(
             new PaymentProviderSubscriptionUpdateRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               poNumber: 'O12345',
               termsAndConditions: 'T&C copy',
             })
@@ -450,12 +459,12 @@ describe('PaymentProviderEntities', function () {
       })
 
       describe('getRequestForTermsAndConditionsUpdate()', function () {
-        it('returns a correct update request', function () {
+        it('returns a correct update request', function (ctx) {
           const updateRequest =
-            this.subscription.getRequestForTermsAndConditionsUpdate('T&C copy')
+            ctx.subscription.getRequestForTermsAndConditionsUpdate('T&C copy')
           expect(updateRequest).to.deep.equal(
             new PaymentProviderSubscriptionUpdateRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               termsAndConditions: 'T&C copy',
             })
           )
@@ -463,41 +472,41 @@ describe('PaymentProviderEntities', function () {
       })
 
       describe('with an add-on pending cancellation', function () {
-        beforeEach(function () {
-          this.subscription.pendingChange =
+        beforeEach(function (ctx) {
+          ctx.subscription.pendingChange =
             new PaymentProviderSubscriptionChange({
-              subscription: this.subscription,
-              nextPlanCode: this.subscription.planCode,
-              nextPlanName: this.subscription.planName,
-              nextPlanPrice: this.subscription.planPrice,
+              subscription: ctx.subscription,
+              nextPlanCode: ctx.subscription.planCode,
+              nextPlanName: ctx.subscription.planName,
+              nextPlanPrice: ctx.subscription.planPrice,
               nextAddOns: [],
             })
         })
 
         describe('getRequestForAddOnReactivation()', function () {
-          it('returns a change request', function () {
+          it('returns a change request', function (ctx) {
             const changeRequest =
-              this.subscription.getRequestForAddOnReactivation(this.addOn.code)
+              ctx.subscription.getRequestForAddOnReactivation(ctx.addOn.code)
             expect(changeRequest).to.deep.equal(
               new PaymentProviderSubscriptionChangeRequest({
-                subscription: this.subscription,
+                subscription: ctx.subscription,
                 timeframe: 'term_end',
-                addOnUpdates: [this.addOn.toAddOnUpdate()],
+                addOnUpdates: [ctx.addOn.toAddOnUpdate()],
               })
             )
           })
 
-          it('throws an AddOnNotPresentError if given the wrong add-on', function () {
+          it('throws an AddOnNotPresentError if given the wrong add-on', function (ctx) {
             expect(() =>
-              this.subscription.getRequestForAddOnReactivation('some-add-on')
+              ctx.subscription.getRequestForAddOnReactivation('some-add-on')
             ).to.throw(Errors.AddOnNotPresentError)
           })
         })
 
         describe('getRequestForPlanRevert()', function () {
-          beforeEach(function () {
-            const { PaymentProviderSubscription } = this.PaymentProviderEntities
-            this.subscription = new PaymentProviderSubscription({
+          beforeEach(function (ctx) {
+            const { PaymentProviderSubscription } = ctx.PaymentProviderEntities
+            ctx.subscription = new PaymentProviderSubscription({
               id: 'subscription-id',
               userId: 'user-id',
               planCode: 'regular-plan',
@@ -523,24 +532,24 @@ describe('PaymentProviderEntities', function () {
             })
           })
 
-          it('throws if the plan to revert to doesnt exist', function () {
+          it('throws if the plan to revert to doesnt exist', function (ctx) {
             const invalidPlanCode = 'non-existent-plan'
             expect(() =>
-              this.subscription.getRequestForPlanRevert(invalidPlanCode, null)
+              ctx.subscription.getRequestForPlanRevert(invalidPlanCode, null)
             ).to.throw('Unable to find plan in settings')
           })
 
-          it('creates a change request with the restore point', function () {
+          it('creates a change request with the restore point', function (ctx) {
             const previousPlanCode = 'cheap-plan'
             const previousAddOns = [
               { addOnCode: 'addon-1', quantity: 1, unitAmountInCents: 500 },
             ]
-            const changeRequest = this.subscription.getRequestForPlanRevert(
+            const changeRequest = ctx.subscription.getRequestForPlanRevert(
               previousPlanCode,
               previousAddOns
             )
             expect(changeRequest).to.be.an.instanceOf(
-              this.PaymentProviderEntities
+              ctx.PaymentProviderEntities
                 .PaymentProviderSubscriptionChangeRequest
             )
             expect(changeRequest.planCode).to.equal(previousPlanCode)
@@ -553,9 +562,9 @@ describe('PaymentProviderEntities', function () {
             ])
           })
 
-          it('defaults to addons to an empty array to clear the addon state', function () {
+          it('defaults to addons to an empty array to clear the addon state', function (ctx) {
             const previousPlanCode = 'cheap-plan'
-            const changeRequest = this.subscription.getRequestForPlanRevert(
+            const changeRequest = ctx.subscription.getRequestForPlanRevert(
               previousPlanCode,
               null
             )
@@ -566,9 +575,9 @@ describe('PaymentProviderEntities', function () {
     })
 
     describe('without add-ons', function () {
-      beforeEach(function () {
-        const { PaymentProviderSubscription } = this.PaymentProviderEntities
-        this.subscription = new PaymentProviderSubscription({
+      beforeEach(function (ctx) {
+        const { PaymentProviderSubscription } = ctx.PaymentProviderEntities
+        ctx.subscription = new PaymentProviderSubscription({
           id: 'subscription-id',
           userId: 'user-id',
           planCode: 'regular-plan',
@@ -583,22 +592,22 @@ describe('PaymentProviderEntities', function () {
       })
 
       describe('hasAddOn()', function () {
-        it('returns false for any add-on', function () {
-          expect(this.subscription.hasAddOn('some-add-on')).to.be.false
+        it('returns false for any add-on', function (ctx) {
+          expect(ctx.subscription.hasAddOn('some-add-on')).to.be.false
         })
       })
 
       describe('getRequestForAddOnPurchase()', function () {
-        it('returns a change request', function () {
+        it('returns a change request', function (ctx) {
           const {
             PaymentProviderSubscriptionChangeRequest,
             PaymentProviderSubscriptionAddOnUpdate,
-          } = this.PaymentProviderEntities
+          } = ctx.PaymentProviderEntities
           const changeRequest =
-            this.subscription.getRequestForAddOnPurchase('some-add-on')
+            ctx.subscription.getRequestForAddOnPurchase('some-add-on')
           expect(changeRequest).to.deep.equal(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               addOnUpdates: [
                 new PaymentProviderSubscriptionAddOnUpdate({
@@ -612,17 +621,17 @@ describe('PaymentProviderEntities', function () {
       })
 
       describe('getRequestForAddOnRemoval()', function () {
-        it('throws an AddOnNotPresentError', function () {
+        it('throws an AddOnNotPresentError', function (ctx) {
           expect(() =>
-            this.subscription.getRequestForAddOnRemoval('some-add-on')
+            ctx.subscription.getRequestForAddOnRemoval('some-add-on')
           ).to.throw(Errors.AddOnNotPresentError)
         })
       })
 
       describe('getRequestForAddOnReactivation()', function () {
-        it('throws an AddOnNotPresentError', function () {
+        it('throws an AddOnNotPresentError', function (ctx) {
           expect(() =>
-            this.subscription.getRequestForAddOnReactivation('some-add-on')
+            ctx.subscription.getRequestForAddOnReactivation('some-add-on')
           ).to.throw(Errors.AddOnNotPresentError)
         })
       })

+ 65 - 70
services/web/test/unit/src/Subscription/PlansLocator.test.mjs

@@ -1,5 +1,4 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
+import { vi, expect } from 'vitest'
 const modulePath = '../../../../app/src/Features/Subscription/PlansLocator'
 
 const plans = [
@@ -27,125 +26,125 @@ const plans = [
 ]
 
 describe('PlansLocator', function () {
-  beforeEach(function () {
-    this.settings = { plans }
-    this.AI_ADD_ON_CODE = 'assistant'
+  beforeEach(async function (ctx) {
+    ctx.settings = { plans }
+    ctx.AI_ADD_ON_CODE = 'assistant'
 
-    this.PlansLocator = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': this.settings,
-      },
-    })
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    ctx.PlansLocator = (await import(modulePath)).default
   })
 
   describe('findLocalPlanInSettings', function () {
-    it('should return the found plan', function () {
-      const plan = this.PlansLocator.findLocalPlanInSettings('second')
+    it('should return the found plan', function (ctx) {
+      const plan = ctx.PlansLocator.findLocalPlanInSettings('second')
       expect(plan).to.have.property('name', '2nd')
       expect(plan).to.have.property('price_in_cents', 1500)
     })
-    it('should return null if no matching plan is found', function () {
-      const plan = this.PlansLocator.findLocalPlanInSettings('gibberish')
+    it('should return null if no matching plan is found', function (ctx) {
+      const plan = ctx.PlansLocator.findLocalPlanInSettings('gibberish')
       expect(plan).to.be.a('null')
     })
   })
 
   describe('buildStripeLookupKey', function () {
-    it('should map "collaborator" plan code to stripe lookup keys', function () {
+    it('should map "collaborator" plan code to stripe lookup keys', function (ctx) {
       const planCode = 'collaborator'
       const currency = 'eur'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         planCode,
         currency
       )
       expect(lookupKey).to.equal('standard_monthly_jun2025_eur')
     })
 
-    it('should map "collaborator_free_trial_7_days" plan code to stripe lookup keys', function () {
+    it('should map "collaborator_free_trial_7_days" plan code to stripe lookup keys', function (ctx) {
       const planCode = 'collaborator_free_trial_7_days'
       const currency = 'eur'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         planCode,
         currency
       )
       expect(lookupKey).to.equal('standard_monthly_jun2025_eur')
     })
 
-    it('should map "collaborator-annual" plan code to stripe lookup keys', function () {
+    it('should map "collaborator-annual" plan code to stripe lookup keys', function (ctx) {
       const planCode = 'collaborator-annual'
       const currency = 'eur'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         planCode,
         currency
       )
       expect(lookupKey).to.equal('standard_annual_jun2025_eur')
     })
 
-    it('should map "professional" plan code to stripe lookup keys', function () {
+    it('should map "professional" plan code to stripe lookup keys', function (ctx) {
       const planCode = 'professional'
       const currency = 'eur'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         planCode,
         currency
       )
       expect(lookupKey).to.equal('professional_monthly_jun2025_eur')
     })
 
-    it('should map "professional_free_trial_7_days" plan code to stripe lookup keys', function () {
+    it('should map "professional_free_trial_7_days" plan code to stripe lookup keys', function (ctx) {
       const planCode = 'professional_free_trial_7_days'
       const currency = 'eur'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         planCode,
         currency
       )
       expect(lookupKey).to.equal('professional_monthly_jun2025_eur')
     })
 
-    it('should map "professional-annual" plan code to stripe lookup keys', function () {
+    it('should map "professional-annual" plan code to stripe lookup keys', function (ctx) {
       const planCode = 'professional-annual'
       const currency = 'eur'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         planCode,
         currency
       )
       expect(lookupKey).to.equal('professional_annual_jun2025_eur')
     })
 
-    it('should map "student" plan code to stripe lookup keys', function () {
+    it('should map "student" plan code to stripe lookup keys', function (ctx) {
       const planCode = 'student'
       const currency = 'eur'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         planCode,
         currency
       )
       expect(lookupKey).to.equal('student_monthly_jun2025_eur')
     })
 
-    it('shoult map "student_free_trial_7_days" plan code to stripe lookup keys', function () {
+    it('shoult map "student_free_trial_7_days" plan code to stripe lookup keys', function (ctx) {
       const planCode = 'student_free_trial_7_days'
       const currency = 'eur'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         planCode,
         currency
       )
       expect(lookupKey).to.equal('student_monthly_jun2025_eur')
     })
 
-    it('should map "student-annual" plan code to stripe lookup keys', function () {
+    it('should map "student-annual" plan code to stripe lookup keys', function (ctx) {
       const planCode = 'student-annual'
       const currency = 'eur'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         planCode,
         currency
       )
       expect(lookupKey).to.equal('student_annual_jun2025_eur')
     })
 
-    it('should return null for unknown add-on codes', function () {
+    it('should return null for unknown add-on codes', function (ctx) {
       const billingCycleInterval = 'month'
       const addOnCode = 'unknown_addon'
       const currency = 'gbp'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         addOnCode,
         currency,
         billingCycleInterval
@@ -153,19 +152,19 @@ describe('PlansLocator', function () {
       expect(lookupKey).to.equal(null)
     })
 
-    it('should handle missing input', function () {
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+    it('should handle missing input', function (ctx) {
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         undefined,
         undefined
       )
       expect(lookupKey).to.equal(null)
     })
 
-    it('returns the key for a monthly AI assist add-on', function () {
+    it('returns the key for a monthly AI assist add-on', function (ctx) {
       const billingCycleInterval = 'month'
-      const addOnCode = this.AI_ADD_ON_CODE
+      const addOnCode = ctx.AI_ADD_ON_CODE
       const currency = 'gbp'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         addOnCode,
         currency,
         billingCycleInterval
@@ -173,11 +172,11 @@ describe('PlansLocator', function () {
       expect(lookupKey).to.equal('assistant_monthly_jun2025_gbp')
     })
 
-    it('returns the key for an annual AI assist add-on', function () {
+    it('returns the key for an annual AI assist add-on', function (ctx) {
       const billingCycleInterval = 'year'
-      const addOnCode = this.AI_ADD_ON_CODE
+      const addOnCode = ctx.AI_ADD_ON_CODE
       const currency = 'gbp'
-      const lookupKey = this.PlansLocator.buildStripeLookupKey(
+      const lookupKey = ctx.PlansLocator.buildStripeLookupKey(
         addOnCode,
         currency,
         billingCycleInterval
@@ -187,79 +186,75 @@ describe('PlansLocator', function () {
   })
 
   describe('getPlanTypeAndPeriodFromRecurlyPlanCode', function () {
-    it('should return the plan type and period for "collaborator"', function () {
+    it('should return the plan type and period for "collaborator"', function (ctx) {
       const { planType, period } =
-        this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
-          'collaborator'
-        )
+        ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode('collaborator')
       expect(planType).to.equal('individual')
       expect(period).to.equal('monthly')
     })
 
-    it('should return the plan type and period for "collaborator_free_trial_7_days"', function () {
+    it('should return the plan type and period for "collaborator_free_trial_7_days"', function (ctx) {
       const { planType, period } =
-        this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
+        ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
           'collaborator_free_trial_7_days'
         )
       expect(planType).to.equal('individual')
       expect(period).to.equal('monthly')
     })
 
-    it('should return the plan type and period for "collaborator-annual"', function () {
+    it('should return the plan type and period for "collaborator-annual"', function (ctx) {
       const { planType, period } =
-        this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
+        ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
           'collaborator-annual'
         )
       expect(planType).to.equal('individual')
       expect(period).to.equal('annual')
     })
 
-    it('should return the plan type and period for "professional"', function () {
+    it('should return the plan type and period for "professional"', function (ctx) {
       const { planType, period } =
-        this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
-          'professional'
-        )
+        ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode('professional')
       expect(planType).to.equal('individual')
       expect(period).to.equal('monthly')
     })
 
-    it('should return the plan type and period for "professional_free_trial_7_days"', function () {
+    it('should return the plan type and period for "professional_free_trial_7_days"', function (ctx) {
       const { planType, period } =
-        this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
+        ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
           'professional_free_trial_7_days'
         )
       expect(planType).to.equal('individual')
       expect(period).to.equal('monthly')
     })
 
-    it('should return the plan type and period for "professional-annual"', function () {
+    it('should return the plan type and period for "professional-annual"', function (ctx) {
       const { planType, period } =
-        this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
+        ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
           'professional-annual'
         )
       expect(planType).to.equal('individual')
       expect(period).to.equal('annual')
     })
 
-    it('should return the plan type and period for "student"', function () {
+    it('should return the plan type and period for "student"', function (ctx) {
       const { planType, period } =
-        this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode('student')
+        ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode('student')
       expect(planType).to.equal('student')
       expect(period).to.equal('monthly')
     })
 
-    it('should return the plan type and period for "student_free_trial_7_days"', function () {
+    it('should return the plan type and period for "student_free_trial_7_days"', function (ctx) {
       const { planType, period } =
-        this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
+        ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
           'student_free_trial_7_days'
         )
       expect(planType).to.equal('student')
       expect(period).to.equal('monthly')
     })
 
-    it('should return the plan type and period for "student-annual"', function () {
+    it('should return the plan type and period for "student-annual"', function (ctx) {
       const { planType, period } =
-        this.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
+        ctx.PlansLocator.getPlanTypeAndPeriodFromRecurlyPlanCode(
           'student-annual'
         )
       expect(planType).to.equal('student')
@@ -268,9 +263,9 @@ describe('PlansLocator', function () {
   })
 
   describe('convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded', function () {
-    it('returns original plan name for non-group plan codes', function () {
+    it('returns original plan name for non-group plan codes', function (ctx) {
       expect(
-        this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
+        ctx.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
           'professional'
         )
       ).to.deep.equal({
@@ -279,9 +274,9 @@ describe('PlansLocator', function () {
       })
     })
 
-    it('converts Recurly enterprise group plan codes to Stripe group plan codes', function () {
+    it('converts Recurly enterprise group plan codes to Stripe group plan codes', function (ctx) {
       expect(
-        this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
+        ctx.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
           'group_collaborator_10_enterprise'
         )
       ).to.deep.equal({
@@ -290,9 +285,9 @@ describe('PlansLocator', function () {
       })
     })
 
-    it('converts Recurly educational group plan codes to Stripe group plan codes', function () {
+    it('converts Recurly educational group plan codes to Stripe group plan codes', function (ctx) {
       expect(
-        this.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
+        ctx.PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
           'group_professional_10_educational'
         )
       ).to.deep.equal({

+ 284 - 275
services/web/test/unit/src/Subscription/RecurlyClient.test.mjs

@@ -1,21 +1,21 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const recurly = require('recurly')
-const SandboxedModule = require('sandboxed-module')
-const {
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import recurly from 'recurly'
+
+import {
   PaymentProviderSubscription,
   PaymentProviderSubscriptionChangeRequest,
   PaymentProviderSubscriptionUpdateRequest,
   PaymentProviderSubscriptionAddOnUpdate,
   PaymentProviderAccount,
   PaymentProviderCoupon,
-} = require('../../../../app/src/Features/Subscription/PaymentProviderEntities')
+} from '../../../../app/src/Features/Subscription/PaymentProviderEntities.js'
 
 const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient'
 
 describe('RecurlyClient', function () {
-  beforeEach(function () {
-    this.settings = {
+  beforeEach(async function (ctx) {
+    ctx.settings = {
       apis: {
         recurly: {
           apiKey: 'nonsense',
@@ -27,15 +27,15 @@ describe('RecurlyClient', function () {
       features: [],
     }
 
-    this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
-    this.subscriptionChange = { id: 'subscription-change-123' }
-    this.recurlyAccount = new recurly.Account()
-    Object.assign(this.recurlyAccount, {
-      code: this.user._id,
-      email: this.user.email,
+    ctx.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
+    ctx.subscriptionChange = { id: 'subscription-change-123' }
+    ctx.recurlyAccount = new recurly.Account()
+    Object.assign(ctx.recurlyAccount, {
+      code: ctx.user._id,
+      email: ctx.user.email,
     })
 
-    this.subscriptionAddOn = {
+    ctx.subscriptionAddOn = {
       code: 'addon-code',
       name: 'My Add-On',
       quantity: 1,
@@ -43,14 +43,14 @@ describe('RecurlyClient', function () {
       preTaxTotal: 2,
     }
 
-    this.subscription = new PaymentProviderSubscription({
+    ctx.subscription = new PaymentProviderSubscription({
       id: 'subscription-id',
       userId: 'user-id',
       currency: 'EUR',
       planCode: 'plan-code',
       planName: 'plan-name',
       planPrice: 13,
-      addOns: [this.subscriptionAddOn],
+      addOns: [ctx.subscriptionAddOn],
       subtotal: 15,
       taxRate: 0.1,
       taxAmount: 1.5,
@@ -63,54 +63,54 @@ describe('RecurlyClient', function () {
       termsAndConditions: '',
     })
 
-    this.recurlySubscription = {
-      uuid: this.subscription.id,
+    ctx.recurlySubscription = {
+      uuid: ctx.subscription.id,
       account: {
-        code: this.subscription.userId,
+        code: ctx.subscription.userId,
       },
       plan: {
-        code: this.subscription.planCode,
-        name: this.subscription.planName,
+        code: ctx.subscription.planCode,
+        name: ctx.subscription.planName,
       },
       addOns: [
         {
           addOn: {
-            code: this.subscriptionAddOn.code,
-            name: this.subscriptionAddOn.name,
+            code: ctx.subscriptionAddOn.code,
+            name: ctx.subscriptionAddOn.name,
           },
-          quantity: this.subscriptionAddOn.quantity,
-          unitAmount: this.subscriptionAddOn.unitPrice,
+          quantity: ctx.subscriptionAddOn.quantity,
+          unitAmount: ctx.subscriptionAddOn.unitPrice,
         },
       ],
-      unitAmount: this.subscription.planPrice,
-      subtotal: this.subscription.subtotal,
-      taxInfo: { rate: this.subscription.taxRate },
-      tax: this.subscription.taxAmount,
-      total: this.subscription.total,
-      currency: this.subscription.currency,
-      currentPeriodStartedAt: this.subscription.periodStart,
-      currentPeriodEndsAt: this.subscription.periodEnd,
-      collectionMethod: this.subscription.collectionMethod,
-      netTerms: this.subscription.netTerms,
-      poNumber: this.subscription.poNumber,
-      termsAndConditions: this.subscription.termsAndConditions,
+      unitAmount: ctx.subscription.planPrice,
+      subtotal: ctx.subscription.subtotal,
+      taxInfo: { rate: ctx.subscription.taxRate },
+      tax: ctx.subscription.taxAmount,
+      total: ctx.subscription.total,
+      currency: ctx.subscription.currency,
+      currentPeriodStartedAt: ctx.subscription.periodStart,
+      currentPeriodEndsAt: ctx.subscription.periodEnd,
+      collectionMethod: ctx.subscription.collectionMethod,
+      netTerms: ctx.subscription.netTerms,
+      poNumber: ctx.subscription.poNumber,
+      termsAndConditions: ctx.subscription.termsAndConditions,
     }
 
-    this.recurlySubscriptionChange = new recurly.SubscriptionChange()
-    Object.assign(this.recurlySubscriptionChange, this.subscriptionChange)
+    ctx.recurlySubscriptionChange = new recurly.SubscriptionChange()
+    Object.assign(ctx.recurlySubscriptionChange, ctx.subscriptionChange)
 
-    this.UserGetter = {
+    ctx.UserGetter = {
       promises: {
         getUser: sinon.stub().callsFake(userId => {
-          if (userId === this.user._id) {
-            return this.user
+          if (userId === ctx.user._id) {
+            return ctx.user
           }
         }),
       },
     }
 
     let client
-    this.client = client = {
+    ctx.client = client = {
       getAccount: sinon.stub(),
       getBillingInfo: sinon.stub(),
       listAccountSubscriptions: sinon.stub(),
@@ -118,39 +118,50 @@ describe('RecurlyClient', function () {
       previewSubscriptionChange: sinon.stub(),
       listSubscriptionInvoices: sinon.stub(),
     }
-    this.recurly = {
+    ctx.recurly = {
       errors: recurly.errors,
       Client: function () {
         return client
       },
     }
-    this.Errors = {
+    ctx.Errors = {
       MissingBillingInfoError: class extends Error {},
       SubtotalLimitExceededError: class extends Error {},
     }
 
-    return (this.RecurlyClient = SandboxedModule.require(MODULE_PATH, {
-      globals: {
-        console,
-      },
-      requires: {
-        '@overleaf/settings': this.settings,
-        recurly: this.recurly,
-        '@overleaf/logger': {
-          err: sinon.stub(),
-          error: sinon.stub(),
-          warn: sinon.stub(),
-          log: sinon.stub(),
-          debug: sinon.stub(),
-        },
-        '../User/UserGetter': this.UserGetter,
-        './Errors': this.Errors,
-        '../../models/Subscription': {},
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('recurly', () => ({
+      default: ctx.recurly,
+    }))
+
+    vi.doMock('@overleaf/logger', () => ({
+      default: {
+        err: sinon.stub(),
+        error: sinon.stub(),
+        warn: sinon.stub(),
+        log: sinon.stub(),
+        debug: sinon.stub(),
       },
     }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/Errors',
+      () => ctx.Errors
+    )
+
+    vi.doMock('../../../../app/src/models/Subscription', () => ({}))
+
+    ctx.RecurlyClient = (await import(MODULE_PATH)).default
   })
 
-  describe('initalizing recurly client with undefined API key parameter', function () {
+  describe('initializing recurly client with undefined API key parameter', function () {
     it('should create a client without error', function () {
       let testClient
       expect(() => {
@@ -161,64 +172,64 @@ describe('RecurlyClient', function () {
   })
 
   describe('getAccountForUserId', function () {
-    it('should return an Account if one exists', async function () {
-      this.client.getAccount = sinon.stub().resolves(this.recurlyAccount)
-      const account = await this.RecurlyClient.promises.getAccountForUserId(
-        this.user._id
+    it('should return an Account if one exists', async function (ctx) {
+      ctx.client.getAccount = sinon.stub().resolves(ctx.recurlyAccount)
+      const account = await ctx.RecurlyClient.promises.getAccountForUserId(
+        ctx.user._id
       )
       const expectedAccount = new PaymentProviderAccount({
-        code: this.user._id,
-        email: this.user.email,
+        code: ctx.user._id,
+        email: ctx.user.email,
         hasPastDueInvoice: false,
       })
       expect(account).to.deep.equal(expectedAccount)
     })
 
-    it('should return null if no account found', async function () {
-      this.client.getAccount = sinon
+    it('should return null if no account found', async function (ctx) {
+      ctx.client.getAccount = sinon
         .stub()
         .throws(new recurly.errors.NotFoundError())
       const account =
-        await this.RecurlyClient.promises.getAccountForUserId('nonsense')
+        await ctx.RecurlyClient.promises.getAccountForUserId('nonsense')
       expect(account).to.equal(null)
     })
 
-    it('should re-throw caught errors', async function () {
-      this.client.getAccount = sinon.stub().throws()
+    it('should re-throw caught errors', async function (ctx) {
+      ctx.client.getAccount = sinon.stub().throws()
       await expect(
-        this.RecurlyClient.promises.getAccountForUserId(this.user._id)
+        ctx.RecurlyClient.promises.getAccountForUserId(ctx.user._id)
       ).to.eventually.be.rejectedWith(Error)
     })
   })
 
   describe('createAccountForUserId', function () {
-    it('should return the Account as created by recurly', async function () {
-      this.client.createAccount = sinon.stub().resolves(this.recurlyAccount)
-      const result = await this.RecurlyClient.promises.createAccountForUserId(
-        this.user._id
+    it('should return the Account as created by recurly', async function (ctx) {
+      ctx.client.createAccount = sinon.stub().resolves(ctx.recurlyAccount)
+      const result = await ctx.RecurlyClient.promises.createAccountForUserId(
+        ctx.user._id
       )
-      expect(result).to.has.property('code', this.user._id)
+      expect(result).to.has.property('code', ctx.user._id)
     })
 
-    it('should throw any API errors', async function () {
-      this.client.createAccount = sinon.stub().throws()
+    it('should throw any API errors', async function (ctx) {
+      ctx.client.createAccount = sinon.stub().throws()
       await expect(
-        this.RecurlyClient.promises.createAccountForUserId(this.user._id)
+        ctx.RecurlyClient.promises.createAccountForUserId(ctx.user._id)
       ).to.eventually.be.rejectedWith(Error)
     })
   })
 
   describe('getActiveCouponsForUserId', function () {
-    it('should return an empty array if no coupons returned', async function () {
-      this.client.listActiveCouponRedemptions.returns({
+    it('should return an empty array if no coupons returned', async function (ctx) {
+      ctx.client.listActiveCouponRedemptions.returns({
         each: async function* () {},
       })
       const coupons =
-        await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+        await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
       expect(coupons).to.deep.equal([])
     })
 
-    it('should return a coupons returned by recurly', async function () {
+    it('should return a coupons returned by recurly', async function (ctx) {
       const recurlyCoupon = {
         coupon: {
           code: 'coupon-code',
@@ -227,13 +238,13 @@ describe('RecurlyClient', function () {
           invoiceDescription: 'invoice description',
         },
       }
-      this.client.listActiveCouponRedemptions.returns({
+      ctx.client.listActiveCouponRedemptions.returns({
         each: async function* () {
           yield recurlyCoupon
         },
       })
       const coupons =
-        await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+        await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
       const expectedCoupons = [
         new PaymentProviderCoupon({
           code: 'coupon-code',
@@ -244,28 +255,28 @@ describe('RecurlyClient', function () {
       expect(coupons).to.deep.equal(expectedCoupons)
     })
 
-    it('should not throw for Recurly not found error', async function () {
-      this.client.listActiveCouponRedemptions = sinon
+    it('should not throw for Recurly not found error', async function (ctx) {
+      ctx.client.listActiveCouponRedemptions = sinon
         .stub()
         .throws(new recurly.errors.NotFoundError())
       const coupons =
-        await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+        await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
       expect(coupons).to.deep.equal([])
     })
 
-    it('should throw any other API errors', async function () {
-      this.client.listActiveCouponRedemptions = sinon.stub().throws()
+    it('should throw any other API errors', async function (ctx) {
+      ctx.client.listActiveCouponRedemptions = sinon.stub().throws()
       await expect(
-        this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
+        ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
       ).to.eventually.be.rejectedWith(Error)
     })
   })
 
   describe('getCustomerManagementLink', function () {
-    it('should throw if recurly token is not returned', async function () {
-      this.client.getAccount.resolves({})
+    it('should throw if recurly token is not returned', async function (ctx) {
+      ctx.client.getAccount.resolves({})
       await expect(
-        this.RecurlyClient.promises.getCustomerManagementLink(
+        ctx.RecurlyClient.promises.getCustomerManagementLink(
           '12345',
           'account-management',
           'en-US'
@@ -273,30 +284,28 @@ describe('RecurlyClient', function () {
       ).to.be.rejectedWith('recurly account does not have hosted login token')
     })
 
-    it('should generate the correct account management url', async function () {
-      this.client.getAccount.resolves({
+    it('should generate the correct account management url', async function (ctx) {
+      ctx.client.getAccount.resolves({
         hostedLoginToken: '987654321',
       })
-      const result =
-        await this.RecurlyClient.promises.getCustomerManagementLink(
-          '12345',
-          'account-management',
-          'en-US'
-        )
+      const result = await ctx.RecurlyClient.promises.getCustomerManagementLink(
+        '12345',
+        'account-management',
+        'en-US'
+      )
 
       expect(result).to.equal('https://test.recurly.com/account/987654321')
     })
 
-    it('should generate the correct billing details url', async function () {
-      this.client.getAccount.resolves({
+    it('should generate the correct billing details url', async function (ctx) {
+      ctx.client.getAccount.resolves({
         hostedLoginToken: '987654321',
       })
-      const result =
-        await this.RecurlyClient.promises.getCustomerManagementLink(
-          '12345',
-          'billing-details',
-          'en-US'
-        )
+      const result = await ctx.RecurlyClient.promises.getCustomerManagementLink(
+        '12345',
+        'billing-details',
+        'en-US'
+      )
 
       expect(result).to.equal(
         'https://test.recurly.com/account/billing_info/edit?ht=987654321'
@@ -305,98 +314,98 @@ describe('RecurlyClient', function () {
   })
 
   describe('getSubscription', function () {
-    it('should return the subscription found by recurly', async function () {
-      this.client.getSubscription = sinon
+    it('should return the subscription found by recurly', async function (ctx) {
+      ctx.client.getSubscription = sinon
         .stub()
         .withArgs('uuid-subscription-id')
-        .resolves(this.recurlySubscription)
-      const subscription = await this.RecurlyClient.promises.getSubscription(
-        this.subscription.id
+        .resolves(ctx.recurlySubscription)
+      const subscription = await ctx.RecurlyClient.promises.getSubscription(
+        ctx.subscription.id
       )
-      expect(subscription).to.deep.equal(this.subscription)
+      expect(subscription).to.deep.equal(ctx.subscription)
     })
 
-    it('should throw any API errors', async function () {
-      this.client.getSubscription = sinon.stub().throws()
+    it('should throw any API errors', async function (ctx) {
+      ctx.client.getSubscription = sinon.stub().throws()
       await expect(
-        this.RecurlyClient.promises.getSubscription(this.user._id)
+        ctx.RecurlyClient.promises.getSubscription(ctx.user._id)
       ).to.eventually.be.rejectedWith(Error)
     })
   })
 
   describe('getSubscriptionForUser', function () {
-    it("should return null if the account doesn't exist", async function () {
-      this.client.listAccountSubscriptions.returns({
+    it("should return null if the account doesn't exist", async function (ctx) {
+      ctx.client.listAccountSubscriptions.returns({
         // eslint-disable-next-line require-yield
         each: async function* () {
           throw new recurly.errors.NotFoundError('account not found')
         },
       })
       const subscription =
-        await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
+        await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
       expect(subscription).to.be.null
     })
 
-    it("should return null if the account doesn't have subscriptions", async function () {
-      this.client.listAccountSubscriptions.returns({
+    it("should return null if the account doesn't have subscriptions", async function (ctx) {
+      ctx.client.listAccountSubscriptions.returns({
         each: async function* () {},
       })
       const subscription =
-        await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
+        await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
       expect(subscription).to.be.null
     })
 
-    it('should return the subscription if the account has one subscription', async function () {
-      const recurlySubscription = this.recurlySubscription
-      this.client.listAccountSubscriptions.returns({
+    it('should return the subscription if the account has one subscription', async function (ctx) {
+      const recurlySubscription = ctx.recurlySubscription
+      ctx.client.listAccountSubscriptions.returns({
         each: async function* () {
           yield recurlySubscription
         },
       })
       const subscription =
-        await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
-      expect(subscription).to.deep.equal(this.subscription)
+        await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
+      expect(subscription).to.deep.equal(ctx.subscription)
     })
 
-    it('should throw an error if the account has more than one subscription', async function () {
-      const recurlySubscription = this.recurlySubscription
-      this.client.listAccountSubscriptions.returns({
+    it('should throw an error if the account has more than one subscription', async function (ctx) {
+      const recurlySubscription = ctx.recurlySubscription
+      ctx.client.listAccountSubscriptions.returns({
         each: async function* () {
           yield recurlySubscription
           yield { another: 'subscription' }
         },
       })
       await expect(
-        this.RecurlyClient.promises.getSubscriptionForUser('some-user')
+        ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
       ).to.be.rejected
     })
   })
 
   describe('applySubscriptionChangeRequest', function () {
-    beforeEach(function () {
-      this.client.createSubscriptionChange = sinon
+    beforeEach(function (ctx) {
+      ctx.client.createSubscriptionChange = sinon
         .stub()
-        .resolves(this.recurlySubscriptionChange)
+        .resolves(ctx.recurlySubscriptionChange)
     })
 
-    it('handles plan changes', async function () {
-      await this.RecurlyClient.promises.applySubscriptionChangeRequest(
+    it('handles plan changes', async function (ctx) {
+      await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
         new PaymentProviderSubscriptionChangeRequest({
-          subscription: this.subscription,
+          subscription: ctx.subscription,
           timeframe: 'now',
           planCode: 'new-plan',
         })
       )
-      expect(this.client.createSubscriptionChange).to.be.calledWith(
+      expect(ctx.client.createSubscriptionChange).to.be.calledWith(
         'uuid-subscription-id',
         { timeframe: 'now', planCode: 'new-plan' }
       )
     })
 
-    it('handles add-on changes', async function () {
-      await this.RecurlyClient.promises.applySubscriptionChangeRequest(
+    it('handles add-on changes', async function (ctx) {
+      await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
         new PaymentProviderSubscriptionChangeRequest({
-          subscription: this.subscription,
+          subscription: ctx.subscription,
           timeframe: 'now',
           addOnUpdates: [
             new PaymentProviderSubscriptionAddOnUpdate({
@@ -407,7 +416,7 @@ describe('RecurlyClient', function () {
           ],
         })
       )
-      expect(this.client.createSubscriptionChange).to.be.calledWith(
+      expect(ctx.client.createSubscriptionChange).to.be.calledWith(
         'uuid-subscription-id',
         {
           timeframe: 'now',
@@ -416,114 +425,114 @@ describe('RecurlyClient', function () {
       )
     })
 
-    it('should throw any API errors', async function () {
-      this.client.createSubscriptionChange = sinon.stub().throws()
+    it('should throw any API errors', async function (ctx) {
+      ctx.client.createSubscriptionChange = sinon.stub().throws()
       await expect(
-        this.RecurlyClient.promises.applySubscriptionChangeRequest({
-          subscription: this.subscription,
+        ctx.RecurlyClient.promises.applySubscriptionChangeRequest({
+          subscription: ctx.subscription,
         })
       ).to.eventually.be.rejectedWith(Error)
     })
 
-    it('should throw SubtotalLimitExceededError', async function () {
+    it('should throw SubtotalLimitExceededError', async function (ctx) {
       class ValidationError extends recurly.errors.ValidationError {
         constructor() {
           super()
           this.params = [{ param: 'subtotal_amount_in_cents' }]
         }
       }
-      this.client.createSubscriptionChange = sinon
+      ctx.client.createSubscriptionChange = sinon
         .stub()
         .throws(new ValidationError())
       await expect(
-        this.RecurlyClient.promises.applySubscriptionChangeRequest({
-          subscription: this.subscription,
+        ctx.RecurlyClient.promises.applySubscriptionChangeRequest({
+          subscription: ctx.subscription,
         })
-      ).to.be.rejectedWith(this.Errors.SubtotalLimitExceededError)
+      ).to.be.rejectedWith(ctx.Errors.SubtotalLimitExceededError)
     })
 
-    it('should rethrow errors different than SubtotalLimitExceededError', async function () {
-      this.client.createSubscriptionChange = sinon.stub().throws(new Error())
+    it('should rethrow errors different than SubtotalLimitExceededError', async function (ctx) {
+      ctx.client.createSubscriptionChange = sinon.stub().throws(new Error())
       await expect(
-        this.RecurlyClient.promises.applySubscriptionChangeRequest({
-          subscription: this.subscription,
+        ctx.RecurlyClient.promises.applySubscriptionChangeRequest({
+          subscription: ctx.subscription,
         })
       ).to.be.rejectedWith(Error)
     })
   })
 
   describe('updateSubscriptionDetails', function () {
-    beforeEach(function () {
-      this.client.updateSubscription = sinon
+    beforeEach(function (ctx) {
+      ctx.client.updateSubscription = sinon
         .stub()
-        .resolves({ id: this.subscription.id })
+        .resolves({ id: ctx.subscription.id })
     })
 
-    it('handles subscription update', async function () {
-      await this.RecurlyClient.promises.updateSubscriptionDetails(
+    it('handles subscription update', async function (ctx) {
+      await ctx.RecurlyClient.promises.updateSubscriptionDetails(
         new PaymentProviderSubscriptionUpdateRequest({
-          subscription: this.subscription,
+          subscription: ctx.subscription,
           poNumber: '012345',
           termsAndConditions: 'T&C',
         })
       )
-      expect(this.client.updateSubscription).to.be.calledWith(
+      expect(ctx.client.updateSubscription).to.be.calledWith(
         'uuid-subscription-id',
         { poNumber: '012345', termsAndConditions: 'T&C' }
       )
     })
 
-    it('should throw any API errors', async function () {
-      this.client.updateSubscription = sinon.stub().throws()
+    it('should throw any API errors', async function (ctx) {
+      ctx.client.updateSubscription = sinon.stub().throws()
       await expect(
-        this.RecurlyClient.promises.updateSubscriptionDetails({
-          subscription: this.subscription,
+        ctx.RecurlyClient.promises.updateSubscriptionDetails({
+          subscription: ctx.subscription,
         })
       ).to.eventually.be.rejectedWith(Error)
     })
   })
 
   describe('removeSubscriptionChange', function () {
-    beforeEach(function () {
-      this.client.removeSubscriptionChange = sinon.stub().resolves()
+    beforeEach(function (ctx) {
+      ctx.client.removeSubscriptionChange = sinon.stub().resolves()
     })
 
-    it('should attempt to remove a pending subscription change', async function () {
-      this.RecurlyClient.promises.removeSubscriptionChange(
-        this.subscription.id,
+    it('should attempt to remove a pending subscription change', async function (ctx) {
+      ctx.RecurlyClient.promises.removeSubscriptionChange(
+        ctx.subscription.id,
         {}
       )
-      expect(this.client.removeSubscriptionChange).to.be.calledWith(
-        this.subscription.id
+      expect(ctx.client.removeSubscriptionChange).to.be.calledWith(
+        ctx.subscription.id
       )
     })
 
-    it('should throw any API errors', async function () {
-      this.client.removeSubscriptionChange = sinon.stub().throws()
+    it('should throw any API errors', async function (ctx) {
+      ctx.client.removeSubscriptionChange = sinon.stub().throws()
       await expect(
-        this.RecurlyClient.promises.removeSubscriptionChange(
-          this.subscription.id,
+        ctx.RecurlyClient.promises.removeSubscriptionChange(
+          ctx.subscription.id,
           {}
         )
       ).to.eventually.be.rejectedWith(Error)
     })
 
     describe('removeSubscriptionChangeByUuid', function () {
-      it('should attempt to remove a pending subscription change', async function () {
-        this.RecurlyClient.promises.removeSubscriptionChangeByUuid(
-          this.subscription.uuid,
+      it('should attempt to remove a pending subscription change', async function (ctx) {
+        ctx.RecurlyClient.promises.removeSubscriptionChangeByUuid(
+          ctx.subscription.uuid,
           {}
         )
-        expect(this.client.removeSubscriptionChange).to.be.calledWith(
-          'uuid-' + this.subscription.uuid
+        expect(ctx.client.removeSubscriptionChange).to.be.calledWith(
+          'uuid-' + ctx.subscription.uuid
         )
       })
 
-      it('should throw any API errors', async function () {
-        this.client.removeSubscriptionChange = sinon.stub().throws()
+      it('should throw any API errors', async function (ctx) {
+        ctx.client.removeSubscriptionChange = sinon.stub().throws()
         await expect(
-          this.RecurlyClient.promises.removeSubscriptionChangeByUuid(
-            this.subscription.id,
+          ctx.RecurlyClient.promises.removeSubscriptionChangeByUuid(
+            ctx.subscription.id,
             {}
           )
         ).to.eventually.be.rejectedWith(Error)
@@ -532,74 +541,74 @@ describe('RecurlyClient', function () {
   })
 
   describe('reactivateSubscriptionByUuid', function () {
-    it('should attempt to reactivate the subscription', async function () {
-      this.client.reactivateSubscription = sinon
+    it('should attempt to reactivate the subscription', async function (ctx) {
+      ctx.client.reactivateSubscription = sinon
         .stub()
-        .resolves(this.recurlySubscription)
+        .resolves(ctx.recurlySubscription)
       const subscription =
-        await this.RecurlyClient.promises.reactivateSubscriptionByUuid(
-          this.subscription.uuid
+        await ctx.RecurlyClient.promises.reactivateSubscriptionByUuid(
+          ctx.subscription.uuid
         )
-      expect(subscription).to.deep.equal(this.recurlySubscription)
-      expect(this.client.reactivateSubscription).to.be.calledWith(
-        'uuid-' + this.subscription.uuid
+      expect(subscription).to.deep.equal(ctx.recurlySubscription)
+      expect(ctx.client.reactivateSubscription).to.be.calledWith(
+        'uuid-' + ctx.subscription.uuid
       )
     })
   })
 
   describe('cancelSubscriptionByUuid', function () {
-    it('should attempt to cancel the subscription', async function () {
-      this.client.cancelSubscription = sinon
+    it('should attempt to cancel the subscription', async function (ctx) {
+      ctx.client.cancelSubscription = sinon
         .stub()
-        .resolves(this.recurlySubscription)
+        .resolves(ctx.recurlySubscription)
       const subscription =
-        await this.RecurlyClient.promises.cancelSubscriptionByUuid(
-          this.subscription.uuid
+        await ctx.RecurlyClient.promises.cancelSubscriptionByUuid(
+          ctx.subscription.uuid
         )
-      expect(subscription).to.deep.equal(this.recurlySubscription)
-      expect(this.client.cancelSubscription).to.be.calledWith(
-        'uuid-' + this.subscription.uuid
+      expect(subscription).to.deep.equal(ctx.recurlySubscription)
+      expect(ctx.client.cancelSubscription).to.be.calledWith(
+        'uuid-' + ctx.subscription.uuid
       )
     })
 
-    it('should terminate subscription when cancellation fails due to being in last cycle of paused term', async function () {
+    it('should terminate subscription when cancellation fails due to being in last cycle of paused term', async function (ctx) {
       const validationError = new recurly.errors.ValidationError()
       validationError.message =
         'Cannot cancel a paused subscription in the last cycle of the term'
 
-      this.client.cancelSubscription = sinon.stub().throws(validationError)
-      this.client.terminateSubscription = sinon
+      ctx.client.cancelSubscription = sinon.stub().throws(validationError)
+      ctx.client.terminateSubscription = sinon
         .stub()
-        .resolves(this.recurlySubscription)
+        .resolves(ctx.recurlySubscription)
 
       const subscription =
-        await this.RecurlyClient.promises.cancelSubscriptionByUuid(
-          this.subscription.uuid
+        await ctx.RecurlyClient.promises.cancelSubscriptionByUuid(
+          ctx.subscription.uuid
         )
 
-      expect(this.client.cancelSubscription).to.be.calledWith(
-        'uuid-' + this.subscription.uuid
+      expect(ctx.client.cancelSubscription).to.be.calledWith(
+        'uuid-' + ctx.subscription.uuid
       )
-      expect(this.client.terminateSubscription).to.be.calledWith(
-        'uuid-' + this.subscription.uuid
+      expect(ctx.client.terminateSubscription).to.be.calledWith(
+        'uuid-' + ctx.subscription.uuid
       )
-      expect(subscription).to.deep.equal(this.recurlySubscription)
+      expect(subscription).to.deep.equal(ctx.recurlySubscription)
     })
   })
 
   describe('pauseSubscriptionByUuid', function () {
-    it('should attempt to pause the subscription', async function () {
-      this.client.pauseSubscription = sinon
+    it('should attempt to pause the subscription', async function (ctx) {
+      ctx.client.pauseSubscription = sinon
         .stub()
-        .resolves(this.recurlySubscription)
+        .resolves(ctx.recurlySubscription)
       const subscription =
-        await this.RecurlyClient.promises.pauseSubscriptionByUuid(
-          this.subscription.uuid,
+        await ctx.RecurlyClient.promises.pauseSubscriptionByUuid(
+          ctx.subscription.uuid,
           3
         )
-      expect(subscription).to.deep.equal(this.recurlySubscription)
-      expect(this.client.pauseSubscription).to.be.calledWith(
-        'uuid-' + this.subscription.uuid,
+      expect(subscription).to.deep.equal(ctx.recurlySubscription)
+      expect(ctx.client.pauseSubscription).to.be.calledWith(
+        'uuid-' + ctx.subscription.uuid,
         { remainingPauseCycles: 3 }
       )
     })
@@ -607,8 +616,8 @@ describe('RecurlyClient', function () {
 
   describe('previewSubscriptionChange', function () {
     describe('compute immediate charge', function () {
-      it('only has charge invoice', async function () {
-        this.client.previewSubscriptionChange.resolves({
+      it('only has charge invoice', async function (ctx) {
+        ctx.client.previewSubscriptionChange.resolves({
           plan: { code: 'test_code', name: 'test name' },
           unitAmount: 0,
           invoiceCollection: {
@@ -620,9 +629,9 @@ describe('RecurlyClient', function () {
           },
         })
         const { immediateCharge } =
-          await this.RecurlyClient.promises.previewSubscriptionChange(
+          await ctx.RecurlyClient.promises.previewSubscriptionChange(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               planCode: 'new-plan',
             })
@@ -632,8 +641,8 @@ describe('RecurlyClient', function () {
         expect(immediateCharge.total).to.be.equal(120)
       })
 
-      it('credit invoice with imprecise float number', async function () {
-        this.client.previewSubscriptionChange.resolves({
+      it('credit invoice with imprecise float number', async function (ctx) {
+        ctx.client.previewSubscriptionChange.resolves({
           plan: { code: 'test_code', name: 'test name' },
           unitAmount: 0,
           invoiceCollection: {
@@ -652,9 +661,9 @@ describe('RecurlyClient', function () {
           },
         })
         const { immediateCharge } =
-          await this.RecurlyClient.promises.previewSubscriptionChange(
+          await ctx.RecurlyClient.promises.previewSubscriptionChange(
             new PaymentProviderSubscriptionChangeRequest({
-              subscription: this.subscription,
+              subscription: ctx.subscription,
               timeframe: 'now',
               planCode: 'new-plan',
             })
@@ -665,33 +674,33 @@ describe('RecurlyClient', function () {
       })
     })
 
-    it('should throw SubtotalLimitExceededError', async function () {
+    it('should throw SubtotalLimitExceededError', async function (ctx) {
       class ValidationError extends recurly.errors.ValidationError {
         constructor() {
           super()
           this.params = [{ param: 'subtotal_amount_in_cents' }]
         }
       }
-      this.client.previewSubscriptionChange = sinon
+      ctx.client.previewSubscriptionChange = sinon
         .stub()
         .throws(new ValidationError())
       await expect(
-        this.RecurlyClient.promises.previewSubscriptionChange(
+        ctx.RecurlyClient.promises.previewSubscriptionChange(
           new PaymentProviderSubscriptionChangeRequest({
-            subscription: this.subscription,
+            subscription: ctx.subscription,
             timeframe: 'now',
             planCode: 'new-plan',
           })
         )
-      ).to.be.rejectedWith(this.Errors.SubtotalLimitExceededError)
+      ).to.be.rejectedWith(ctx.Errors.SubtotalLimitExceededError)
     })
 
-    it('should rethrow errors different than SubtotalLimitExceededError', async function () {
-      this.client.previewSubscriptionChange = sinon.stub().throws(new Error())
+    it('should rethrow errors different than SubtotalLimitExceededError', async function (ctx) {
+      ctx.client.previewSubscriptionChange = sinon.stub().throws(new Error())
       await expect(
-        this.RecurlyClient.promises.previewSubscriptionChange(
+        ctx.RecurlyClient.promises.previewSubscriptionChange(
           new PaymentProviderSubscriptionChangeRequest({
-            subscription: this.subscription,
+            subscription: ctx.subscription,
             timeframe: 'now',
             planCode: 'new-plan',
           })
@@ -701,81 +710,81 @@ describe('RecurlyClient', function () {
   })
 
   describe('getPaymentMethod', function () {
-    it('should throw MissingBillingInfoError', async function () {
-      this.client.getBillingInfo = sinon
+    it('should throw MissingBillingInfoError', async function (ctx) {
+      ctx.client.getBillingInfo = sinon
         .stub()
         .throws(new recurly.errors.NotFoundError())
       await expect(
-        this.RecurlyClient.promises.getPaymentMethod(this.user._id)
-      ).to.be.rejectedWith(this.Errors.MissingBillingInfoError)
+        ctx.RecurlyClient.promises.getPaymentMethod(ctx.user._id)
+      ).to.be.rejectedWith(ctx.Errors.MissingBillingInfoError)
     })
 
-    it('should rethrow errors different than MissingBillingInfoError', async function () {
-      this.client.getBillingInfo = sinon.stub().throws(new Error())
+    it('should rethrow errors different than MissingBillingInfoError', async function (ctx) {
+      ctx.client.getBillingInfo = sinon.stub().throws(new Error())
       await expect(
-        this.RecurlyClient.promises.getPaymentMethod(this.user._id)
+        ctx.RecurlyClient.promises.getPaymentMethod(ctx.user._id)
       ).to.be.rejectedWith(Error)
     })
   })
 
   describe('terminateSubscriptionByUuid', function () {
-    it('should attempt to terminate the subscription', async function () {
-      this.client.terminateSubscription = sinon
+    it('should attempt to terminate the subscription', async function (ctx) {
+      ctx.client.terminateSubscription = sinon
         .stub()
-        .resolves(this.recurlySubscription)
+        .resolves(ctx.recurlySubscription)
       const subscription =
-        await this.RecurlyClient.promises.terminateSubscriptionByUuid(
-          this.subscription.uuid
+        await ctx.RecurlyClient.promises.terminateSubscriptionByUuid(
+          ctx.subscription.uuid
         )
-      expect(subscription).to.deep.equal(this.recurlySubscription)
-      expect(this.client.terminateSubscription).to.be.calledWith(
-        'uuid-' + this.subscription.uuid
+      expect(subscription).to.deep.equal(ctx.recurlySubscription)
+      expect(ctx.client.terminateSubscription).to.be.calledWith(
+        'uuid-' + ctx.subscription.uuid
       )
     })
   })
 
   describe('getPastDueInvoices', function () {
-    beforeEach(function () {
-      this.client.listSubscriptionInvoices = sinon.stub()
+    beforeEach(function (ctx) {
+      ctx.client.listSubscriptionInvoices = sinon.stub()
     })
 
-    it('should return empty if no past due are found', async function () {
-      this.client.listSubscriptionInvoices.returns({
+    it('should return empty if no past due are found', async function (ctx) {
+      ctx.client.listSubscriptionInvoices.returns({
         each: async function* () {},
       })
-      const invoices = await this.RecurlyClient.promises.getPastDueInvoices(
-        this.subscription.id
+      const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices(
+        ctx.subscription.id
       )
       expect(invoices).to.deep.equal([])
     })
 
-    it('should return past due invoice', async function () {
+    it('should return past due invoice', async function (ctx) {
       const pastDueInvoice = { id: 'invoice-1', state: 'past_due' }
-      this.client.listSubscriptionInvoices.returns({
+      ctx.client.listSubscriptionInvoices.returns({
         each: async function* () {
           yield pastDueInvoice
         },
       })
-      const invoices = await this.RecurlyClient.promises.getPastDueInvoices(
-        this.subscription.id
+      const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices(
+        ctx.subscription.id
       )
       expect(invoices).to.deep.equal([pastDueInvoice])
     })
 
-    it('should return multiple invoices if multiple past due exist', async function () {
+    it('should return multiple invoices if multiple past due exist', async function (ctx) {
       const pastDueInvoices = [
         { id: 'invoice-1', state: 'past_due' },
         { id: 'invoice-2', state: 'past_due' },
       ]
-      this.client.listSubscriptionInvoices.returns({
+      ctx.client.listSubscriptionInvoices.returns({
         each: async function* () {
           for (const invoice of pastDueInvoices) {
             yield invoice
           }
         },
       })
-      const invoices = await this.RecurlyClient.promises.getPastDueInvoices(
-        this.subscription.id
+      const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices(
+        ctx.subscription.id
       )
       expect(invoices).to.deep.equal(pastDueInvoices)
     })

Разница между файлами не показана из-за своего большого размера
+ 319 - 309
services/web/test/unit/src/Subscription/RecurlyWrapper.test.mjs


+ 67 - 62
services/web/test/unit/src/Subscription/SubscriptionLocator.test.mjs

@@ -1,14 +1,13 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
 const modulePath =
   '../../../../app/src/Features/Subscription/SubscriptionLocator'
-const { expect } = require('chai')
 
 describe('Subscription Locator Tests', function () {
-  beforeEach(function () {
-    this.user = { _id: '5208dd34438842e2db333333' }
-    this.subscription = { hello: 'world' }
-    this.Subscription = {
+  beforeEach(async function (ctx) {
+    ctx.user = { _id: '5208dd34438842e2db333333' }
+    ctx.subscription = { hello: 'world' }
+    ctx.Subscription = {
       findOne: sinon.stub().returns({
         exec: sinon.stub().resolves(),
       }),
@@ -21,7 +20,7 @@ describe('Subscription Locator Tests', function () {
         exec: sinon.stub().resolves(),
       }),
     }
-    this.DeletedSubscription = {
+    ctx.DeletedSubscription = {
       findOne: sinon.stub().returns({
         exec: sinon.stub().resolves(),
       }),
@@ -30,63 +29,69 @@ describe('Subscription Locator Tests', function () {
       }),
     }
 
-    this.SubscriptionLocator = SandboxedModule.require(modulePath, {
-      requires: {
-        './GroupPlansData': {},
-        '../../models/Subscription': {
-          Subscription: this.Subscription,
-        },
-        '../../models/DeletedSubscription': {
-          DeletedSubscription: this.DeletedSubscription,
-        },
-        '../../models/SSOConfig': {
-          SSOConfig: this.SSOConfig,
-        },
-      },
-    })
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/GroupPlansData',
+      () => ({
+        default: {},
+      })
+    )
+
+    vi.doMock('../../../../app/src/models/Subscription', () => ({
+      Subscription: ctx.Subscription,
+    }))
+
+    vi.doMock('../../../../app/src/models/DeletedSubscription', () => ({
+      DeletedSubscription: ctx.DeletedSubscription,
+    }))
+
+    vi.doMock('../../../../app/src/models/SSOConfig', () => ({
+      SSOConfig: ctx.SSOConfig,
+    }))
+
+    ctx.SubscriptionLocator = (await import(modulePath)).default
   })
 
   describe('finding users subscription', function () {
-    it('should send the users features', async function () {
-      this.Subscription.findOne.returns({
-        exec: sinon.stub().resolves(this.subscription),
+    it('should send the users features', async function (ctx) {
+      ctx.Subscription.findOne.returns({
+        exec: sinon.stub().resolves(ctx.subscription),
       })
       const subscription =
-        await this.SubscriptionLocator.promises.getUsersSubscription(this.user)
-      this.Subscription.findOne
-        .calledWith({ admin_id: this.user._id })
+        await ctx.SubscriptionLocator.promises.getUsersSubscription(ctx.user)
+      ctx.Subscription.findOne
+        .calledWith({ admin_id: ctx.user._id })
         .should.equal(true)
-      subscription.should.equal(this.subscription)
+      subscription.should.equal(ctx.subscription)
     })
 
-    it('should error if not found', async function () {
-      this.Subscription.findOne.returns({
+    it('should error if not found', async function (ctx) {
+      ctx.Subscription.findOne.returns({
         exec: sinon.stub().rejects('not found'),
       })
       await expect(
-        this.SubscriptionLocator.promises.getUsersSubscription(this.user)
+        ctx.SubscriptionLocator.promises.getUsersSubscription(ctx.user)
       ).to.be.rejected
     })
 
-    it('should take a user id rather than the user object', async function () {
-      this.Subscription.findOne.returns({
-        exec: sinon.stub().resolves(this.subscription),
+    it('should take a user id rather than the user object', async function (ctx) {
+      ctx.Subscription.findOne.returns({
+        exec: sinon.stub().resolves(ctx.subscription),
       })
       const subscription =
-        await this.SubscriptionLocator.promises.getUsersSubscription(
-          this.user._id
+        await ctx.SubscriptionLocator.promises.getUsersSubscription(
+          ctx.user._id
         )
-      this.Subscription.findOne
-        .calledWith({ admin_id: this.user._id })
+      ctx.Subscription.findOne
+        .calledWith({ admin_id: ctx.user._id })
         .should.equal(true)
-      subscription.should.equal(this.subscription)
+      subscription.should.equal(ctx.subscription)
     })
   })
 
   describe('getUserSubscriptionStatus', function () {
-    it('should return no active personal or group subscription when no user is passed', async function () {
+    it('should return no active personal or group subscription when no user is passed', async function (ctx) {
       const subscriptionStatus =
-        await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
+        await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus(
           undefined
         )
       expect(subscriptionStatus).to.deep.equal({
@@ -95,10 +100,10 @@ describe('Subscription Locator Tests', function () {
       })
     })
 
-    it('should return no active personal or group subscription when the user has no subscription', async function () {
+    it('should return no active personal or group subscription when the user has no subscription', async function (ctx) {
       const subscriptionStatus =
-        await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
-          this.user._id
+        await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus(
+          ctx.user._id
         )
       expect(subscriptionStatus).to.deep.equal({
         personal: false,
@@ -106,8 +111,8 @@ describe('Subscription Locator Tests', function () {
       })
     })
 
-    it('should return active personal subscription', async function () {
-      this.Subscription.findOne.returns({
+    it('should return active personal subscription', async function (ctx) {
+      ctx.Subscription.findOne.returns({
         exec: sinon.stub().resolves({
           recurlyStatus: {
             state: 'active',
@@ -115,14 +120,14 @@ describe('Subscription Locator Tests', function () {
         }),
       })
       const subscriptionStatus =
-        await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
-          this.user._id
+        await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus(
+          ctx.user._id
         )
       expect(subscriptionStatus).to.deep.equal({ personal: true, group: false })
     })
 
-    it('should return active group subscription when member of a group plan', async function () {
-      this.Subscription.find.returns({
+    it('should return active group subscription when member of a group plan', async function (ctx) {
+      ctx.Subscription.find.returns({
         populate: sinon.stub().returns({
           populate: sinon.stub().returns({
             exec: sinon.stub().resolves([
@@ -137,14 +142,14 @@ describe('Subscription Locator Tests', function () {
         }),
       })
       const subscriptionStatus =
-        await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
-          this.user._id
+        await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus(
+          ctx.user._id
         )
       expect(subscriptionStatus).to.deep.equal({ personal: false, group: true })
     })
 
-    it('should return active group subscription when owner of a group plan', async function () {
-      this.Subscription.findOne.returns({
+    it('should return active group subscription when owner of a group plan', async function (ctx) {
+      ctx.Subscription.findOne.returns({
         exec: sinon.stub().resolves({
           recurlyStatus: {
             state: 'active',
@@ -153,14 +158,14 @@ describe('Subscription Locator Tests', function () {
         }),
       })
       const subscriptionStatus =
-        await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
-          this.user._id
+        await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus(
+          ctx.user._id
         )
       expect(subscriptionStatus).to.deep.equal({ personal: false, group: true })
     })
 
-    it('should return active personal and group subscription when has personal subscription and member of a group', async function () {
-      this.Subscription.find.returns({
+    it('should return active personal and group subscription when has personal subscription and member of a group', async function (ctx) {
+      ctx.Subscription.find.returns({
         populate: sinon.stub().returns({
           populate: sinon.stub().returns({
             exec: sinon.stub().resolves([
@@ -174,7 +179,7 @@ describe('Subscription Locator Tests', function () {
           }),
         }),
       })
-      this.Subscription.findOne.returns({
+      ctx.Subscription.findOne.returns({
         exec: sinon.stub().resolves({
           recurlyStatus: {
             state: 'active',
@@ -182,8 +187,8 @@ describe('Subscription Locator Tests', function () {
         }),
       })
       const subscriptionStatus =
-        await this.SubscriptionLocator.promises.getUserSubscriptionStatus(
-          this.user._id
+        await ctx.SubscriptionLocator.promises.getUserSubscriptionStatus(
+          ctx.user._id
         )
       expect(subscriptionStatus).to.deep.equal({ personal: true, group: true })
     })

Разница между файлами не показана из-за своего большого размера
+ 379 - 337
services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs


+ 27 - 26
services/web/test/unit/src/Subscription/UserFeaturesUpdater.test.mjs

@@ -1,12 +1,11 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const sinon = require('sinon')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
 const modulePath =
   '../../../../app/src/Features/Subscription/UserFeaturesUpdater'
 
 describe('UserFeaturesUpdater', function () {
-  beforeEach(function () {
-    this.features = {
+  beforeEach(async function (ctx) {
+    ctx.features = {
       collaborators: 6,
       dropbox: true,
       github: true,
@@ -22,23 +21,25 @@ describe('UserFeaturesUpdater', function () {
       mendeley: true,
       symbolPalette: true,
     }
-    this.User = {
+    ctx.User = {
       findByIdAndUpdate: sinon.stub().returns({
-        exec: sinon.stub().resolves({ features: this.features }),
+        exec: sinon.stub().resolves({ features: ctx.features }),
       }),
     }
-    this.UserFeaturesUpdater = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../models/User': {
-          User: this.User,
-        },
-        '@overleaf/settings': (this.Settings = {}),
-      },
-    })
+
+    vi.doMock('../../../../app/src/models/User', () => ({
+      User: ctx.User,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.Settings = {}),
+    }))
+
+    ctx.UserFeaturesUpdater = (await import(modulePath)).default
   })
 
   describe('updateFeatures', function () {
-    it('should send the users features', async function () {
+    it('should send the users features', async function (ctx) {
       const userId = '5208dd34438842e2db000005'
       const update = {
         versioning: true,
@@ -46,9 +47,9 @@ describe('UserFeaturesUpdater', function () {
       }
 
       const { features } =
-        await this.UserFeaturesUpdater.promises.updateFeatures(userId, update)
+        await ctx.UserFeaturesUpdater.promises.updateFeatures(userId, update)
 
-      const updateArgs = this.User.findByIdAndUpdate.lastCall.args
+      const updateArgs = ctx.User.findByIdAndUpdate.lastCall.args
       expect(updateArgs[0]).to.deep.equal(userId)
       expect(Object.keys(updateArgs[1]).length).to.equal(3)
       expect(updateArgs[1]['features.versioning']).to.equal(update.versioning)
@@ -60,16 +61,16 @@ describe('UserFeaturesUpdater', function () {
       expect(updateArgs[1].featuresEpoch).to.be.undefined
     })
 
-    it('should set the featuresEpoch when present', async function () {
+    it('should set the featuresEpoch when present', async function (ctx) {
       const userId = '5208dd34438842e2db000005'
       const update = {
         versioning: true,
       }
-      this.Settings.featuresEpoch = 'epoch-1'
+      ctx.Settings.featuresEpoch = 'epoch-1'
       const { features } =
-        await this.UserFeaturesUpdater.promises.updateFeatures(userId, update)
+        await ctx.UserFeaturesUpdater.promises.updateFeatures(userId, update)
 
-      const updateArgs = this.User.findByIdAndUpdate.lastCall.args
+      const updateArgs = ctx.User.findByIdAndUpdate.lastCall.args
       expect(updateArgs[0]).to.deep.equal(userId)
       expect(Object.keys(updateArgs[1]).length).to.equal(3)
       expect(updateArgs[1]['features.versioning']).to.equal(update.versioning)
@@ -80,13 +81,13 @@ describe('UserFeaturesUpdater', function () {
   })
 
   describe('overrideFeatures', function () {
-    it('should send the users features', async function () {
+    it('should send the users features', async function (ctx) {
       const userId = '5208dd34438842e2db000005'
-      const update = Object.assign({}, { mendeley: !this.features.mendeley })
+      const update = Object.assign({}, { mendeley: !ctx.features.mendeley })
       const featuresChanged =
-        await this.UserFeaturesUpdater.promises.overrideFeatures(userId, update)
+        await ctx.UserFeaturesUpdater.promises.overrideFeatures(userId, update)
 
-      const updateArgs = this.User.findByIdAndUpdate.lastCall.args
+      const updateArgs = ctx.User.findByIdAndUpdate.lastCall.args
       expect(updateArgs[0]).to.equal(userId)
       expect(Object.keys(updateArgs[1]).length).to.equal(2)
       expect(updateArgs[1].features).to.deep.equal(update)

+ 202 - 163
services/web/test/unit/src/Subscription/V1SusbcriptionManager.test.mjs

@@ -1,80 +1,89 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
+import { vi, expect } from 'vitest'
+import path from 'path'
+import sinon from 'sinon'
 const modulePath = path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/Features/Subscription/V1SubscriptionManager'
 )
-const sinon = require('sinon')
-const { expect } = require('chai')
 
 describe('V1SubscriptionManager', function () {
-  beforeEach(function () {
-    this.V1SubscriptionManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '../User/UserGetter': (this.UserGetter = {}),
-        '@overleaf/settings': (this.Settings = {
-          apis: {
-            v1: {
-              host: (this.host = 'http://overleaf.example.com'),
-              url: 'v1.url',
-            },
-          },
-          v1GrandfatheredFeaturesUidCutoff: 10,
-          v1GrandfatheredFeatures: {
-            github: true,
-            mendeley: true,
+  beforeEach(async function (ctx) {
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: (ctx.UserGetter = {}),
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.Settings = {
+        apis: {
+          v1: {
+            host: (ctx.host = 'http://overleaf.example.com'),
+            url: 'v1.url',
           },
-        }),
-        requestretry: (this.request = sinon.stub()),
-      },
-    })
-    this.userId = 'abcd'
-    this.v1UserId = 42
-    this.user = {
-      _id: this.userId,
+        },
+        v1GrandfatheredFeaturesUidCutoff: 10,
+        v1GrandfatheredFeatures: {
+          github: true,
+          mendeley: true,
+        },
+      }),
+    }))
+
+    vi.doMock('requestretry', () => ({
+      default: (ctx.request = sinon.stub()),
+    }))
+
+    ctx.V1SubscriptionManager = (await import(modulePath)).default
+    ctx.userId = 'abcd'
+    ctx.v1UserId = 42
+    ctx.user = {
+      _id: ctx.userId,
       email: 'user@example.com',
       overleaf: {
-        id: this.v1UserId,
+        id: ctx.v1UserId,
       },
     }
   })
 
   describe('getGrandfatheredFeaturesForV1User', function () {
     describe('when the user ID is greater than the cutoff', function () {
-      it('should return an empty feature set', function (done) {
-        expect(
-          this.V1SubscriptionManager.getGrandfatheredFeaturesForV1User(100)
-        ).to.eql({})
-        done()
+      it('should return an empty feature set', async function (ctx) {
+        await new Promise(resolve => {
+          expect(
+            ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User(100)
+          ).to.eql({})
+          resolve()
+        })
       })
     })
 
     describe('when the user ID is less than the cutoff', function () {
-      it('should return a feature set with grandfathered properties for github and mendeley', function (done) {
-        expect(
-          this.V1SubscriptionManager.getGrandfatheredFeaturesForV1User(1)
-        ).to.eql({
-          github: true,
-          mendeley: true,
+      it('should return a feature set with grandfathered properties for github and mendeley', async function (ctx) {
+        await new Promise(resolve => {
+          expect(
+            ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User(1)
+          ).to.eql({
+            github: true,
+            mendeley: true,
+          })
+          resolve()
         })
-        done()
       })
     })
   })
 
   describe('_v1Request', function () {
-    beforeEach(function () {
-      this.UserGetter.getUser = sinon.stub().yields(null, this.user)
+    beforeEach(function (ctx) {
+      ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user)
     })
 
     describe('when v1IdForUser produces an error', function () {
-      beforeEach(function () {
-        this.V1SubscriptionManager.v1IdForUser = sinon
+      beforeEach(function (ctx) {
+        ctx.V1SubscriptionManager.v1IdForUser = sinon
           .stub()
           .yields(new Error('woops'))
-        this.call = cb => {
-          this.V1SubscriptionManager._v1Request(
-            this.user_id,
+        ctx.call = cb => {
+          ctx.V1SubscriptionManager._v1Request(
+            ctx.user_id,
             {
               url() {
                 return '/foo'
@@ -85,27 +94,31 @@ describe('V1SubscriptionManager', function () {
         }
       })
 
-      it('should not call request', function (done) {
-        this.call(() => {
-          expect(this.request.callCount).to.equal(0)
-          done()
+      it('should not call request', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call(() => {
+            expect(ctx.request.callCount).to.equal(0)
+            resolve()
+          })
         })
       })
 
-      it('should produce an error', function (done) {
-        this.call((err, planCode) => {
-          expect(err).to.exist
-          done()
+      it('should produce an error', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, planCode) => {
+            expect(err).to.exist
+            resolve()
+          })
         })
       })
     })
 
     describe('when v1IdForUser does not find a user', function () {
-      beforeEach(function () {
-        this.V1SubscriptionManager.v1IdForUser = sinon.stub().yields(null, null)
-        this.call = cb => {
-          this.V1SubscriptionManager._v1Request(
-            this.user_id,
+      beforeEach(function (ctx) {
+        ctx.V1SubscriptionManager.v1IdForUser = sinon.stub().yields(null, null)
+        ctx.call = cb => {
+          ctx.V1SubscriptionManager._v1Request(
+            ctx.user_id,
             {
               url() {
                 return '/foo'
@@ -116,28 +129,32 @@ describe('V1SubscriptionManager', function () {
         }
       })
 
-      it('should not call request', function (done) {
-        this.call((err, planCode) => {
-          if (err) return done(err)
-          expect(this.request.callCount).to.equal(0)
-          done()
+      it('should not call request', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, planCode) => {
+            if (err) return resolve(err)
+            expect(ctx.request.callCount).to.equal(0)
+            resolve()
+          })
         })
       })
 
-      it('should not error', function (done) {
-        this.call(err => {
-          expect(err).to.not.exist
-          done()
+      it('should not error', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call(err => {
+            expect(err).to.not.exist
+            resolve()
+          })
         })
       })
     })
 
     describe('when the request to v1 fails', function () {
-      beforeEach(function () {
-        this.request.yields(new Error('woops'))
-        this.call = cb => {
-          this.V1SubscriptionManager._v1Request(
-            this.user_id,
+      beforeEach(function (ctx) {
+        ctx.request.yields(new Error('woops'))
+        ctx.call = cb => {
+          ctx.V1SubscriptionManager._v1Request(
+            ctx.user_id,
             {
               url() {
                 return '/foo'
@@ -148,23 +165,25 @@ describe('V1SubscriptionManager', function () {
         }
       })
 
-      it('should produce an error', function (done) {
-        this.call(err => {
-          expect(err).to.exist
-          done()
+      it('should produce an error', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call(err => {
+            expect(err).to.exist
+            resolve()
+          })
         })
       })
     })
 
     describe('when the call succeeds', function () {
-      beforeEach(function () {
-        this.V1SubscriptionManager.v1IdForUser = sinon
+      beforeEach(function (ctx) {
+        ctx.V1SubscriptionManager.v1IdForUser = sinon
           .stub()
-          .yields(null, this.v1UserId)
-        this.request.yields(null, { statusCode: 200 }, '{}')
-        this.call = cb => {
-          this.V1SubscriptionManager._v1Request(
-            this.user_id,
+          .yields(null, ctx.v1UserId)
+        ctx.request.yields(null, { statusCode: 200 }, '{}')
+        ctx.call = cb => {
+          ctx.V1SubscriptionManager._v1Request(
+            ctx.user_id,
             {
               method: 'GET',
               url() {
@@ -176,52 +195,60 @@ describe('V1SubscriptionManager', function () {
         }
       })
 
-      it('should not produce an error', function (done) {
-        this.call((err, body, v1Id) => {
-          expect(err).not.to.exist
-          done()
+      it('should not produce an error', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, body, v1Id) => {
+            expect(err).not.to.exist
+            resolve()
+          })
         })
       })
 
-      it('should have supplied retry options to request', function (done) {
-        this.call((err, body, v1Id) => {
-          if (err) return done(err)
-          const requestOptions = this.request.lastCall.args[0]
-          expect(requestOptions.url).to.equal('/foo')
-          expect(requestOptions.maxAttempts).to.exist
-          expect(requestOptions.maxAttempts > 0).to.be.true
-          expect(requestOptions.retryDelay).to.exist
-          expect(requestOptions.retryDelay > 0).to.be.true
-          done()
+      it('should have supplied retry options to request', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, body, v1Id) => {
+            if (err) return resolve(err)
+            const requestOptions = ctx.request.lastCall.args[0]
+            expect(requestOptions.url).to.equal('/foo')
+            expect(requestOptions.maxAttempts).to.exist
+            expect(requestOptions.maxAttempts > 0).to.be.true
+            expect(requestOptions.retryDelay).to.exist
+            expect(requestOptions.retryDelay > 0).to.be.true
+            resolve()
+          })
         })
       })
 
-      it('should return the v1 user id', function (done) {
-        this.call((err, body, v1Id) => {
-          if (err) return done(err)
-          expect(v1Id).to.equal(this.v1UserId)
-          done()
+      it('should return the v1 user id', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, body, v1Id) => {
+            if (err) return resolve(err)
+            expect(v1Id).to.equal(ctx.v1UserId)
+            resolve()
+          })
         })
       })
 
-      it('should return the http response body', function (done) {
-        this.call((err, body, v1Id) => {
-          if (err) return done(err)
-          expect(body).to.equal('{}')
-          done()
+      it('should return the http response body', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, body, v1Id) => {
+            if (err) return resolve(err)
+            expect(body).to.equal('{}')
+            resolve()
+          })
         })
       })
     })
 
     describe('when the call returns an http error status code', function () {
-      beforeEach(function () {
-        this.V1SubscriptionManager.v1IdForUser = sinon
+      beforeEach(function (ctx) {
+        ctx.V1SubscriptionManager.v1IdForUser = sinon
           .stub()
-          .yields(null, this.v1UserId)
-        this.request.yields(null, { statusCode: 500 }, '{}')
-        this.call = cb => {
-          this.V1SubscriptionManager._v1Request(
-            this.user_id,
+          .yields(null, ctx.v1UserId)
+        ctx.request.yields(null, { statusCode: 500 }, '{}')
+        ctx.call = cb => {
+          ctx.V1SubscriptionManager._v1Request(
+            ctx.user_id,
             {
               url() {
                 return '/foo'
@@ -232,23 +259,25 @@ describe('V1SubscriptionManager', function () {
         }
       })
 
-      it('should produce an error', function (done) {
-        this.call((err, body, v1Id) => {
-          expect(err).to.exist
-          done()
+      it('should produce an error', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, body, v1Id) => {
+            expect(err).to.exist
+            resolve()
+          })
         })
       })
     })
 
     describe('when the call returns an http not-found status code', function () {
-      beforeEach(function () {
-        this.V1SubscriptionManager.v1IdForUser = sinon
+      beforeEach(function (ctx) {
+        ctx.V1SubscriptionManager.v1IdForUser = sinon
           .stub()
-          .yields(null, this.v1UserId)
-        this.request.yields(null, { statusCode: 404 }, '{}')
-        this.call = cb => {
-          this.V1SubscriptionManager._v1Request(
-            this.user_id,
+          .yields(null, ctx.v1UserId)
+        ctx.request.yields(null, { statusCode: 404 }, '{}')
+        ctx.call = cb => {
+          ctx.V1SubscriptionManager._v1Request(
+            ctx.user_id,
             {
               url() {
                 return '/foo'
@@ -259,72 +288,82 @@ describe('V1SubscriptionManager', function () {
         }
       })
 
-      it('should produce an not-found error', function (done) {
-        this.call((err, body, v1Id) => {
-          expect(err).to.exist
-          expect(err.name).to.equal('NotFoundError')
-          done()
+      it('should produce an not-found error', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, body, v1Id) => {
+            expect(err).to.exist
+            expect(err.name).to.equal('NotFoundError')
+            resolve()
+          })
         })
       })
     })
   })
 
   describe('v1IdForUser', function () {
-    beforeEach(function () {
-      this.UserGetter.getUser = sinon.stub().yields(null, this.user)
+    beforeEach(function (ctx) {
+      ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user)
     })
 
     describe('when getUser produces an error', function () {
-      beforeEach(function () {
-        this.UserGetter.getUser = sinon.stub().yields(new Error('woops'))
-        this.call = cb => {
-          this.V1SubscriptionManager.v1IdForUser(this.user_id, cb)
+      beforeEach(function (ctx) {
+        ctx.UserGetter.getUser = sinon.stub().yields(new Error('woops'))
+        ctx.call = cb => {
+          ctx.V1SubscriptionManager.v1IdForUser(ctx.user_id, cb)
         }
       })
 
-      it('should produce an error', function (done) {
-        this.call(err => {
-          expect(err).to.exist
-          done()
+      it('should produce an error', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call(err => {
+            expect(err).to.exist
+            resolve()
+          })
         })
       })
     })
 
     describe('when getUser does not find a user', function () {
-      beforeEach(function () {
-        this.UserGetter.getUser = sinon.stub().yields(null, null)
-        this.call = cb => {
-          this.V1SubscriptionManager.v1IdForUser(this.user_id, cb)
+      beforeEach(function (ctx) {
+        ctx.UserGetter.getUser = sinon.stub().yields(null, null)
+        ctx.call = cb => {
+          ctx.V1SubscriptionManager.v1IdForUser(ctx.user_id, cb)
         }
       })
 
-      it('should not error', function (done) {
-        this.call((err, userId) => {
-          expect(err).to.not.exist
-          done()
+      it('should not error', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, userId) => {
+            expect(err).to.not.exist
+            resolve()
+          })
         })
       })
     })
 
     describe('when it works', function () {
-      beforeEach(function () {
-        this.call = cb => {
-          this.V1SubscriptionManager.v1IdForUser(this.user_id, cb)
+      beforeEach(function (ctx) {
+        ctx.call = cb => {
+          ctx.V1SubscriptionManager.v1IdForUser(ctx.user_id, cb)
         }
       })
 
-      it('should not error', function (done) {
-        this.call((err, userId) => {
-          expect(err).to.not.exist
-          done()
+      it('should not error', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, userId) => {
+            expect(err).to.not.exist
+            resolve()
+          })
         })
       })
 
-      it('should return the v1 user id', function (done) {
-        this.call((err, userId) => {
-          if (err) return done(err)
-          expect(userId).to.eql(42)
-          done()
+      it('should return the v1 user id', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.call((err, userId) => {
+            if (err) return resolve(err)
+            expect(userId).to.eql(42)
+            resolve()
+          })
         })
       })
     })

+ 153 - 154
services/web/test/unit/src/Tags/TagsHandler.test.mjs

@@ -1,92 +1,91 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const sinon = require('sinon')
-const { Tag } = require('../helpers/models/Tag')
-const { ObjectId } = require('mongodb-legacy')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/Tags/TagsHandler.js'
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import { Tag } from '../helpers/models/Tag.js'
+import mongodb from 'mongodb-legacy'
+import path from 'node:path'
+const { ObjectId } = mongodb
+
+const modulePath = path.join(
+  import.meta.dirname,
+  '../../../../app/src/Features/Tags/TagsHandler.mjs'
 )
 
 describe('TagsHandler', function () {
-  beforeEach(function () {
-    this.userId = new ObjectId().toString()
-    this.callback = sinon.stub()
+  beforeEach(async function (ctx) {
+    ctx.userId = new ObjectId().toString()
+    ctx.callback = sinon.stub()
 
-    this.tag = { user_id: this.userId, name: 'some name', color: '#3399CC' }
-    this.tagId = new ObjectId().toString()
-    this.projectId = new ObjectId().toString()
-    this.projectIds = [new ObjectId().toString(), new ObjectId().toString()]
+    ctx.tag = { user_id: ctx.userId, name: 'some name', color: '#3399CC' }
+    ctx.tagId = new ObjectId().toString()
+    ctx.projectId = new ObjectId().toString()
+    ctx.projectIds = [new ObjectId().toString(), new ObjectId().toString()]
 
-    this.mongodb = { ObjectId }
-    this.TagMock = sinon.mock(Tag)
+    ctx.mongodb = { ObjectId }
+    ctx.TagMock = sinon.mock(Tag)
 
-    this.TagsHandler = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../infrastructure/mongodb': this.mongodb,
-        '../../models/Tag': { Tag },
-      },
-    })
+    vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({
+      default: ctx.mongodb,
+    }))
+
+    vi.doMock('../../../../app/src/models/Tag', () => ({
+      Tag,
+    }))
+
+    ctx.TagsHandler = (await import(modulePath)).default
   })
 
   describe('finding users tags', function () {
-    it('should find all the documents with that user id', async function () {
+    it('should find all the documents with that user id', async function (ctx) {
       const stubbedTags = [{ name: 'tag1' }, { name: 'tag2' }, { name: 'tag3' }]
-      this.TagMock.expects('find')
+      ctx.TagMock.expects('find')
         .once()
-        .withArgs({ user_id: this.userId })
+        .withArgs({ user_id: ctx.userId })
         .resolves(stubbedTags)
-      const result = await this.TagsHandler.promises.getAllTags(this.userId)
-      this.TagMock.verify()
+      const result = await ctx.TagsHandler.promises.getAllTags(ctx.userId)
+      ctx.TagMock.verify()
       expect(result).to.deep.equal(stubbedTags)
     })
   })
 
   describe('createTag', function () {
     describe('when insert succeeds', function () {
-      it('should call insert in mongo', async function () {
-        this.TagMock.expects('create')
-          .withArgs(this.tag)
-          .once()
-          .resolves(this.tag)
-        const resultTag = await this.TagsHandler.promises.createTag(
-          this.tag.user_id,
-          this.tag.name,
-          this.tag.color
+      it('should call insert in mongo', async function (ctx) {
+        ctx.TagMock.expects('create').withArgs(ctx.tag).once().resolves(ctx.tag)
+        const resultTag = await ctx.TagsHandler.promises.createTag(
+          ctx.tag.user_id,
+          ctx.tag.name,
+          ctx.tag.color
         )
-        this.TagMock.verify()
-        expect(resultTag.user_id).to.equal(this.tag.user_id)
-        expect(resultTag.name).to.equal(this.tag.name)
-        expect(resultTag.color).to.equal(this.tag.color)
+        ctx.TagMock.verify()
+        expect(resultTag.user_id).to.equal(ctx.tag.user_id)
+        expect(resultTag.name).to.equal(ctx.tag.name)
+        expect(resultTag.color).to.equal(ctx.tag.color)
       })
     })
 
     describe('when truncate=true, and tag is too long', function () {
-      it('should truncate the tag name', async function () {
+      it('should truncate the tag name', async function (ctx) {
         // Expect the tag to end up with this truncated name
-        this.tag.name = 'a comically long tag that will be truncated intern'
-        this.TagMock.expects('create')
-          .withArgs(this.tag)
-          .once()
-          .resolves(this.tag)
-        const resultTag = await this.TagsHandler.promises.createTag(
-          this.tag.user_id,
+        ctx.tag.name = 'a comically long tag that will be truncated intern'
+        ctx.TagMock.expects('create').withArgs(ctx.tag).once().resolves(ctx.tag)
+        const resultTag = await ctx.TagsHandler.promises.createTag(
+          ctx.tag.user_id,
           // Pass this too-long name
           'a comically long tag that will be truncated internally and not throw an error',
-          this.tag.color,
+          ctx.tag.color,
           { truncate: true }
         )
-        expect(resultTag.name).to.equal(this.tag.name)
+        expect(resultTag.name).to.equal(ctx.tag.name)
       })
     })
 
     describe('when tag is too long', function () {
-      it('should throw an error', async function () {
+      it('should throw an error', async function (ctx) {
         let error
 
         try {
-          await this.TagsHandler.promises.createTag(
-            this.tag.user_id,
+          await ctx.TagsHandler.promises.createTag(
+            ctx.tag.user_id,
             'this is a tag that is very very very very very very long',
             undefined
           )
@@ -100,230 +99,230 @@ describe('TagsHandler', function () {
     })
 
     describe('when insert has duplicate key error error', function () {
-      beforeEach(function () {
-        this.duplicateKeyError = new Error('Duplicate')
-        this.duplicateKeyError.code = 11000
+      beforeEach(function (ctx) {
+        ctx.duplicateKeyError = new Error('Duplicate')
+        ctx.duplicateKeyError.code = 11000
       })
 
-      it('should get tag with findOne and return that tag', async function () {
-        this.TagMock.expects('create')
-          .withArgs(this.tag)
+      it('should get tag with findOne and return that tag', async function (ctx) {
+        ctx.TagMock.expects('create')
+          .withArgs(ctx.tag)
           .once()
-          .throws(this.duplicateKeyError)
-        this.TagMock.expects('findOne')
-          .withArgs({ user_id: this.tag.user_id, name: this.tag.name })
+          .throws(ctx.duplicateKeyError)
+        ctx.TagMock.expects('findOne')
+          .withArgs({ user_id: ctx.tag.user_id, name: ctx.tag.name })
           .once()
-          .resolves(this.tag)
-        const resultTag = await this.TagsHandler.promises.createTag(
-          this.tag.user_id,
-          this.tag.name,
-          this.tag.color
+          .resolves(ctx.tag)
+        const resultTag = await ctx.TagsHandler.promises.createTag(
+          ctx.tag.user_id,
+          ctx.tag.name,
+          ctx.tag.color
         )
-        this.TagMock.verify()
-        expect(resultTag.user_id).to.equal(this.tag.user_id)
-        expect(resultTag.name).to.equal(this.tag.name)
-        expect(resultTag.color).to.equal(this.tag.color)
+        ctx.TagMock.verify()
+        expect(resultTag.user_id).to.equal(ctx.tag.user_id)
+        expect(resultTag.name).to.equal(ctx.tag.name)
+        expect(resultTag.color).to.equal(ctx.tag.color)
       })
     })
   })
 
   describe('addProjectToTag', function () {
     describe('with a valid tag_id', function () {
-      it('should call update in mongo', async function () {
-        this.TagMock.expects('findOneAndUpdate')
+      it('should call update in mongo', async function (ctx) {
+        ctx.TagMock.expects('findOneAndUpdate')
           .once()
           .withArgs(
-            { _id: this.tagId, user_id: this.userId },
-            { $addToSet: { project_ids: this.projectId } }
+            { _id: ctx.tagId, user_id: ctx.userId },
+            { $addToSet: { project_ids: ctx.projectId } }
           )
           .resolves()
-        await this.TagsHandler.promises.addProjectToTag(
-          this.userId,
-          this.tagId,
-          this.projectId
+        await ctx.TagsHandler.promises.addProjectToTag(
+          ctx.userId,
+          ctx.tagId,
+          ctx.projectId
         )
-        this.TagMock.verify()
+        ctx.TagMock.verify()
       })
     })
   })
 
   describe('addProjectsToTag', function () {
     describe('with a valid tag_id', function () {
-      it('should call update in mongo', async function () {
-        this.TagMock.expects('findOneAndUpdate')
+      it('should call update in mongo', async function (ctx) {
+        ctx.TagMock.expects('findOneAndUpdate')
           .once()
           .withArgs(
-            { _id: this.tagId, user_id: this.userId },
-            { $addToSet: { project_ids: { $each: this.projectIds } } }
+            { _id: ctx.tagId, user_id: ctx.userId },
+            { $addToSet: { project_ids: { $each: ctx.projectIds } } }
           )
           .resolves()
-        await this.TagsHandler.promises.addProjectsToTag(
-          this.userId,
-          this.tagId,
-          this.projectIds
+        await ctx.TagsHandler.promises.addProjectsToTag(
+          ctx.userId,
+          ctx.tagId,
+          ctx.projectIds
         )
-        this.TagMock.verify()
+        ctx.TagMock.verify()
       })
     })
   })
 
   describe('addProjectToTagName', function () {
-    it('should call update in mongo', async function () {
-      this.TagMock.expects('updateOne')
+    it('should call update in mongo', async function (ctx) {
+      ctx.TagMock.expects('updateOne')
         .once()
         .withArgs(
-          { name: this.tag.name, user_id: this.tag.userId },
-          { $addToSet: { project_ids: this.projectId } },
+          { name: ctx.tag.name, user_id: ctx.tag.userId },
+          { $addToSet: { project_ids: ctx.projectId } },
           { upsert: true }
         )
         .resolves()
-      await this.TagsHandler.promises.addProjectToTagName(
-        this.tag.userId,
-        this.tag.name,
-        this.projectId
+      await ctx.TagsHandler.promises.addProjectToTagName(
+        ctx.tag.userId,
+        ctx.tag.name,
+        ctx.projectId
       )
-      this.TagMock.verify()
+      ctx.TagMock.verify()
     })
   })
 
   describe('removeProjectFromTag', function () {
     describe('with a valid tag_id', function () {
-      it('should call update in mongo', async function () {
-        this.TagMock.expects('updateOne')
+      it('should call update in mongo', async function (ctx) {
+        ctx.TagMock.expects('updateOne')
           .once()
           .withArgs(
             {
-              _id: this.tagId,
-              user_id: this.userId,
+              _id: ctx.tagId,
+              user_id: ctx.userId,
             },
             {
-              $pull: { project_ids: this.projectId },
+              $pull: { project_ids: ctx.projectId },
             }
           )
           .resolves()
-        await this.TagsHandler.promises.removeProjectFromTag(
-          this.userId,
-          this.tagId,
-          this.projectId
+        await ctx.TagsHandler.promises.removeProjectFromTag(
+          ctx.userId,
+          ctx.tagId,
+          ctx.projectId
         )
 
-        this.TagMock.verify()
+        ctx.TagMock.verify()
       })
     })
   })
 
   describe('removeProjectsFromTag', function () {
     describe('with a valid tag_id', function () {
-      it('should call update in mongo', async function () {
-        this.TagMock.expects('updateOne')
+      it('should call update in mongo', async function (ctx) {
+        ctx.TagMock.expects('updateOne')
           .once()
           .withArgs(
             {
-              _id: this.tagId,
-              user_id: this.userId,
+              _id: ctx.tagId,
+              user_id: ctx.userId,
             },
             {
-              $pullAll: { project_ids: this.projectIds },
+              $pullAll: { project_ids: ctx.projectIds },
             }
           )
           .resolves()
-        await this.TagsHandler.promises.removeProjectsFromTag(
-          this.userId,
-          this.tagId,
-          this.projectIds
+        await ctx.TagsHandler.promises.removeProjectsFromTag(
+          ctx.userId,
+          ctx.tagId,
+          ctx.projectIds
         )
-        this.TagMock.verify()
+        ctx.TagMock.verify()
       })
     })
   })
 
   describe('removeProjectFromAllTags', function () {
-    it('should pull the project id from the tag', async function () {
-      this.TagMock.expects('updateMany')
+    it('should pull the project id from the tag', async function (ctx) {
+      ctx.TagMock.expects('updateMany')
         .once()
         .withArgs(
           {
-            user_id: this.userId,
+            user_id: ctx.userId,
           },
           {
-            $pull: { project_ids: this.projectId },
+            $pull: { project_ids: ctx.projectId },
           }
         )
         .resolves()
-      await this.TagsHandler.promises.removeProjectFromAllTags(
-        this.userId,
-        this.projectId
+      await ctx.TagsHandler.promises.removeProjectFromAllTags(
+        ctx.userId,
+        ctx.projectId
       )
-      this.TagMock.verify()
+      ctx.TagMock.verify()
     })
   })
 
   describe('addProjectToTags', function () {
-    it('should add the project id to each tag', async function () {
+    it('should add the project id to each tag', async function (ctx) {
       const tagIds = []
 
-      this.TagMock.expects('updateMany')
+      ctx.TagMock.expects('updateMany')
         .once()
         .withArgs(
           {
-            user_id: this.userId,
+            user_id: ctx.userId,
             _id: { $in: tagIds },
           },
           {
-            $addToSet: { project_ids: this.projectId },
+            $addToSet: { project_ids: ctx.projectId },
           }
         )
         .resolves()
-      await this.TagsHandler.promises.addProjectToTags(
-        this.userId,
+      await ctx.TagsHandler.promises.addProjectToTags(
+        ctx.userId,
         tagIds,
-        this.projectId
+        ctx.projectId
       )
-      this.TagMock.verify()
+      ctx.TagMock.verify()
     })
   })
 
   describe('deleteTag', function () {
     describe('with a valid tag_id', function () {
-      it('should call remove in mongo', async function () {
-        this.TagMock.expects('deleteOne')
+      it('should call remove in mongo', async function (ctx) {
+        ctx.TagMock.expects('deleteOne')
           .once()
-          .withArgs({ _id: this.tagId, user_id: this.userId })
+          .withArgs({ _id: ctx.tagId, user_id: ctx.userId })
           .resolves()
-        await this.TagsHandler.promises.deleteTag(this.userId, this.tagId)
-        this.TagMock.verify()
+        await ctx.TagsHandler.promises.deleteTag(ctx.userId, ctx.tagId)
+        ctx.TagMock.verify()
       })
     })
   })
 
   describe('renameTag', function () {
     describe('with a valid tag_id', function () {
-      it('should call remove in mongo', async function () {
-        this.newName = 'new name'
-        this.TagMock.expects('updateOne')
+      it('should call remove in mongo', async function (ctx) {
+        ctx.newName = 'new name'
+        ctx.TagMock.expects('updateOne')
           .once()
           .withArgs(
-            { _id: this.tagId, user_id: this.userId },
-            { $set: { name: this.newName } }
+            { _id: ctx.tagId, user_id: ctx.userId },
+            { $set: { name: ctx.newName } }
           )
           .resolves()
-        await this.TagsHandler.promises.renameTag(
-          this.userId,
-          this.tagId,
-          this.newName
+        await ctx.TagsHandler.promises.renameTag(
+          ctx.userId,
+          ctx.tagId,
+          ctx.newName
         )
-        this.TagMock.verify()
+        ctx.TagMock.verify()
       })
     })
 
     describe('when tag is too long', function () {
-      it('should throw an error', async function () {
+      it('should throw an error', async function (ctx) {
         let error
 
         try {
-          await this.TagsHandler.promises.renameTag(
-            this.userId,
-            this.tagId,
+          await ctx.TagsHandler.promises.renameTag(
+            ctx.userId,
+            ctx.tagId,
             'this is a tag that is very very very very very very long'
           )
         } catch (err) {

+ 9 - 11
services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs

@@ -245,17 +245,15 @@ describe('TokenAccessController', function () {
       () => ({ default: ctx.AdminAuthorizationHelper })
     )
 
-    vi.doMock(
-      '../../../../app/src/Features/Helpers/UrlHelper',
-      () =>
-        (ctx.UrlHelper = {
-          getSafeAdminDomainRedirect: sinon
-            .stub()
-            .callsFake(
-              path => `${ctx.Settings.adminUrl}${getSafeRedirectPath(path)}`
-            ),
-        })
-    )
+    vi.doMock('../../../../app/src/Features/Helpers/UrlHelper', () => ({
+      default: (ctx.UrlHelper = {
+        getSafeAdminDomainRedirect: sinon
+          .stub()
+          .callsFake(
+            path => `${ctx.Settings.adminUrl}${getSafeRedirectPath(path)}`
+          ),
+      }),
+    }))
 
     vi.doMock(
       '../../../../app/src/Features/Analytics/AnalyticsManager',

Разница между файлами не показана из-за своего большого размера
+ 319 - 293
services/web/test/unit/src/TokenAccess/TokenAccessHandler.test.mjs


+ 386 - 351
services/web/test/unit/src/Uploads/ArchiveManager.test.mjs

@@ -1,9 +1,4 @@
-/* eslint-disable
-    n/handle-callback-err,
-    max-len,
-    no-return-assign,
-    no-unused-vars,
-*/
+import { vi, expect } from 'vitest'
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
 /*
@@ -12,17 +7,20 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../../app/src/Features/Uploads/ArchiveManager.js'
-const ArchiveErrors = require('../../../../app/src/Features/Uploads/ArchiveErrors')
-const SandboxedModule = require('sandboxed-module')
-const events = require('events')
+import sinon from 'sinon'
+import ArchiveErrors from '../../../../app/src/Features/Uploads/ArchiveErrors.js'
+import events from 'events'
+
+vi.mock('../../../../app/src/Features/Uploads/ArchiveErrors.js', () =>
+  vi.importActual('../../../../app/src/Features/Uploads/ArchiveErrors.js')
+)
+
+const modulePath = '../../../../app/src/Features/Uploads/ArchiveManager.mjs'
 
 describe('ArchiveManager', function () {
-  beforeEach(function () {
+  beforeEach(async function (ctx) {
     let Timer
-    this.metrics = {
+    ctx.metrics = {
       Timer: (Timer = (function () {
         Timer = class Timer {
           static initClass() {
@@ -33,181 +31,206 @@ describe('ArchiveManager', function () {
         return Timer
       })()),
     }
-    this.zipfile = new events.EventEmitter()
-    this.zipfile.readEntry = sinon.stub()
-    this.zipfile.close = sinon.stub()
-
-    this.ArchiveManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': {},
-        yauzl: (this.yauzl = {
-          open: sinon.stub().callsArgWith(2, null, this.zipfile),
-        }),
-        '@overleaf/metrics': this.metrics,
-        fs: (this.fs = { mkdir: sinon.stub().yields() }),
-        './ArchiveErrors': ArchiveErrors,
-      },
-    })
-    return (this.callback = sinon.stub())
+    ctx.zipfile = new events.EventEmitter()
+    ctx.zipfile.readEntry = sinon.stub()
+    ctx.zipfile.close = sinon.stub()
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {},
+    }))
+
+    vi.doMock('yauzl', () => ({
+      default: (ctx.yauzl = {
+        open: sinon.stub().callsArgWith(2, null, ctx.zipfile),
+      }),
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: ctx.metrics,
+    }))
+    ctx.fs = { mkdir: sinon.stub().yields(), stat: sinon.stub() }
+    vi.doMock('fs', () => ({
+      default: ctx.fs,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Uploads/ArchiveErrors',
+      () => ArchiveErrors
+    )
+
+    ctx.ArchiveManager = (await import(modulePath)).default
+    ctx.callback = sinon.stub()
   })
 
   describe('extractZipArchive', function () {
-    beforeEach(function () {
-      this.source = '/path/to/zip/source.zip'
-      this.destination = '/path/to/zip/destination'
-      return (this.ArchiveManager._isZipTooLarge = sinon
+    beforeEach(function (ctx) {
+      ctx.source = '/path/to/zip/source.zip'
+      ctx.destination = '/path/to/zip/destination'
+      ctx.ArchiveManager._isZipTooLarge = sinon
         .stub()
-        .callsArgWith(1, null, false))
+        .callsArgWith(1, null, false)
     })
 
     describe('successfully', function () {
-      beforeEach(function (done) {
-        this.readStream = new events.EventEmitter()
-        this.readStream.pipe = sinon.stub()
-        this.zipfile.openReadStream = sinon
-          .stub()
-          .callsArgWith(1, null, this.readStream)
-        this.writeStream = new events.EventEmitter()
-        this.fs.createWriteStream = sinon.stub().returns(this.writeStream)
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          done
-        )
-
-        // entry contains a single file
-        this.zipfile.emit('entry', { fileName: 'testfile.txt' })
-        this.readStream.emit('end')
-        return this.zipfile.emit('end')
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.readStream = new events.EventEmitter()
+          ctx.readStream.pipe = sinon.stub()
+          ctx.zipfile.openReadStream = sinon
+            .stub()
+            .callsArgWith(1, null, ctx.readStream)
+          ctx.writeStream = new events.EventEmitter()
+          ctx.fs.createWriteStream = sinon.stub().returns(ctx.writeStream)
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            resolve
+          )
+
+          // entry contains a single file
+          ctx.zipfile.emit('entry', { fileName: 'testfile.txt' })
+          ctx.readStream.emit('end')
+          ctx.zipfile.emit('end')
+        })
       })
 
-      it('should run yauzl', function () {
-        return this.yauzl.open.calledWith(this.source).should.equal(true)
+      it('should run yauzl', function (ctx) {
+        ctx.yauzl.open.calledWith(ctx.source).should.equal(true)
       })
 
-      it('should time the unzip', function () {
-        return this.metrics.Timer.prototype.done.called.should.equal(true)
+      it('should time the unzip', function (ctx) {
+        ctx.metrics.Timer.prototype.done.called.should.equal(true)
       })
     })
 
     describe('with a zipfile containing an empty directory', function () {
-      beforeEach(function (done) {
-        this.readStream = new events.EventEmitter()
-        this.readStream.pipe = sinon.stub()
-        this.zipfile.openReadStream = sinon
-          .stub()
-          .callsArgWith(1, null, this.readStream)
-        this.writeStream = new events.EventEmitter()
-        this.fs.createWriteStream = sinon.stub().returns(this.writeStream)
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            done()
-          }
-        )
-
-        // entry contains a single, empty directory
-        this.zipfile.emit('entry', { fileName: 'testdir/' })
-        this.readStream.emit('end')
-        return this.zipfile.emit('end')
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.readStream = new events.EventEmitter()
+          ctx.readStream.pipe = sinon.stub()
+          ctx.zipfile.openReadStream = sinon
+            .stub()
+            .callsArgWith(1, null, ctx.readStream)
+          ctx.writeStream = new events.EventEmitter()
+          ctx.fs.createWriteStream = sinon.stub().returns(ctx.writeStream)
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              resolve()
+            }
+          )
+
+          // entry contains a single, empty directory
+          ctx.zipfile.emit('entry', { fileName: 'testdir/' })
+          ctx.readStream.emit('end')
+          ctx.zipfile.emit('end')
+        })
       })
 
-      it('should return the callback with an error', function () {
-        return sinon.assert.calledWithExactly(
-          this.callback,
+      it('should return the callback with an error', function (ctx) {
+        sinon.assert.calledWithExactly(
+          ctx.callback,
           sinon.match.instanceOf(ArchiveErrors.EmptyZipFileError)
         )
       })
     })
 
     describe('with an empty zipfile', function () {
-      beforeEach(function (done) {
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
-        return this.zipfile.emit('end')
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              resolve()
+            }
+          )
+          ctx.zipfile.emit('end')
+        })
       })
 
-      it('should return the callback with an error', function () {
-        return sinon.assert.calledWithExactly(
-          this.callback,
+      it('should return the callback with an error', function (ctx) {
+        sinon.assert.calledWithExactly(
+          ctx.callback,
           sinon.match.instanceOf(ArchiveErrors.EmptyZipFileError)
         )
       })
     })
 
     describe('with an error in the zip file header', function () {
-      beforeEach(function (done) {
-        this.yauzl.open = sinon
-          .stub()
-          .callsArgWith(2, new ArchiveErrors.InvalidZipFileError())
-        return this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.yauzl.open = sinon
+            .stub()
+            .callsArgWith(2, new ArchiveErrors.InvalidZipFileError())
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should return the callback with an error', function () {
-        return sinon.assert.calledWithExactly(
-          this.callback,
+      it('should return the callback with an error', function (ctx) {
+        sinon.assert.calledWithExactly(
+          ctx.callback,
           sinon.match.instanceOf(ArchiveErrors.InvalidZipFileError)
         )
       })
     })
 
     describe('with a zip that is too large', function () {
-      beforeEach(function (done) {
-        this.ArchiveManager._isZipTooLarge = sinon
-          .stub()
-          .callsArgWith(1, null, true)
-        return this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.ArchiveManager._isZipTooLarge = sinon
+            .stub()
+            .callsArgWith(1, null, true)
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should return the callback with an error', function () {
-        return sinon.assert.calledWithExactly(
-          this.callback,
+      it('should return the callback with an error', function (ctx) {
+        sinon.assert.calledWithExactly(
+          ctx.callback,
           sinon.match.instanceOf(ArchiveErrors.ZipContentsTooLargeError)
         )
       })
 
-      it('should not call yauzl.open', function () {
-        return this.yauzl.open.called.should.equal(false)
+      it('should not call yauzl.open', function (ctx) {
+        ctx.yauzl.open.called.should.equal(false)
       })
     })
 
     describe('with an error in the extracted files', function () {
-      beforeEach(function (done) {
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
-        return this.zipfile.emit('error', new Error('Something went wrong'))
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              resolve()
+            }
+          )
+          ctx.zipfile.emit('error', new Error('Something went wrong'))
+        })
       })
 
-      it('should return the callback with an error', function () {
-        return this.callback.should.have.been.calledWithExactly(
+      it('should return the callback with an error', function (ctx) {
+        return ctx.callback.should.have.been.calledWithExactly(
           sinon.match
             .instanceOf(Error)
             .and(sinon.match.has('message', 'Something went wrong'))
@@ -216,346 +239,358 @@ describe('ArchiveManager', function () {
     })
 
     describe('with a relative extracted file path', function () {
-      beforeEach(function (done) {
-        this.zipfile.openReadStream = sinon.stub()
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
-        this.zipfile.emit('entry', { fileName: '../testfile.txt' })
-        return this.zipfile.emit('end')
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.zipfile.openReadStream = sinon.stub()
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              return resolve()
+            }
+          )
+          ctx.zipfile.emit('entry', { fileName: '../testfile.txt' })
+          return ctx.zipfile.emit('end')
+        })
       })
 
-      it('should not write try to read the file entry', function () {
-        return this.zipfile.openReadStream.called.should.equal(false)
+      it('should not write try to read the file entry', function (ctx) {
+        return ctx.zipfile.openReadStream.called.should.equal(false)
       })
     })
 
     describe('with an unnormalized extracted file path', function () {
-      beforeEach(function (done) {
-        this.zipfile.openReadStream = sinon.stub()
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
-        this.zipfile.emit('entry', { fileName: 'foo/./testfile.txt' })
-        return this.zipfile.emit('end')
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.zipfile.openReadStream = sinon.stub()
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              return resolve()
+            }
+          )
+          ctx.zipfile.emit('entry', { fileName: 'foo/./testfile.txt' })
+          return ctx.zipfile.emit('end')
+        })
       })
 
-      it('should not try to read the file entry', function () {
-        return this.zipfile.openReadStream.called.should.equal(false)
+      it('should not try to read the file entry', function (ctx) {
+        return ctx.zipfile.openReadStream.called.should.equal(false)
       })
     })
 
     describe('with backslashes in the path', function () {
-      beforeEach(function (done) {
-        this.readStream = new events.EventEmitter()
-        this.readStream.pipe = sinon.stub()
-        this.writeStream = new events.EventEmitter()
-        this.fs.createWriteStream = sinon.stub().returns(this.writeStream)
-        this.zipfile.openReadStream = sinon
-          .stub()
-          .callsArgWith(1, null, this.readStream)
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
-        this.zipfile.emit('entry', { fileName: 'wombat\\foo.tex' })
-        this.zipfile.emit('entry', { fileName: 'potato\\bar.tex' })
-        return this.zipfile.emit('end')
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.readStream = new events.EventEmitter()
+          ctx.readStream.pipe = sinon.stub()
+          ctx.writeStream = new events.EventEmitter()
+          ctx.fs.createWriteStream = sinon.stub().returns(ctx.writeStream)
+          ctx.zipfile.openReadStream = sinon
+            .stub()
+            .callsArgWith(1, null, ctx.readStream)
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              return resolve()
+            }
+          )
+          ctx.zipfile.emit('entry', { fileName: 'wombat\\foo.tex' })
+          ctx.zipfile.emit('entry', { fileName: 'potato\\bar.tex' })
+          return ctx.zipfile.emit('end')
+        })
       })
 
-      it('should read the file entry with its original path', function () {
-        this.zipfile.openReadStream.should.be.calledWith({
+      it('should read the file entry with its original path', function (ctx) {
+        ctx.zipfile.openReadStream.should.be.calledWith({
           fileName: 'wombat\\foo.tex',
         })
-        return this.zipfile.openReadStream.should.be.calledWith({
+        ctx.zipfile.openReadStream.should.be.calledWith({
           fileName: 'potato\\bar.tex',
         })
       })
 
-      it('should treat the backslashes as a directory separator when creating the directory', function () {
-        this.fs.mkdir.should.be.calledWith(`${this.destination}/wombat`, {
+      it('should treat the backslashes as a directory separator when creating the directory', function (ctx) {
+        ctx.fs.mkdir.should.be.calledWith(`${ctx.destination}/wombat`, {
           recursive: true,
         })
-        this.fs.mkdir.should.be.calledWith(`${this.destination}/potato`, {
+        ctx.fs.mkdir.should.be.calledWith(`${ctx.destination}/potato`, {
           recursive: true,
         })
       })
 
-      it('should treat the backslashes as a directory separator when creating the file', function () {
-        this.fs.createWriteStream.should.be.calledWith(
-          `${this.destination}/wombat/foo.tex`
+      it('should treat the backslashes as a directory separator when creating the file', function (ctx) {
+        ctx.fs.createWriteStream.should.be.calledWith(
+          `${ctx.destination}/wombat/foo.tex`
         )
-        return this.fs.createWriteStream.should.be.calledWith(
-          `${this.destination}/potato/bar.tex`
+        ctx.fs.createWriteStream.should.be.calledWith(
+          `${ctx.destination}/potato/bar.tex`
         )
       })
     })
 
     describe('with a directory entry', function () {
-      beforeEach(function (done) {
-        this.zipfile.openReadStream = sinon.stub()
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
-        this.zipfile.emit('entry', { fileName: 'testdir/' })
-        return this.zipfile.emit('end')
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.zipfile.openReadStream = sinon.stub()
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              resolve()
+            }
+          )
+          ctx.zipfile.emit('entry', { fileName: 'testdir/' })
+          ctx.zipfile.emit('end')
+        })
       })
 
-      it('should not try to read the entry', function () {
-        return this.zipfile.openReadStream.called.should.equal(false)
+      it('should not try to read the entry', function (ctx) {
+        ctx.zipfile.openReadStream.called.should.equal(false)
       })
     })
 
     describe('with an error opening the file read stream', function () {
-      beforeEach(function (done) {
-        this.zipfile.openReadStream = sinon
-          .stub()
-          .callsArgWith(1, new Error('Something went wrong'))
-        this.writeStream = new events.EventEmitter()
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
-        this.zipfile.emit('entry', { fileName: 'testfile.txt' })
-        return this.zipfile.emit('end')
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.zipfile.openReadStream = sinon
+            .stub()
+            .callsArgWith(1, new Error('Something went wrong'))
+          ctx.writeStream = new events.EventEmitter()
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              resolve()
+            }
+          )
+          ctx.zipfile.emit('entry', { fileName: 'testfile.txt' })
+          ctx.zipfile.emit('end')
+        })
       })
 
-      it('should return the callback with an error', function () {
-        return this.callback.should.have.been.calledWithExactly(
+      it('should return the callback with an error', function (ctx) {
+        ctx.callback.should.have.been.calledWithExactly(
           sinon.match
             .instanceOf(Error)
             .and(sinon.match.has('message', 'Something went wrong'))
         )
       })
 
-      it('should close the zipfile', function () {
-        return this.zipfile.close.called.should.equal(true)
+      it('should close the zipfile', function (ctx) {
+        ctx.zipfile.close.called.should.equal(true)
       })
     })
 
     describe('with an error in the file read stream', function () {
-      beforeEach(function (done) {
-        this.readStream = new events.EventEmitter()
-        this.readStream.pipe = sinon.stub()
-        this.zipfile.openReadStream = sinon
-          .stub()
-          .callsArgWith(1, null, this.readStream)
-        this.writeStream = new events.EventEmitter()
-        this.fs.createWriteStream = sinon.stub().returns(this.writeStream)
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
-        this.zipfile.emit('entry', { fileName: 'testfile.txt' })
-        this.readStream.emit('error', new Error('Something went wrong'))
-        return this.zipfile.emit('end')
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.readStream = new events.EventEmitter()
+          ctx.readStream.pipe = sinon.stub()
+          ctx.zipfile.openReadStream = sinon
+            .stub()
+            .callsArgWith(1, null, ctx.readStream)
+          ctx.writeStream = new events.EventEmitter()
+          ctx.fs.createWriteStream = sinon.stub().returns(ctx.writeStream)
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              return resolve()
+            }
+          )
+          ctx.zipfile.emit('entry', { fileName: 'testfile.txt' })
+          ctx.readStream.emit('error', new Error('Something went wrong'))
+          ctx.zipfile.emit('end')
+        })
       })
 
-      it('should return the callback with an error', function () {
-        return this.callback.should.have.been.calledWithExactly(
+      it('should return the callback with an error', function (ctx) {
+        ctx.callback.should.have.been.calledWithExactly(
           sinon.match
             .instanceOf(Error)
             .and(sinon.match.has('message', 'Something went wrong'))
         )
       })
 
-      it('should close the zipfile', function () {
-        return this.zipfile.close.called.should.equal(true)
+      it('should close the zipfile', function (ctx) {
+        ctx.zipfile.close.called.should.equal(true)
       })
     })
 
     describe('with an error in the file write stream', function () {
-      beforeEach(function (done) {
-        this.readStream = new events.EventEmitter()
-        this.readStream.pipe = sinon.stub()
-        this.readStream.unpipe = sinon.stub()
-        this.readStream.destroy = sinon.stub()
-        this.zipfile.openReadStream = sinon
-          .stub()
-          .callsArgWith(1, null, this.readStream)
-        this.writeStream = new events.EventEmitter()
-        this.fs.createWriteStream = sinon.stub().returns(this.writeStream)
-        this.ArchiveManager.extractZipArchive(
-          this.source,
-          this.destination,
-          error => {
-            this.callback(error)
-            return done()
-          }
-        )
-        this.zipfile.emit('entry', { fileName: 'testfile.txt' })
-        this.writeStream.emit('error', new Error('Something went wrong'))
-        return this.zipfile.emit('end')
+      beforeEach(async function (ctx) {
+        await new Promise(resolve => {
+          ctx.readStream = new events.EventEmitter()
+          ctx.readStream.pipe = sinon.stub()
+          ctx.readStream.unpipe = sinon.stub()
+          ctx.readStream.destroy = sinon.stub()
+          ctx.zipfile.openReadStream = sinon
+            .stub()
+            .callsArgWith(1, null, ctx.readStream)
+          ctx.writeStream = new events.EventEmitter()
+          ctx.fs.createWriteStream = sinon.stub().returns(ctx.writeStream)
+          ctx.ArchiveManager.extractZipArchive(
+            ctx.source,
+            ctx.destination,
+            error => {
+              ctx.callback(error)
+              return resolve()
+            }
+          )
+          ctx.zipfile.emit('entry', { fileName: 'testfile.txt' })
+          ctx.writeStream.emit('error', new Error('Something went wrong'))
+          ctx.zipfile.emit('end')
+        })
       })
 
-      it('should return the callback with an error', function () {
-        return this.callback.should.have.been.calledWithExactly(
+      it('should return the callback with an error', function (ctx) {
+        ctx.callback.should.have.been.calledWithExactly(
           sinon.match
             .instanceOf(Error)
             .and(sinon.match.has('message', 'Something went wrong'))
         )
       })
 
-      it('should unpipe from the readstream', function () {
-        return this.readStream.unpipe.called.should.equal(true)
+      it('should unpipe from the readstream', function (ctx) {
+        ctx.readStream.unpipe.called.should.equal(true)
       })
 
-      it('should destroy the readstream', function () {
-        return this.readStream.destroy.called.should.equal(true)
+      it('should destroy the readstream', function (ctx) {
+        ctx.readStream.destroy.called.should.equal(true)
       })
 
-      it('should close the zipfile', function () {
-        return this.zipfile.close.called.should.equal(true)
+      it('should close the zipfile', function (ctx) {
+        ctx.zipfile.close.called.should.equal(true)
       })
     })
   })
 
   describe('_isZipTooLarge', function () {
-    it('should return false with small output', function (done) {
-      this.ArchiveManager._isZipTooLarge(this.source, (error, isTooLarge) => {
-        isTooLarge.should.equal(false)
-        return done()
+    it('should return false with small output', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.ArchiveManager._isZipTooLarge(ctx.source, (error, isTooLarge) => {
+          expect(error).not.to.exist
+          isTooLarge.should.equal(false)
+          resolve()
+        })
+        ctx.zipfile.emit('entry', { uncompressedSize: 109042 })
+        ctx.zipfile.emit('end')
       })
-      this.zipfile.emit('entry', { uncompressedSize: 109042 })
-      return this.zipfile.emit('end')
     })
 
-    it('should return true with large bytes', function (done) {
-      this.ArchiveManager._isZipTooLarge(this.source, (error, isTooLarge) => {
-        isTooLarge.should.equal(true)
-        return done()
+    it('should return true with large bytes', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.ArchiveManager._isZipTooLarge(ctx.source, (error, isTooLarge) => {
+          expect(error).not.to.exist
+          isTooLarge.should.equal(true)
+          resolve()
+        })
+        ctx.zipfile.emit('entry', { uncompressedSize: 109e16 })
+        ctx.zipfile.emit('end')
       })
-      this.zipfile.emit('entry', { uncompressedSize: 109e16 })
-      return this.zipfile.emit('end')
     })
 
-    it('should return error on no data', function (done) {
-      this.ArchiveManager._isZipTooLarge(this.source, (error, isTooLarge) => {
-        expect(error).to.exist
-        return done()
+    it('should return error on no data', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.ArchiveManager._isZipTooLarge(ctx.source, (error, isTooLarge) => {
+          expect(error).to.exist
+          resolve()
+        })
+        ctx.zipfile.emit('entry', {})
+        ctx.zipfile.emit('end')
       })
-      this.zipfile.emit('entry', {})
-      return this.zipfile.emit('end')
     })
 
-    it("should return error if it didn't get a number", function (done) {
-      this.ArchiveManager._isZipTooLarge(this.source, (error, isTooLarge) => {
-        expect(error).to.exist
-        return done()
+    it("should return error if it didn't get a number", async function (ctx) {
+      await new Promise(resolve => {
+        ctx.ArchiveManager._isZipTooLarge(ctx.source, (error, isTooLarge) => {
+          expect(error).to.exist
+          resolve()
+        })
+        ctx.zipfile.emit('entry', { uncompressedSize: 'random-error' })
+        ctx.zipfile.emit('end')
       })
-      this.zipfile.emit('entry', { uncompressedSize: 'random-error' })
-      return this.zipfile.emit('end')
     })
 
-    it('should return error if there is no data', function (done) {
-      this.ArchiveManager._isZipTooLarge(this.source, (error, isTooLarge) => {
-        expect(error).to.exist
-        return done()
+    it('should return error if there is no data', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.ArchiveManager._isZipTooLarge(ctx.source, (error, isTooLarge) => {
+          expect(error).to.exist
+          resolve()
+        })
+        ctx.zipfile.emit('end')
       })
-      return this.zipfile.emit('end')
     })
   })
 
   describe('findTopLevelDirectory', function () {
-    beforeEach(function () {
-      this.fs.readdir = sinon.stub()
-      this.fs.stat = sinon.stub()
-      return (this.directory = 'test/directory')
+    beforeEach(function (ctx) {
+      ctx.fs.readdir = sinon.stub()
+      ctx.fs.stat((ctx.directory = 'test/directory'))
     })
 
     describe('with multiple files', function () {
-      beforeEach(function () {
-        this.fs.readdir.callsArgWith(1, null, ['multiple', 'files'])
-        return this.ArchiveManager.findTopLevelDirectory(
-          this.directory,
-          this.callback
-        )
+      beforeEach(function (ctx) {
+        ctx.fs.readdir.callsArgWith(1, null, ['multiple', 'files'])
+        ctx.ArchiveManager.findTopLevelDirectory(ctx.directory, ctx.callback)
       })
 
-      it('should find the files in the directory', function () {
-        return this.fs.readdir.calledWith(this.directory).should.equal(true)
+      it('should find the files in the directory', function (ctx) {
+        ctx.fs.readdir.calledWith(ctx.directory).should.equal(true)
       })
 
-      it('should return the original directory', function () {
-        return this.callback.calledWith(null, this.directory).should.equal(true)
+      it('should return the original directory', function (ctx) {
+        ctx.callback.calledWith(null, ctx.directory).should.equal(true)
       })
     })
 
     describe('with a single file (not folder)', function () {
-      beforeEach(function () {
-        this.fs.readdir.callsArgWith(1, null, ['foo.tex'])
-        this.fs.stat.callsArgWith(1, null, {
+      beforeEach(function (ctx) {
+        ctx.fs.readdir.callsArgWith(1, null, ['foo.tex'])
+        ctx.fs.stat.callsArgWith(1, null, {
           isDirectory() {
             return false
           },
         })
-        return this.ArchiveManager.findTopLevelDirectory(
-          this.directory,
-          this.callback
-        )
+        ctx.ArchiveManager.findTopLevelDirectory(ctx.directory, ctx.callback)
       })
 
-      it('should check if the file is a directory', function () {
-        return this.fs.stat
-          .calledWith(this.directory + '/foo.tex')
-          .should.equal(true)
+      it('should check if the file is a directory', function (ctx) {
+        ctx.fs.stat.calledWith(ctx.directory + '/foo.tex').should.equal(true)
       })
 
-      it('should return the original directory', function () {
-        return this.callback.calledWith(null, this.directory).should.equal(true)
+      it('should return the original directory', function (ctx) {
+        ctx.callback.calledWith(null, ctx.directory).should.equal(true)
       })
     })
 
     describe('with a single top-level folder', function () {
-      beforeEach(function () {
-        this.fs.readdir.callsArgWith(1, null, ['folder'])
-        this.fs.stat.callsArgWith(1, null, {
+      beforeEach(function (ctx) {
+        ctx.fs.readdir.callsArgWith(1, null, ['folder'])
+        ctx.fs.stat.callsArgWith(1, null, {
           isDirectory() {
             return true
           },
         })
-        return this.ArchiveManager.findTopLevelDirectory(
-          this.directory,
-          this.callback
-        )
+        ctx.ArchiveManager.findTopLevelDirectory(ctx.directory, ctx.callback)
       })
 
-      it('should check if the file is a directory', function () {
-        return this.fs.stat
-          .calledWith(this.directory + '/folder')
-          .should.equal(true)
+      it('should check if the file is a directory', function (ctx) {
+        ctx.fs.stat.calledWith(ctx.directory + '/folder').should.equal(true)
       })
 
-      it('should return the child directory', function () {
-        return this.callback
-          .calledWith(null, this.directory + '/folder')
+      it('should return the child directory', function (ctx) {
+        ctx.callback
+          .calledWith(null, ctx.directory + '/folder')
           .should.equal(true)
       })
     })

+ 129 - 125
services/web/test/unit/src/Uploads/FileTypeManager.test.mjs

@@ -1,114 +1,119 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const isUtf8 = require('utf-8-validate')
-const Settings = require('@overleaf/settings')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import isUtf8 from 'utf-8-validate'
+import Settings from '@overleaf/settings'
 
-const MODULE_PATH = '../../../../app/src/Features/Uploads/FileTypeManager.js'
+const MODULE_PATH = '../../../../app/src/Features/Uploads/FileTypeManager.mjs'
 
 describe('FileTypeManager', function () {
   const fileContents = 'Ich bin eine kleine Teekanne, kurz und kräftig.'
 
-  beforeEach(function () {
-    this.isUtf8 = sinon.spy(isUtf8)
-    this.stats = {
+  beforeEach(async function (ctx) {
+    ctx.isUtf8 = sinon.spy(isUtf8)
+    ctx.stats = {
       isDirectory: sinon.stub().returns(false),
       size: 100,
     }
-    this.fs = {
-      stat: sinon.stub().resolves(this.stats),
+    ctx.fs = {
+      stat: sinon.stub().resolves(ctx.stats),
       readFile: sinon.stub(),
     }
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('utf8.tex')
       .resolves(Buffer.from(fileContents, 'utf-8'))
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('utf16.tex')
       .resolves(Buffer.from(`\uFEFF${fileContents}`, 'utf-16le'))
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('latin1.tex')
       .resolves(Buffer.from(fileContents, 'latin1'))
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('latin1-null.tex')
       .resolves(Buffer.from(`${fileContents}\x00${fileContents}`, 'utf-8'))
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('utf8-null.tex')
       .resolves(Buffer.from(`${fileContents}\x00${fileContents}`, 'utf-8'))
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('utf8-non-bmp.tex')
       .resolves(Buffer.from(`${fileContents}😈`))
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('utf8-control-chars.tex')
       .resolves(Buffer.from(`${fileContents}\x0c${fileContents}`))
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('text-short.tex')
       .resolves(Buffer.from('a'.repeat(0.5 * 1024 * 1024), 'utf-8'))
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('text-smaller.tex')
       .resolves(Buffer.from('a'.repeat(2 * 1024 * 1024 - 1), 'utf-8'))
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('text-exact.tex')
       .resolves(Buffer.from('a'.repeat(2 * 1024 * 1024), 'utf-8'))
-    this.fs.readFile
+    ctx.fs.readFile
       .withArgs('text-long.tex')
       .resolves(Buffer.from('a'.repeat(3 * 1024 * 1024), 'utf-8'))
 
-    this.FileTypeManager = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        'fs/promises': this.fs,
-        'utf-8-validate': this.isUtf8,
-        '@overleaf/settings': Settings,
-      },
-    })
+    vi.doMock('fs/promises', () => ({
+      default: ctx.fs,
+    }))
+
+    vi.doMock('utf-8-validate', () => ({
+      default: ctx.isUtf8,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: Settings,
+    }))
+
+    ctx.FileTypeManager = (await import(MODULE_PATH)).default
   })
 
   describe('isDirectory', function () {
     describe('when it is a directory', function () {
-      beforeEach(function () {
-        this.stats.isDirectory.returns(true)
+      beforeEach(function (ctx) {
+        ctx.stats.isDirectory.returns(true)
       })
 
-      it('should return true', async function () {
+      it('should return true', async function (ctx) {
         const result =
-          await this.FileTypeManager.promises.isDirectory('/some/path')
+          await ctx.FileTypeManager.promises.isDirectory('/some/path')
 
         expect(result).to.equal(true)
       })
     })
 
     describe('when it is not a directory', function () {
-      beforeEach(function () {
-        this.stats.isDirectory.returns(false)
+      beforeEach(function (ctx) {
+        ctx.stats.isDirectory.returns(false)
       })
 
-      it('should return false', async function () {
+      it('should return false', async function (ctx) {
         const result =
-          await this.FileTypeManager.promises.isDirectory('/some/path')
+          await ctx.FileTypeManager.promises.isDirectory('/some/path')
         expect(result).to.equal(false)
       })
     })
   })
 
   describe('isEditable', function () {
-    it('classifies simple UTF-8 as editable', function () {
-      expect(this.FileTypeManager.isEditable(fileContents)).to.be.true
+    it('classifies simple UTF-8 as editable', function (ctx) {
+      expect(ctx.FileTypeManager.isEditable(fileContents)).to.be.true
     })
 
-    it('classifies text with non-BMP characters as binary', function () {
-      expect(this.FileTypeManager.isEditable(`${fileContents}😈`)).to.be.false
+    it('classifies text with non-BMP characters as binary', function (ctx) {
+      expect(ctx.FileTypeManager.isEditable(`${fileContents}😈`)).to.be.false
     })
 
-    it('classifies a .tex file as editable', function () {
+    it('classifies a .tex file as editable', function (ctx) {
       expect(
-        this.FileTypeManager.isEditable(fileContents, {
+        ctx.FileTypeManager.isEditable(fileContents, {
           filename: 'some/file.tex',
         })
       ).to.be.true
     })
 
-    it('classifies a .exe file as binary', function () {
+    it('classifies a .exe file as binary', function (ctx) {
       expect(
-        this.FileTypeManager.isEditable(fileContents, {
+        ctx.FileTypeManager.isEditable(fileContents, {
           filename: 'command.exe',
         })
       ).to.be.false
@@ -143,8 +148,8 @@ describe('FileTypeManager', function () {
         '/GNUMakefile',
       ]
       TEXT_FILENAMES.forEach(filename => {
-        it(`should classify ${filename} as text`, async function () {
-          const { binary } = await this.FileTypeManager.promises.getType(
+        it(`should classify ${filename} as text`, async function (ctx) {
+          const { binary } = await ctx.FileTypeManager.promises.getType(
             filename,
             'utf8.tex',
             null
@@ -154,9 +159,9 @@ describe('FileTypeManager', function () {
         })
       })
 
-      it('should not classify short text files as binary', async function () {
-        this.stats.size = 2 * 1024 * 1024 // 2MB
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should not classify short text files as binary', async function (ctx) {
+        ctx.stats.size = 2 * 1024 * 1024 // 2MB
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'text-short.tex',
           null
@@ -165,9 +170,9 @@ describe('FileTypeManager', function () {
         binary.should.equal(false)
       })
 
-      it('should not classify text files just under the size limit as binary', async function () {
-        this.stats.size = 2 * 1024 * 1024 // 2MB
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should not classify text files just under the size limit as binary', async function (ctx) {
+        ctx.stats.size = 2 * 1024 * 1024 // 2MB
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'text-smaller.tex',
           null
@@ -176,9 +181,9 @@ describe('FileTypeManager', function () {
         binary.should.equal(false)
       })
 
-      it('should classify text files at the size limit as binary', async function () {
-        this.stats.size = 2 * 1024 * 1024 // 2MB
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should classify text files at the size limit as binary', async function (ctx) {
+        ctx.stats.size = 2 * 1024 * 1024 // 2MB
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'text-exact.tex',
           null
@@ -187,9 +192,9 @@ describe('FileTypeManager', function () {
         binary.should.equal(true)
       })
 
-      it('should classify long text files as binary', async function () {
-        this.stats.size = 2 * 1024 * 1024 // 2MB
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should classify long text files as binary', async function (ctx) {
+        ctx.stats.size = 2 * 1024 * 1024 // 2MB
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'text-long.tex',
           null
@@ -198,9 +203,9 @@ describe('FileTypeManager', function () {
         binary.should.equal(true)
       })
 
-      it('should classify large text files as binary', async function () {
-        this.stats.size = 8 * 1024 * 1024 // 8MB
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should classify large text files as binary', async function (ctx) {
+        ctx.stats.size = 8 * 1024 * 1024 // 8MB
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'utf8.tex',
           null
@@ -209,55 +214,55 @@ describe('FileTypeManager', function () {
         binary.should.equal(true)
       })
 
-      it('should not try to determine the encoding of large files', async function () {
-        this.stats.size = 8 * 1024 * 1024 // 8MB
-        await this.FileTypeManager.promises.getType(
+      it('should not try to determine the encoding of large files', async function (ctx) {
+        ctx.stats.size = 8 * 1024 * 1024 // 8MB
+        await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'utf8.tex',
           null
         )
 
-        sinon.assert.notCalled(this.isUtf8)
+        sinon.assert.notCalled(ctx.isUtf8)
       })
 
-      it('should detect the encoding of a utf8 file', async function () {
-        const { encoding } = await this.FileTypeManager.promises.getType(
+      it('should detect the encoding of a utf8 file', async function (ctx) {
+        const { encoding } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'utf8.tex',
           null
         )
 
-        sinon.assert.calledOnce(this.isUtf8)
-        this.isUtf8.returned(true).should.equal(true)
+        sinon.assert.calledOnce(ctx.isUtf8)
+        ctx.isUtf8.returned(true).should.equal(true)
         encoding.should.equal('utf-8')
       })
 
-      it("should return 'latin1' for non-unicode encodings", async function () {
-        const { encoding } = await this.FileTypeManager.promises.getType(
+      it("should return 'latin1' for non-unicode encodings", async function (ctx) {
+        const { encoding } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'latin1.tex',
           null
         )
 
-        sinon.assert.calledOnce(this.isUtf8)
-        this.isUtf8.returned(false).should.equal(true)
+        sinon.assert.calledOnce(ctx.isUtf8)
+        ctx.isUtf8.returned(false).should.equal(true)
         encoding.should.equal('latin1')
       })
 
-      it('should classify utf16 with BOM as utf-16', async function () {
-        const { encoding } = await this.FileTypeManager.promises.getType(
+      it('should classify utf16 with BOM as utf-16', async function (ctx) {
+        const { encoding } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'utf16.tex',
           null
         )
 
-        sinon.assert.calledOnce(this.isUtf8)
-        this.isUtf8.returned(false).should.equal(true)
+        sinon.assert.calledOnce(ctx.isUtf8)
+        ctx.isUtf8.returned(false).should.equal(true)
         encoding.should.equal('utf-16le')
       })
 
-      it('should classify latin1 files with a null char as binary', async function () {
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should classify latin1 files with a null char as binary', async function (ctx) {
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'latin1-null.tex',
           null
@@ -265,8 +270,8 @@ describe('FileTypeManager', function () {
         expect(binary).to.equal(true)
       })
 
-      it('should classify utf8 files with a null char as binary', async function () {
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should classify utf8 files with a null char as binary', async function (ctx) {
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'utf8-null.tex',
           null
@@ -275,8 +280,8 @@ describe('FileTypeManager', function () {
         expect(binary).to.equal(true)
       })
 
-      it('should classify utf8 files with non-BMP chars as binary', async function () {
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should classify utf8 files with non-BMP chars as binary', async function (ctx) {
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.tex',
           'utf8-non-bmp.tex',
           null
@@ -285,13 +290,12 @@ describe('FileTypeManager', function () {
         expect(binary).to.equal(true)
       })
 
-      it('should classify utf8 files with ascii control chars as utf-8', async function () {
-        const { binary, encoding } =
-          await this.FileTypeManager.promises.getType(
-            '/file.tex',
-            'utf8-control-chars.tex',
-            null
-          )
+      it('should classify utf8 files with ascii control chars as utf-8', async function (ctx) {
+        const { binary, encoding } = await ctx.FileTypeManager.promises.getType(
+          '/file.tex',
+          'utf8-control-chars.tex',
+          null
+        )
 
         expect(binary).to.equal(false)
         expect(encoding).to.equal('utf-8')
@@ -307,8 +311,8 @@ describe('FileTypeManager', function () {
         '/tex',
       ]
       BINARY_FILENAMES.forEach(filename => {
-        it(`should classify ${filename} as binary`, async function () {
-          const { binary } = await this.FileTypeManager.promises.getType(
+        it(`should classify ${filename} as binary`, async function (ctx) {
+          const { binary } = await ctx.FileTypeManager.promises.getType(
             filename,
             'latin1.tex', // even if the content is not binary
             null
@@ -318,18 +322,18 @@ describe('FileTypeManager', function () {
         })
       })
 
-      it('should not try to get the character encoding', async function () {
-        await this.FileTypeManager.promises.getType(
+      it('should not try to get the character encoding', async function (ctx) {
+        await ctx.FileTypeManager.promises.getType(
           '/file.png',
           'utf8.tex',
           null
         )
 
-        sinon.assert.notCalled(this.isUtf8)
+        sinon.assert.notCalled(ctx.isUtf8)
       })
 
-      it('should recognise new binary files as binary', async function () {
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should recognise new binary files as binary', async function (ctx) {
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.py',
           'latin1.tex',
           null
@@ -338,8 +342,8 @@ describe('FileTypeManager', function () {
         binary.should.equal(true)
       })
 
-      it('should recognise existing binary files as binary', async function () {
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should recognise existing binary files as binary', async function (ctx) {
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.py',
           'latin1.tex',
           'file'
@@ -348,8 +352,8 @@ describe('FileTypeManager', function () {
         binary.should.equal(true)
       })
 
-      it('should preserve existing non-binary files as non-binary', async function () {
-        const { binary } = await this.FileTypeManager.promises.getType(
+      it('should preserve existing non-binary files as non-binary', async function (ctx) {
+        const { binary } = await ctx.FileTypeManager.promises.getType(
           '/file.py',
           'latin1.tex',
           'doc'
@@ -361,59 +365,59 @@ describe('FileTypeManager', function () {
   })
 
   describe('shouldIgnore', function () {
-    it('should ignore tex auxiliary files', async function () {
-      const ignore = this.FileTypeManager.shouldIgnore('file.aux')
+    it('should ignore tex auxiliary files', async function (ctx) {
+      const ignore = ctx.FileTypeManager.shouldIgnore('file.aux')
       ignore.should.equal(true)
     })
 
-    it('should ignore dotfiles', async function () {
-      const ignore = this.FileTypeManager.shouldIgnore('path/.git')
+    it('should ignore dotfiles', async function (ctx) {
+      const ignore = ctx.FileTypeManager.shouldIgnore('path/.git')
       ignore.should.equal(true)
     })
 
-    it('should ignore .git directories and contained files', async function () {
-      const ignore = await this.FileTypeManager.shouldIgnore('path/.git/info')
+    it('should ignore .git directories and contained files', async function (ctx) {
+      const ignore = await ctx.FileTypeManager.shouldIgnore('path/.git/info')
       ignore.should.equal(true)
     })
 
-    it('should not ignore .latexmkrc dotfile', async function () {
-      const ignore = this.FileTypeManager.shouldIgnore('path/.latexmkrc')
+    it('should not ignore .latexmkrc dotfile', async function (ctx) {
+      const ignore = ctx.FileTypeManager.shouldIgnore('path/.latexmkrc')
       ignore.should.equal(false)
     })
 
-    it('should ignore __MACOSX', async function () {
-      const ignore = this.FileTypeManager.shouldIgnore('path/__MACOSX')
+    it('should ignore __MACOSX', async function (ctx) {
+      const ignore = ctx.FileTypeManager.shouldIgnore('path/__MACOSX')
       ignore.should.equal(true)
     })
 
-    it('should ignore synctex files', async function () {
-      const ignore = this.FileTypeManager.shouldIgnore('file.synctex')
+    it('should ignore synctex files', async function (ctx) {
+      const ignore = ctx.FileTypeManager.shouldIgnore('file.synctex')
       ignore.should.equal(true)
     })
 
-    it('should ignore synctex(busy) files', async function () {
-      const ignore = this.FileTypeManager.shouldIgnore('file.synctex(busy)')
+    it('should ignore synctex(busy) files', async function (ctx) {
+      const ignore = ctx.FileTypeManager.shouldIgnore('file.synctex(busy)')
       ignore.should.equal(true)
     })
 
-    it('should not ignore .tex files', async function () {
-      const ignore = this.FileTypeManager.shouldIgnore('file.tex')
+    it('should not ignore .tex files', async function (ctx) {
+      const ignore = ctx.FileTypeManager.shouldIgnore('file.tex')
       ignore.should.equal(false)
     })
 
-    it('should ignore the case of the extension', async function () {
-      const ignore = this.FileTypeManager.shouldIgnore('file.AUX')
+    it('should ignore the case of the extension', async function (ctx) {
+      const ignore = ctx.FileTypeManager.shouldIgnore('file.AUX')
       ignore.should.equal(true)
     })
 
-    it('should not ignore files with an ignored extension as full name', async function () {
-      const ignore = this.FileTypeManager.shouldIgnore('dvi')
+    it('should not ignore files with an ignored extension as full name', async function (ctx) {
+      const ignore = ctx.FileTypeManager.shouldIgnore('dvi')
       ignore.should.equal(false)
     })
 
-    it('should not ignore directories with an ignored extension as full name', async function () {
-      this.stats.isDirectory.returns(true)
-      const ignore = this.FileTypeManager.shouldIgnore('dvi')
+    it('should not ignore directories with an ignored extension as full name', async function (ctx) {
+      ctx.stats.isDirectory.returns(true)
+      const ignore = ctx.FileTypeManager.shouldIgnore('dvi')
       ignore.should.equal(false)
     })
   })

+ 130 - 129
services/web/test/unit/src/User/ThirdPartyIdentityManager.test.mjs

@@ -1,131 +1,132 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const SandboxedModule = require('sandboxed-module')
-const OError = require('@overleaf/o-error')
-const {
-  ThirdPartyUserNotFoundError,
-} = require('../../../../app/src/Features/Errors/Errors')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import OError from '@overleaf/o-error'
+import { ThirdPartyUserNotFoundError } from '../../../../app/src/Features/Errors/Errors.js'
 const modulePath =
-  '../../../../app/src/Features/User/ThirdPartyIdentityManager.js'
+  '../../../../app/src/Features/User/ThirdPartyIdentityManager.mjs'
+
+vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+)
 
 describe('ThirdPartyIdentityManager', function () {
-  beforeEach(function () {
-    this.userId = 'a1b2c3'
-    this.user = {
-      _id: this.userId,
+  beforeEach(async function (ctx) {
+    ctx.userId = 'a1b2c3'
+    ctx.user = {
+      _id: ctx.userId,
       email: 'example@overleaf.com',
     }
-    this.externalUserId = 'id789'
-    this.externalData = {}
-    this.auditLog = { initiatorId: this.userId, ipAddress: '0:0:0:0' }
-    this.ThirdPartyIdentityManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../../../app/src/Features/User/UserAuditLogHandler':
-          (this.UserAuditLogHandler = {
-            promises: {
-              addEntry: sinon.stub().resolves(),
-            },
-          }),
-        '../../../../app/src/Features/Email/EmailHandler': (this.EmailHandler =
-          {
-            promises: {
-              sendEmail: sinon.stub().resolves(),
-            },
-          }),
-        '../../../../app/src/models/User': {
-          User: (this.User = {
-            findOneAndUpdate: sinon
-              .stub()
-              .returns({ exec: sinon.stub().resolves(this.user) }),
-            findOne: sinon.stub().returns({
-              exec: sinon.stub().resolves(undefined),
-            }),
-          }),
+    ctx.externalUserId = 'id789'
+    ctx.externalData = {}
+    ctx.auditLog = { initiatorId: ctx.userId, ipAddress: '0:0:0:0' }
+
+    vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
+      default: (ctx.UserAuditLogHandler = {
+        promises: {
+          addEntry: sinon.stub().resolves(),
         },
-        '@overleaf/settings': {
-          oauthProviders: {
-            google: {
-              name: 'Google',
-            },
-            orcid: {
-              name: 'ORCID',
-            },
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
+      default: (ctx.EmailHandler = {
+        promises: {
+          sendEmail: sinon.stub().resolves(),
+        },
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/models/User', () => ({
+      User: (ctx.User = {
+        findOneAndUpdate: sinon
+          .stub()
+          .returns({ exec: sinon.stub().resolves(ctx.user) }),
+        findOne: sinon.stub().returns({
+          exec: sinon.stub().resolves(undefined),
+        }),
+      }),
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
+        oauthProviders: {
+          google: {
+            name: 'Google',
+          },
+          orcid: {
+            name: 'ORCID',
           },
         },
       },
-    })
+    }))
+
+    ctx.ThirdPartyIdentityManager = (await import(modulePath)).default
   })
   describe('getUser', function () {
-    it('should throw an error when missing providerId or externalUserId', async function () {
+    it('should throw an error when missing providerId or externalUserId', async function (ctx) {
       await expect(
-        this.ThirdPartyIdentityManager.promises.getUser(undefined, undefined)
+        ctx.ThirdPartyIdentityManager.promises.getUser(undefined, undefined)
       ).to.be.rejectedWith(OError, `invalid SSO arguments`)
     })
 
     describe('when user linked', function () {
-      beforeEach(function () {
-        this.User.findOne.returns({
-          exec: sinon.stub().resolves(this.user),
+      beforeEach(function (ctx) {
+        ctx.User.findOne.returns({
+          exec: sinon.stub().resolves(ctx.user),
         })
       })
 
-      it('should return the user', async function () {
-        this.User.findOne.returns({
-          exec: sinon.stub().resolves(this.user),
+      it('should return the user', async function (ctx) {
+        ctx.User.findOne.returns({
+          exec: sinon.stub().resolves(ctx.user),
         })
-        const user = await this.ThirdPartyIdentityManager.promises.getUser(
+        const user = await ctx.ThirdPartyIdentityManager.promises.getUser(
           'google',
           'an-id-linked'
         )
-        expect(user).to.deep.equal(this.user)
+        expect(user).to.deep.equal(ctx.user)
       })
     })
-    it('should return ThirdPartyUserNotFoundError when no user linked', async function () {
-      let error
-
-      try {
-        await this.ThirdPartyIdentityManager.promises.getUser(
+    it('should throw ThirdPartyUserNotFoundError when no user linked', async function (ctx) {
+      await expect(
+        ctx.ThirdPartyIdentityManager.promises.getUser(
           'google',
           'an-id-not-linked'
         )
-      } catch (err) {
-        error = err
-      }
-
-      expect(error).to.be.instanceOf(ThirdPartyUserNotFoundError)
+      ).to.be.rejectedWith(ThirdPartyUserNotFoundError)
     })
   })
   describe('link', function () {
-    it('should send email alert', async function () {
-      await this.ThirdPartyIdentityManager.promises.link(
-        this.userId,
+    it('should send email alert', async function (ctx) {
+      await ctx.ThirdPartyIdentityManager.promises.link(
+        ctx.userId,
         'google',
-        this.externalUserId,
-        this.externalData,
-        this.auditLog
+        ctx.externalUserId,
+        ctx.externalData,
+        ctx.auditLog
       )
-      const emailCall = this.EmailHandler.promises.sendEmail.getCall(0)
+      const emailCall = ctx.EmailHandler.promises.sendEmail.getCall(0)
       expect(emailCall.args[0]).to.equal('securityAlert')
       expect(emailCall.args[1].actionDescribed).to.contain(
         'a Google account was linked'
       )
     })
 
-    it('should update user audit log', async function () {
-      await this.ThirdPartyIdentityManager.promises.link(
-        this.userId,
+    it('should update user audit log', async function (ctx) {
+      await ctx.ThirdPartyIdentityManager.promises.link(
+        ctx.userId,
         'google',
-        this.externalUserId,
-        this.externalData,
-        this.auditLog
+        ctx.externalUserId,
+        ctx.externalData,
+        ctx.auditLog
       )
       expect(
-        this.UserAuditLogHandler.promises.addEntry
+        ctx.UserAuditLogHandler.promises.addEntry
       ).to.have.been.calledOnceWith(
-        this.userId,
+        ctx.userId,
         'link-sso',
-        this.auditLog.initiatorId,
-        this.auditLog.ipAddress,
+        ctx.auditLog.initiatorId,
+        ctx.auditLog.ipAddress,
         {
           providerId: 'google',
         }
@@ -134,38 +135,38 @@ describe('ThirdPartyIdentityManager', function () {
     describe('errors', function () {
       const anError = new Error('oops')
 
-      it('should not unlink if the UserAuditLogHandler throws an error', async function () {
-        this.UserAuditLogHandler.promises.addEntry.throws(anError)
+      it('should not unlink if the UserAuditLogHandler throws an error', async function (ctx) {
+        ctx.UserAuditLogHandler.promises.addEntry.throws(anError)
         await expect(
-          this.ThirdPartyIdentityManager.promises.link(
-            this.userId,
+          ctx.ThirdPartyIdentityManager.promises.link(
+            ctx.userId,
             'google',
-            this.externalUserId,
-            this.externalData,
-            this.auditLog
+            ctx.externalUserId,
+            ctx.externalData,
+            ctx.auditLog
           )
         ).to.be.rejectedWith(anError)
-        expect(this.User.findOneAndUpdate).to.not.have.been.called
+        expect(ctx.User.findOneAndUpdate).to.not.have.been.called
       })
 
       describe('EmailHandler', function () {
-        beforeEach(function () {
-          this.EmailHandler.promises.sendEmail.rejects(anError)
+        beforeEach(function (ctx) {
+          ctx.EmailHandler.promises.sendEmail.rejects(anError)
         })
-        it('should log but not return the error', async function () {
+        it('should log but not return the error', async function (ctx) {
           await expect(
-            this.ThirdPartyIdentityManager.promises.link(
-              this.userId,
+            ctx.ThirdPartyIdentityManager.promises.link(
+              ctx.userId,
               'google',
-              this.externalUserId,
-              this.externalData,
-              this.auditLog
+              ctx.externalUserId,
+              ctx.externalData,
+              ctx.auditLog
             )
           ).to.be.fulfilled
-          expect(this.logger.error.lastCall).to.be.calledWithExactly(
+          expect(ctx.logger.error).toBeCalledWith(
             {
               err: anError,
-              userId: this.userId,
+              userId: ctx.userId,
             },
             'could not send security alert email when new account linked'
           )
@@ -175,31 +176,31 @@ describe('ThirdPartyIdentityManager', function () {
   })
 
   describe('unlink', function () {
-    it('should send email alert', async function () {
-      await this.ThirdPartyIdentityManager.promises.unlink(
-        this.userId,
+    it('should send email alert', async function (ctx) {
+      await ctx.ThirdPartyIdentityManager.promises.unlink(
+        ctx.userId,
         'orcid',
-        this.auditLog
+        ctx.auditLog
       )
-      const emailCall = this.EmailHandler.promises.sendEmail.getCall(0)
+      const emailCall = ctx.EmailHandler.promises.sendEmail.getCall(0)
       expect(emailCall.args[0]).to.equal('securityAlert')
       expect(emailCall.args[1].actionDescribed).to.contain(
         'an ORCID account was unlinked from'
       )
     })
-    it('should update user audit log', async function () {
-      await this.ThirdPartyIdentityManager.promises.unlink(
-        this.userId,
+    it('should update user audit log', async function (ctx) {
+      await ctx.ThirdPartyIdentityManager.promises.unlink(
+        ctx.userId,
         'orcid',
-        this.auditLog
+        ctx.auditLog
       )
       expect(
-        this.UserAuditLogHandler.promises.addEntry
+        ctx.UserAuditLogHandler.promises.addEntry
       ).to.have.been.calledOnceWith(
-        this.userId,
+        ctx.userId,
         'unlink-sso',
-        this.auditLog.initiatorId,
-        this.auditLog.ipAddress,
+        ctx.auditLog.initiatorId,
+        ctx.auditLog.ipAddress,
         {
           providerId: 'orcid',
         }
@@ -209,37 +210,37 @@ describe('ThirdPartyIdentityManager', function () {
     describe('errors', function () {
       const anError = new Error('oops')
 
-      it('should not unlink if the UserAuditLogHandler throws an error', async function () {
-        this.UserAuditLogHandler.promises.addEntry.throws(anError)
+      it('should not unlink if the UserAuditLogHandler throws an error', async function (ctx) {
+        ctx.UserAuditLogHandler.promises.addEntry.throws(anError)
 
         await expect(
-          this.ThirdPartyIdentityManager.promises.unlink(
-            this.userId,
+          ctx.ThirdPartyIdentityManager.promises.unlink(
+            ctx.userId,
             'orcid',
-            this.auditLog
+            ctx.auditLog
           )
         ).to.be.rejectedWith(anError)
 
-        expect(this.User.findOneAndUpdate).to.not.have.been.called
+        expect(ctx.User.findOneAndUpdate).to.not.have.been.called
       })
 
       describe('EmailHandler', function () {
-        beforeEach(function () {
-          this.EmailHandler.promises.sendEmail.rejects(anError)
+        beforeEach(function (ctx) {
+          ctx.EmailHandler.promises.sendEmail.rejects(anError)
         })
-        it('should log but not return the error', async function () {
+        it('should log but not return the error', async function (ctx) {
           await expect(
-            this.ThirdPartyIdentityManager.promises.unlink(
-              this.userId,
+            ctx.ThirdPartyIdentityManager.promises.unlink(
+              ctx.userId,
               'google',
-              this.auditLog
+              ctx.auditLog
             )
           ).to.be.fulfilled
 
-          expect(this.logger.error.lastCall).to.be.calledWithExactly(
+          expect(ctx.logger.error).toBeCalledWith(
             {
               err: anError,
-              userId: this.userId,
+              userId: ctx.userId,
             },
             'could not send security alert email when account no longer linked'
           )

+ 91 - 83
services/web/test/unit/src/User/UserAuditLogHandler.test.mjs

@@ -1,19 +1,22 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const { ObjectId } = require('mongodb-legacy')
-const SandboxedModule = require('sandboxed-module')
-const { UserAuditLogEntry } = require('../helpers/models/UserAuditLogEntry')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+import indirectlyImportModels from '../helpers/indirectlyImportModels.js'
+
+const { UserAuditLogEntry } = indirectlyImportModels(['UserAuditLogEntry'])
+
+const { ObjectId } = mongodb
 
 const MODULE_PATH = '../../../../app/src/Features/User/UserAuditLogHandler'
 
 describe('UserAuditLogHandler', function () {
-  beforeEach(function () {
-    this.userId = new ObjectId()
-    this.initiatorId = new ObjectId()
-    this.subscriptionId = new ObjectId()
-    this.action = {
+  beforeEach(async function (ctx) {
+    ctx.userId = new ObjectId()
+    ctx.initiatorId = new ObjectId()
+    ctx.subscriptionId = new ObjectId()
+    ctx.action = {
       operation: 'clear-sessions',
-      initiatorId: this.initiatorId,
+      initiatorId: ctx.initiatorId,
       info: {
         sessions: [
           {
@@ -24,71 +27,77 @@ describe('UserAuditLogHandler', function () {
       },
       ip: '0:0:0:0',
     }
-    this.UserAuditLogEntryMock = sinon.mock(UserAuditLogEntry)
-    this.getUniqueManagedSubscriptionMemberOfMock = sinon.stub().resolves()
-    this.UserAuditLogHandler = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '../../models/UserAuditLogEntry': { UserAuditLogEntry },
-        '../Subscription/SubscriptionLocator': {
+    ctx.UserAuditLogEntryMock = sinon.mock(UserAuditLogEntry)
+    ctx.getUniqueManagedSubscriptionMemberOfMock = sinon.stub().resolves()
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: {
           promises: {
             getUniqueManagedSubscriptionMemberOf:
-              this.getUniqueManagedSubscriptionMemberOfMock,
+              ctx.getUniqueManagedSubscriptionMemberOfMock,
           },
         },
-      },
-    })
+      })
+    )
+
+    vi.doMock('../../../../app/src/models/UserAuditLogEntry', () => ({
+      UserAuditLogEntry,
+    }))
+
+    ctx.UserAuditLogHandler = (await import(MODULE_PATH)).default
   })
 
-  afterEach(function () {
-    this.UserAuditLogEntryMock.restore()
+  afterEach(function (ctx) {
+    ctx.UserAuditLogEntryMock.restore()
   })
 
   describe('addEntry', function () {
     describe('success', function () {
-      beforeEach(function () {
-        this.dbUpdate = this.UserAuditLogEntryMock.expects('create')
+      beforeEach(function (ctx) {
+        ctx.dbUpdate = ctx.UserAuditLogEntryMock.expects('create')
           .chain('exec')
           .resolves({ modifiedCount: 1 })
       })
-      it('writes a log', async function () {
-        await this.UserAuditLogHandler.promises.addEntry(
-          this.userId,
-          this.action.operation,
-          this.action.initiatorId,
-          this.action.ip,
-          this.action.info
+      it('writes a log', async function (ctx) {
+        await ctx.UserAuditLogHandler.promises.addEntry(
+          ctx.userId,
+          ctx.action.operation,
+          ctx.action.initiatorId,
+          ctx.action.ip,
+          ctx.action.info
         )
-        this.UserAuditLogEntryMock.verify()
+        ctx.UserAuditLogEntryMock.verify()
       })
 
-      it('updates the log for password reset operation without a initiatorId', async function () {
-        await this.UserAuditLogHandler.promises.addEntry(
-          this.userId,
+      it('updates the log for password reset operation without a initiatorId', async function (ctx) {
+        await ctx.UserAuditLogHandler.promises.addEntry(
+          ctx.userId,
           'reset-password',
           undefined,
-          this.action.ip,
-          this.action.info
+          ctx.action.ip,
+          ctx.action.info
         )
-        this.UserAuditLogEntryMock.verify()
+        ctx.UserAuditLogEntryMock.verify()
       })
 
-      it('updates the log for a email removal via script', async function () {
-        await this.UserAuditLogHandler.promises.addEntry(
-          this.userId,
+      it('updates the log for a email removal via script', async function (ctx) {
+        await ctx.UserAuditLogHandler.promises.addEntry(
+          ctx.userId,
           'remove-email',
           undefined,
-          this.action.ip,
+          ctx.action.ip,
           {
             removedEmail: 'foo',
             script: true,
           }
         )
-        this.UserAuditLogEntryMock.verify()
+        ctx.UserAuditLogEntryMock.verify()
       })
 
-      it('updates the log when no ip address or initiatorId is specified for a group join event', async function () {
-        await this.UserAuditLogHandler.promises.addEntry(
-          this.userId,
+      it('updates the log when no ip address or initiatorId is specified for a group join event', async function (ctx) {
+        await ctx.UserAuditLogHandler.promises.addEntry(
+          ctx.userId,
           'join-group-subscription',
           undefined,
           undefined,
@@ -96,79 +105,78 @@ describe('UserAuditLogHandler', function () {
             subscriptionId: 'foo',
           }
         )
-        this.UserAuditLogEntryMock.verify()
+        ctx.UserAuditLogEntryMock.verify()
       })
 
-      it('includes managedSubscriptionId for managed group user events ', async function () {
-        await this.UserAuditLogHandler.promises.addEntry(
-          this.userId,
+      it('includes managedSubscriptionId for managed group user events ', async function (ctx) {
+        await ctx.UserAuditLogHandler.promises.addEntry(
+          ctx.userId,
           'reset-password',
           undefined,
-          this.action.ip
+          ctx.action.ip
         )
-        this.UserAuditLogEntryMock.verify()
-        expect(this.getUniqueManagedSubscriptionMemberOfMock).to.have.been
-          .called
+        ctx.UserAuditLogEntryMock.verify()
+        expect(ctx.getUniqueManagedSubscriptionMemberOfMock).to.have.been.called
       })
 
-      it('does not includes managedSubscriptionId for events not in the managed group event list', async function () {
-        await this.UserAuditLogHandler.promises.addEntry(
-          this.userId,
+      it('does not includes managedSubscriptionId for events not in the managed group event list', async function (ctx) {
+        await ctx.UserAuditLogHandler.promises.addEntry(
+          ctx.userId,
           'foo',
-          this.action.initiatorId,
-          this.action.ip
+          ctx.action.initiatorId,
+          ctx.action.ip
         )
-        this.UserAuditLogEntryMock.verify()
-        expect(this.getUniqueManagedSubscriptionMemberOfMock).not.to.have.been
+        ctx.UserAuditLogEntryMock.verify()
+        expect(ctx.getUniqueManagedSubscriptionMemberOfMock).not.to.have.been
           .called
       })
     })
 
     describe('errors', function () {
       describe('missing parameters', function () {
-        it('throws an error when no operation', async function () {
+        it('throws an error when no operation', async function (ctx) {
           await expect(
-            this.UserAuditLogHandler.promises.addEntry(
-              this.userId,
+            ctx.UserAuditLogHandler.promises.addEntry(
+              ctx.userId,
               undefined,
-              this.action.initiatorId,
-              this.action.ip,
-              this.action.info
+              ctx.action.initiatorId,
+              ctx.action.ip,
+              ctx.action.info
             )
           ).to.be.rejected
         })
 
-        it('throws an error when no IP and not excempt', async function () {
+        it('throws an error when no IP and not excempt', async function (ctx) {
           await expect(
-            this.UserAuditLogHandler.promises.addEntry(
-              this.userId,
-              this.action.operation,
-              this.action.initiatorId,
+            ctx.UserAuditLogHandler.promises.addEntry(
+              ctx.userId,
+              ctx.action.operation,
+              ctx.action.initiatorId,
               undefined,
-              this.action.info
+              ctx.action.info
             )
           ).to.be.rejected
         })
 
-        it('throws an error when no initiatorId and not a password reset operation', async function () {
+        it('throws an error when no initiatorId and not a password reset operation', async function (ctx) {
           await expect(
-            this.UserAuditLogHandler.promises.addEntry(
-              this.userId,
-              this.action.operation,
+            ctx.UserAuditLogHandler.promises.addEntry(
+              ctx.userId,
+              ctx.action.operation,
               undefined,
-              this.action.ip,
-              this.action.info
+              ctx.action.ip,
+              ctx.action.info
             )
           ).to.be.rejected
         })
 
-        it('throws an error when remove-email is not from a script, but has no initiatorId', async function () {
+        it('throws an error when remove-email is not from a script, but has no initiatorId', async function (ctx) {
           await expect(
-            this.UserAuditLogHandler.promises.addEntry(
-              this.userId,
+            ctx.UserAuditLogHandler.promises.addEntry(
+              ctx.userId,
               'remove-email',
               undefined,
-              this.action.ip,
+              ctx.action.ip,
               {
                 removedEmail: 'foo',
               }

+ 306 - 281
services/web/test/unit/src/User/UserGetter.test.mjs

@@ -1,24 +1,29 @@
-const { ObjectId } = require('mongodb-legacy')
-const SandboxedModule = require('sandboxed-module')
-const assert = require('assert')
-const moment = require('moment')
-const path = require('path')
-const sinon = require('sinon')
+import { vi, expect } from 'vitest'
+import mongodb from 'mongodb-legacy'
+import assert from 'assert'
+import moment from 'moment'
+import path from 'path'
+import sinon from 'sinon'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+import {
+  normalizeQuery,
+  normalizeMultiQuery,
+} from '../../../../app/src/Features/Helpers/Mongo.js'
 const modulePath = path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/Features/User/UserGetter'
 )
-const { expect } = require('chai')
-const Errors = require('../../../../app/src/Features/Errors/Errors')
-const {
-  normalizeQuery,
-  normalizeMultiQuery,
-} = require('../../../../app/src/Features/Helpers/Mongo')
+
+vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+)
+
+const { ObjectId } = mongodb
 
 describe('UserGetter', function () {
-  beforeEach(function () {
+  beforeEach(async function (ctx) {
     const confirmedAt = new Date()
-    this.fakeUser = {
+    ctx.fakeUser = {
       _id: new ObjectId(),
       email: 'email2@foo.bar',
       emails: [
@@ -31,128 +36,149 @@ describe('UserGetter', function () {
         { email: 'email2@foo.bar', reversedHostname: 'rab.oof' },
       ],
     }
-    this.findOne = sinon.stub().resolves(this.fakeUser)
-    this.findToArrayStub = sinon.stub().resolves([this.fakeUser])
-    this.find = sinon.stub().returns({ toArray: this.findToArrayStub })
-    this.Mongo = {
+    ctx.findOne = sinon.stub().resolves(ctx.fakeUser)
+    ctx.findToArrayStub = sinon.stub().resolves([ctx.fakeUser])
+    ctx.find = sinon.stub().returns({ toArray: ctx.findToArrayStub })
+    ctx.Mongo = {
       db: {
         users: {
-          findOne: this.findOne,
-          find: this.find,
+          findOne: ctx.findOne,
+          find: ctx.find,
         },
       },
       ObjectId,
     }
-    this.getUserAffiliations = sinon.stub().resolves([])
+    ctx.getUserAffiliations = sinon.stub().resolves([])
 
-    this.Modules = {
+    ctx.Modules = {
       promises: { hooks: { fire: sinon.stub().resolves() } },
     }
-    this.AsyncLocalStorage = {
+    ctx.AsyncLocalStorage = {
       storage: {
         getStore: sinon.stub().returns(undefined),
       },
     }
 
-    this.UserGetter = SandboxedModule.require(modulePath, {
-      requires: {
-        '../Helpers/Mongo': { normalizeQuery, normalizeMultiQuery },
-        '../../infrastructure/mongodb': this.Mongo,
-        '@overleaf/settings': (this.settings = {
-          reconfirmNotificationDays: 14,
-        }),
-        '../Institutions/InstitutionsAPI': {
+    vi.doMock('../../../../app/src/Features/Helpers/Mongo', () => ({
+      normalizeQuery,
+      normalizeMultiQuery,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/mongodb', () => ctx.Mongo)
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {
+        reconfirmNotificationDays: 14,
+      }),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Institutions/InstitutionsAPI',
+      () => ({
+        default: {
           promises: {
-            getUserAffiliations: this.getUserAffiliations,
+            getUserAffiliations: ctx.getUserAffiliations,
           },
         },
-        '../../infrastructure/Features': {
-          hasFeature: sinon.stub().returns(true),
-        },
-        '../../models/User': {
-          User: (this.User = {}),
-        },
-        '../../infrastructure/Modules': this.Modules,
-        '../../infrastructure/AsyncLocalStorage': this.AsyncLocalStorage,
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Features', () => ({
+      default: {
+        hasFeature: sinon.stub().returns(true),
       },
-    })
+    }))
+
+    vi.doMock('../../../../app/src/models/User', () => ({
+      User: (ctx.User = {}),
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: ctx.Modules,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/AsyncLocalStorage', () => ({
+      default: ctx.AsyncLocalStorage,
+    }))
+
+    ctx.UserGetter = (await import(modulePath)).default
   })
 
   describe('getSsoUsersAtInstitution', function () {
-    it('should throw an error when no projection is passed', async function () {
+    it('should throw an error when no projection is passed', async function (ctx) {
       await expect(
-        this.UserGetter.promises.getSsoUsersAtInstitution(1, undefined)
+        ctx.UserGetter.promises.getSsoUsersAtInstitution(1, undefined)
       ).to.be.rejectedWith('missing projection')
     })
   })
 
   describe('getUser', function () {
-    it('should get user', async function () {
+    it('should get user', async function (ctx) {
       const query = { _id: '000000000000000000000000' }
       const projection = { email: 1 }
-      const user = await this.UserGetter.promises.getUser(query, projection)
-      this.findOne.called.should.equal(true)
-      this.findOne.calledWith(query, { projection }).should.equal(true)
-      expect(user).to.deep.equal(this.fakeUser)
+      const user = await ctx.UserGetter.promises.getUser(query, projection)
+      ctx.findOne.called.should.equal(true)
+      ctx.findOne.calledWith(query, { projection }).should.equal(true)
+      expect(user).to.deep.equal(ctx.fakeUser)
     })
 
-    it('should not allow null query', async function () {
+    it('should not allow null query', async function (ctx) {
       await expect(
-        this.UserGetter.promises.getUser(null, {})
+        ctx.UserGetter.promises.getUser(null, {})
       ).to.be.rejectedWith('no query provided')
     })
   })
 
   describe('getUsers', function () {
-    it('should get users with array of userIds', async function () {
+    it('should get users with array of userIds', async function (ctx) {
       const query = [new ObjectId()]
       const projection = { email: 1 }
-      const users = await this.UserGetter.promises.getUsers(query, projection)
-      this.find.should.have.been.calledWithMatch(
+      const users = await ctx.UserGetter.promises.getUsers(query, projection)
+      ctx.find.should.have.been.calledWithMatch(
         { _id: { $in: query } },
         { projection }
       )
-      users.should.deep.equal([this.fakeUser])
+      users.should.deep.equal([ctx.fakeUser])
     })
 
-    it('should not call mongo with empty list', async function () {
+    it('should not call mongo with empty list', async function (ctx) {
       const query = []
       const projection = { email: 1 }
-      const users = await this.UserGetter.promises.getUsers(query, projection)
+      const users = await ctx.UserGetter.promises.getUsers(query, projection)
       expect(users).to.deep.equal([])
-      expect(this.find).to.not.have.been.called
+      expect(ctx.find).to.not.have.been.called
     })
 
-    it('should not allow null query', async function () {
+    it('should not allow null query', async function (ctx) {
       await expect(
-        this.UserGetter.promises.getUsers(null, {})
+        ctx.UserGetter.promises.getUsers(null, {})
       ).to.be.rejectedWith('no query provided')
     })
   })
 
   describe('getUserFullEmails', function () {
-    it('should get user', async function () {
-      this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser)
+    it('should get user', async function (ctx) {
+      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
       const projection = { email: 1, emails: 1, samlIdentifiers: 1 }
-      await this.UserGetter.promises.getUserFullEmails(this.fakeUser._id)
-      this.UserGetter.promises.getUser.called.should.equal(true)
-      this.UserGetter.promises.getUser
-        .calledWith(this.fakeUser._id, projection)
+      await ctx.UserGetter.promises.getUserFullEmails(ctx.fakeUser._id)
+      ctx.UserGetter.promises.getUser.called.should.equal(true)
+      ctx.UserGetter.promises.getUser
+        .calledWith(ctx.fakeUser._id, projection)
         .should.equal(true)
     })
 
-    it('should fetch emails data', async function () {
-      this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser)
-      const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-        this.fakeUser._id
+    it('should fetch emails data', async function (ctx) {
+      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
+      const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+        ctx.fakeUser._id
       )
 
       assert.deepEqual(fullEmails, [
         {
           email: 'email1@foo.bar',
           reversedHostname: 'rab.oof',
-          confirmedAt: this.fakeUser.emails[0].confirmedAt,
-          lastConfirmedAt: this.fakeUser.emails[0].lastConfirmedAt,
+          confirmedAt: ctx.fakeUser.emails[0].confirmedAt,
+          lastConfirmedAt: ctx.fakeUser.emails[0].lastConfirmedAt,
           emailHasInstitutionLicence: false,
           default: false,
         },
@@ -166,8 +192,8 @@ describe('UserGetter', function () {
       ])
     })
 
-    it('should merge affiliation data', async function () {
-      this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser)
+    it('should merge affiliation data', async function (ctx) {
+      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
       const affiliationsData = [
         {
           email: 'email1@foo.bar',
@@ -188,17 +214,17 @@ describe('UserGetter', function () {
           portal: undefined,
         },
       ]
-      this.getUserAffiliations.resolves(affiliationsData)
-      const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-        this.fakeUser._id
+      ctx.getUserAffiliations.resolves(affiliationsData)
+      const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+        ctx.fakeUser._id
       )
 
       assert.deepEqual(fullEmails, [
         {
           email: 'email1@foo.bar',
           reversedHostname: 'rab.oof',
-          confirmedAt: this.fakeUser.emails[0].confirmedAt,
-          lastConfirmedAt: this.fakeUser.emails[0].lastConfirmedAt,
+          confirmedAt: ctx.fakeUser.emails[0].confirmedAt,
+          lastConfirmedAt: ctx.fakeUser.emails[0].lastConfirmedAt,
           default: false,
           emailHasInstitutionLicence: true,
           affiliation: {
@@ -228,25 +254,25 @@ describe('UserGetter', function () {
       ])
     })
 
-    it('should merge SAML identifier', async function () {
+    it('should merge SAML identifier', async function (ctx) {
       const fakeSamlIdentifiers = [
         { providerId: 'saml_id', externalUserId: 'whatever' },
       ]
-      const fakeUserWithSaml = this.fakeUser
+      const fakeUserWithSaml = ctx.fakeUser
       fakeUserWithSaml.emails[0].samlProviderId = 'saml_id'
       fakeUserWithSaml.samlIdentifiers = fakeSamlIdentifiers
-      this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser)
-      this.getUserAffiliations.resolves([])
-      const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-        this.fakeUser._id
+      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
+      ctx.getUserAffiliations.resolves([])
+      const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+        ctx.fakeUser._id
       )
 
       assert.deepEqual(fullEmails, [
         {
           email: 'email1@foo.bar',
           reversedHostname: 'rab.oof',
-          confirmedAt: this.fakeUser.emails[0].confirmedAt,
-          lastConfirmedAt: this.fakeUser.emails[0].lastConfirmedAt,
+          confirmedAt: ctx.fakeUser.emails[0].confirmedAt,
+          lastConfirmedAt: ctx.fakeUser.emails[0].lastConfirmedAt,
           default: false,
           emailHasInstitutionLicence: false,
           samlProviderId: 'saml_id',
@@ -262,21 +288,21 @@ describe('UserGetter', function () {
       ])
     })
 
-    it('should get user when it has no emails field', async function () {
-      this.fakeUserNoEmails = {
+    it('should get user when it has no emails field', async function (ctx) {
+      ctx.fakeUserNoEmails = {
         _id: '12390i',
         email: 'email2@foo.bar',
       }
-      this.UserGetter.promises.getUser = sinon
+      ctx.UserGetter.promises.getUser = sinon
         .stub()
-        .resolves(this.fakeUserNoEmails)
+        .resolves(ctx.fakeUserNoEmails)
       const projection = { email: 1, emails: 1, samlIdentifiers: 1 }
-      const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-        this.fakeUserNoEmails._id
+      const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+        ctx.fakeUserNoEmails._id
       )
-      this.UserGetter.promises.getUser.called.should.equal(true)
-      this.UserGetter.promises.getUser
-        .calledWith(this.fakeUserNoEmails._id, projection)
+      ctx.UserGetter.promises.getUser.called.should.equal(true)
+      ctx.UserGetter.promises.getUser
+        .calledWith(ctx.fakeUserNoEmails._id, projection)
         .should.equal(true)
       assert.deepEqual(fullEmails, [])
     })
@@ -322,7 +348,7 @@ describe('UserGetter', function () {
             institution: institutionNonSSO,
           },
         ]
-        it('should flag inReconfirmNotificationPeriod for all affiliations in period', async function () {
+        it('should flag inReconfirmNotificationPeriod for all affiliations in period', async function (ctx) {
           const { maxConfirmationMonths } = institutionNonSSO
           const confirmed1 = moment()
             .subtract(maxConfirmationMonths + 2, 'months')
@@ -356,10 +382,10 @@ describe('UserGetter', function () {
           const affiliations = [...affiliationsData]
           affiliations[0].last_day_to_reconfirm = lastDayToReconfirm1
           affiliations[1].last_day_to_reconfirm = lastDayToReconfirm2
-          this.getUserAffiliations.resolves(affiliations)
-          this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-          const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-            this.fakeUser._id
+          ctx.getUserAffiliations.resolves(affiliations)
+          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+            ctx.fakeUser._id
           )
           expect(
             fullEmails[0].affiliation.inReconfirmNotificationPeriod
@@ -369,7 +395,7 @@ describe('UserGetter', function () {
           ).to.equal(true)
         })
 
-        it('should not flag affiliations outside of notification period', async function () {
+        it('should not flag affiliations outside of notification period', async function (ctx) {
           const { maxConfirmationMonths } = institutionNonSSO
           const confirmed1 = new Date()
           const lastDayToReconfirm1 = moment(confirmed1)
@@ -402,10 +428,10 @@ describe('UserGetter', function () {
           const affiliations = [...affiliationsData]
           affiliations[0].last_day_to_reconfirm = lastDayToReconfirm1
           affiliations[1].last_day_to_reconfirm = lastDayToReconfirm2
-          this.getUserAffiliations.resolves(affiliations)
-          this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-          const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-            this.fakeUser._id
+          ctx.getUserAffiliations.resolves(affiliations)
+          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+            ctx.fakeUser._id
           )
           expect(
             fullEmails[0].affiliation.inReconfirmNotificationPeriod
@@ -417,7 +443,7 @@ describe('UserGetter', function () {
       })
 
       describe('SSO institutions', function () {
-        it('should flag only linked email, if in notification period', async function () {
+        it('should flag only linked email, if in notification period', async function (ctx) {
           const { maxConfirmationMonths } = institutionSSO
           const email1 = 'email1@sso.bar'
           const email2 = 'email2@sso.bar'
@@ -487,10 +513,10 @@ describe('UserGetter', function () {
               last_day_to_reconfirm: lastDayToReconfirm,
             },
           ]
-          this.getUserAffiliations.resolves(affiliations)
-          this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-          const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-            this.fakeUser._id
+          ctx.getUserAffiliations.resolves(affiliations)
+          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+            ctx.fakeUser._id
           )
           expect(
             fullEmails[0].affiliation.inReconfirmNotificationPeriod
@@ -505,7 +531,7 @@ describe('UserGetter', function () {
       })
 
       describe('multiple institution affiliations', function () {
-        it('should flag each institution', async function () {
+        it('should flag each institution', async function (ctx) {
           const { maxConfirmationMonths } = institutionSSO
           const email1 = 'email1@sso.bar'
           const email2 = 'email2@sso.bar'
@@ -593,10 +619,10 @@ describe('UserGetter', function () {
             ],
           }
 
-          this.getUserAffiliations.resolves(affiliationsData)
-          this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-          const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-            this.fakeUser._id
+          ctx.getUserAffiliations.resolves(affiliationsData)
+          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+            ctx.fakeUser._id
           )
           expect(
             fullEmails[0].affiliation.inReconfirmNotificationPeriod
@@ -614,7 +640,7 @@ describe('UserGetter', function () {
       })
 
       describe('reconfirmedAt', function () {
-        it('only use confirmedAt when no reconfirmedAt', async function () {
+        it('only use confirmedAt when no reconfirmedAt', async function (ctx) {
           const { maxConfirmationMonths } = institutionSSO
           const email1 = 'email1@foo.bar'
           const reconfirmed1 = moment().subtract(
@@ -704,10 +730,10 @@ describe('UserGetter', function () {
               },
             ],
           }
-          this.getUserAffiliations.resolves(affiliationsData)
-          this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-          const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-            this.fakeUser._id
+          ctx.getUserAffiliations.resolves(affiliationsData)
+          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+            ctx.fakeUser._id
           )
           expect(
             fullEmails[0].affiliation.inReconfirmNotificationPeriod
@@ -723,7 +749,7 @@ describe('UserGetter', function () {
 
       describe('before reconfirmation period expires and within reconfirmation notification period', function () {
         const email = 'leonard@example-affiliation.com'
-        it('should flag the email', async function () {
+        it('should flag the email', async function (ctx) {
           const { maxConfirmationMonths } = institutionNonSSO
           const confirmedAt = moment()
             .subtract(maxConfirmationMonths, 'months')
@@ -753,10 +779,10 @@ describe('UserGetter', function () {
               },
             ],
           }
-          this.getUserAffiliations.resolves(affiliationsData)
-          this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-          const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-            this.fakeUser._id
+          ctx.getUserAffiliations.resolves(affiliationsData)
+          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+            ctx.fakeUser._id
           )
           expect(
             fullEmails[0].affiliation.inReconfirmNotificationPeriod
@@ -765,7 +791,7 @@ describe('UserGetter', function () {
       })
 
       describe('when no Settings.reconfirmNotificationDays', function () {
-        it('should always return inReconfirmNotificationPeriod:false', async function () {
+        it('should always return inReconfirmNotificationPeriod:false', async function (ctx) {
           const email1 = 'email1@sso.bar'
           const email2 = 'email2@foo.bar'
           const email3 = 'email3@foo.bar'
@@ -814,11 +840,11 @@ describe('UserGetter', function () {
               },
             ],
           }
-          this.settings.reconfirmNotificationDays = undefined
-          this.getUserAffiliations.resolves(affiliationsData)
-          this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-          const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-            this.fakeUser._id
+          ctx.settings.reconfirmNotificationDays = undefined
+          ctx.getUserAffiliations.resolves(affiliationsData)
+          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+            ctx.fakeUser._id
           )
           expect(
             fullEmails[0].affiliation.inReconfirmNotificationPeriod
@@ -832,7 +858,7 @@ describe('UserGetter', function () {
         })
       })
 
-      it('should flag to show notification if v1 shows as past reconfirmation but v2 does not', async function () {
+      it('should flag to show notification if v1 shows as past reconfirmation but v2 does not', async function (ctx) {
         const email = 'abc123@test.com'
         const confirmedAt = new Date()
         const affiliationsData = [
@@ -855,17 +881,17 @@ describe('UserGetter', function () {
             },
           ],
         }
-        this.getUserAffiliations.resolves(affiliationsData)
-        this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-        const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-          this.fakeUser._id
+        ctx.getUserAffiliations.resolves(affiliationsData)
+        ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+          ctx.fakeUser._id
         )
         expect(
           fullEmails[0].affiliation.inReconfirmNotificationPeriod
         ).to.equal(true)
       })
 
-      it('should flag to show notification if v1 shows as reconfirmation upcoming but v2 does not', async function () {
+      it('should flag to show notification if v1 shows as reconfirmation upcoming but v2 does not', async function (ctx) {
         const email = 'abc123@test.com'
         const { maxConfirmationMonths } = institutionNonSSO
         const affiliationsData = [
@@ -890,17 +916,17 @@ describe('UserGetter', function () {
             },
           ],
         }
-        this.getUserAffiliations.resolves(affiliationsData)
-        this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-        const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-          this.fakeUser._id
+        ctx.getUserAffiliations.resolves(affiliationsData)
+        ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+          ctx.fakeUser._id
         )
         expect(
           fullEmails[0].affiliation.inReconfirmNotificationPeriod
         ).to.equal(true)
       })
 
-      it('should flag to show notification if v2 shows as reconfirmation upcoming but v1 does not', async function () {
+      it('should flag to show notification if v2 shows as reconfirmation upcoming but v1 does not', async function (ctx) {
         const email = 'abc123@test.com'
         const { maxConfirmationMonths } = institutionNonSSO
 
@@ -930,10 +956,10 @@ describe('UserGetter', function () {
             },
           ],
         }
-        this.getUserAffiliations.resolves(affiliationsData)
-        this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-        const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-          this.fakeUser._id
+        ctx.getUserAffiliations.resolves(affiliationsData)
+        ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+          ctx.fakeUser._id
         )
         expect(
           fullEmails[0].affiliation.inReconfirmNotificationPeriod
@@ -965,35 +991,35 @@ describe('UserGetter', function () {
           ],
         }
 
-        it('should set cachedLastDayToReconfirm for SSO institutions if email is linked to SSO', async function () {
+        it('should set cachedLastDayToReconfirm for SSO institutions if email is linked to SSO', async function (ctx) {
           const userLinked = Object.assign({}, user)
           userLinked.emails[0].samlProviderId = institutionSSO.id.toString()
-          this.getUserAffiliations.resolves(affiliationsData)
-          this.UserGetter.promises.getUser = sinon.stub().resolves(userLinked)
-          const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-            this.fakeUser._id
+          ctx.getUserAffiliations.resolves(affiliationsData)
+          ctx.UserGetter.promises.getUser = sinon.stub().resolves(userLinked)
+          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+            ctx.fakeUser._id
           )
           expect(fullEmails[0].affiliation.cachedLastDayToReconfirm).to.equal(
             lastDay
           )
         })
 
-        it('should NOT set cachedLastDayToReconfirm for SSO institutions if email is NOT linked to SSO', async function () {
-          this.getUserAffiliations.resolves(affiliationsData)
-          this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-          const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-            this.fakeUser._id
+        it('should NOT set cachedLastDayToReconfirm for SSO institutions if email is NOT linked to SSO', async function (ctx) {
+          ctx.getUserAffiliations.resolves(affiliationsData)
+          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+            ctx.fakeUser._id
           )
           expect(fullEmails[0].affiliation.cachedLastDayToReconfirm).to.equal(
             lastDay
           )
         })
 
-        it('should set cachedLastDayToReconfirm for non-SSO institutions', async function () {
-          this.getUserAffiliations.resolves(affiliationsData)
-          this.UserGetter.promises.getUser = sinon.stub().resolves(user)
-          const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-            this.fakeUser._id
+        it('should set cachedLastDayToReconfirm for non-SSO institutions', async function (ctx) {
+          ctx.getUserAffiliations.resolves(affiliationsData)
+          ctx.UserGetter.promises.getUser = sinon.stub().resolves(user)
+          const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+            ctx.fakeUser._id
           )
           expect(fullEmails[0].affiliation.cachedLastDayToReconfirm).to.equal(
             lastDay
@@ -1003,63 +1029,63 @@ describe('UserGetter', function () {
     })
 
     describe('caching full emails data if run inside AsyncLocalStorage context', function () {
-      it('should store the data in the AsyncLocalStorage store', async function () {
-        this.store = {}
-        this.AsyncLocalStorage.storage.getStore.returns(this.store)
-        this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser)
-        this.getUserAffiliations.resolves([
+      it('should store the data in the AsyncLocalStorage store', async function (ctx) {
+        ctx.store = {}
+        ctx.AsyncLocalStorage.storage.getStore.returns(ctx.store)
+        ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
+        ctx.getUserAffiliations.resolves([
           {
             email: 'email1@foo.bar',
             licence: 'professional',
             institution: {},
           },
         ])
-        const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-          this.fakeUser._id
+        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+          ctx.fakeUser._id
         )
-        expect(this.UserGetter.promises.getUser).to.have.been.calledOnce
-        expect(this.getUserAffiliations).to.have.been.calledOnce
+        expect(ctx.UserGetter.promises.getUser).to.have.been.calledOnce
+        expect(ctx.getUserAffiliations).to.have.been.calledOnce
         expect(fullEmails).to.be.an('array')
         expect(fullEmails.length).to.equal(2)
-        expect(this.store.userFullEmails[this.fakeUser._id]).to.deep.equal(
+        expect(ctx.store.userFullEmails[ctx.fakeUser._id]).to.deep.equal(
           fullEmails
         )
       })
 
-      it('should fetch data from the store if available', async function () {
-        this.store = {
+      it('should fetch data from the store if available', async function (ctx) {
+        ctx.store = {
           userFullEmails: {
-            [this.fakeUser._id]: [{ email: '1' }, { email: '2' }],
+            [ctx.fakeUser._id]: [{ email: '1' }, { email: '2' }],
           },
         }
-        this.AsyncLocalStorage.storage.getStore.returns(this.store)
-        this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser)
-        const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-          this.fakeUser._id,
-          this.req
+        ctx.AsyncLocalStorage.storage.getStore.returns(ctx.store)
+        ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
+        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+          ctx.fakeUser._id,
+          ctx.req
         )
-        expect(this.UserGetter.promises.getUser).to.not.have.been.called
-        expect(this.getUserAffiliations).to.not.have.been.called
+        expect(ctx.UserGetter.promises.getUser).to.not.have.been.called
+        expect(ctx.getUserAffiliations).to.not.have.been.called
         expect(fullEmails).to.be.an('array')
         expect(fullEmails.length).to.equal(2)
-        expect(this.store.userFullEmails[this.fakeUser._id]).to.deep.equal(
+        expect(ctx.store.userFullEmails[ctx.fakeUser._id]).to.deep.equal(
           fullEmails
         )
       })
 
-      it('should not return cached data for different user ids', async function () {
-        this.store = {}
-        this.AsyncLocalStorage.storage.getStore.returns(this.store)
-        this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser)
-        const fullEmails = await this.UserGetter.promises.getUserFullEmails(
-          this.fakeUser._id,
-          this.req
+      it('should not return cached data for different user ids', async function (ctx) {
+        ctx.store = {}
+        ctx.AsyncLocalStorage.storage.getStore.returns(ctx.store)
+        ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
+        const fullEmails = await ctx.UserGetter.promises.getUserFullEmails(
+          ctx.fakeUser._id,
+          ctx.req
         )
-        expect(this.UserGetter.promises.getUser).to.have.been.calledOnce
-        expect(this.getUserAffiliations).to.have.been.calledOnce
+        expect(ctx.UserGetter.promises.getUser).to.have.been.calledOnce
+        expect(ctx.getUserAffiliations).to.have.been.calledOnce
         expect(fullEmails).to.be.an('array')
         expect(fullEmails.length).to.equal(2)
-        this.otherUser = {
+        ctx.otherUser = {
           _id: new ObjectId(),
           email: 'other@foo.bar',
           emails: [
@@ -1071,28 +1097,27 @@ describe('UserGetter', function () {
             },
           ],
         }
-        this.UserGetter.promises.getUser.resolves(this.otherUser)
-        this.getUserAffiliations.resolves([
+        ctx.UserGetter.promises.getUser.resolves(ctx.otherUser)
+        ctx.getUserAffiliations.resolves([
           {
             email: 'other@foo.bar',
             licence: 'professional',
             institution: {},
           },
         ])
-        const fullEmailsOther =
-          await this.UserGetter.promises.getUserFullEmails(
-            this.otherUser._id,
-            this.req
-          )
-        expect(this.UserGetter.promises.getUser).to.have.been.calledTwice
-        expect(this.getUserAffiliations).to.have.been.calledTwice
+        const fullEmailsOther = await ctx.UserGetter.promises.getUserFullEmails(
+          ctx.otherUser._id,
+          ctx.req
+        )
+        expect(ctx.UserGetter.promises.getUser).to.have.been.calledTwice
+        expect(ctx.getUserAffiliations).to.have.been.calledTwice
         expect(fullEmailsOther).to.not.deep.equal(fullEmails)
         expect(fullEmailsOther).to.be.an('array')
         expect(fullEmailsOther.length).to.equal(1)
-        expect(this.store.userFullEmails[this.fakeUser._id]).to.deep.equal(
+        expect(ctx.store.userFullEmails[ctx.fakeUser._id]).to.deep.equal(
           fullEmails
         )
-        expect(this.store.userFullEmails[this.otherUser._id]).to.deep.equal(
+        expect(ctx.store.userFullEmails[ctx.otherUser._id]).to.deep.equal(
           fullEmailsOther
         )
       })
@@ -1100,8 +1125,8 @@ describe('UserGetter', function () {
   })
 
   describe('getUserConfirmedEmails', function () {
-    beforeEach(function () {
-      this.fakeUser = {
+    beforeEach(function (ctx) {
+      ctx.fakeUser = {
         emails: [
           {
             email: 'email1@foo.bar',
@@ -1116,21 +1141,21 @@ describe('UserGetter', function () {
           },
         ],
       }
-      this.UserGetter.promises.getUser = sinon.stub().resolves(this.fakeUser)
+      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.fakeUser)
     })
 
-    it('should get user', async function () {
+    it('should get user', async function (ctx) {
       const projection = { emails: 1 }
-      await this.UserGetter.promises.getUserConfirmedEmails(this.fakeUser._id)
+      await ctx.UserGetter.promises.getUserConfirmedEmails(ctx.fakeUser._id)
 
-      this.UserGetter.promises.getUser
-        .calledWith(this.fakeUser._id, projection)
+      ctx.UserGetter.promises.getUser
+        .calledWith(ctx.fakeUser._id, projection)
         .should.equal(true)
     })
 
-    it('should return only confirmed emails', async function () {
+    it('should return only confirmed emails', async function (ctx) {
       const confirmedEmails =
-        await this.UserGetter.promises.getUserConfirmedEmails(this.fakeUser._id)
+        await ctx.UserGetter.promises.getUserConfirmedEmails(ctx.fakeUser._id)
 
       expect(confirmedEmails.length).to.equal(2)
       expect(confirmedEmails[0].email).to.equal('email1@foo.bar')
@@ -1139,85 +1164,85 @@ describe('UserGetter', function () {
   })
 
   describe('getUserbyMainEmail', function () {
-    it('query user by main email', async function () {
+    it('query user by main email', async function (ctx) {
       const email = 'hello@world.com'
       const projection = { emails: 1 }
-      await this.UserGetter.promises.getUserByMainEmail(email, projection)
-      this.findOne.called.should.equal(true)
-      this.findOne.calledWith({ email }, { projection }).should.equal(true)
+      await ctx.UserGetter.promises.getUserByMainEmail(email, projection)
+      ctx.findOne.called.should.equal(true)
+      ctx.findOne.calledWith({ email }, { projection }).should.equal(true)
     })
 
-    it('return user if found', async function () {
+    it('return user if found', async function (ctx) {
       const email = 'hello@world.com'
-      const user = await this.UserGetter.promises.getUserByMainEmail(email)
-      user.should.deep.equal(this.fakeUser)
+      const user = await ctx.UserGetter.promises.getUserByMainEmail(email)
+      user.should.deep.equal(ctx.fakeUser)
     })
 
-    it('trim email', async function () {
+    it('trim email', async function (ctx) {
       const email = 'hello@world.com'
-      await this.UserGetter.promises.getUserByMainEmail(` ${email} `)
-      this.findOne.called.should.equal(true)
-      this.findOne.calledWith({ email }).should.equal(true)
+      await ctx.UserGetter.promises.getUserByMainEmail(` ${email} `)
+      ctx.findOne.called.should.equal(true)
+      ctx.findOne.calledWith({ email }).should.equal(true)
     })
   })
 
   describe('getUserByAnyEmail', function () {
-    it('query user for any email', async function () {
+    it('query user for any email', async function (ctx) {
       const email = 'hello@world.com'
       const expectedQuery = {
         emails: { $exists: true },
         'emails.email': email,
       }
       const projection = { emails: 1 }
-      const user = await this.UserGetter.promises.getUserByAnyEmail(
+      const user = await ctx.UserGetter.promises.getUserByAnyEmail(
         ` ${email} `,
         projection
       )
-      this.findOne.calledWith(expectedQuery, { projection }).should.equal(true)
-      user.should.deep.equal(this.fakeUser)
+      ctx.findOne.calledWith(expectedQuery, { projection }).should.equal(true)
+      user.should.deep.equal(ctx.fakeUser)
     })
 
-    it('query contains $exists:true so partial index is used', async function () {
+    it('query contains $exists:true so partial index is used', async function (ctx) {
       const expectedQuery = {
         emails: { $exists: true },
         'emails.email': '',
       }
-      await this.UserGetter.promises.getUserByAnyEmail('', {})
-      this.findOne
+      await ctx.UserGetter.promises.getUserByAnyEmail('', {})
+      ctx.findOne
         .calledWith(expectedQuery, { projection: {} })
         .should.equal(true)
     })
 
-    it('checks main email as well', async function () {
-      this.findOne.resolves(null)
+    it('checks main email as well', async function (ctx) {
+      ctx.findOne.resolves(null)
       const email = 'hello@world.com'
       const projection = { emails: 1 }
-      await this.UserGetter.promises.getUserByAnyEmail(` ${email} `, projection)
-      this.findOne.calledTwice.should.equal(true)
-      this.findOne.calledWith({ email }, { projection }).should.equal(true)
+      await ctx.UserGetter.promises.getUserByAnyEmail(` ${email} `, projection)
+      ctx.findOne.calledTwice.should.equal(true)
+      ctx.findOne.calledWith({ email }, { projection }).should.equal(true)
     })
   })
 
   describe('getUsersByHostname', function () {
-    it('should find user by hostname', async function () {
+    it('should find user by hostname', async function (ctx) {
       const hostname = 'bar.foo'
       const expectedQuery = {
         emails: { $exists: true },
         'emails.reversedHostname': hostname.split('').reverse().join(''),
       }
       const projection = { emails: 1 }
-      await this.UserGetter.promises.getUsersByHostname(hostname, projection)
-      this.find.calledOnce.should.equal(true)
-      this.find.calledWith(expectedQuery, { projection }).should.equal(true)
+      await ctx.UserGetter.promises.getUsersByHostname(hostname, projection)
+      ctx.find.calledOnce.should.equal(true)
+      ctx.find.calledWith(expectedQuery, { projection }).should.equal(true)
     })
   })
 
   describe('getUsersByAnyConfirmedEmail', function () {
-    it('should find users by confirmed email', async function () {
+    it('should find users by confirmed email', async function (ctx) {
       const emails = ['confirmed@example.com']
 
-      await this.UserGetter.promises.getUsersByAnyConfirmedEmail(emails)
-      expect(this.find).to.be.calledOnceWith(
+      await ctx.UserGetter.promises.getUsersByAnyConfirmedEmail(emails)
+      expect(ctx.find).to.be.calledOnceWith(
         {
           'emails.email': { $in: emails }, // use the index on emails.email
           emails: {
@@ -1234,85 +1259,85 @@ describe('UserGetter', function () {
   })
 
   describe('getUsersByV1Id', function () {
-    it('should find users by list of v1 ids', async function () {
+    it('should find users by list of v1 ids', async function (ctx) {
       const v1Ids = [501]
       const expectedQuery = {
         'overleaf.id': { $in: v1Ids },
       }
       const projection = { emails: 1 }
-      await this.UserGetter.promises.getUsersByV1Ids(v1Ids, projection)
-      this.find.calledOnce.should.equal(true)
-      this.find.calledWith(expectedQuery, { projection }).should.equal(true)
+      await ctx.UserGetter.promises.getUsersByV1Ids(v1Ids, projection)
+      ctx.find.calledOnce.should.equal(true)
+      ctx.find.calledWith(expectedQuery, { projection }).should.equal(true)
     })
   })
 
   describe('ensureUniqueEmailAddress', function () {
-    beforeEach(function () {
-      this.UserGetter.promises.getUserByAnyEmail = sinon.stub()
+    beforeEach(function (ctx) {
+      ctx.UserGetter.promises.getUserByAnyEmail = sinon.stub()
     })
 
-    it('should return error if existing user is found', async function () {
-      this.UserGetter.promises.getUserByAnyEmail.resolves(this.fakeUser)
+    it('should return error if existing user is found', async function (ctx) {
+      ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.fakeUser)
       await expect(
-        this.UserGetter.promises.ensureUniqueEmailAddress(this.newEmail)
+        ctx.UserGetter.promises.ensureUniqueEmailAddress(ctx.newEmail)
       ).to.be.rejectedWith(Errors.EmailExistsError)
     })
 
-    it('should return null if no user is found', async function () {
-      this.UserGetter.promises.getUserByAnyEmail.resolves(null)
+    it('should return null if no user is found', async function (ctx) {
+      ctx.UserGetter.promises.getUserByAnyEmail.resolves(null)
       await expect(
-        this.UserGetter.promises.ensureUniqueEmailAddress(this.newEmail)
+        ctx.UserGetter.promises.ensureUniqueEmailAddress(ctx.newEmail)
       ).to.be.fulfilled
     })
   })
 
   describe('getUserFeatures', function () {
-    beforeEach(function () {
-      this.Modules.promises.hooks.fire = sinon.stub().resolves()
-      this.fakeUser.features = {}
+    beforeEach(function (ctx) {
+      ctx.Modules.promises.hooks.fire = sinon.stub().resolves()
+      ctx.fakeUser.features = {}
     })
 
-    it('should return user features', async function () {
-      this.fakeUser.features = { feature1: true, feature2: false }
-      const features = await this.UserGetter.promises.getUserFeatures(
+    it('should return user features', async function (ctx) {
+      ctx.fakeUser.features = { feature1: true, feature2: false }
+      const features = await ctx.UserGetter.promises.getUserFeatures(
         new ObjectId()
       )
-      expect(features).to.deep.equal(this.fakeUser.features)
+      expect(features).to.deep.equal(ctx.fakeUser.features)
     })
 
-    it('should return user features when using promises', async function () {
-      this.fakeUser.features = { feature1: true, feature2: false }
-      const features = await this.UserGetter.promises.getUserFeatures(
-        this.fakeUser._id
+    it('should return user features when using promises', async function (ctx) {
+      ctx.fakeUser.features = { feature1: true, feature2: false }
+      const features = await ctx.UserGetter.promises.getUserFeatures(
+        ctx.fakeUser._id
       )
-      expect(features).to.deep.equal(this.fakeUser.features)
+      expect(features).to.deep.equal(ctx.fakeUser.features)
     })
 
-    it('should take into account features overrides from modules', async function () {
+    it('should take into account features overrides from modules', async function (ctx) {
       // this case occurs when the user has bought the ai bundle on WF, which should include our error assistant
       const bundleFeatures = { aiErrorAssistant: true }
-      this.fakeUser.features = { aiErrorAssistant: false }
-      this.Modules.promises.hooks.fire = sinon.stub().resolves([bundleFeatures])
-      const features = await this.UserGetter.promises.getUserFeatures(
-        this.fakeUser._id
+      ctx.fakeUser.features = { aiErrorAssistant: false }
+      ctx.Modules.promises.hooks.fire = sinon.stub().resolves([bundleFeatures])
+      const features = await ctx.UserGetter.promises.getUserFeatures(
+        ctx.fakeUser._id
       )
       expect(features).to.deep.equal(bundleFeatures)
-      this.Modules.promises.hooks.fire.should.have.been.calledWith(
+      ctx.Modules.promises.hooks.fire.should.have.been.calledWith(
         'getModuleProvidedFeatures',
-        this.fakeUser._id
+        ctx.fakeUser._id
       )
     })
 
-    it('should handle modules not returning any features', async function () {
-      this.Modules.promises.hooks.fire = sinon.stub().resolves([])
-      this.fakeUser.features = { test: true }
-      const features = await this.UserGetter.promises.getUserFeatures(
-        this.fakeUser._id
+    it('should handle modules not returning any features', async function (ctx) {
+      ctx.Modules.promises.hooks.fire = sinon.stub().resolves([])
+      ctx.fakeUser.features = { test: true }
+      const features = await ctx.UserGetter.promises.getUserFeatures(
+        ctx.fakeUser._id
       )
       expect(features).to.deep.equal({ test: true })
-      this.Modules.promises.hooks.fire.should.have.been.calledWith(
+      ctx.Modules.promises.hooks.fire.should.have.been.calledWith(
         'getModuleProvidedFeatures',
-        this.fakeUser._id
+        ctx.fakeUser._id
       )
     })
   })

+ 328 - 338
services/web/test/unit/src/User/UserSessionsManager.test.mjs

@@ -1,17 +1,17 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../../app/src/Features/User/UserSessionsManager.js'
-const SandboxedModule = require('sandboxed-module')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
+const modulePath = '../../../../app/src/Features/User/UserSessionsManager.mjs'
 
 describe('UserSessionsManager', function () {
-  beforeEach(function () {
-    this.user = {
+  beforeEach(async function (ctx) {
+    ctx.user = {
       _id: 'abcd',
       email: 'user@example.com',
     }
-    this.sessionId = 'some_session_id'
+    ctx.sessionId = 'some_session_id'
 
-    this.rclient = {
+    ctx.rclient = {
       multi: sinon.stub(),
       exec: sinon.stub(),
       get: sinon.stub(),
@@ -22,348 +22,338 @@ describe('UserSessionsManager', function () {
       mget: sinon.stub(),
       pexpire: sinon.stub(),
     }
-    this.rclient.multi.returns({
+    ctx.rclient.multi.returns({
       sadd: sinon.stub().returnsThis(),
       srem: sinon.stub().returnsThis(),
       pexpire: sinon.stub().returnsThis(),
       exec: sinon.stub().resolves(),
     })
-    this.rclient.get.resolves()
-    this.rclient.del.resolves()
-    this.rclient.sadd.resolves()
-    this.rclient.srem.resolves()
-    this.rclient.smembers.resolves([])
-    this.rclient.pexpire.resolves()
+    ctx.rclient.get.resolves()
+    ctx.rclient.del.resolves()
+    ctx.rclient.sadd.resolves()
+    ctx.rclient.srem.resolves()
+    ctx.rclient.smembers.resolves([])
+    ctx.rclient.pexpire.resolves()
 
-    this.UserSessionsRedis = {
-      client: () => this.rclient,
+    ctx.UserSessionsRedis = {
+      client: () => ctx.rclient,
       sessionSetKey: user => `UserSessions:{${user._id}}`,
     }
-    this.settings = {
+    ctx.settings = {
       redis: {
         web: {},
       },
     }
-    return (this.UserSessionsManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': this.settings,
-        './UserSessionsRedis': this.UserSessionsRedis,
-      },
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
     }))
+
+    vi.doMock('../../../../app/src/Features/User/UserSessionsRedis', () => ({
+      default: ctx.UserSessionsRedis,
+    }))
+
+    return (ctx.UserSessionsManager = (await import(modulePath)).default)
   })
 
   describe('_sessionKey', function () {
-    it('should build the correct key', function () {
-      const result = this.UserSessionsManager._sessionKey(this.sessionId)
+    it('should build the correct key', function (ctx) {
+      const result = ctx.UserSessionsManager._sessionKey(ctx.sessionId)
       return result.should.equal('sess:some_session_id')
     })
   })
 
   describe('trackSession', function () {
-    beforeEach(function () {
-      this._checkSessions = sinon
-        .stub(this.UserSessionsManager.promises, '_checkSessions')
+    beforeEach(function (ctx) {
+      ctx._checkSessions = sinon
+        .stub(ctx.UserSessionsManager.promises, '_checkSessions')
         .resolves()
     })
 
-    afterEach(function () {
-      return this._checkSessions.restore()
+    afterEach(function (ctx) {
+      return ctx._checkSessions.restore()
     })
 
-    it('should not produce an error', async function () {
-      await this.UserSessionsManager.promises.trackSession(
-        this.user,
-        this.sessionId
+    it('should not produce an error', async function (ctx) {
+      await ctx.UserSessionsManager.promises.trackSession(
+        ctx.user,
+        ctx.sessionId
       )
     })
 
-    it('should call the appropriate redis methods', async function () {
-      await this.UserSessionsManager.promises.trackSession(
-        this.user,
-        this.sessionId
+    it('should call the appropriate redis methods', async function (ctx) {
+      await ctx.UserSessionsManager.promises.trackSession(
+        ctx.user,
+        ctx.sessionId
       )
-      this.rclient.multi.callCount.should.equal(1)
-      const multiInstance = this.rclient.multi.returnValues[0]
+      ctx.rclient.multi.callCount.should.equal(1)
+      const multiInstance = ctx.rclient.multi.returnValues[0]
       multiInstance.sadd.callCount.should.equal(1)
       multiInstance.pexpire.callCount.should.equal(1)
       multiInstance.exec.callCount.should.equal(1)
     })
 
-    it('should call _checkSessions', async function () {
-      await this.UserSessionsManager.promises.trackSession(
-        this.user,
-        this.sessionId
+    it('should call _checkSessions', async function (ctx) {
+      await ctx.UserSessionsManager.promises.trackSession(
+        ctx.user,
+        ctx.sessionId
       )
-      this._checkSessions.callCount.should.equal(1)
+      ctx._checkSessions.callCount.should.equal(1)
     })
 
     describe('when rclient produces an error', function () {
-      beforeEach(function () {
-        this.rclient.multi.returns({
+      beforeEach(function (ctx) {
+        ctx.rclient.multi.returns({
           sadd: sinon.stub().returnsThis(),
           pexpire: sinon.stub().returnsThis(),
           exec: sinon.stub().rejects(new Error('woops')),
         })
       })
 
-      it('should produce an error', async function () {
+      it('should produce an error', async function (ctx) {
         await expect(
-          this.UserSessionsManager.promises.trackSession(
-            this.user,
-            this.sessionId
-          )
+          ctx.UserSessionsManager.promises.trackSession(ctx.user, ctx.sessionId)
         ).to.be.rejectedWith(Error)
       })
 
-      it('should not call _checkSessions', async function () {
+      it('should not call _checkSessions', async function (ctx) {
         try {
-          await this.UserSessionsManager.promises.trackSession(
-            this.user,
-            this.sessionId
+          await ctx.UserSessionsManager.promises.trackSession(
+            ctx.user,
+            ctx.sessionId
           )
         } catch (err) {
           // Expected error
         }
-        this._checkSessions.callCount.should.equal(0)
+        ctx._checkSessions.callCount.should.equal(0)
       })
     })
 
     describe('when no user is supplied', function () {
-      it('should not produce an error', async function () {
-        await this.UserSessionsManager.promises.trackSession(
-          null,
-          this.sessionId
-        )
+      it('should not produce an error', async function (ctx) {
+        await ctx.UserSessionsManager.promises.trackSession(null, ctx.sessionId)
       })
 
-      it('should not call the appropriate redis methods', async function () {
-        await this.UserSessionsManager.promises.trackSession(
-          null,
-          this.sessionId
-        )
-        this.rclient.multi.callCount.should.equal(0)
+      it('should not call the appropriate redis methods', async function (ctx) {
+        await ctx.UserSessionsManager.promises.trackSession(null, ctx.sessionId)
+        ctx.rclient.multi.callCount.should.equal(0)
       })
 
-      it('should not call _checkSessions', async function () {
-        await this.UserSessionsManager.promises.trackSession(
-          null,
-          this.sessionId
-        )
-        this._checkSessions.callCount.should.equal(0)
+      it('should not call _checkSessions', async function (ctx) {
+        await ctx.UserSessionsManager.promises.trackSession(null, ctx.sessionId)
+        ctx._checkSessions.callCount.should.equal(0)
       })
     })
 
     describe('when no sessionId is supplied', function () {
-      it('should not produce an error', async function () {
-        await this.UserSessionsManager.promises.trackSession(this.user, null)
+      it('should not produce an error', async function (ctx) {
+        await ctx.UserSessionsManager.promises.trackSession(ctx.user, null)
       })
 
-      it('should not call the appropriate redis methods', async function () {
-        await this.UserSessionsManager.promises.trackSession(this.user, null)
-        this.rclient.multi.callCount.should.equal(0)
+      it('should not call the appropriate redis methods', async function (ctx) {
+        await ctx.UserSessionsManager.promises.trackSession(ctx.user, null)
+        ctx.rclient.multi.callCount.should.equal(0)
       })
 
-      it('should not call _checkSessions', async function () {
-        await this.UserSessionsManager.promises.trackSession(this.user, null)
-        this._checkSessions.callCount.should.equal(0)
+      it('should not call _checkSessions', async function (ctx) {
+        await ctx.UserSessionsManager.promises.trackSession(ctx.user, null)
+        ctx._checkSessions.callCount.should.equal(0)
       })
     })
   })
 
   describe('untrackSession', function () {
-    beforeEach(function () {
-      this._checkSessions = sinon
-        .stub(this.UserSessionsManager.promises, '_checkSessions')
+    beforeEach(function (ctx) {
+      ctx._checkSessions = sinon
+        .stub(ctx.UserSessionsManager.promises, '_checkSessions')
         .resolves()
     })
 
-    afterEach(function () {
-      return this._checkSessions.restore()
+    afterEach(function (ctx) {
+      return ctx._checkSessions.restore()
     })
 
-    it('should not produce an error', async function () {
-      await this.UserSessionsManager.promises.untrackSession(
-        this.user,
-        this.sessionId
+    it('should not produce an error', async function (ctx) {
+      await ctx.UserSessionsManager.promises.untrackSession(
+        ctx.user,
+        ctx.sessionId
       )
     })
 
-    it('should call the appropriate redis methods', async function () {
-      await this.UserSessionsManager.promises.untrackSession(
-        this.user,
-        this.sessionId
+    it('should call the appropriate redis methods', async function (ctx) {
+      await ctx.UserSessionsManager.promises.untrackSession(
+        ctx.user,
+        ctx.sessionId
       )
-      this.rclient.multi.callCount.should.equal(1)
-      const multiInstance = this.rclient.multi.returnValues[0]
+      ctx.rclient.multi.callCount.should.equal(1)
+      const multiInstance = ctx.rclient.multi.returnValues[0]
       multiInstance.srem.callCount.should.equal(1)
       multiInstance.pexpire.callCount.should.equal(1)
       multiInstance.exec.callCount.should.equal(1)
     })
 
-    it('should call _checkSessions', async function () {
-      await this.UserSessionsManager.promises.untrackSession(
-        this.user,
-        this.sessionId
+    it('should call _checkSessions', async function (ctx) {
+      await ctx.UserSessionsManager.promises.untrackSession(
+        ctx.user,
+        ctx.sessionId
       )
-      this._checkSessions.callCount.should.equal(1)
+      ctx._checkSessions.callCount.should.equal(1)
     })
 
     describe('when rclient produces an error', function () {
-      beforeEach(function () {
-        this.rclient.multi.returns({
+      beforeEach(function (ctx) {
+        ctx.rclient.multi.returns({
           srem: sinon.stub().returnsThis(),
           pexpire: sinon.stub().returnsThis(),
           exec: sinon.stub().rejects(new Error('woops')),
         })
       })
 
-      it('should produce an error', async function () {
+      it('should produce an error', async function (ctx) {
         await expect(
-          this.UserSessionsManager.promises.untrackSession(
-            this.user,
-            this.sessionId
+          ctx.UserSessionsManager.promises.untrackSession(
+            ctx.user,
+            ctx.sessionId
           )
         ).to.be.rejectedWith(Error)
       })
 
-      it('should not call _checkSessions', async function () {
+      it('should not call _checkSessions', async function (ctx) {
         try {
-          await this.UserSessionsManager.promises.untrackSession(
-            this.user,
-            this.sessionId
+          await ctx.UserSessionsManager.promises.untrackSession(
+            ctx.user,
+            ctx.sessionId
           )
         } catch (err) {
           // Expected error
         }
-        this._checkSessions.callCount.should.equal(0)
+        ctx._checkSessions.callCount.should.equal(0)
       })
     })
 
     describe('when no user is supplied', function () {
-      it('should not produce an error', async function () {
-        await this.UserSessionsManager.promises.untrackSession(
+      it('should not produce an error', async function (ctx) {
+        await ctx.UserSessionsManager.promises.untrackSession(
           null,
-          this.sessionId
+          ctx.sessionId
         )
       })
 
-      it('should not call the appropriate redis methods', async function () {
-        await this.UserSessionsManager.promises.untrackSession(
+      it('should not call the appropriate redis methods', async function (ctx) {
+        await ctx.UserSessionsManager.promises.untrackSession(
           null,
-          this.sessionId
+          ctx.sessionId
         )
-        this.rclient.multi.callCount.should.equal(0)
+        ctx.rclient.multi.callCount.should.equal(0)
       })
 
-      it('should not call _checkSessions', async function () {
-        await this.UserSessionsManager.promises.untrackSession(
+      it('should not call _checkSessions', async function (ctx) {
+        await ctx.UserSessionsManager.promises.untrackSession(
           null,
-          this.sessionId
+          ctx.sessionId
         )
-        this._checkSessions.callCount.should.equal(0)
+        ctx._checkSessions.callCount.should.equal(0)
       })
     })
 
     describe('when no sessionId is supplied', function () {
-      it('should not produce an error', async function () {
-        await this.UserSessionsManager.promises.untrackSession(this.user, null)
+      it('should not produce an error', async function (ctx) {
+        await ctx.UserSessionsManager.promises.untrackSession(ctx.user, null)
       })
 
-      it('should not call the appropriate redis methods', async function () {
-        await this.UserSessionsManager.promises.untrackSession(this.user, null)
-        this.rclient.multi.callCount.should.equal(0)
+      it('should not call the appropriate redis methods', async function (ctx) {
+        await ctx.UserSessionsManager.promises.untrackSession(ctx.user, null)
+        ctx.rclient.multi.callCount.should.equal(0)
       })
 
-      it('should not call _checkSessions', async function () {
-        await this.UserSessionsManager.promises.untrackSession(this.user, null)
-        this._checkSessions.callCount.should.equal(0)
+      it('should not call _checkSessions', async function (ctx) {
+        await ctx.UserSessionsManager.promises.untrackSession(ctx.user, null)
+        ctx._checkSessions.callCount.should.equal(0)
       })
     })
   })
 
   describe('removeSessionsFromRedis', function () {
-    beforeEach(function () {
-      this.sessionKeys = ['sess:one', 'sess:two']
-      this.currentSessionID = undefined
-      this.rclient.smembers.resolves(this.sessionKeys)
-      this.rclient.del.resolves()
-      this.rclient.srem.resolves()
-    })
-
-    it('should not produce an error', async function () {
-      await this.UserSessionsManager.promises.removeSessionsFromRedis(
-        this.user,
-        this.currentSessionID
+    beforeEach(function (ctx) {
+      ctx.sessionKeys = ['sess:one', 'sess:two']
+      ctx.currentSessionID = undefined
+      ctx.rclient.smembers.resolves(ctx.sessionKeys)
+      ctx.rclient.del.resolves()
+      ctx.rclient.srem.resolves()
+    })
+
+    it('should not produce an error', async function (ctx) {
+      await ctx.UserSessionsManager.promises.removeSessionsFromRedis(
+        ctx.user,
+        ctx.currentSessionID
       )
     })
 
-    it('should yield the number of purged sessions', async function () {
+    it('should yield the number of purged sessions', async function (ctx) {
       const result =
-        await this.UserSessionsManager.promises.removeSessionsFromRedis(
-          this.user,
-          this.currentSessionID
+        await ctx.UserSessionsManager.promises.removeSessionsFromRedis(
+          ctx.user,
+          ctx.currentSessionID
         )
-      expect(result).to.equal(this.sessionKeys.length)
+      expect(result).to.equal(ctx.sessionKeys.length)
     })
 
-    it('should call the appropriate redis methods', async function () {
-      await this.UserSessionsManager.promises.removeSessionsFromRedis(
-        this.user,
-        this.currentSessionID
+    it('should call the appropriate redis methods', async function (ctx) {
+      await ctx.UserSessionsManager.promises.removeSessionsFromRedis(
+        ctx.user,
+        ctx.currentSessionID
       )
-      this.rclient.smembers.callCount.should.equal(1)
+      ctx.rclient.smembers.callCount.should.equal(1)
 
-      this.rclient.del.callCount.should.equal(2)
-      expect(this.rclient.del.firstCall.args[0]).to.deep.equal(
-        this.sessionKeys[0]
+      ctx.rclient.del.callCount.should.equal(2)
+      expect(ctx.rclient.del.firstCall.args[0]).to.deep.equal(
+        ctx.sessionKeys[0]
       )
-      expect(this.rclient.del.secondCall.args[0]).to.deep.equal(
-        this.sessionKeys[1]
+      expect(ctx.rclient.del.secondCall.args[0]).to.deep.equal(
+        ctx.sessionKeys[1]
       )
 
-      this.rclient.srem.callCount.should.equal(1)
-      expect(this.rclient.srem.firstCall.args[0]).to.deep.equal(
+      ctx.rclient.srem.callCount.should.equal(1)
+      expect(ctx.rclient.srem.firstCall.args[0]).to.deep.equal(
         'UserSessions:{abcd}'
       )
-      expect(this.rclient.srem.firstCall.args[1]).to.deep.equal(
-        this.sessionKeys
-      )
+      expect(ctx.rclient.srem.firstCall.args[1]).to.deep.equal(ctx.sessionKeys)
     })
 
     describe('when a session is retained', function () {
-      beforeEach(function () {
-        this.sessionKeys = ['sess:one', 'sess:two', 'sess:three', 'sess:four']
-        this.currentSessionID = 'two'
-        this.rclient.smembers.resolves(this.sessionKeys)
-        this.rclient.del.resolves()
+      beforeEach(function (ctx) {
+        ctx.sessionKeys = ['sess:one', 'sess:two', 'sess:three', 'sess:four']
+        ctx.currentSessionID = 'two'
+        ctx.rclient.smembers.resolves(ctx.sessionKeys)
+        ctx.rclient.del.resolves()
       })
 
-      it('should not produce an error', async function () {
-        await this.UserSessionsManager.promises.removeSessionsFromRedis(
-          this.user,
-          this.currentSessionID
+      it('should not produce an error', async function (ctx) {
+        await ctx.UserSessionsManager.promises.removeSessionsFromRedis(
+          ctx.user,
+          ctx.currentSessionID
         )
       })
 
-      it('should call the appropriate redis methods', async function () {
-        await this.UserSessionsManager.promises.removeSessionsFromRedis(
-          this.user,
-          this.currentSessionID
+      it('should call the appropriate redis methods', async function (ctx) {
+        await ctx.UserSessionsManager.promises.removeSessionsFromRedis(
+          ctx.user,
+          ctx.currentSessionID
         )
-        this.rclient.smembers.callCount.should.equal(1)
-        this.rclient.del.callCount.should.equal(this.sessionKeys.length - 1)
-        this.rclient.srem.callCount.should.equal(1)
+        ctx.rclient.smembers.callCount.should.equal(1)
+        ctx.rclient.del.callCount.should.equal(ctx.sessionKeys.length - 1)
+        ctx.rclient.srem.callCount.should.equal(1)
       })
 
-      it('should remove all sessions except for the retained one', async function () {
-        await this.UserSessionsManager.promises.removeSessionsFromRedis(
-          this.user,
-          this.currentSessionID
+      it('should remove all sessions except for the retained one', async function (ctx) {
+        await ctx.UserSessionsManager.promises.removeSessionsFromRedis(
+          ctx.user,
+          ctx.currentSessionID
         )
-        expect(this.rclient.del.firstCall.args[0]).to.deep.equal('sess:one')
-        expect(this.rclient.del.secondCall.args[0]).to.deep.equal('sess:three')
-        expect(this.rclient.del.thirdCall.args[0]).to.deep.equal('sess:four')
-        expect(this.rclient.srem.firstCall.args[1]).to.deep.equal([
+        expect(ctx.rclient.del.firstCall.args[0]).to.deep.equal('sess:one')
+        expect(ctx.rclient.del.secondCall.args[0]).to.deep.equal('sess:three')
+        expect(ctx.rclient.del.thirdCall.args[0]).to.deep.equal('sess:four')
+        expect(ctx.rclient.srem.firstCall.args[1]).to.deep.equal([
           'sess:one',
           'sess:three',
           'sess:four',
@@ -372,141 +362,141 @@ describe('UserSessionsManager', function () {
     })
 
     describe('when rclient produces an error', function () {
-      beforeEach(function () {
-        this.rclient.del.rejects(new Error('woops'))
+      beforeEach(function (ctx) {
+        ctx.rclient.del.rejects(new Error('woops'))
       })
 
-      it('should produce an error', async function () {
+      it('should produce an error', async function (ctx) {
         await expect(
-          this.UserSessionsManager.promises.removeSessionsFromRedis(
-            this.user,
-            this.currentSessionID
+          ctx.UserSessionsManager.promises.removeSessionsFromRedis(
+            ctx.user,
+            ctx.currentSessionID
           )
         ).to.be.rejectedWith(Error)
       })
 
-      it('should not call rclient.srem', async function () {
+      it('should not call rclient.srem', async function (ctx) {
         try {
-          await this.UserSessionsManager.promises.removeSessionsFromRedis(
-            this.user,
-            this.currentSessionID
+          await ctx.UserSessionsManager.promises.removeSessionsFromRedis(
+            ctx.user,
+            ctx.currentSessionID
           )
         } catch (err) {
           // Expected error
         }
-        this.rclient.srem.callCount.should.equal(0)
+        ctx.rclient.srem.callCount.should.equal(0)
       })
     })
 
     describe('when no user is supplied', function () {
-      it('should produce an error', async function () {
+      it('should produce an error', async function (ctx) {
         await expect(
-          this.UserSessionsManager.promises.removeSessionsFromRedis(
+          ctx.UserSessionsManager.promises.removeSessionsFromRedis(
             null,
-            this.currentSessionID
+            ctx.currentSessionID
           )
         ).to.be.rejectedWith(/bug: user not passed to removeSessionsFromRedis/)
       })
 
-      it('should not call the appropriate redis methods', async function () {
+      it('should not call the appropriate redis methods', async function (ctx) {
         try {
-          await this.UserSessionsManager.promises.removeSessionsFromRedis(
+          await ctx.UserSessionsManager.promises.removeSessionsFromRedis(
             null,
-            this.currentSessionID
+            ctx.currentSessionID
           )
         } catch (err) {
           // Expected error
         }
-        this.rclient.smembers.callCount.should.equal(0)
-        this.rclient.del.callCount.should.equal(0)
-        this.rclient.srem.callCount.should.equal(0)
+        ctx.rclient.smembers.callCount.should.equal(0)
+        ctx.rclient.del.callCount.should.equal(0)
+        ctx.rclient.srem.callCount.should.equal(0)
       })
     })
 
     describe('when there are no keys to delete', function () {
-      beforeEach(function () {
-        this.rclient.smembers.resolves([])
+      beforeEach(function (ctx) {
+        ctx.rclient.smembers.resolves([])
       })
 
-      it('should not produce an error', async function () {
-        await this.UserSessionsManager.promises.removeSessionsFromRedis(
-          this.user,
-          this.currentSessionID
+      it('should not produce an error', async function (ctx) {
+        await ctx.UserSessionsManager.promises.removeSessionsFromRedis(
+          ctx.user,
+          ctx.currentSessionID
         )
       })
 
-      it('should not do the delete operation', async function () {
-        await this.UserSessionsManager.promises.removeSessionsFromRedis(
-          this.user,
-          this.currentSessionID
+      it('should not do the delete operation', async function (ctx) {
+        await ctx.UserSessionsManager.promises.removeSessionsFromRedis(
+          ctx.user,
+          ctx.currentSessionID
         )
-        this.rclient.smembers.callCount.should.equal(1)
-        this.rclient.del.callCount.should.equal(0)
-        this.rclient.srem.callCount.should.equal(0)
+        ctx.rclient.smembers.callCount.should.equal(1)
+        ctx.rclient.del.callCount.should.equal(0)
+        ctx.rclient.srem.callCount.should.equal(0)
       })
     })
   })
 
   describe('touch', function () {
-    it('should not produce an error', async function () {
-      await this.UserSessionsManager.promises.touch(this.user)
+    it('should not produce an error', async function (ctx) {
+      await ctx.UserSessionsManager.promises.touch(ctx.user)
     })
 
-    it('should call rclient.pexpire', async function () {
-      await this.UserSessionsManager.promises.touch(this.user)
-      this.rclient.pexpire.callCount.should.equal(1)
+    it('should call rclient.pexpire', async function (ctx) {
+      await ctx.UserSessionsManager.promises.touch(ctx.user)
+      ctx.rclient.pexpire.callCount.should.equal(1)
     })
 
     describe('when rclient produces an error', function () {
-      beforeEach(function () {
-        this.rclient.pexpire.rejects(new Error('woops'))
+      beforeEach(function (ctx) {
+        ctx.rclient.pexpire.rejects(new Error('woops'))
       })
 
-      it('should produce an error', async function () {
+      it('should produce an error', async function (ctx) {
         await expect(
-          this.UserSessionsManager.promises.touch(this.user)
+          ctx.UserSessionsManager.promises.touch(ctx.user)
         ).to.be.rejectedWith(Error)
       })
     })
 
     describe('when no user is supplied', function () {
-      it('should not produce an error', async function () {
-        await this.UserSessionsManager.promises.touch(null)
+      it('should not produce an error', async function (ctx) {
+        await ctx.UserSessionsManager.promises.touch(null)
       })
 
-      it('should not call pexpire', async function () {
-        await this.UserSessionsManager.promises.touch(null)
-        this.rclient.pexpire.callCount.should.equal(0)
+      it('should not call pexpire', async function (ctx) {
+        await ctx.UserSessionsManager.promises.touch(null)
+        ctx.rclient.pexpire.callCount.should.equal(0)
       })
     })
   })
 
   describe('getAllUserSessions', function () {
-    beforeEach(function () {
-      this.sessionKeys = ['sess:one', 'sess:two', 'sess:three']
-      this.sessions = [
+    beforeEach(function (ctx) {
+      ctx.sessionKeys = ['sess:one', 'sess:two', 'sess:three']
+      ctx.sessions = [
         '{"user": {"ip_address": "a", "session_created": "b"}}',
         '{"passport": {"user": {"ip_address": "c", "session_created": "d"}}}',
       ]
-      this.exclude = ['two']
-      this.rclient.smembers.resolves(this.sessionKeys)
-      this.rclient.get = sinon.stub()
-      this.rclient.get.onCall(0).resolves(this.sessions[0])
-      this.rclient.get.onCall(1).resolves(this.sessions[1])
+      ctx.exclude = ['two']
+      ctx.rclient.smembers.resolves(ctx.sessionKeys)
+      ctx.rclient.get = sinon.stub()
+      ctx.rclient.get.onCall(0).resolves(ctx.sessions[0])
+      ctx.rclient.get.onCall(1).resolves(ctx.sessions[1])
     })
 
-    it('should not produce an error', async function () {
-      await this.UserSessionsManager.promises.getAllUserSessions(
-        this.user,
-        this.exclude
+    it('should not produce an error', async function (ctx) {
+      await ctx.UserSessionsManager.promises.getAllUserSessions(
+        ctx.user,
+        ctx.exclude
       )
     })
 
-    it('should get sessions', async function () {
+    it('should get sessions', async function (ctx) {
       const sessions =
-        await this.UserSessionsManager.promises.getAllUserSessions(
-          this.user,
-          this.exclude
+        await ctx.UserSessionsManager.promises.getAllUserSessions(
+          ctx.user,
+          ctx.exclude
         )
       expect(sessions).to.deep.equal([
         { ip_address: 'a', session_created: 'b' },
@@ -514,98 +504,98 @@ describe('UserSessionsManager', function () {
       ])
     })
 
-    it('should have called rclient.smembers', async function () {
-      await this.UserSessionsManager.promises.getAllUserSessions(
-        this.user,
-        this.exclude
+    it('should have called rclient.smembers', async function (ctx) {
+      await ctx.UserSessionsManager.promises.getAllUserSessions(
+        ctx.user,
+        ctx.exclude
       )
-      this.rclient.smembers.callCount.should.equal(1)
+      ctx.rclient.smembers.callCount.should.equal(1)
     })
 
-    it('should have called rclient.get', async function () {
-      await this.UserSessionsManager.promises.getAllUserSessions(
-        this.user,
-        this.exclude
+    it('should have called rclient.get', async function (ctx) {
+      await ctx.UserSessionsManager.promises.getAllUserSessions(
+        ctx.user,
+        ctx.exclude
       )
-      this.rclient.get.callCount.should.equal(this.sessionKeys.length - 1)
+      ctx.rclient.get.callCount.should.equal(ctx.sessionKeys.length - 1)
     })
 
     describe('when there are no other sessions', function () {
-      beforeEach(function () {
-        this.sessionKeys = ['sess:two']
-        this.rclient.smembers.resolves(this.sessionKeys)
+      beforeEach(function (ctx) {
+        ctx.sessionKeys = ['sess:two']
+        ctx.rclient.smembers.resolves(ctx.sessionKeys)
       })
 
-      it('should not produce an error', async function () {
-        await this.UserSessionsManager.promises.getAllUserSessions(
-          this.user,
-          this.exclude
+      it('should not produce an error', async function (ctx) {
+        await ctx.UserSessionsManager.promises.getAllUserSessions(
+          ctx.user,
+          ctx.exclude
         )
       })
 
-      it('should produce an empty list of sessions', async function () {
+      it('should produce an empty list of sessions', async function (ctx) {
         const sessions =
-          await this.UserSessionsManager.promises.getAllUserSessions(
-            this.user,
-            this.exclude
+          await ctx.UserSessionsManager.promises.getAllUserSessions(
+            ctx.user,
+            ctx.exclude
           )
         expect(sessions).to.deep.equal([])
       })
 
-      it('should have called rclient.smembers', async function () {
-        await this.UserSessionsManager.promises.getAllUserSessions(
-          this.user,
-          this.exclude
+      it('should have called rclient.smembers', async function (ctx) {
+        await ctx.UserSessionsManager.promises.getAllUserSessions(
+          ctx.user,
+          ctx.exclude
         )
-        this.rclient.smembers.callCount.should.equal(1)
+        ctx.rclient.smembers.callCount.should.equal(1)
       })
 
-      it('should not have called rclient.get for individual keys', async function () {
-        await this.UserSessionsManager.promises.getAllUserSessions(
-          this.user,
-          this.exclude
+      it('should not have called rclient.get for individual keys', async function (ctx) {
+        await ctx.UserSessionsManager.promises.getAllUserSessions(
+          ctx.user,
+          ctx.exclude
         )
-        this.rclient.get.callCount.should.equal(0)
+        ctx.rclient.get.callCount.should.equal(0)
       })
     })
 
     describe('when smembers produces an error', function () {
-      beforeEach(function () {
-        this.rclient.smembers.rejects(new Error('woops'))
+      beforeEach(function (ctx) {
+        ctx.rclient.smembers.rejects(new Error('woops'))
       })
 
-      it('should produce an error', async function () {
+      it('should produce an error', async function (ctx) {
         await expect(
-          this.UserSessionsManager.promises.getAllUserSessions(
-            this.user,
-            this.exclude
+          ctx.UserSessionsManager.promises.getAllUserSessions(
+            ctx.user,
+            ctx.exclude
           )
         ).to.be.rejectedWith(Error)
       })
 
-      it('should not have called rclient.get', async function () {
+      it('should not have called rclient.get', async function (ctx) {
         try {
-          await this.UserSessionsManager.promises.getAllUserSessions(
-            this.user,
-            this.exclude
+          await ctx.UserSessionsManager.promises.getAllUserSessions(
+            ctx.user,
+            ctx.exclude
           )
         } catch (err) {
           // Expected error
         }
-        this.rclient.get.callCount.should.equal(0)
+        ctx.rclient.get.callCount.should.equal(0)
       })
     })
 
     describe('when get produces an error', function () {
-      beforeEach(function () {
-        this.rclient.get = sinon.stub().rejects(new Error('woops'))
+      beforeEach(function (ctx) {
+        ctx.rclient.get = sinon.stub().rejects(new Error('woops'))
       })
 
-      it('should produce an error', async function () {
+      it('should produce an error', async function (ctx) {
         await expect(
-          this.UserSessionsManager.promises.getAllUserSessions(
-            this.user,
-            this.exclude
+          ctx.UserSessionsManager.promises.getAllUserSessions(
+            ctx.user,
+            ctx.exclude
           )
         ).to.be.rejectedWith(Error)
       })
@@ -613,76 +603,76 @@ describe('UserSessionsManager', function () {
   })
 
   describe('_checkSessions', function () {
-    beforeEach(function () {
-      this.sessionKeys = ['one', 'two']
-      this.rclient.smembers.resolves(this.sessionKeys)
-      this.rclient.get.resolves('some-value')
-      this.rclient.srem.resolves({})
+    beforeEach(function (ctx) {
+      ctx.sessionKeys = ['one', 'two']
+      ctx.rclient.smembers.resolves(ctx.sessionKeys)
+      ctx.rclient.get.resolves('some-value')
+      ctx.rclient.srem.resolves({})
     })
 
-    it('should not produce an error', async function () {
-      await this.UserSessionsManager.promises._checkSessions(this.user)
+    it('should not produce an error', async function (ctx) {
+      await ctx.UserSessionsManager.promises._checkSessions(ctx.user)
     })
 
-    it('should call the appropriate redis methods', async function () {
-      await this.UserSessionsManager.promises._checkSessions(this.user)
-      this.rclient.smembers.callCount.should.equal(1)
-      this.rclient.get.callCount.should.equal(2)
-      this.rclient.srem.callCount.should.equal(0)
+    it('should call the appropriate redis methods', async function (ctx) {
+      await ctx.UserSessionsManager.promises._checkSessions(ctx.user)
+      ctx.rclient.smembers.callCount.should.equal(1)
+      ctx.rclient.get.callCount.should.equal(2)
+      ctx.rclient.srem.callCount.should.equal(0)
     })
 
     describe('when one of the keys is not present in redis', function () {
-      beforeEach(function () {
-        this.rclient.get.onCall(0).resolves('some-val')
-        this.rclient.get.onCall(1).resolves(null)
+      beforeEach(function (ctx) {
+        ctx.rclient.get.onCall(0).resolves('some-val')
+        ctx.rclient.get.onCall(1).resolves(null)
       })
 
-      it('should not produce an error', async function () {
-        await this.UserSessionsManager.promises._checkSessions(this.user)
+      it('should not produce an error', async function (ctx) {
+        await ctx.UserSessionsManager.promises._checkSessions(ctx.user)
       })
 
-      it('should remove that key from the set', async function () {
-        await this.UserSessionsManager.promises._checkSessions(this.user)
-        this.rclient.smembers.callCount.should.equal(1)
-        this.rclient.get.callCount.should.equal(2)
-        this.rclient.srem.callCount.should.equal(1)
-        this.rclient.srem.firstCall.args[1].should.equal('two')
+      it('should remove that key from the set', async function (ctx) {
+        await ctx.UserSessionsManager.promises._checkSessions(ctx.user)
+        ctx.rclient.smembers.callCount.should.equal(1)
+        ctx.rclient.get.callCount.should.equal(2)
+        ctx.rclient.srem.callCount.should.equal(1)
+        ctx.rclient.srem.firstCall.args[1].should.equal('two')
       })
     })
 
     describe('when no user is supplied', function () {
-      it('should not produce an error', async function () {
-        await this.UserSessionsManager.promises._checkSessions(null)
+      it('should not produce an error', async function (ctx) {
+        await ctx.UserSessionsManager.promises._checkSessions(null)
       })
 
-      it('should not call redis methods', async function () {
-        await this.UserSessionsManager.promises._checkSessions(null)
-        this.rclient.smembers.callCount.should.equal(0)
-        this.rclient.get.callCount.should.equal(0)
+      it('should not call redis methods', async function (ctx) {
+        await ctx.UserSessionsManager.promises._checkSessions(null)
+        ctx.rclient.smembers.callCount.should.equal(0)
+        ctx.rclient.get.callCount.should.equal(0)
       })
     })
 
     describe('when one of the get operations produces an error', function () {
-      beforeEach(function () {
-        this.rclient.get.onCall(0).rejects(new Error('woops'))
-        this.rclient.get.onCall(1).resolves(null)
+      beforeEach(function (ctx) {
+        ctx.rclient.get.onCall(0).rejects(new Error('woops'))
+        ctx.rclient.get.onCall(1).resolves(null)
       })
 
-      it('should produce an error', async function () {
+      it('should produce an error', async function (ctx) {
         await expect(
-          this.UserSessionsManager.promises._checkSessions(this.user)
+          ctx.UserSessionsManager.promises._checkSessions(ctx.user)
         ).to.be.rejectedWith(Error)
       })
 
-      it('should call the right redis methods, bailing out early', async function () {
+      it('should call the right redis methods, bailing out early', async function (ctx) {
         try {
-          await this.UserSessionsManager.promises._checkSessions(this.user)
+          await ctx.UserSessionsManager.promises._checkSessions(ctx.user)
         } catch (err) {
           // Expected error
         }
-        this.rclient.smembers.callCount.should.equal(1)
-        this.rclient.get.callCount.should.equal(1)
-        this.rclient.srem.callCount.should.equal(0)
+        ctx.rclient.smembers.callCount.should.equal(1)
+        ctx.rclient.get.callCount.should.equal(1)
+        ctx.rclient.srem.callCount.should.equal(0)
       })
     })
   })

Разница между файлами не показана из-за своего большого размера
+ 450 - 381
services/web/test/unit/src/User/UserUpdater.test.mjs


+ 18 - 0
services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs

@@ -103,6 +103,24 @@ describe('UserMembershipController', () => {
       managedUsers: {
         enabled: false,
       },
+      plans: [
+        {
+          planCode: 'personal',
+          name: 'Personal',
+          price_in_cents: 0,
+          features: {
+            collaborators: -1,
+            dropbox: true,
+            github: true,
+            gitBridge: true,
+            versioning: true,
+            compileTimeout: 180,
+            compileGroup: 'standard',
+            references: true,
+            trackChanges: true,
+          },
+        },
+      ],
     }
 
     ctx.SessionManager = {

+ 43 - 53
services/web/test/unit/src/UserMembership/UserMembershipsHandler.test.mjs

@@ -1,66 +1,56 @@
-/* eslint-disable
-    n/handle-callback-err,
-    max-len,
-    no-return-assign,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const sinon = require('sinon')
+import { vi } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+
 const assertCalledWith = sinon.assert.calledWith
-const { ObjectId } = require('mongodb-legacy')
 const modulePath =
   '../../../../app/src/Features/UserMembership/UserMembershipsHandler'
-const SandboxedModule = require('sandboxed-module')
+
+const { ObjectId } = mongodb
 
 describe('UserMembershipsHandler', function () {
-  beforeEach(function () {
-    this.user = { _id: new ObjectId() }
+  beforeEach(async function (ctx) {
+    ctx.user = { _id: new ObjectId() }
+
+    ctx.Institution = { updateMany: sinon.stub().resolves(null) }
+    ctx.Subscription = { updateMany: sinon.stub().resolves(null) }
+    ctx.Publisher = { updateMany: sinon.stub().resolves(null) }
+
+    vi.doMock('../../../../app/src/models/Institution', () => ({
+      default: { Institution: ctx.Institution },
+    }))
+
+    vi.doMock('../../../../app/src/models/Subscription', () => ({
+      default: { Subscription: ctx.Subscription },
+    }))
 
-    this.Institution = { updateMany: sinon.stub().resolves(null) }
-    this.Subscription = { updateMany: sinon.stub().resolves(null) }
-    this.Publisher = { updateMany: sinon.stub().resolves(null) }
-    return (this.UserMembershipsHandler = SandboxedModule.require(modulePath, {
-      requires: {
-        '../../models/Institution': {
-          Institution: this.Institution,
-        },
-        '../../models/Subscription': {
-          Subscription: this.Subscription,
-        },
-        '../../models/Publisher': {
-          Publisher: this.Publisher,
-        },
-      },
+    vi.doMock('../../../../app/src/models/Publisher', () => ({
+      default: { Publisher: ctx.Publisher },
     }))
+
+    ctx.UserMembershipsHandler = (await import(modulePath)).default
   })
 
   describe('remove user', function () {
-    it('remove user from all entities', function (done) {
-      return this.UserMembershipsHandler.removeUserFromAllEntities(
-        this.user._id,
-        error => {
-          assertCalledWith(
-            this.Institution.updateMany,
-            {},
-            { $pull: { managerIds: this.user._id } }
-          )
-          assertCalledWith(
-            this.Subscription.updateMany,
-            {},
-            { $pull: { manager_ids: this.user._id } }
-          )
-          assertCalledWith(
-            this.Publisher.updateMany,
-            {},
-            { $pull: { managerIds: this.user._id } }
-          )
-          return done()
-        }
+    it('remove user from all entities', async function (ctx) {
+      await ctx.UserMembershipsHandler.promises.removeUserFromAllEntities(
+        ctx.user._id
+      )
+
+      assertCalledWith(
+        ctx.Institution.updateMany,
+        {},
+        { $pull: { managerIds: ctx.user._id } }
+      )
+      assertCalledWith(
+        ctx.Subscription.updateMany,
+        {},
+        { $pull: { manager_ids: ctx.user._id } }
+      )
+      assertCalledWith(
+        ctx.Publisher.updateMany,
+        {},
+        { $pull: { managerIds: ctx.user._id } }
       )
     })
   })

Некоторые файлы не были показаны из-за большого количества измененных файлов