Sfoglia il codice sorgente

Update test files with vitest compat changes

GitOrigin-RevId: 494f906089d250268a5ff8c8a2150ff2692c37e2
Andrew Rumble 1 anno fa
parent
commit
873068a187
42 ha cambiato i file con 8905 aggiunte e 7408 eliminazioni
  1. 395 424
      services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs
  2. 104 74
      services/web/modules/user-activate/test/unit/src/UserActivateController.test.mjs
  3. 73 52
      services/web/test/unit/src/Analytics/AnalyticsController.test.mjs
  4. 79 72
      services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs
  5. 148 105
      services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs
  6. 98 71
      services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs
  7. 337 252
      services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs
  8. 754 659
      services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs
  9. 399 343
      services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs
  10. 94 75
      services/web/test/unit/src/Contact/ContactController.test.mjs
  11. 66 74
      services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs
  12. 55 45
      services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs
  13. 124 99
      services/web/test/unit/src/Documents/DocumentController.test.mjs
  14. 88 73
      services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs
  15. 268 242
      services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs
  16. 149 129
      services/web/test/unit/src/Exports/ExportsController.test.mjs
  17. 486 447
      services/web/test/unit/src/Exports/ExportsHandler.test.mjs
  18. 127 106
      services/web/test/unit/src/FileStore/FileStoreController.test.mjs
  19. 155 105
      services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs
  20. 42 35
      services/web/test/unit/src/Metadata/MetaController.test.mjs
  21. 53 42
      services/web/test/unit/src/Metadata/MetaHandler.test.mjs
  22. 45 31
      services/web/test/unit/src/Notifications/NotificationsController.test.mjs
  23. 414 330
      services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs
  24. 387 333
      services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs
  25. 20 22
      services/web/test/unit/src/Project/DocLinesComparitor.test.mjs
  26. 38 38
      services/web/test/unit/src/Project/ProjectApiController.test.mjs
  27. 418 317
      services/web/test/unit/src/Project/ProjectListController.test.mjs
  28. 112 91
      services/web/test/unit/src/Referal/ReferalConnect.test.mjs
  29. 4 8
      services/web/test/unit/src/Referal/ReferalController.test.mjs
  30. 26 28
      services/web/test/unit/src/Referal/ReferalHandler.test.mjs
  31. 141 116
      services/web/test/unit/src/References/ReferencesController.test.mjs
  32. 244 196
      services/web/test/unit/src/References/ReferencesHandler.test.mjs
  33. 663 552
      services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs
  34. 178 128
      services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs
  35. 224 197
      services/web/test/unit/src/Tags/TagsController.test.mjs
  36. 386 327
      services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs
  37. 214 175
      services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs
  38. 441 331
      services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs
  39. 185 152
      services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs
  40. 387 297
      services/web/test/unit/src/User/UserPagesController.test.mjs
  41. 262 194
      services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs
  42. 22 21
      services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs

File diff suppressed because it is too large
+ 395 - 424
services/web/modules/launchpad/test/unit/src/LaunchpadController.test.mjs


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

@@ -1,132 +1,162 @@
+import { vi } from 'vitest'
 import Path from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { strict as esmock } from 'esmock'
 import sinon from 'sinon'
 
-const __dirname = Path.dirname(fileURLToPath(import.meta.url))
-
 const MODULE_PATH = '../../../app/src/UserActivateController.mjs'
 
-const VIEW_PATH = Path.join(__dirname, '../../../app/views/user/activate')
+const VIEW_PATH = Path.join(
+  import.meta.dirname,
+  '../../../app/views/user/activate'
+)
 
 describe('UserActivateController', function () {
-  beforeEach(async function () {
-    this.user = {
-      _id: (this.user_id = 'kwjewkl'),
+  beforeEach(async function (ctx) {
+    ctx.user = {
+      _id: (ctx.user_id = 'kwjewkl'),
       features: {},
       email: 'joe@example.com',
     }
 
-    this.UserGetter = {
+    ctx.UserGetter = {
       promises: {
         getUser: sinon.stub(),
       },
     }
-    this.UserRegistrationHandler = { promises: {} }
-    this.ErrorController = { notFound: sinon.stub() }
-    this.SplitTestHandler = {
+    ctx.UserRegistrationHandler = { promises: {} }
+    ctx.ErrorController = { notFound: sinon.stub() }
+    ctx.SplitTestHandler = {
       promises: {
         getAssignment: sinon.stub().resolves({ variant: 'default' }),
       },
     }
-    this.UserActivateController = await esmock(MODULE_PATH, {
-      '../../../../../app/src/Features/User/UserGetter.js': this.UserGetter,
-      '../../../../../app/src/Features/User/UserRegistrationHandler.js':
-        this.UserRegistrationHandler,
-      '../../../../../app/src/Features/Errors/ErrorController.js':
-        this.ErrorController,
-      '../../../../../app/src/Features/SplitTests/SplitTestHandler':
-        this.SplitTestHandler,
-    })
-    this.req = {
+
+    vi.doMock('../../../../../app/src/Features/User/UserGetter.js', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../../app/src/Features/User/UserRegistrationHandler.js',
+      () => ({
+        default: ctx.UserRegistrationHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../../app/src/Features/Errors/ErrorController.js',
+      () => ({
+        default: ctx.ErrorController,
+      })
+    )
+
+    vi.doMock(
+      '../../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+
+    ctx.UserActivateController = (await import(MODULE_PATH)).default
+    ctx.req = {
       body: {},
       query: {},
       session: {
-        user: this.user,
+        user: ctx.user,
       },
     }
-    this.res = {
+    ctx.res = {
       json: sinon.stub(),
     }
   })
 
   describe('activateAccountPage', function () {
-    beforeEach(function () {
-      this.UserGetter.promises.getUser = sinon.stub().resolves(this.user)
-      this.req.query.user_id = this.user_id
-      this.req.query.token = this.token = 'mock-token-123'
+    beforeEach(function (ctx) {
+      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.user)
+      ctx.req.query.user_id = ctx.user_id
+      ctx.req.query.token = ctx.token = 'mock-token-123'
     })
 
-    it('should 404 without a user_id', async function (done) {
-      delete this.req.query.user_id
-      this.ErrorController.notFound = () => done()
-      this.UserActivateController.activateAccountPage(this.req, this.res)
+    it('should 404 without a user_id', async function (ctx) {
+      delete ctx.req.query.user_id
+      return new Promise(resolve => {
+        ctx.ErrorController.notFound = () => resolve()
+        ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res)
+      })
     })
 
-    it('should 404 without a token', function (done) {
-      delete this.req.query.token
-      this.ErrorController.notFound = () => done()
-      this.UserActivateController.activateAccountPage(this.req, this.res)
+    it('should 404 without a token', function (ctx) {
+      return new Promise(resolve => {
+        delete ctx.req.query.token
+        ctx.ErrorController.notFound = resolve
+        ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res)
+      })
     })
 
-    it('should 404 without a valid user_id', function (done) {
-      this.UserGetter.promises.getUser = sinon.stub().resolves(null)
-      this.ErrorController.notFound = () => done()
-      this.UserActivateController.activateAccountPage(this.req, this.res)
+    it('should 404 without a valid user_id', function (ctx) {
+      return new Promise(resolve => {
+        ctx.UserGetter.promises.getUser = sinon.stub().resolves(null)
+        ctx.ErrorController.notFound = resolve
+        ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res)
+      })
     })
 
-    it('should 403 for complex user_id', function (done) {
-      this.ErrorController.forbidden = () => done()
-      this.req.query.user_id = { first_name: 'X' }
-      this.UserActivateController.activateAccountPage(this.req, this.res)
+    it('should 403 for complex user_id', function (ctx) {
+      return new Promise(resolve => {
+        ctx.ErrorController.forbidden = resolve
+        ctx.req.query.user_id = { first_name: 'X' }
+        ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res)
+      })
     })
 
-    it('should redirect activated users to login', function (done) {
-      this.user.loginCount = 1
-      this.res.redirect = url => {
-        sinon.assert.calledWith(this.UserGetter.promises.getUser, this.user_id)
-        url.should.equal('/login')
-        return done()
-      }
-      this.UserActivateController.activateAccountPage(this.req, this.res)
+    it('should redirect activated users to login', function (ctx) {
+      return new Promise(resolve => {
+        ctx.user.loginCount = 1
+        ctx.res.redirect = url => {
+          sinon.assert.calledWith(ctx.UserGetter.promises.getUser, ctx.user_id)
+          url.should.equal('/login')
+          resolve()
+        }
+        ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res)
+      })
     })
 
-    it('render the activation page if the user has not logged in before', function (done) {
-      this.user.loginCount = 0
-      this.res.render = (page, opts) => {
-        page.should.equal(VIEW_PATH)
-        opts.email.should.equal(this.user.email)
-        opts.token.should.equal(this.token)
-        return done()
-      }
-      this.UserActivateController.activateAccountPage(this.req, this.res)
+    it('render the activation page if the user has not logged in before', function (ctx) {
+      return new Promise(resolve => {
+        ctx.user.loginCount = 0
+        ctx.res.render = (page, opts) => {
+          page.should.equal(VIEW_PATH)
+          opts.email.should.equal(ctx.user.email)
+          opts.token.should.equal(ctx.token)
+          resolve()
+        }
+        ctx.UserActivateController.activateAccountPage(ctx.req, ctx.res)
+      })
     })
   })
 
   describe('register', function () {
-    beforeEach(async function () {
-      this.UserRegistrationHandler.promises.registerNewUserAndSendActivationEmail =
+    beforeEach(async function (ctx) {
+      ctx.UserRegistrationHandler.promises.registerNewUserAndSendActivationEmail =
         sinon.stub().resolves({
-          user: this.user,
-          setNewPasswordUrl: (this.url = 'mock/url'),
+          user: ctx.user,
+          setNewPasswordUrl: (ctx.url = 'mock/url'),
         })
-      this.req.body.email = this.user.email = this.email = 'email@example.com'
-      await this.UserActivateController.register(this.req, this.res)
+      ctx.req.body.email = ctx.user.email = ctx.email = 'email@example.com'
+      await ctx.UserActivateController.register(ctx.req, ctx.res)
     })
 
-    it('should register the user and send them an email', function () {
+    it('should register the user and send them an email', function (ctx) {
       sinon.assert.calledWith(
-        this.UserRegistrationHandler.promises
+        ctx.UserRegistrationHandler.promises
           .registerNewUserAndSendActivationEmail,
-        this.email
+        ctx.email
       )
     })
 
-    it('should return the user and activation url', function () {
-      this.res.json
+    it('should return the user and activation url', function (ctx) {
+      ctx.res.json
         .calledWith({
-          email: this.email,
-          setNewPasswordUrl: this.url,
+          email: ctx.email,
+          setNewPasswordUrl: ctx.url,
         })
         .should.equal(true)
     })

+ 73 - 52
services/web/test/unit/src/Analytics/AnalyticsController.test.mjs

@@ -1,4 +1,4 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import MockResponse from '../helpers/MockResponse.js'
 const modulePath = new URL(
@@ -7,37 +7,52 @@ const modulePath = new URL(
 ).pathname
 
 describe('AnalyticsController', function () {
-  beforeEach(async function () {
-    this.SessionManager = { getLoggedInUserId: sinon.stub() }
+  beforeEach(async function (ctx) {
+    ctx.SessionManager = { getLoggedInUserId: sinon.stub() }
 
-    this.AnalyticsManager = {
+    ctx.AnalyticsManager = {
       updateEditingSession: sinon.stub(),
       recordEventForSession: sinon.stub(),
     }
 
-    this.Features = {
+    ctx.Features = {
       hasFeature: sinon.stub().returns(true),
     }
 
-    this.controller = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Analytics/AnalyticsManager.js':
-        this.AnalyticsManager,
-      '../../../../app/src/Features/Authentication/SessionManager.js':
-        this.SessionManager,
-      '../../../../app/src/infrastructure/Features.js': this.Features,
-      '../../../../app/src/infrastructure/GeoIpLookup.js': (this.GeoIpLookup = {
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager.js',
+      () => ({
+        default: ctx.AnalyticsManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager.js',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Features.js', () => ({
+      default: ctx.Features,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/GeoIpLookup.js', () => ({
+      default: (ctx.GeoIpLookup = {
         promises: {
           getDetails: sinon.stub().resolves(),
         },
       }),
-    })
+    }))
+
+    ctx.controller = (await import(modulePath)).default
 
-    this.res = new MockResponse()
+    ctx.res = new MockResponse()
   })
 
   describe('updateEditingSession', function () {
-    beforeEach(function () {
-      this.req = {
+    beforeEach(function (ctx) {
+      ctx.req = {
         params: {
           projectId: 'a project id',
         },
@@ -48,34 +63,36 @@ describe('AnalyticsController', function () {
           },
         },
       }
-      this.GeoIpLookup.promises.getDetails = sinon
+      ctx.GeoIpLookup.promises.getDetails = sinon
         .stub()
         .resolves({ country_code: 'XY' })
     })
 
-    it('delegates to the AnalyticsManager', function (done) {
-      this.SessionManager.getLoggedInUserId.returns('1234')
-      this.res.callback = () => {
-        sinon.assert.calledWith(
-          this.AnalyticsManager.updateEditingSession,
-          '1234',
-          'a project id',
-          'XY',
-          { editorType: 'abc' }
-        )
-        done()
-      }
-      this.controller.updateEditingSession(this.req, this.res)
+    it('delegates to the AnalyticsManager', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SessionManager.getLoggedInUserId.returns('1234')
+        ctx.res.callback = () => {
+          sinon.assert.calledWith(
+            ctx.AnalyticsManager.updateEditingSession,
+            '1234',
+            'a project id',
+            'XY',
+            { editorType: 'abc' }
+          )
+          resolve()
+        }
+        ctx.controller.updateEditingSession(ctx.req, ctx.res)
+      })
     })
   })
 
   describe('recordEvent', function () {
-    beforeEach(function () {
+    beforeEach(function (ctx) {
       const body = {
         foo: 'stuff',
         _csrf: 'atoken123',
       }
-      this.req = {
+      ctx.req = {
         params: {
           event: 'i_did_something',
         },
@@ -84,30 +101,34 @@ describe('AnalyticsController', function () {
         session: {},
       }
 
-      this.expectedData = Object.assign({}, body)
-      delete this.expectedData._csrf
+      ctx.expectedData = Object.assign({}, body)
+      delete ctx.expectedData._csrf
     })
 
-    it('should use the session', function (done) {
-      this.controller.recordEvent(this.req, this.res)
-      sinon.assert.calledWith(
-        this.AnalyticsManager.recordEventForSession,
-        this.req.session,
-        this.req.params.event,
-        this.expectedData
-      )
-      done()
+    it('should use the session', function (ctx) {
+      return new Promise(resolve => {
+        ctx.controller.recordEvent(ctx.req, ctx.res)
+        sinon.assert.calledWith(
+          ctx.AnalyticsManager.recordEventForSession,
+          ctx.req.session,
+          ctx.req.params.event,
+          ctx.expectedData
+        )
+        resolve()
+      })
     })
 
-    it('should remove the CSRF token before sending to the manager', function (done) {
-      this.controller.recordEvent(this.req, this.res)
-      sinon.assert.calledWith(
-        this.AnalyticsManager.recordEventForSession,
-        this.req.session,
-        this.req.params.event,
-        this.expectedData
-      )
-      done()
+    it('should remove the CSRF token before sending to the manager', function (ctx) {
+      return new Promise(resolve => {
+        ctx.controller.recordEvent(ctx.req, ctx.res)
+        sinon.assert.calledWith(
+          ctx.AnalyticsManager.recordEventForSession,
+          ctx.req.session,
+          ctx.req.params.event,
+          ctx.expectedData
+        )
+        resolve()
+      })
     })
   })
 })

+ 79 - 72
services/web/test/unit/src/Analytics/AnalyticsUTMTrackingMiddleware.test.mjs

@@ -1,4 +1,4 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import MockRequest from '../helpers/MockRequest.js'
 import MockResponse from '../helpers/MockResponse.js'
@@ -10,83 +10,90 @@ const MODULE_PATH = new URL(
 ).pathname
 
 describe('AnalyticsUTMTrackingMiddleware', function () {
-  beforeEach(async function () {
-    this.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55'
-    this.userId = '61795fcb013504bb7b663092'
-
-    this.req = new MockRequest()
-    this.res = new MockResponse()
-    this.next = sinon.stub().returns()
-    this.req.session = {
+  beforeEach(async function (ctx) {
+    ctx.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55'
+    ctx.userId = '61795fcb013504bb7b663092'
+
+    ctx.req = new MockRequest()
+    ctx.res = new MockResponse()
+    ctx.next = sinon.stub().returns()
+    ctx.req.session = {
       user: {
-        _id: this.userId,
-        analyticsId: this.analyticsId,
+        _id: ctx.userId,
+        analyticsId: ctx.analyticsId,
       },
     }
 
-    this.AnalyticsUTMTrackingMiddleware = await esmock.strict(MODULE_PATH, {
-      '../../../../app/src/Features/Analytics/AnalyticsManager.js':
-        (this.AnalyticsManager = {
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager.js',
+      () => ({
+        default: (ctx.AnalyticsManager = {
           recordEventForSession: sinon.stub().resolves(),
           setUserPropertyForSessionInBackground: sinon.stub(),
         }),
-      '@overleaf/settings': {
+      })
+    )
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
         siteUrl: 'https://www.overleaf.com',
       },
-    })
+    }))
+
+    ctx.AnalyticsUTMTrackingMiddleware = (await import(MODULE_PATH)).default
 
-    this.middleware = this.AnalyticsUTMTrackingMiddleware.recordUTMTags()
+    ctx.middleware = ctx.AnalyticsUTMTrackingMiddleware.recordUTMTags()
   })
 
   describe('without UTM tags in query', function () {
-    beforeEach(function () {
-      this.req.url = '/project'
-      this.middleware(this.req, this.res, this.next)
+    beforeEach(function (ctx) {
+      ctx.req.url = '/project'
+      ctx.middleware(ctx.req, ctx.res, ctx.next)
     })
 
-    it('user is not redirected', function () {
-      assert.isFalse(this.res.redirected)
+    it('user is not redirected', function (ctx) {
+      assert.isFalse(ctx.res.redirected)
     })
 
-    it('next middleware is executed', function () {
-      sinon.assert.calledOnce(this.next)
+    it('next middleware is executed', function (ctx) {
+      sinon.assert.calledOnce(ctx.next)
     })
 
-    it('no event or user property is recorded', function () {
-      sinon.assert.notCalled(this.AnalyticsManager.recordEventForSession)
+    it('no event or user property is recorded', function (ctx) {
+      sinon.assert.notCalled(ctx.AnalyticsManager.recordEventForSession)
       sinon.assert.notCalled(
-        this.AnalyticsManager.setUserPropertyForSessionInBackground
+        ctx.AnalyticsManager.setUserPropertyForSessionInBackground
       )
     })
   })
 
   describe('with all UTM tags in query', function () {
-    beforeEach(function () {
-      this.req.url =
+    beforeEach(function (ctx) {
+      ctx.req.url =
         '/project?utm_source=Organic&utm_medium=Facebook&utm_campaign=Some%20Campaign&utm_content=foo-bar&utm_term=overridden'
-      this.req.query = {
+      ctx.req.query = {
         utm_source: 'Organic',
         utm_medium: 'Facebook',
         utm_campaign: 'Some Campaign',
         utm_content: 'foo-bar',
         utm_term: 'overridden',
       }
-      this.middleware(this.req, this.res, this.next)
+      ctx.middleware(ctx.req, ctx.res, ctx.next)
     })
 
-    it('user is redirected', function () {
-      assert.isTrue(this.res.redirected)
-      assert.equal('/project', this.res.redirectedTo)
+    it('user is redirected', function (ctx) {
+      assert.isTrue(ctx.res.redirected)
+      assert.equal('/project', ctx.res.redirectedTo)
     })
 
-    it('next middleware is not executed', function () {
-      sinon.assert.notCalled(this.next)
+    it('next middleware is not executed', function (ctx) {
+      sinon.assert.notCalled(ctx.next)
     })
 
-    it('page-view event is recorded for session', function () {
+    it('page-view event is recorded for session', function (ctx) {
       sinon.assert.calledWith(
-        this.AnalyticsManager.recordEventForSession,
-        this.req.session,
+        ctx.AnalyticsManager.recordEventForSession,
+        ctx.req.session,
         'page-view',
         {
           path: '/project',
@@ -99,10 +106,10 @@ describe('AnalyticsUTMTrackingMiddleware', function () {
       )
     })
 
-    it('utm-tags user property is set for session', function () {
+    it('utm-tags user property is set for session', function (ctx) {
       sinon.assert.calledWith(
-        this.AnalyticsManager.setUserPropertyForSessionInBackground,
-        this.req.session,
+        ctx.AnalyticsManager.setUserPropertyForSessionInBackground,
+        ctx.req.session,
         'utm-tags',
         'Organic;Facebook;Some Campaign;foo-bar'
       )
@@ -110,30 +117,30 @@ describe('AnalyticsUTMTrackingMiddleware', function () {
   })
 
   describe('with some UTM tags in query', function () {
-    beforeEach(function () {
-      this.req.url =
+    beforeEach(function (ctx) {
+      ctx.req.url =
         '/project?utm_medium=Facebook&utm_campaign=Some%20Campaign&utm_term=foo'
-      this.req.query = {
+      ctx.req.query = {
         utm_medium: 'Facebook',
         utm_campaign: 'Some Campaign',
         utm_term: 'foo',
       }
-      this.middleware(this.req, this.res, this.next)
+      ctx.middleware(ctx.req, ctx.res, ctx.next)
     })
 
-    it('user is redirected', function () {
-      assert.isTrue(this.res.redirected)
-      assert.equal('/project', this.res.redirectedTo)
+    it('user is redirected', function (ctx) {
+      assert.isTrue(ctx.res.redirected)
+      assert.equal('/project', ctx.res.redirectedTo)
     })
 
-    it('next middleware is not executed', function () {
-      sinon.assert.notCalled(this.next)
+    it('next middleware is not executed', function (ctx) {
+      sinon.assert.notCalled(ctx.next)
     })
 
-    it('page-view event is recorded for session', function () {
+    it('page-view event is recorded for session', function (ctx) {
       sinon.assert.calledWith(
-        this.AnalyticsManager.recordEventForSession,
-        this.req.session,
+        ctx.AnalyticsManager.recordEventForSession,
+        ctx.req.session,
         'page-view',
         {
           path: '/project',
@@ -144,10 +151,10 @@ describe('AnalyticsUTMTrackingMiddleware', function () {
       )
     })
 
-    it('utm-tags user property is set for session', function () {
+    it('utm-tags user property is set for session', function (ctx) {
       sinon.assert.calledWith(
-        this.AnalyticsManager.setUserPropertyForSessionInBackground,
-        this.req.session,
+        ctx.AnalyticsManager.setUserPropertyForSessionInBackground,
+        ctx.req.session,
         'utm-tags',
         'N/A;Facebook;Some Campaign;foo'
       )
@@ -155,30 +162,30 @@ describe('AnalyticsUTMTrackingMiddleware', function () {
   })
 
   describe('with some UTM tags and additional parameters in query', function () {
-    beforeEach(function () {
-      this.req.url =
+    beforeEach(function (ctx) {
+      ctx.req.url =
         '/project?utm_medium=Facebook&utm_campaign=Some%20Campaign&other_param=some-value'
-      this.req.query = {
+      ctx.req.query = {
         utm_medium: 'Facebook',
         utm_campaign: 'Some Campaign',
         other_param: 'some-value',
       }
-      this.middleware(this.req, this.res, this.next)
+      ctx.middleware(ctx.req, ctx.res, ctx.next)
     })
 
-    it('user is redirected', function () {
-      assert.isTrue(this.res.redirected)
-      assert.equal('/project?other_param=some-value', this.res.redirectedTo)
+    it('user is redirected', function (ctx) {
+      assert.isTrue(ctx.res.redirected)
+      assert.equal('/project?other_param=some-value', ctx.res.redirectedTo)
     })
 
-    it('next middleware is not executed', function () {
-      sinon.assert.notCalled(this.next)
+    it('next middleware is not executed', function (ctx) {
+      sinon.assert.notCalled(ctx.next)
     })
 
-    it('page-view event is recorded for session', function () {
+    it('page-view event is recorded for session', function (ctx) {
       sinon.assert.calledWith(
-        this.AnalyticsManager.recordEventForSession,
-        this.req.session,
+        ctx.AnalyticsManager.recordEventForSession,
+        ctx.req.session,
         'page-view',
         {
           path: '/project',
@@ -188,10 +195,10 @@ describe('AnalyticsUTMTrackingMiddleware', function () {
       )
     })
 
-    it('utm-tags user property is set for session', function () {
+    it('utm-tags user property is set for session', function (ctx) {
       sinon.assert.calledWith(
-        this.AnalyticsManager.setUserPropertyForSessionInBackground,
-        this.req.session,
+        ctx.AnalyticsManager.setUserPropertyForSessionInBackground,
+        ctx.req.session,
         'utm-tags',
         'N/A;Facebook;Some Campaign;N/A'
       )

+ 148 - 105
services/web/test/unit/src/BetaProgram/BetaProgramController.test.mjs

@@ -1,4 +1,4 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import path from 'node:path'
 import sinon from 'sinon'
 import { expect } from 'chai'
@@ -13,185 +13,228 @@ const modulePath = path.join(
 )
 
 describe('BetaProgramController', function () {
-  beforeEach(async function () {
-    this.user = {
-      _id: (this.user_id = 'a_simple_id'),
+  beforeEach(async function (ctx) {
+    ctx.user = {
+      _id: (ctx.user_id = 'a_simple_id'),
       email: 'user@example.com',
       features: {},
       betaProgram: false,
     }
-    this.req = {
+    ctx.req = {
       query: {},
       session: {
-        user: this.user,
+        user: ctx.user,
       },
     }
-    this.SplitTestSessionHandler = {
+    ctx.SplitTestSessionHandler = {
       promises: {
         sessionMaintenance: sinon.stub(),
       },
     }
-    this.BetaProgramController = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/SplitTests/SplitTestSessionHandler':
-        this.SplitTestSessionHandler,
-      '../../../../app/src/Features/BetaProgram/BetaProgramHandler':
-        (this.BetaProgramHandler = {
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestSessionHandler',
+      () => ({
+        default: ctx.SplitTestSessionHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/BetaProgram/BetaProgramHandler',
+      () => ({
+        default: (ctx.BetaProgramHandler = {
           promises: {
             optIn: sinon.stub().resolves(),
             optOut: sinon.stub().resolves(),
           },
         }),
-      '../../../../app/src/Features/User/UserGetter': (this.UserGetter = {
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: (ctx.UserGetter = {
         promises: {
           getUser: sinon.stub().resolves(),
         },
       }),
-      '@overleaf/settings': (this.settings = {
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {
         languages: {},
       }),
-      '../../../../app/src/Features/Authentication/AuthenticationController':
-        (this.AuthenticationController = {
-          getLoggedInUserId: sinon.stub().returns(this.user._id),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/AuthenticationController',
+      () => ({
+        default: (ctx.AuthenticationController = {
+          getLoggedInUserId: sinon.stub().returns(ctx.user._id),
         }),
-    })
-    this.res = new MockResponse()
-    this.next = sinon.stub()
+      })
+    )
+
+    ctx.BetaProgramController = (await import(modulePath)).default
+    ctx.res = new MockResponse()
+    ctx.next = sinon.stub()
   })
 
   describe('optIn', function () {
-    it("should redirect to '/beta/participate'", function (done) {
-      this.res.callback = () => {
-        this.res.redirectedTo.should.equal('/beta/participate')
-        done()
-      }
-      this.BetaProgramController.optIn(this.req, this.res, done)
+    it("should redirect to '/beta/participate'", function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.res.redirectedTo.should.equal('/beta/participate')
+          resolve()
+        }
+        ctx.BetaProgramController.optIn(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should not call next with an error', function () {
-      this.BetaProgramController.optIn(this.req, this.res, this.next)
-      this.next.callCount.should.equal(0)
+    it('should not call next with an error', function (ctx) {
+      ctx.BetaProgramController.optIn(ctx.req, ctx.res, ctx.next)
+      ctx.next.callCount.should.equal(0)
     })
 
-    it('should call BetaProgramHandler.optIn', function () {
-      this.BetaProgramController.optIn(this.req, this.res, this.next)
-      this.BetaProgramHandler.promises.optIn.callCount.should.equal(1)
+    it('should call BetaProgramHandler.optIn', function (ctx) {
+      ctx.BetaProgramController.optIn(ctx.req, ctx.res, ctx.next)
+      ctx.BetaProgramHandler.promises.optIn.callCount.should.equal(1)
     })
 
-    it('should invoke the session maintenance', function (done) {
-      this.res.callback = () => {
-        this.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
-          this.req
-        )
-        done()
-      }
-      this.BetaProgramController.optIn(this.req, this.res, done)
+    it('should invoke the session maintenance', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
+            ctx.req
+          )
+          resolve()
+        }
+        ctx.BetaProgramController.optIn(ctx.req, ctx.res, resolve)
+      })
     })
 
     describe('when BetaProgramHandler.opIn produces an error', function () {
-      beforeEach(function () {
-        this.BetaProgramHandler.promises.optIn.throws(new Error('woops'))
+      beforeEach(function (ctx) {
+        ctx.BetaProgramHandler.promises.optIn.throws(new Error('woops'))
       })
 
-      it("should not redirect to '/beta/participate'", function () {
-        this.BetaProgramController.optIn(this.req, this.res, this.next)
-        this.res.redirect.callCount.should.equal(0)
+      it("should not redirect to '/beta/participate'", function (ctx) {
+        ctx.BetaProgramController.optIn(ctx.req, ctx.res, ctx.next)
+        ctx.res.redirect.callCount.should.equal(0)
       })
 
-      it('should produce an error', function (done) {
-        this.BetaProgramController.optIn(this.req, this.res, err => {
-          expect(err).to.be.instanceof(Error)
-          done()
+      it('should produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.BetaProgramController.optIn(ctx.req, ctx.res, err => {
+            expect(err).to.be.instanceof(Error)
+            resolve()
+          })
         })
       })
     })
   })
 
   describe('optOut', function () {
-    it("should redirect to '/beta/participate'", function (done) {
-      this.res.callback = () => {
-        expect(this.res.redirectedTo).to.equal('/beta/participate')
-        done()
-      }
-      this.BetaProgramController.optOut(this.req, this.res, done)
+    it("should redirect to '/beta/participate'", function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          expect(ctx.res.redirectedTo).to.equal('/beta/participate')
+          resolve()
+        }
+        ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should not call next with an error', function (done) {
-      this.res.callback = () => {
-        this.next.callCount.should.equal(0)
-        done()
-      }
-      this.BetaProgramController.optOut(this.req, this.res, done)
+    it('should not call next with an error', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.next.callCount.should.equal(0)
+          resolve()
+        }
+        ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should call BetaProgramHandler.optOut', function (done) {
-      this.res.callback = () => {
-        this.BetaProgramHandler.promises.optOut.callCount.should.equal(1)
-        done()
-      }
-      this.BetaProgramController.optOut(this.req, this.res, done)
+    it('should call BetaProgramHandler.optOut', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.BetaProgramHandler.promises.optOut.callCount.should.equal(1)
+          resolve()
+        }
+        ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should invoke the session maintenance', function (done) {
-      this.res.callback = () => {
-        this.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
-          this.req,
-          null
-        )
-        done()
-      }
-      this.BetaProgramController.optOut(this.req, this.res, done)
+    it('should invoke the session maintenance', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.SplitTestSessionHandler.promises.sessionMaintenance.should.have.been.calledWith(
+            ctx.req,
+            null
+          )
+          resolve()
+        }
+        ctx.BetaProgramController.optOut(ctx.req, ctx.res, resolve)
+      })
     })
 
     describe('when BetaProgramHandler.optOut produces an error', function () {
-      beforeEach(function () {
-        this.BetaProgramHandler.promises.optOut.throws(new Error('woops'))
+      beforeEach(function (ctx) {
+        ctx.BetaProgramHandler.promises.optOut.throws(new Error('woops'))
       })
 
-      it("should not redirect to '/beta/participate'", function (done) {
-        this.BetaProgramController.optOut(this.req, this.res, error => {
-          expect(error).to.exist
-          expect(this.res.redirected).to.equal(false)
-          done()
+      it("should not redirect to '/beta/participate'", function (ctx) {
+        return new Promise(resolve => {
+          ctx.BetaProgramController.optOut(ctx.req, ctx.res, error => {
+            expect(error).to.exist
+            expect(ctx.res.redirected).to.equal(false)
+            resolve()
+          })
         })
       })
 
-      it('should produce an error', function (done) {
-        this.BetaProgramController.optOut(this.req, this.res, error => {
-          expect(error).to.exist
-          done()
+      it('should produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.BetaProgramController.optOut(ctx.req, ctx.res, error => {
+            expect(error).to.exist
+            resolve()
+          })
         })
       })
     })
   })
 
   describe('optInPage', function () {
-    beforeEach(function () {
-      this.UserGetter.promises.getUser.resolves(this.user)
+    beforeEach(function (ctx) {
+      ctx.UserGetter.promises.getUser.resolves(ctx.user)
     })
 
-    it('should render the opt-in page', function (done) {
-      this.res.callback = () => {
-        expect(this.res.renderedTemplate).to.equal('beta_program/opt_in')
-        done()
-      }
-      this.BetaProgramController.optInPage(this.req, this.res, done)
+    it('should render the opt-in page', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          expect(ctx.res.renderedTemplate).to.equal('beta_program/opt_in')
+          resolve()
+        }
+        ctx.BetaProgramController.optInPage(ctx.req, ctx.res, resolve)
+      })
     })
 
     describe('when UserGetter.getUser produces an error', function () {
-      beforeEach(function () {
-        this.UserGetter.promises.getUser.throws(new Error('woops'))
+      beforeEach(function (ctx) {
+        ctx.UserGetter.promises.getUser.throws(new Error('woops'))
       })
 
-      it('should not render the opt-in page', function () {
-        this.BetaProgramController.optInPage(this.req, this.res, this.next)
-        this.res.render.callCount.should.equal(0)
+      it('should not render the opt-in page', function (ctx) {
+        ctx.BetaProgramController.optInPage(ctx.req, ctx.res, ctx.next)
+        ctx.res.render.callCount.should.equal(0)
       })
 
-      it('should produce an error', function (done) {
-        this.BetaProgramController.optInPage(this.req, this.res, error => {
-          expect(error).to.exist
-          expect(error).to.be.instanceof(Error)
-          done()
+      it('should produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.BetaProgramController.optInPage(ctx.req, ctx.res, error => {
+            expect(error).to.exist
+            expect(error).to.be.instanceof(Error)
+            resolve()
+          })
         })
       })
     })

+ 98 - 71
services/web/test/unit/src/BetaProgram/BetaProgramHandler.test.mjs

@@ -1,4 +1,4 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import path from 'node:path'
 
 import sinon from 'sinon'
@@ -13,128 +13,155 @@ const modulePath = path.join(
 )
 
 describe('BetaProgramHandler', function () {
-  beforeEach(async function () {
-    this.user_id = 'some_id'
-    this.user = {
-      _id: this.user_id,
+  beforeEach(async function (ctx) {
+    ctx.user_id = 'some_id'
+    ctx.user = {
+      _id: ctx.user_id,
       email: 'user@example.com',
       features: {},
       betaProgram: false,
       save: sinon.stub().callsArgWith(0, null),
     }
-    this.handler = await esmock.strict(modulePath, {
-      '@overleaf/metrics': {
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: {
         inc: sinon.stub(),
       },
-      '../../../../app/src/Features/User/UserUpdater': (this.UserUpdater = {
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
+      default: (ctx.UserUpdater = {
         promises: {
           updateUser: sinon.stub().resolves(),
         },
       }),
-      '../../../../app/src/Features/Analytics/AnalyticsManager':
-        (this.AnalyticsManager = {
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager',
+      () => ({
+        default: (ctx.AnalyticsManager = {
           setUserPropertyForUserInBackground: sinon.stub(),
         }),
-    })
+      })
+    )
+
+    ctx.handler = (await import(modulePath)).default
   })
 
   describe('optIn', function () {
-    beforeEach(function () {
-      this.user.betaProgram = false
-      this.call = callback => {
-        this.handler.optIn(this.user_id, callback)
+    beforeEach(function (ctx) {
+      ctx.user.betaProgram = false
+      ctx.call = callback => {
+        ctx.handler.optIn(ctx.user_id, callback)
       }
     })
 
-    it('should call userUpdater', function (done) {
-      this.call(err => {
-        expect(err).to.not.exist
-        this.UserUpdater.promises.updateUser.callCount.should.equal(1)
-        done()
+    it('should call userUpdater', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(err => {
+          expect(err).to.not.exist
+          ctx.UserUpdater.promises.updateUser.callCount.should.equal(1)
+          resolve()
+        })
       })
     })
 
-    it('should set beta-program user property to true', function (done) {
-      this.call(err => {
-        expect(err).to.not.exist
-        sinon.assert.calledWith(
-          this.AnalyticsManager.setUserPropertyForUserInBackground,
-          this.user_id,
-          'beta-program',
-          true
-        )
-        done()
+    it('should set beta-program user property to true', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(err => {
+          expect(err).to.not.exist
+          sinon.assert.calledWith(
+            ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+            ctx.user_id,
+            'beta-program',
+            true
+          )
+          resolve()
+        })
       })
     })
 
-    it('should not produce an error', function (done) {
-      this.call(err => {
-        expect(err).to.not.exist
-        done()
+    it('should not produce an error', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(err => {
+          expect(err).to.not.exist
+          resolve()
+        })
       })
     })
 
     describe('when userUpdater produces an error', function () {
-      beforeEach(function () {
-        this.UserUpdater.promises.updateUser.rejects()
+      beforeEach(function (ctx) {
+        ctx.UserUpdater.promises.updateUser.rejects()
       })
 
-      it('should produce an error', function (done) {
-        this.call(err => {
-          expect(err).to.exist
-          expect(err).to.be.instanceof(Error)
-          done()
+      it('should produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(err => {
+            expect(err).to.exist
+            expect(err).to.be.instanceof(Error)
+            resolve()
+          })
         })
       })
     })
   })
 
   describe('optOut', function () {
-    beforeEach(function () {
-      this.user.betaProgram = true
-      this.call = callback => {
-        this.handler.optOut(this.user_id, callback)
+    beforeEach(function (ctx) {
+      ctx.user.betaProgram = true
+      ctx.call = callback => {
+        ctx.handler.optOut(ctx.user_id, callback)
       }
     })
 
-    it('should call userUpdater', function (done) {
-      this.call(err => {
-        expect(err).to.not.exist
-        this.UserUpdater.promises.updateUser.callCount.should.equal(1)
-        done()
+    it('should call userUpdater', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(err => {
+          expect(err).to.not.exist
+          ctx.UserUpdater.promises.updateUser.callCount.should.equal(1)
+          resolve()
+        })
       })
     })
 
-    it('should set beta-program user property to false', function (done) {
-      this.call(err => {
-        expect(err).to.not.exist
-        sinon.assert.calledWith(
-          this.AnalyticsManager.setUserPropertyForUserInBackground,
-          this.user_id,
-          'beta-program',
-          false
-        )
-        done()
+    it('should set beta-program user property to false', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(err => {
+          expect(err).to.not.exist
+          sinon.assert.calledWith(
+            ctx.AnalyticsManager.setUserPropertyForUserInBackground,
+            ctx.user_id,
+            'beta-program',
+            false
+          )
+          resolve()
+        })
       })
     })
 
-    it('should not produce an error', function (done) {
-      this.call(err => {
-        expect(err).to.not.exist
-        done()
+    it('should not produce an error', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(err => {
+          expect(err).to.not.exist
+          resolve()
+        })
       })
     })
 
     describe('when userUpdater produces an error', function () {
-      beforeEach(function () {
-        this.UserUpdater.promises.updateUser.rejects()
+      beforeEach(function (ctx) {
+        ctx.UserUpdater.promises.updateUser.rejects()
       })
 
-      it('should produce an error', function (done) {
-        this.call(err => {
-          expect(err).to.exist
-          expect(err).to.be.instanceof(Error)
-          done()
+      it('should produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(err => {
+            expect(err).to.exist
+            expect(err).to.be.instanceof(Error)
+            resolve()
+          })
         })
       })
     })

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

@@ -1,6 +1,6 @@
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import { expect } from 'chai'
-import esmock from 'esmock'
 import mongodb from 'mongodb-legacy'
 import Errors from '../../../../app/src/Features/Errors/Errors.js'
 import MockRequest from '../helpers/MockRequest.js'
@@ -11,425 +11,510 @@ const ObjectId = mongodb.ObjectId
 const MODULE_PATH =
   '../../../../app/src/Features/Collaborators/CollaboratorsController.mjs'
 
+vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+)
+
 describe('CollaboratorsController', function () {
-  beforeEach(async function () {
-    this.res = new MockResponse()
-    this.req = new MockRequest()
+  beforeEach(async function (ctx) {
+    ctx.res = new MockResponse()
+    ctx.req = new MockRequest()
 
-    this.user = { _id: new ObjectId() }
-    this.projectId = new ObjectId()
-    this.callback = sinon.stub()
+    ctx.user = { _id: new ObjectId() }
+    ctx.projectId = new ObjectId()
+    ctx.callback = sinon.stub()
 
-    this.CollaboratorsHandler = {
+    ctx.CollaboratorsHandler = {
       promises: {
         removeUserFromProject: sinon.stub().resolves(),
         setCollaboratorPrivilegeLevel: sinon.stub().resolves(),
       },
       createTokenHashPrefix: sinon.stub().returns('abc123'),
     }
-    this.CollaboratorsGetter = {
+    ctx.CollaboratorsGetter = {
       promises: {
         getAllInvitedMembers: sinon.stub(),
       },
     }
-    this.EditorRealTimeController = {
+    ctx.EditorRealTimeController = {
       emitToRoom: sinon.stub(),
     }
-    this.HttpErrorHandler = {
+    ctx.HttpErrorHandler = {
       forbidden: sinon.stub(),
       notFound: sinon.stub(),
     }
-    this.TagsHandler = {
+    ctx.TagsHandler = {
       promises: {
         removeProjectFromAllTags: sinon.stub().resolves(),
       },
     }
-    this.SessionManager = {
-      getSessionUser: sinon.stub().returns(this.user),
-      getLoggedInUserId: sinon.stub().returns(this.user._id),
+    ctx.SessionManager = {
+      getSessionUser: sinon.stub().returns(ctx.user),
+      getLoggedInUserId: sinon.stub().returns(ctx.user._id),
     }
-    this.OwnershipTransferHandler = {
+    ctx.OwnershipTransferHandler = {
       promises: {
         transferOwnership: sinon.stub().resolves(),
       },
     }
-    this.TokenAccessHandler = {
+    ctx.TokenAccessHandler = {
       getRequestToken: sinon.stub().returns('access-token'),
     }
 
-    this.ProjectAuditLogHandler = {
+    ctx.ProjectAuditLogHandler = {
       addEntryInBackground: sinon.stub(),
     }
 
-    this.ProjectGetter = {
+    ctx.ProjectGetter = {
       promises: {
-        getProject: sinon.stub().resolves({ owner_ref: this.user._id }),
+        getProject: sinon.stub().resolves({ owner_ref: ctx.user._id }),
       },
     }
 
-    this.SplitTestHandler = {
+    ctx.SplitTestHandler = {
       promises: {
         getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
       },
     }
 
-    this.LimitationsManager = {
+    ctx.LimitationsManager = {
       promises: {
         canAddXEditCollaborators: sinon.stub().resolves(),
         canChangeCollaboratorPrivilegeLevel: sinon.stub().resolves(true),
       },
     }
 
-    this.CollaboratorsController = await esmock.strict(MODULE_PATH, {
-      'mongodb-legacy': { ObjectId },
-      '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js':
-        this.CollaboratorsHandler,
-      '../../../../app/src/Features/Collaborators/CollaboratorsGetter.js':
-        this.CollaboratorsGetter,
-      '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.js':
-        this.OwnershipTransferHandler,
-      '../../../../app/src/Features/Editor/EditorRealTimeController':
-        this.EditorRealTimeController,
-      '../../../../app/src/Features/Errors/HttpErrorHandler.js':
-        this.HttpErrorHandler,
-      '../../../../app/src/Features/Tags/TagsHandler.js': this.TagsHandler,
-      '../../../../app/src/Features/Authentication/SessionManager.js':
-        this.SessionManager,
-      '../../../../app/src/Features/TokenAccess/TokenAccessHandler.js':
-        this.TokenAccessHandler,
-      '../../../../app/src/Features/Project/ProjectAuditLogHandler.js':
-        this.ProjectAuditLogHandler,
-      '../../../../app/src/Features/Project/ProjectGetter.js':
-        this.ProjectGetter,
-      '../../../../app/src/Features/SplitTests/SplitTestHandler.js':
-        this.SplitTestHandler,
-      '../../../../app/src/Features/Subscription/LimitationsManager.js':
-        this.LimitationsManager,
-    })
+    vi.doMock('mongodb-legacy', () => ({
+      default: { ObjectId },
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Collaborators/CollaboratorsHandler.js',
+      () => ({
+        default: ctx.CollaboratorsHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Collaborators/CollaboratorsGetter.js',
+      () => ({
+        default: ctx.CollaboratorsGetter,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.js',
+      () => ({
+        default: ctx.OwnershipTransferHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Editor/EditorRealTimeController',
+      () => ({
+        default: ctx.EditorRealTimeController,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Errors/HttpErrorHandler.js',
+      () => ({
+        default: ctx.HttpErrorHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Tags/TagsHandler.js', () => ({
+      default: ctx.TagsHandler,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager.js',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/TokenAccess/TokenAccessHandler.js',
+      () => ({
+        default: ctx.TokenAccessHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectAuditLogHandler.js',
+      () => ({
+        default: ctx.ProjectAuditLogHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({
+      default: ctx.ProjectGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler.js',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/LimitationsManager.js',
+      () => ({
+        default: ctx.LimitationsManager,
+      })
+    )
+
+    ctx.CollaboratorsController = (await import(MODULE_PATH)).default
   })
 
   describe('removeUserFromProject', function () {
-    beforeEach(function (done) {
-      this.req.params = {
-        Project_id: this.projectId,
-        user_id: this.user._id,
-      }
-      this.res.sendStatus = sinon.spy(() => {
-        done()
+    beforeEach(function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.params = {
+          Project_id: ctx.projectId,
+          user_id: ctx.user._id,
+        }
+        ctx.res.sendStatus = sinon.spy(() => {
+          resolve()
+        })
+        ctx.CollaboratorsController.removeUserFromProject(ctx.req, ctx.res)
       })
-      this.CollaboratorsController.removeUserFromProject(this.req, this.res)
     })
 
-    it('should from the user from the project', function () {
+    it('should from the user from the project', function (ctx) {
       expect(
-        this.CollaboratorsHandler.promises.removeUserFromProject
-      ).to.have.been.calledWith(this.projectId, this.user._id)
+        ctx.CollaboratorsHandler.promises.removeUserFromProject
+      ).to.have.been.calledWith(ctx.projectId, ctx.user._id)
     })
 
-    it('should emit a userRemovedFromProject event to the proejct', function () {
-      expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
-        this.projectId,
+    it('should emit a userRemovedFromProject event to the proejct', function (ctx) {
+      expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
+        ctx.projectId,
         'userRemovedFromProject',
-        this.user._id
+        ctx.user._id
       )
     })
 
-    it('should send the back a success response', function () {
-      this.res.sendStatus.calledWith(204).should.equal(true)
+    it('should send the back a success response', function (ctx) {
+      ctx.res.sendStatus.calledWith(204).should.equal(true)
     })
 
-    it('should have called emitToRoom', function () {
-      expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
-        this.projectId,
+    it('should have called emitToRoom', function (ctx) {
+      expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
+        ctx.projectId,
         'project:membership:changed'
       )
     })
 
-    it('should write a project audit log', function () {
-      this.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith(
-        this.projectId,
+    it('should write a project audit log', function (ctx) {
+      ctx.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith(
+        ctx.projectId,
         'remove-collaborator',
-        this.user._id,
-        this.req.ip,
-        { userId: this.user._id }
+        ctx.user._id,
+        ctx.req.ip,
+        { userId: ctx.user._id }
       )
     })
   })
 
   describe('removeSelfFromProject', function () {
-    beforeEach(function (done) {
-      this.req.params = { Project_id: this.projectId }
-      this.res.sendStatus = sinon.spy(() => {
-        done()
+    beforeEach(function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.params = { Project_id: ctx.projectId }
+        ctx.res.sendStatus = sinon.spy(() => {
+          resolve()
+        })
+        ctx.CollaboratorsController.removeSelfFromProject(ctx.req, ctx.res)
       })
-      this.CollaboratorsController.removeSelfFromProject(this.req, this.res)
     })
 
-    it('should remove the logged in user from the project', function () {
+    it('should remove the logged in user from the project', function (ctx) {
       expect(
-        this.CollaboratorsHandler.promises.removeUserFromProject
-      ).to.have.been.calledWith(this.projectId, this.user._id)
+        ctx.CollaboratorsHandler.promises.removeUserFromProject
+      ).to.have.been.calledWith(ctx.projectId, ctx.user._id)
     })
 
-    it('should emit a userRemovedFromProject event to the proejct', function () {
-      expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
-        this.projectId,
+    it('should emit a userRemovedFromProject event to the proejct', function (ctx) {
+      expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
+        ctx.projectId,
         'userRemovedFromProject',
-        this.user._id
+        ctx.user._id
       )
     })
 
-    it('should remove the project from all tags', function () {
+    it('should remove the project from all tags', function (ctx) {
       expect(
-        this.TagsHandler.promises.removeProjectFromAllTags
-      ).to.have.been.calledWith(this.user._id, this.projectId)
+        ctx.TagsHandler.promises.removeProjectFromAllTags
+      ).to.have.been.calledWith(ctx.user._id, ctx.projectId)
     })
 
-    it('should return a success code', function () {
-      this.res.sendStatus.calledWith(204).should.equal(true)
+    it('should return a success code', function (ctx) {
+      ctx.res.sendStatus.calledWith(204).should.equal(true)
     })
 
-    it('should write a project audit log', function () {
-      this.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith(
-        this.projectId,
+    it('should write a project audit log', function (ctx) {
+      ctx.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith(
+        ctx.projectId,
         'leave-project',
-        this.user._id,
-        this.req.ip
+        ctx.user._id,
+        ctx.req.ip
       )
     })
   })
 
   describe('getAllMembers', function () {
-    beforeEach(function (done) {
-      this.req.params = { Project_id: this.projectId }
-      this.res.json = sinon.spy(() => {
-        done()
+    beforeEach(function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.params = { Project_id: ctx.projectId }
+        ctx.res.json = sinon.spy(() => {
+          resolve()
+        })
+        ctx.next = sinon.stub()
+        ctx.members = [{ a: 1 }]
+        ctx.CollaboratorsGetter.promises.getAllInvitedMembers.resolves(
+          ctx.members
+        )
+        ctx.CollaboratorsController.getAllMembers(ctx.req, ctx.res, ctx.next)
       })
-      this.next = sinon.stub()
-      this.members = [{ a: 1 }]
-      this.CollaboratorsGetter.promises.getAllInvitedMembers.resolves(
-        this.members
-      )
-      this.CollaboratorsController.getAllMembers(this.req, this.res, this.next)
     })
 
-    it('should not produce an error', function () {
-      this.next.callCount.should.equal(0)
+    it('should not produce an error', function (ctx) {
+      ctx.next.callCount.should.equal(0)
     })
 
-    it('should produce a json response', function () {
-      this.res.json.callCount.should.equal(1)
-      this.res.json.calledWith({ members: this.members }).should.equal(true)
+    it('should produce a json response', function (ctx) {
+      ctx.res.json.callCount.should.equal(1)
+      ctx.res.json.calledWith({ members: ctx.members }).should.equal(true)
     })
 
-    it('should call CollaboratorsGetter.getAllInvitedMembers', function () {
-      expect(this.CollaboratorsGetter.promises.getAllInvitedMembers).to.have
-        .been.calledOnce
+    it('should call CollaboratorsGetter.getAllInvitedMembers', function (ctx) {
+      expect(ctx.CollaboratorsGetter.promises.getAllInvitedMembers).to.have.been
+        .calledOnce
     })
 
     describe('when CollaboratorsGetter.getAllInvitedMembers produces an error', function () {
-      beforeEach(function (done) {
-        this.res.json = sinon.stub()
-        this.next = sinon.spy(() => {
-          done()
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.res.json = sinon.stub()
+          ctx.next = sinon.spy(() => {
+            resolve()
+          })
+          ctx.CollaboratorsGetter.promises.getAllInvitedMembers.rejects(
+            new Error('woops')
+          )
+          ctx.CollaboratorsController.getAllMembers(ctx.req, ctx.res, ctx.next)
         })
-        this.CollaboratorsGetter.promises.getAllInvitedMembers.rejects(
-          new Error('woops')
-        )
-        this.CollaboratorsController.getAllMembers(
-          this.req,
-          this.res,
-          this.next
-        )
       })
 
-      it('should produce an error', function () {
-        expect(this.next).to.have.been.calledOnce
-        expect(this.next).to.have.been.calledWithMatch(
+      it('should produce an error', function (ctx) {
+        expect(ctx.next).to.have.been.calledOnce
+        expect(ctx.next).to.have.been.calledWithMatch(
           sinon.match.instanceOf(Error)
         )
       })
 
-      it('should not produce a json response', function () {
-        this.res.json.callCount.should.equal(0)
+      it('should not produce a json response', function (ctx) {
+        ctx.res.json.callCount.should.equal(0)
       })
     })
   })
 
   describe('setCollaboratorInfo', function () {
-    beforeEach(function () {
-      this.req.params = {
-        Project_id: this.projectId,
-        user_id: this.user._id,
+    beforeEach(function (ctx) {
+      ctx.req.params = {
+        Project_id: ctx.projectId,
+        user_id: ctx.user._id,
       }
-      this.req.body = { privilegeLevel: 'readOnly' }
+      ctx.req.body = { privilegeLevel: 'readOnly' }
     })
 
-    it('should set the collaborator privilege level', function (done) {
-      this.res.sendStatus = status => {
-        expect(status).to.equal(204)
-        expect(
-          this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
-        ).to.have.been.calledWith(this.projectId, this.user._id, 'readOnly')
-        done()
-      }
-      this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
+    it('should set the collaborator privilege level', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.sendStatus = status => {
+          expect(status).to.equal(204)
+          expect(
+            ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
+          ).to.have.been.calledWith(ctx.projectId, ctx.user._id, 'readOnly')
+          resolve()
+        }
+        ctx.CollaboratorsController.setCollaboratorInfo(ctx.req, ctx.res)
+      })
     })
 
-    it('should return a 404 when the project or collaborator is not found', function (done) {
-      this.HttpErrorHandler.notFound = sinon.spy((req, res) => {
-        expect(req).to.equal(this.req)
-        expect(res).to.equal(this.res)
-        done()
-      })
+    it('should return a 404 when the project or collaborator is not found', function (ctx) {
+      return new Promise(resolve => {
+        ctx.HttpErrorHandler.notFound = sinon.spy((req, res) => {
+          expect(req).to.equal(ctx.req)
+          expect(res).to.equal(ctx.res)
+          resolve()
+        })
 
-      this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects(
-        new Errors.NotFoundError()
-      )
-      this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
+        ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects(
+          new Errors.NotFoundError()
+        )
+        ctx.CollaboratorsController.setCollaboratorInfo(ctx.req, ctx.res)
+      })
     })
 
-    it('should pass the error to the next handler when setting the privilege level fails', function (done) {
-      this.next = sinon.spy(err => {
-        expect(err).instanceOf(Error)
-        done()
-      })
+    it('should pass the error to the next handler when setting the privilege level fails', function (ctx) {
+      return new Promise(resolve => {
+        ctx.next = sinon.spy(err => {
+          expect(err).instanceOf(Error)
+          resolve()
+        })
 
-      this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects(
-        new Error()
-      )
-      this.CollaboratorsController.setCollaboratorInfo(
-        this.req,
-        this.res,
-        this.next
-      )
+        ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects(
+          new Error()
+        )
+        ctx.CollaboratorsController.setCollaboratorInfo(
+          ctx.req,
+          ctx.res,
+          ctx.next
+        )
+      })
     })
 
     describe('when setting privilege level to readAndWrite', function () {
-      beforeEach(function () {
-        this.req.body = { privilegeLevel: 'readAndWrite' }
+      beforeEach(function (ctx) {
+        ctx.req.body = { privilegeLevel: 'readAndWrite' }
       })
 
       describe('when owner can add new edit collaborators', function () {
-        it('should set privilege level after checking collaborators can be added', function (done) {
-          this.res.sendStatus = status => {
-            expect(status).to.equal(204)
-            expect(
-              this.LimitationsManager.promises
-                .canChangeCollaboratorPrivilegeLevel
-            ).to.have.been.calledWith(
-              this.projectId,
-              this.user._id,
-              'readAndWrite'
-            )
-            done()
-          }
-          this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
+        it('should set privilege level after checking collaborators can be added', function (ctx) {
+          return new Promise(resolve => {
+            ctx.res.sendStatus = status => {
+              expect(status).to.equal(204)
+              expect(
+                ctx.LimitationsManager.promises
+                  .canChangeCollaboratorPrivilegeLevel
+              ).to.have.been.calledWith(
+                ctx.projectId,
+                ctx.user._id,
+                'readAndWrite'
+              )
+              resolve()
+            }
+            ctx.CollaboratorsController.setCollaboratorInfo(ctx.req, ctx.res)
+          })
         })
       })
 
       describe('when owner cannot add edit collaborators', function () {
-        beforeEach(function () {
-          this.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel.resolves(
+        beforeEach(function (ctx) {
+          ctx.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel.resolves(
             false
           )
         })
 
-        it('should return a 403 if trying to set a new edit collaborator', function (done) {
-          this.HttpErrorHandler.forbidden = sinon.spy((req, res) => {
-            expect(req).to.equal(this.req)
-            expect(res).to.equal(this.res)
-            expect(
-              this.LimitationsManager.promises
-                .canChangeCollaboratorPrivilegeLevel
-            ).to.have.been.calledWith(
-              this.projectId,
-              this.user._id,
-              'readAndWrite'
-            )
-            expect(
-              this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
-            ).to.not.have.been.called
-            done()
+        it('should return a 403 if trying to set a new edit collaborator', function (ctx) {
+          return new Promise(resolve => {
+            ctx.HttpErrorHandler.forbidden = sinon.spy((req, res) => {
+              expect(req).to.equal(ctx.req)
+              expect(res).to.equal(ctx.res)
+              expect(
+                ctx.LimitationsManager.promises
+                  .canChangeCollaboratorPrivilegeLevel
+              ).to.have.been.calledWith(
+                ctx.projectId,
+                ctx.user._id,
+                'readAndWrite'
+              )
+              expect(
+                ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
+              ).to.not.have.been.called
+              resolve()
+            })
+            ctx.CollaboratorsController.setCollaboratorInfo(ctx.req, ctx.res)
           })
-          this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
         })
       })
     })
 
     describe('when setting privilege level to readOnly', function () {
-      beforeEach(function () {
-        this.req.body = { privilegeLevel: 'readOnly' }
+      beforeEach(function (ctx) {
+        ctx.req.body = { privilegeLevel: 'readOnly' }
       })
 
       describe('when owner cannot add edit collaborators', function () {
-        beforeEach(function () {
-          this.LimitationsManager.promises.canAddXEditCollaborators.resolves(
+        beforeEach(function (ctx) {
+          ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves(
             false
           )
         })
 
-        it('should always allow setting a collaborator to viewer even if user cant add edit collaborators', function (done) {
-          this.res.sendStatus = status => {
-            expect(status).to.equal(204)
-            expect(this.LimitationsManager.promises.canAddXEditCollaborators).to
-              .not.have.been.called
-            expect(
-              this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
-            ).to.have.been.calledWith(this.projectId, this.user._id, 'readOnly')
-            done()
-          }
-          this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
+        it('should always allow setting a collaborator to viewer even if user cant add edit collaborators', function (ctx) {
+          return new Promise(resolve => {
+            ctx.res.sendStatus = status => {
+              expect(status).to.equal(204)
+              expect(ctx.LimitationsManager.promises.canAddXEditCollaborators)
+                .to.not.have.been.called
+              expect(
+                ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
+              ).to.have.been.calledWith(ctx.projectId, ctx.user._id, 'readOnly')
+              resolve()
+            }
+            ctx.CollaboratorsController.setCollaboratorInfo(ctx.req, ctx.res)
+          })
         })
       })
     })
   })
 
   describe('transferOwnership', function () {
-    beforeEach(function () {
-      this.req.body = { user_id: this.user._id.toString() }
+    beforeEach(function (ctx) {
+      ctx.req.body = { user_id: ctx.user._id.toString() }
     })
 
-    it('returns 204 on success', function (done) {
-      this.res.sendStatus = status => {
-        expect(status).to.equal(204)
-        done()
-      }
-      this.CollaboratorsController.transferOwnership(this.req, this.res)
+    it('returns 204 on success', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.sendStatus = status => {
+          expect(status).to.equal(204)
+          resolve()
+        }
+        ctx.CollaboratorsController.transferOwnership(ctx.req, ctx.res)
+      })
     })
 
-    it('returns 404 if the project does not exist', function (done) {
-      this.HttpErrorHandler.notFound = sinon.spy((req, res, message) => {
-        expect(req).to.equal(this.req)
-        expect(res).to.equal(this.res)
-        expect(message).to.match(/project not found/)
-        done()
+    it('returns 404 if the project does not exist', function (ctx) {
+      return new Promise(resolve => {
+        ctx.HttpErrorHandler.notFound = sinon.spy((req, res, message) => {
+          expect(req).to.equal(ctx.req)
+          expect(res).to.equal(ctx.res)
+          expect(message).to.match(/project not found/)
+          resolve()
+        })
+        ctx.OwnershipTransferHandler.promises.transferOwnership.rejects(
+          new Errors.ProjectNotFoundError()
+        )
+        ctx.CollaboratorsController.transferOwnership(ctx.req, ctx.res)
       })
-      this.OwnershipTransferHandler.promises.transferOwnership.rejects(
-        new Errors.ProjectNotFoundError()
-      )
-      this.CollaboratorsController.transferOwnership(this.req, this.res)
     })
 
-    it('returns 404 if the user does not exist', function (done) {
-      this.HttpErrorHandler.notFound = sinon.spy((req, res, message) => {
-        expect(req).to.equal(this.req)
-        expect(res).to.equal(this.res)
-        expect(message).to.match(/user not found/)
-        done()
+    it('returns 404 if the user does not exist', function (ctx) {
+      return new Promise(resolve => {
+        ctx.HttpErrorHandler.notFound = sinon.spy((req, res, message) => {
+          expect(req).to.equal(ctx.req)
+          expect(res).to.equal(ctx.res)
+          expect(message).to.match(/user not found/)
+          resolve()
+        })
+        ctx.OwnershipTransferHandler.promises.transferOwnership.rejects(
+          new Errors.UserNotFoundError()
+        )
+        ctx.CollaboratorsController.transferOwnership(ctx.req, ctx.res)
       })
-      this.OwnershipTransferHandler.promises.transferOwnership.rejects(
-        new Errors.UserNotFoundError()
-      )
-      this.CollaboratorsController.transferOwnership(this.req, this.res)
     })
 
-    it('invokes HTTP forbidden error handler if the user is not a collaborator', function (done) {
-      this.HttpErrorHandler.forbidden = sinon.spy(() => done())
-      this.OwnershipTransferHandler.promises.transferOwnership.rejects(
-        new Errors.UserNotCollaboratorError()
-      )
-      this.CollaboratorsController.transferOwnership(this.req, this.res)
+    it('invokes HTTP forbidden error handler if the user is not a collaborator', function (ctx) {
+      return new Promise(resolve => {
+        ctx.HttpErrorHandler.forbidden = sinon.spy(() => resolve())
+        ctx.OwnershipTransferHandler.promises.transferOwnership.rejects(
+          new Errors.UserNotCollaboratorError()
+        )
+        ctx.CollaboratorsController.transferOwnership(ctx.req, ctx.res)
+      })
     })
   })
 })

File diff suppressed because it is too large
+ 754 - 659
services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs


File diff suppressed because it is too large
+ 399 - 343
services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs


+ 94 - 75
services/web/test/unit/src/Contact/ContactController.test.mjs

@@ -1,34 +1,47 @@
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import { expect } from 'chai'
-import esmock from 'esmock'
 import MockResponse from '../helpers/MockResponse.js'
 const modulePath = '../../../../app/src/Features/Contacts/ContactController.mjs'
 
 describe('ContactController', function () {
-  beforeEach(async function () {
-    this.SessionManager = { getLoggedInUserId: sinon.stub() }
-    this.ContactController = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/User/UserGetter': (this.UserGetter = {
+  beforeEach(async function (ctx) {
+    ctx.SessionManager = { getLoggedInUserId: sinon.stub() }
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: (ctx.UserGetter = {
         promises: {},
       }),
-      '../../../../app/src/Features/Contacts/ContactManager':
-        (this.ContactManager = { promises: {} }),
-      '../../../../app/src/Features/Authentication/SessionManager':
-        (this.SessionManager = {}),
-      '../../../../app/src/infrastructure/Modules': (this.Modules = {
+    }))
+
+    vi.doMock('../../../../app/src/Features/Contacts/ContactManager', () => ({
+      default: (ctx.ContactManager = { promises: {} }),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: (ctx.SessionManager = {}),
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: (ctx.Modules = {
         promises: { hooks: {} },
       }),
-    })
+    }))
 
-    this.req = {}
-    this.res = new MockResponse()
+    ctx.ContactController = (await import(modulePath)).default
+
+    ctx.req = {}
+    ctx.res = new MockResponse()
   })
 
   describe('getContacts', function () {
-    beforeEach(function () {
-      this.user_id = 'mock-user-id'
-      this.contact_ids = ['contact-1', 'contact-2', 'contact-3']
-      this.contacts = [
+    beforeEach(function (ctx) {
+      ctx.user_id = 'mock-user-id'
+      ctx.contact_ids = ['contact-1', 'contact-2', 'contact-3']
+      ctx.contacts = [
         {
           _id: 'contact-1',
           email: 'joe@example.com',
@@ -52,78 +65,84 @@ describe('ContactController', function () {
           unsued: 'foo',
         },
       ]
-      this.SessionManager.getLoggedInUserId = sinon.stub().returns(this.user_id)
-      this.ContactManager.promises.getContactIds = sinon
+      ctx.SessionManager.getLoggedInUserId = sinon.stub().returns(ctx.user_id)
+      ctx.ContactManager.promises.getContactIds = sinon
         .stub()
-        .resolves(this.contact_ids)
-      this.UserGetter.promises.getUsers = sinon.stub().resolves(this.contacts)
-      this.Modules.promises.hooks.fire = sinon.stub()
+        .resolves(ctx.contact_ids)
+      ctx.UserGetter.promises.getUsers = sinon.stub().resolves(ctx.contacts)
+      ctx.Modules.promises.hooks.fire = sinon.stub()
     })
 
-    it('should look up the logged in user id', async function () {
-      this.ContactController.getContacts(this.req, this.res)
-      this.SessionManager.getLoggedInUserId
-        .calledWith(this.req.session)
+    it('should look up the logged in user id', async function (ctx) {
+      ctx.ContactController.getContacts(ctx.req, ctx.res)
+      ctx.SessionManager.getLoggedInUserId
+        .calledWith(ctx.req.session)
         .should.equal(true)
     })
 
-    it('should get the users contact ids', async function () {
-      this.res.callback = () => {
+    it('should get the users contact ids', async function (ctx) {
+      ctx.res.callback = () => {
         expect(
-          this.ContactManager.promises.getContactIds
-        ).to.have.been.calledWith(this.user_id, { limit: 50 })
+          ctx.ContactManager.promises.getContactIds
+        ).to.have.been.calledWith(ctx.user_id, { limit: 50 })
       }
-      this.ContactController.getContacts(this.req, this.res)
+      ctx.ContactController.getContacts(ctx.req, ctx.res)
     })
 
-    it('should populate the users contacts ids', function (done) {
-      this.res.callback = () => {
-        expect(this.UserGetter.promises.getUsers).to.have.been.calledWith(
-          this.contact_ids,
-          {
-            email: 1,
-            first_name: 1,
-            last_name: 1,
-            holdingAccount: 1,
-          }
-        )
-        done()
-      }
-      this.ContactController.getContacts(this.req, this.res, done)
+    it('should populate the users contacts ids', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          expect(ctx.UserGetter.promises.getUsers).to.have.been.calledWith(
+            ctx.contact_ids,
+            {
+              email: 1,
+              first_name: 1,
+              last_name: 1,
+              holdingAccount: 1,
+            }
+          )
+          resolve()
+        }
+        ctx.ContactController.getContacts(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should fire the getContact module hook', function (done) {
-      this.res.callback = () => {
-        expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
-          'getContacts',
-          this.user_id
-        )
-        done()
-      }
-      this.ContactController.getContacts(this.req, this.res, done)
+    it('should fire the getContact module hook', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
+            'getContacts',
+            ctx.user_id
+          )
+          resolve()
+        }
+        ctx.ContactController.getContacts(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should return a formatted list of contacts in contact list order, without holding accounts', function (done) {
-      this.res.callback = () => {
-        this.res.json.args[0][0].contacts.should.deep.equal([
-          {
-            id: 'contact-1',
-            email: 'joe@example.com',
-            first_name: 'Joe',
-            last_name: 'Example',
-            type: 'user',
-          },
-          {
-            id: 'contact-3',
-            email: 'jim@example.com',
-            first_name: 'Jim',
-            last_name: 'Example',
-            type: 'user',
-          },
-        ])
-        done()
-      }
-      this.ContactController.getContacts(this.req, this.res, done)
+    it('should return a formatted list of contacts in contact list order, without holding accounts', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.res.json.args[0][0].contacts.should.deep.equal([
+            {
+              id: 'contact-1',
+              email: 'joe@example.com',
+              first_name: 'Joe',
+              last_name: 'Example',
+              type: 'user',
+            },
+            {
+              id: 'contact-3',
+              email: 'jim@example.com',
+              first_name: 'Jim',
+              last_name: 'Example',
+              type: 'user',
+            },
+          ])
+          resolve()
+        }
+        ctx.ContactController.getContacts(ctx.req, ctx.res, resolve)
+      })
     })
   })
 })

+ 66 - 74
services/web/test/unit/src/Cooldown/CooldownMiddleware.test.mjs

@@ -1,15 +1,4 @@
-/* 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:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import { expect } from 'chai'
 const modulePath = new URL(
@@ -18,116 +7,119 @@ const modulePath = new URL(
 ).pathname
 
 describe('CooldownMiddleware', function () {
-  beforeEach(async function () {
-    this.CooldownManager = { isProjectOnCooldown: sinon.stub() }
-    return (this.CooldownMiddleware = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Cooldown/CooldownManager.js':
-        this.CooldownManager,
-    }))
+  beforeEach(async function (ctx) {
+    ctx.CooldownManager = { isProjectOnCooldown: sinon.stub() }
+
+    vi.doMock(
+      '../../../../app/src/Features/Cooldown/CooldownManager.js',
+      () => ({
+        default: ctx.CooldownManager,
+      })
+    )
+
+    ctx.CooldownMiddleware = (await import(modulePath)).default
   })
 
   describe('freezeProject', function () {
     describe('when project is on cooldown', function () {
-      beforeEach(function () {
-        this.CooldownManager.isProjectOnCooldown = sinon
+      beforeEach(function (ctx) {
+        ctx.CooldownManager.isProjectOnCooldown = sinon
           .stub()
           .callsArgWith(1, null, true)
-        this.req = { params: { Project_id: 'abc' } }
-        this.res = { sendStatus: sinon.stub() }
-        return (this.next = sinon.stub())
+        ctx.req = { params: { Project_id: 'abc' } }
+        ctx.res = { sendStatus: sinon.stub() }
+        return (ctx.next = sinon.stub())
       })
 
-      it('should call CooldownManager.isProjectOnCooldown', function () {
-        this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
-        this.CooldownManager.isProjectOnCooldown.callCount.should.equal(1)
-        return this.CooldownManager.isProjectOnCooldown
+      it('should call CooldownManager.isProjectOnCooldown', function (ctx) {
+        ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next)
+        ctx.CooldownManager.isProjectOnCooldown.callCount.should.equal(1)
+        return ctx.CooldownManager.isProjectOnCooldown
           .calledWith('abc')
           .should.equal(true)
       })
 
-      it('should not produce an error', function () {
-        this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
-        return this.next.callCount.should.equal(0)
+      it('should not produce an error', function (ctx) {
+        ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next)
+        return ctx.next.callCount.should.equal(0)
       })
 
-      it('should send a 429 status', function () {
-        this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
-        this.res.sendStatus.callCount.should.equal(1)
-        return this.res.sendStatus.calledWith(429).should.equal(true)
+      it('should send a 429 status', function (ctx) {
+        ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next)
+        ctx.res.sendStatus.callCount.should.equal(1)
+        return ctx.res.sendStatus.calledWith(429).should.equal(true)
       })
     })
 
     describe('when project is not on cooldown', function () {
-      beforeEach(function () {
-        this.CooldownManager.isProjectOnCooldown = sinon
+      beforeEach(function (ctx) {
+        ctx.CooldownManager.isProjectOnCooldown = sinon
           .stub()
           .callsArgWith(1, null, false)
-        this.req = { params: { Project_id: 'abc' } }
-        this.res = { sendStatus: sinon.stub() }
-        return (this.next = sinon.stub())
+        ctx.req = { params: { Project_id: 'abc' } }
+        ctx.res = { sendStatus: sinon.stub() }
+        return (ctx.next = sinon.stub())
       })
 
-      it('should call CooldownManager.isProjectOnCooldown', function () {
-        this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
-        this.CooldownManager.isProjectOnCooldown.callCount.should.equal(1)
-        return this.CooldownManager.isProjectOnCooldown
+      it('should call CooldownManager.isProjectOnCooldown', function (ctx) {
+        ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next)
+        ctx.CooldownManager.isProjectOnCooldown.callCount.should.equal(1)
+        return ctx.CooldownManager.isProjectOnCooldown
           .calledWith('abc')
           .should.equal(true)
       })
 
-      it('call next with no arguments', function () {
-        this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
-        this.next.callCount.should.equal(1)
-        return expect(this.next.lastCall.args.length).to.equal(0)
+      it('call next with no arguments', function (ctx) {
+        ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next)
+        ctx.next.callCount.should.equal(1)
+        return expect(ctx.next.lastCall.args.length).to.equal(0)
       })
     })
 
     describe('when isProjectOnCooldown produces an error', function () {
-      beforeEach(function () {
-        this.CooldownManager.isProjectOnCooldown = sinon
+      beforeEach(function (ctx) {
+        ctx.CooldownManager.isProjectOnCooldown = sinon
           .stub()
           .callsArgWith(1, new Error('woops'))
-        this.req = { params: { Project_id: 'abc' } }
-        this.res = { sendStatus: sinon.stub() }
-        return (this.next = sinon.stub())
+        ctx.req = { params: { Project_id: 'abc' } }
+        ctx.res = { sendStatus: sinon.stub() }
+        return (ctx.next = sinon.stub())
       })
 
-      it('should call CooldownManager.isProjectOnCooldown', function () {
-        this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
-        this.CooldownManager.isProjectOnCooldown.callCount.should.equal(1)
-        return this.CooldownManager.isProjectOnCooldown
+      it('should call CooldownManager.isProjectOnCooldown', function (ctx) {
+        ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next)
+        ctx.CooldownManager.isProjectOnCooldown.callCount.should.equal(1)
+        return ctx.CooldownManager.isProjectOnCooldown
           .calledWith('abc')
           .should.equal(true)
       })
 
-      it('call next with an error', function () {
-        this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
-        this.next.callCount.should.equal(1)
-        return expect(this.next.lastCall.args[0]).to.be.instanceof(Error)
+      it('call next with an error', function (ctx) {
+        ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next)
+        ctx.next.callCount.should.equal(1)
+        return expect(ctx.next.lastCall.args[0]).to.be.instanceof(Error)
       })
     })
 
     describe('when projectId is not part of route', function () {
-      beforeEach(function () {
-        this.CooldownManager.isProjectOnCooldown = sinon
+      beforeEach(function (ctx) {
+        ctx.CooldownManager.isProjectOnCooldown = sinon
           .stub()
           .callsArgWith(1, null, true)
-        this.req = { params: { lol: 'abc' } }
-        this.res = { sendStatus: sinon.stub() }
-        return (this.next = sinon.stub())
+        ctx.req = { params: { lol: 'abc' } }
+        ctx.res = { sendStatus: sinon.stub() }
+        return (ctx.next = sinon.stub())
       })
 
-      it('call next with an error', function () {
-        this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
-        this.next.callCount.should.equal(1)
-        return expect(this.next.lastCall.args[0]).to.be.instanceof(Error)
+      it('call next with an error', function (ctx) {
+        ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next)
+        ctx.next.callCount.should.equal(1)
+        return expect(ctx.next.lastCall.args[0]).to.be.instanceof(Error)
       })
 
-      it('should not call CooldownManager.isProjectOnCooldown', function () {
-        this.CooldownMiddleware.freezeProject(this.req, this.res, this.next)
-        return this.CooldownManager.isProjectOnCooldown.callCount.should.equal(
-          0
-        )
+      it('should not call CooldownManager.isProjectOnCooldown', function (ctx) {
+        ctx.CooldownMiddleware.freezeProject(ctx.req, ctx.res, ctx.next)
+        return ctx.CooldownManager.isProjectOnCooldown.callCount.should.equal(0)
       })
     })
   })

+ 55 - 45
services/web/test/unit/src/DocumentUpdater/DocumentUpdaterController.test.mjs

@@ -1,92 +1,102 @@
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import { expect } from 'chai'
-import esmock from 'esmock'
 import MockResponse from '../helpers/MockResponse.js'
 
 const MODULE_PATH =
   '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterController.mjs'
 
 describe('DocumentUpdaterController', function () {
-  beforeEach(async function () {
-    this.DocumentUpdaterHandler = {
+  beforeEach(async function (ctx) {
+    ctx.DocumentUpdaterHandler = {
       promises: {
         getDocument: sinon.stub(),
       },
     }
-    this.ProjectLocator = {
+    ctx.ProjectLocator = {
       promises: {
         findElement: sinon.stub(),
       },
     }
-    this.controller = await esmock.strict(MODULE_PATH, {
-      '@overleaf/settings': this.settings,
-      '../../../../app/src/Features/Project/ProjectLocator.js':
-        this.ProjectLocator,
-      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js':
-        this.DocumentUpdaterHandler,
-    })
-    this.projectId = '2k3j1lk3j21lk3j'
-    this.fileId = '12321kklj1lk3jk12'
-    this.req = {
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectLocator.js', () => ({
+      default: ctx.ProjectLocator,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js',
+      () => ({
+        default: ctx.DocumentUpdaterHandler,
+      })
+    )
+
+    ctx.controller = (await import(MODULE_PATH)).default
+    ctx.projectId = '2k3j1lk3j21lk3j'
+    ctx.fileId = '12321kklj1lk3jk12'
+    ctx.req = {
       params: {
-        Project_id: this.projectId,
-        Doc_id: this.docId,
+        Project_id: ctx.projectId,
+        Doc_id: ctx.docId,
       },
       get(key) {
         return undefined
       },
     }
-    this.lines = ['test', '', 'testing']
-    this.res = new MockResponse()
-    this.next = sinon.stub()
-    this.doc = { name: 'myfile.tex' }
+    ctx.lines = ['test', '', 'testing']
+    ctx.res = new MockResponse()
+    ctx.next = sinon.stub()
+    ctx.doc = { name: 'myfile.tex' }
   })
 
   describe('getDoc', function () {
-    beforeEach(function () {
-      this.DocumentUpdaterHandler.promises.getDocument.resolves({
-        lines: this.lines,
+    beforeEach(function (ctx) {
+      ctx.DocumentUpdaterHandler.promises.getDocument.resolves({
+        lines: ctx.lines,
       })
-      this.ProjectLocator.promises.findElement.resolves({
-        element: this.doc,
+      ctx.ProjectLocator.promises.findElement.resolves({
+        element: ctx.doc,
       })
-      this.res = new MockResponse()
+      ctx.res = new MockResponse()
     })
 
-    it('should call the document updater handler with the project_id and doc_id', async function () {
-      await this.controller.getDoc(this.req, this.res, this.next)
+    it('should call the document updater handler with the project_id and doc_id', async function (ctx) {
+      await ctx.controller.getDoc(ctx.req, ctx.res, ctx.next)
       expect(
-        this.DocumentUpdaterHandler.promises.getDocument
+        ctx.DocumentUpdaterHandler.promises.getDocument
       ).to.have.been.calledOnceWith(
-        this.req.params.Project_id,
-        this.req.params.Doc_id,
+        ctx.req.params.Project_id,
+        ctx.req.params.Doc_id,
         -1
       )
     })
 
-    it('should return the content', async function () {
-      await this.controller.getDoc(this.req, this.res)
-      expect(this.next).to.not.have.been.called
-      expect(this.res.statusCode).to.equal(200)
-      expect(this.res.body).to.equal('test\n\ntesting')
+    it('should return the content', async function (ctx) {
+      await ctx.controller.getDoc(ctx.req, ctx.res)
+      expect(ctx.next).to.not.have.been.called
+      expect(ctx.res.statusCode).to.equal(200)
+      expect(ctx.res.body).to.equal('test\n\ntesting')
     })
 
-    it('should find the doc in the project', async function () {
-      await this.controller.getDoc(this.req, this.res)
+    it('should find the doc in the project', async function (ctx) {
+      await ctx.controller.getDoc(ctx.req, ctx.res)
       expect(
-        this.ProjectLocator.promises.findElement
+        ctx.ProjectLocator.promises.findElement
       ).to.have.been.calledOnceWith({
-        project_id: this.projectId,
-        element_id: this.docId,
+        project_id: ctx.projectId,
+        element_id: ctx.docId,
         type: 'doc',
       })
     })
 
-    it('should set the Content-Disposition header', async function () {
-      await this.controller.getDoc(this.req, this.res)
-      expect(this.res.setContentDisposition).to.have.been.calledWith(
+    it('should set the Content-Disposition header', async function (ctx) {
+      await ctx.controller.getDoc(ctx.req, ctx.res)
+      expect(ctx.res.setContentDisposition).to.have.been.calledWith(
         'attachment',
-        { filename: this.doc.name }
+        { filename: ctx.doc.name }
       )
     })
   })

+ 124 - 99
services/web/test/unit/src/Documents/DocumentController.test.mjs

@@ -1,5 +1,5 @@
+import { vi } from 'vitest'
 import sinon from 'sinon'
-import esmock from 'esmock'
 import MockRequest from '../helpers/MockRequest.js'
 import MockResponse from '../helpers/MockResponse.js'
 import Errors from '../../../../app/src/Features/Errors/Errors.js'
@@ -8,14 +8,14 @@ const MODULE_PATH =
   '../../../../app/src/Features/Documents/DocumentController.mjs'
 
 describe('DocumentController', function () {
-  beforeEach(async function () {
-    this.res = new MockResponse()
-    this.req = new MockRequest()
-    this.next = sinon.stub()
-    this.doc = { _id: 'doc-id-123' }
-    this.doc_lines = ['one', 'two', 'three']
-    this.version = 42
-    this.ranges = {
+  beforeEach(async function (ctx) {
+    ctx.res = new MockResponse()
+    ctx.req = new MockRequest()
+    ctx.next = sinon.stub()
+    ctx.doc = { _id: 'doc-id-123' }
+    ctx.doc_lines = ['one', 'two', 'three']
+    ctx.version = 42
+    ctx.ranges = {
       comments: [
         {
           id: 'comment1',
@@ -35,11 +35,11 @@ describe('DocumentController', function () {
         },
       ],
     }
-    this.pathname = '/a/b/c/file.tex'
-    this.lastUpdatedAt = new Date().getTime()
-    this.lastUpdatedBy = 'fake-last-updater-id'
-    this.rev = 5
-    this.project = {
+    ctx.pathname = '/a/b/c/file.tex'
+    ctx.lastUpdatedAt = new Date().getTime()
+    ctx.lastUpdatedBy = 'fake-last-updater-id'
+    ctx.rev = 5
+    ctx.project = {
       _id: 'project-id-123',
       overleaf: {
         history: {
@@ -48,81 +48,100 @@ describe('DocumentController', function () {
         },
       },
     }
-    this.resolvedThreadIds = [
+    ctx.resolvedThreadIds = [
       'comment2',
       'comment4', // Comment in project but not in doc
     ]
 
-    this.ProjectGetter = {
+    ctx.ProjectGetter = {
       promises: {
-        getProject: sinon.stub().resolves(this.project),
+        getProject: sinon.stub().resolves(ctx.project),
       },
     }
-    this.ProjectLocator = {
+    ctx.ProjectLocator = {
       promises: {
         findElement: sinon
           .stub()
-          .resolves({ element: this.doc, path: { fileSystem: this.pathname } }),
+          .resolves({ element: ctx.doc, path: { fileSystem: ctx.pathname } }),
       },
     }
-    this.ProjectEntityHandler = {
+    ctx.ProjectEntityHandler = {
       promises: {
         getDoc: sinon.stub().resolves({
-          lines: this.doc_lines,
-          rev: this.rev,
-          version: this.version,
-          ranges: this.ranges,
+          lines: ctx.doc_lines,
+          rev: ctx.rev,
+          version: ctx.version,
+          ranges: ctx.ranges,
         }),
       },
     }
-    this.ProjectEntityUpdateHandler = {
+    ctx.ProjectEntityUpdateHandler = {
       promises: {
         updateDocLines: sinon.stub().resolves(),
       },
     }
 
-    this.ChatApiHandler = {
+    ctx.ChatApiHandler = {
       promises: {
-        getResolvedThreadIds: sinon.stub().resolves(this.resolvedThreadIds),
+        getResolvedThreadIds: sinon.stub().resolves(ctx.resolvedThreadIds),
       },
     }
 
-    this.DocumentController = await esmock.strict(MODULE_PATH, {
-      '../../../../app/src/Features/Project/ProjectGetter': this.ProjectGetter,
-      '../../../../app/src/Features/Project/ProjectLocator':
-        this.ProjectLocator,
-      '../../../../app/src/Features/Project/ProjectEntityHandler':
-        this.ProjectEntityHandler,
-      '../../../../app/src/Features/Project/ProjectEntityUpdateHandler':
-        this.ProjectEntityUpdateHandler,
-      '../../../../app/src/Features/Chat/ChatApiHandler': this.ChatApiHandler,
-    })
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: ctx.ProjectGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
+      default: ctx.ProjectLocator,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityHandler',
+      () => ({
+        default: ctx.ProjectEntityHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityUpdateHandler',
+      () => ({
+        default: ctx.ProjectEntityUpdateHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Chat/ChatApiHandler', () => ({
+      default: ctx.ChatApiHandler,
+    }))
+
+    ctx.DocumentController = (await import(MODULE_PATH)).default
   })
 
   describe('getDocument', function () {
-    beforeEach(function () {
-      this.req.params = {
-        Project_id: this.project._id,
-        doc_id: this.doc._id,
+    beforeEach(function (ctx) {
+      ctx.req.params = {
+        Project_id: ctx.project._id,
+        doc_id: ctx.doc._id,
       }
     })
 
     describe('when project exists with project history enabled', function () {
-      beforeEach(function (done) {
-        this.res.callback = err => {
-          done(err)
-        }
-        this.DocumentController.getDocument(this.req, this.res, this.next)
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.res.callback = err => {
+            resolve(err)
+          }
+          ctx.DocumentController.getDocument(ctx.req, ctx.res, ctx.next)
+        })
       })
 
-      it('should return the history id and display setting to the client as JSON', function () {
-        this.res.type.should.equal('application/json')
-        JSON.parse(this.res.body).should.deep.equal({
-          lines: this.doc_lines,
-          version: this.version,
-          ranges: this.ranges,
-          pathname: this.pathname,
-          projectHistoryId: this.project.overleaf.history.id,
+      it('should return the history id and display setting to the client as JSON', function (ctx) {
+        ctx.res.type.should.equal('application/json')
+        JSON.parse(ctx.res.body).should.deep.equal({
+          lines: ctx.doc_lines,
+          version: ctx.version,
+          ranges: ctx.ranges,
+          pathname: ctx.pathname,
+          projectHistoryId: ctx.project.overleaf.history.id,
           projectHistoryType: 'project-history',
           resolvedCommentIds: ['comment2'],
           historyRangesSupport: false,
@@ -132,75 +151,81 @@ describe('DocumentController', function () {
     })
 
     describe('when the project does not exist', function () {
-      beforeEach(function (done) {
-        this.ProjectGetter.promises.getProject.resolves(null)
-        this.res.callback = err => {
-          done(err)
-        }
-        this.DocumentController.getDocument(this.req, this.res, this.next)
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.ProjectGetter.promises.getProject.resolves(null)
+          ctx.res.callback = err => {
+            resolve(err)
+          }
+          ctx.DocumentController.getDocument(ctx.req, ctx.res, ctx.next)
+        })
       })
 
-      it('returns a 404', function () {
-        this.res.statusCode.should.equal(404)
+      it('returns a 404', function (ctx) {
+        ctx.res.statusCode.should.equal(404)
       })
     })
   })
 
   describe('setDocument', function () {
-    beforeEach(function () {
-      this.req.params = {
-        Project_id: this.project._id,
-        doc_id: this.doc._id,
+    beforeEach(function (ctx) {
+      ctx.req.params = {
+        Project_id: ctx.project._id,
+        doc_id: ctx.doc._id,
       }
     })
 
     describe('when the document exists', function () {
-      beforeEach(function (done) {
-        this.req.body = {
-          lines: this.doc_lines,
-          version: this.version,
-          ranges: this.ranges,
-          lastUpdatedAt: this.lastUpdatedAt,
-          lastUpdatedBy: this.lastUpdatedBy,
-        }
-        this.res.callback = err => {
-          done(err)
-        }
-        this.DocumentController.setDocument(this.req, this.res, this.next)
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.body = {
+            lines: ctx.doc_lines,
+            version: ctx.version,
+            ranges: ctx.ranges,
+            lastUpdatedAt: ctx.lastUpdatedAt,
+            lastUpdatedBy: ctx.lastUpdatedBy,
+          }
+          ctx.res.callback = err => {
+            resolve(err)
+          }
+          ctx.DocumentController.setDocument(ctx.req, ctx.res, ctx.next)
+        })
       })
 
-      it('should update the document in Mongo', function () {
+      it('should update the document in Mongo', function (ctx) {
         sinon.assert.calledWith(
-          this.ProjectEntityUpdateHandler.promises.updateDocLines,
-          this.project._id,
-          this.doc._id,
-          this.doc_lines,
-          this.version,
-          this.ranges,
-          this.lastUpdatedAt,
-          this.lastUpdatedBy
+          ctx.ProjectEntityUpdateHandler.promises.updateDocLines,
+          ctx.project._id,
+          ctx.doc._id,
+          ctx.doc_lines,
+          ctx.version,
+          ctx.ranges,
+          ctx.lastUpdatedAt,
+          ctx.lastUpdatedBy
         )
       })
 
-      it('should return a successful response', function () {
-        this.res.success.should.equal(true)
+      it('should return a successful response', function (ctx) {
+        ctx.res.success.should.equal(true)
       })
     })
 
     describe("when the document doesn't exist", function () {
-      beforeEach(function (done) {
-        this.ProjectEntityUpdateHandler.promises.updateDocLines.rejects(
-          new Errors.NotFoundError('document does not exist')
-        )
-        this.req.body = { lines: this.doc_lines }
-        this.next.callsFake(() => {
-          done()
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.ProjectEntityUpdateHandler.promises.updateDocLines.rejects(
+            new Errors.NotFoundError('document does not exist')
+          )
+          ctx.req.body = { lines: ctx.doc_lines }
+          ctx.next.callsFake(() => {
+            resolve()
+          })
+          ctx.DocumentController.setDocument(ctx.req, ctx.res, ctx.next)
         })
-        this.DocumentController.setDocument(this.req, this.res, this.next)
       })
 
-      it('should call next with the NotFoundError', function () {
-        this.next
+      it('should call next with the NotFoundError', function (ctx) {
+        ctx.next
           .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
           .should.equal(true)
       })

+ 88 - 73
services/web/test/unit/src/Downloads/ProjectDownloadsController.test.mjs

@@ -1,3 +1,4 @@
+import { vi } from 'vitest'
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
 /*
@@ -6,136 +7,150 @@
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
 import sinon from 'sinon'
-import esmock from 'esmock'
 import MockRequest from '../helpers/MockRequest.js'
 import MockResponse from '../helpers/MockResponse.js'
 const modulePath =
   '../../../../app/src/Features/Downloads/ProjectDownloadsController.mjs'
 
 describe('ProjectDownloadsController', function () {
-  beforeEach(async function () {
-    this.project_id = 'project-id-123'
-    this.req = new MockRequest()
-    this.res = new MockResponse()
-    this.next = sinon.stub()
-    this.DocumentUpdaterHandler = sinon.stub()
-    return (this.ProjectDownloadsController = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs':
-        (this.ProjectZipStreamManager = {}),
-      '../../../../app/src/Features/Project/ProjectGetter.js':
-        (this.ProjectGetter = {}),
-      '@overleaf/metrics': (this.metrics = {}),
-      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js':
-        this.DocumentUpdaterHandler,
+  beforeEach(async function (ctx) {
+    ctx.project_id = 'project-id-123'
+    ctx.req = new MockRequest()
+    ctx.res = new MockResponse()
+    ctx.next = sinon.stub()
+    ctx.DocumentUpdaterHandler = sinon.stub()
+
+    vi.doMock(
+      '../../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs',
+      () => ({
+        default: (ctx.ProjectZipStreamManager = {}),
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({
+      default: (ctx.ProjectGetter = {}),
+    }))
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: (ctx.metrics = {}),
     }))
+
+    vi.doMock(
+      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler.js',
+      () => ({
+        default: ctx.DocumentUpdaterHandler,
+      })
+    )
+
+    ctx.ProjectDownloadsController = (await import(modulePath)).default
   })
 
   describe('downloadProject', function () {
-    beforeEach(function () {
-      this.stream = { pipe: sinon.stub() }
-      this.ProjectZipStreamManager.createZipStreamForProject = sinon
+    beforeEach(function (ctx) {
+      ctx.stream = { pipe: sinon.stub() }
+      ctx.ProjectZipStreamManager.createZipStreamForProject = sinon
         .stub()
-        .callsArgWith(1, null, this.stream)
-      this.req.params = { Project_id: this.project_id }
-      this.project_name = 'project name with accênts'
-      this.ProjectGetter.getProject = sinon
+        .callsArgWith(1, null, ctx.stream)
+      ctx.req.params = { Project_id: ctx.project_id }
+      ctx.project_name = 'project name with accênts'
+      ctx.ProjectGetter.getProject = sinon
         .stub()
-        .callsArgWith(2, null, { name: this.project_name })
-      this.DocumentUpdaterHandler.flushProjectToMongo = sinon
+        .callsArgWith(2, null, { name: ctx.project_name })
+      ctx.DocumentUpdaterHandler.flushProjectToMongo = sinon
         .stub()
         .callsArgWith(1)
-      this.metrics.inc = sinon.stub()
-      return this.ProjectDownloadsController.downloadProject(
-        this.req,
-        this.res,
-        this.next
+      ctx.metrics.inc = sinon.stub()
+      return ctx.ProjectDownloadsController.downloadProject(
+        ctx.req,
+        ctx.res,
+        ctx.next
       )
     })
 
-    it('should create a zip from the project', function () {
-      return this.ProjectZipStreamManager.createZipStreamForProject
-        .calledWith(this.project_id)
+    it('should create a zip from the project', function (ctx) {
+      return ctx.ProjectZipStreamManager.createZipStreamForProject
+        .calledWith(ctx.project_id)
         .should.equal(true)
     })
 
-    it('should stream the zip to the request', function () {
-      return this.stream.pipe.calledWith(this.res).should.equal(true)
+    it('should stream the zip to the request', function (ctx) {
+      return ctx.stream.pipe.calledWith(ctx.res).should.equal(true)
     })
 
-    it('should set the correct content type on the request', function () {
-      return this.res.contentType
+    it('should set the correct content type on the request', function (ctx) {
+      return ctx.res.contentType
         .calledWith('application/zip')
         .should.equal(true)
     })
 
-    it('should flush the project to mongo', function () {
-      return this.DocumentUpdaterHandler.flushProjectToMongo
-        .calledWith(this.project_id)
+    it('should flush the project to mongo', function (ctx) {
+      return ctx.DocumentUpdaterHandler.flushProjectToMongo
+        .calledWith(ctx.project_id)
         .should.equal(true)
     })
 
-    it("should look up the project's name", function () {
-      return this.ProjectGetter.getProject
-        .calledWith(this.project_id, { name: true })
+    it("should look up the project's name", function (ctx) {
+      return ctx.ProjectGetter.getProject
+        .calledWith(ctx.project_id, { name: true })
         .should.equal(true)
     })
 
-    it('should name the downloaded file after the project', function () {
-      this.res.headers.should.deep.equal({
-        'Content-Disposition': `attachment; filename="${this.project_name}.zip"`,
+    it('should name the downloaded file after the project', function (ctx) {
+      ctx.res.headers.should.deep.equal({
+        'Content-Disposition': `attachment; filename="${ctx.project_name}.zip"`,
         'Content-Type': 'application/zip',
         'X-Content-Type-Options': 'nosniff',
       })
     })
 
-    it('should record the action via Metrics', function () {
-      return this.metrics.inc.calledWith('zip-downloads').should.equal(true)
+    it('should record the action via Metrics', function (ctx) {
+      return ctx.metrics.inc.calledWith('zip-downloads').should.equal(true)
     })
   })
 
   describe('downloadMultipleProjects', function () {
-    beforeEach(function () {
-      this.stream = { pipe: sinon.stub() }
-      this.ProjectZipStreamManager.createZipStreamForMultipleProjects = sinon
+    beforeEach(function (ctx) {
+      ctx.stream = { pipe: sinon.stub() }
+      ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects = sinon
         .stub()
-        .callsArgWith(1, null, this.stream)
-      this.project_ids = ['project-1', 'project-2']
-      this.req.query = { project_ids: this.project_ids.join(',') }
-      this.DocumentUpdaterHandler.flushMultipleProjectsToMongo = sinon
+        .callsArgWith(1, null, ctx.stream)
+      ctx.project_ids = ['project-1', 'project-2']
+      ctx.req.query = { project_ids: ctx.project_ids.join(',') }
+      ctx.DocumentUpdaterHandler.flushMultipleProjectsToMongo = sinon
         .stub()
         .callsArgWith(1)
-      this.metrics.inc = sinon.stub()
-      return this.ProjectDownloadsController.downloadMultipleProjects(
-        this.req,
-        this.res,
-        this.next
+      ctx.metrics.inc = sinon.stub()
+      return ctx.ProjectDownloadsController.downloadMultipleProjects(
+        ctx.req,
+        ctx.res,
+        ctx.next
       )
     })
 
-    it('should create a zip from the project', function () {
-      return this.ProjectZipStreamManager.createZipStreamForMultipleProjects
-        .calledWith(this.project_ids)
+    it('should create a zip from the project', function (ctx) {
+      return ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects
+        .calledWith(ctx.project_ids)
         .should.equal(true)
     })
 
-    it('should stream the zip to the request', function () {
-      return this.stream.pipe.calledWith(this.res).should.equal(true)
+    it('should stream the zip to the request', function (ctx) {
+      return ctx.stream.pipe.calledWith(ctx.res).should.equal(true)
     })
 
-    it('should set the correct content type on the request', function () {
-      return this.res.contentType
+    it('should set the correct content type on the request', function (ctx) {
+      return ctx.res.contentType
         .calledWith('application/zip')
         .should.equal(true)
     })
 
-    it('should flush the projects to mongo', function () {
-      return this.DocumentUpdaterHandler.flushMultipleProjectsToMongo
-        .calledWith(this.project_ids)
+    it('should flush the projects to mongo', function (ctx) {
+      return ctx.DocumentUpdaterHandler.flushMultipleProjectsToMongo
+        .calledWith(ctx.project_ids)
         .should.equal(true)
     })
 
-    it('should name the downloaded file after the project', function () {
-      this.res.headers.should.deep.equal({
+    it('should name the downloaded file after the project', function (ctx) {
+      ctx.res.headers.should.deep.equal({
         'Content-Disposition':
           'attachment; filename="Overleaf Projects (2 items).zip"',
         'Content-Type': 'application/zip',
@@ -143,8 +158,8 @@ describe('ProjectDownloadsController', function () {
       })
     })
 
-    it('should record the action via Metrics', function () {
-      return this.metrics.inc
+    it('should record the action via Metrics', function (ctx) {
+      return ctx.metrics.inc
         .calledWith('zip-downloads-multiple')
         .should.equal(true)
     })

+ 268 - 242
services/web/test/unit/src/Downloads/ProjectZipStreamManager.test.mjs

@@ -1,3 +1,4 @@
+import { vi } from 'vitest'
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
 /*
@@ -9,120 +10,143 @@
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
 import sinon from 'sinon'
-import esmock from 'esmock'
 import { EventEmitter } from 'events'
 const modulePath =
   '../../../../app/src/Features/Downloads/ProjectZipStreamManager.mjs'
 
 describe('ProjectZipStreamManager', function () {
-  beforeEach(async function () {
-    this.project_id = 'project-id-123'
-    this.callback = sinon.stub()
-    this.archive = {
+  beforeEach(async function (ctx) {
+    ctx.project_id = 'project-id-123'
+    ctx.callback = sinon.stub()
+    ctx.archive = {
       on() {},
       append: sinon.stub(),
     }
-    this.logger = {
+    ctx.logger = {
       error: sinon.stub(),
       info: sinon.stub(),
       debug: sinon.stub(),
     }
 
-    return (this.ProjectZipStreamManager = await esmock.strict(modulePath, {
-      archiver: (this.archiver = sinon.stub().returns(this.archive)),
-      '@overleaf/logger': this.logger,
-      '../../../../app/src/Features/Project/ProjectEntityHandler':
-        (this.ProjectEntityHandler = {}),
-      '../../../../app/src/Features/History/HistoryManager.js':
-        (this.HistoryManager = {}),
-      '../../../../app/src/Features/Project/ProjectGetter':
-        (this.ProjectGetter = {}),
-      '../../../../app/src/Features/FileStore/FileStoreHandler':
-        (this.FileStoreHandler = {}),
-      '../../../../app/src/infrastructure/Features': (this.Features = {
+    vi.doMock('archiver', () => ({
+      default: (ctx.archiver = sinon.stub().returns(ctx.archive)),
+    }))
+
+    vi.doMock('@overleaf/logger', () => ({
+      default: ctx.logger,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityHandler',
+      () => ({
+        default: (ctx.ProjectEntityHandler = {}),
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/History/HistoryManager.js', () => ({
+      default: (ctx.HistoryManager = {}),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: (ctx.ProjectGetter = {}),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/FileStore/FileStoreHandler',
+      () => ({
+        default: (ctx.FileStoreHandler = {}),
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Features', () => ({
+      default: (ctx.Features = {
         hasFeature: sinon
           .stub()
           .withArgs('project-history-blobs')
           .returns(true),
       }),
     }))
+
+    ctx.ProjectZipStreamManager = (await import(modulePath)).default
   })
 
   describe('createZipStreamForMultipleProjects', function () {
     describe('successfully', function () {
-      beforeEach(function (done) {
-        this.project_ids = ['project-1', 'project-2']
-        this.zip_streams = {
-          'project-1': new EventEmitter(),
-          'project-2': new EventEmitter(),
-        }
-
-        this.project_names = {
-          'project-1': 'Project One Name',
-          'project-2': 'Project Two Name',
-        }
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.project_ids = ['project-1', 'project-2']
+          ctx.zip_streams = {
+            'project-1': new EventEmitter(),
+            'project-2': new EventEmitter(),
+          }
 
-        this.ProjectZipStreamManager.createZipStreamForProject = (
-          projectId,
-          callback
-        ) => {
-          callback(null, this.zip_streams[projectId])
-          setTimeout(() => {
-            return this.zip_streams[projectId].emit('end')
-          })
-          return 0
-        }
-        sinon.spy(this.ProjectZipStreamManager, 'createZipStreamForProject')
+          ctx.project_names = {
+            'project-1': 'Project One Name',
+            'project-2': 'Project Two Name',
+          }
 
-        this.ProjectGetter.getProject = (projectId, fields, callback) => {
-          return callback(null, { name: this.project_names[projectId] })
-        }
-        sinon.spy(this.ProjectGetter, 'getProject')
+          ctx.ProjectZipStreamManager.createZipStreamForProject = (
+            projectId,
+            callback
+          ) => {
+            callback(null, ctx.zip_streams[projectId])
+            setTimeout(() => {
+              return ctx.zip_streams[projectId].emit('end')
+            })
+            return 0
+          }
+          sinon.spy(ctx.ProjectZipStreamManager, 'createZipStreamForProject')
 
-        this.ProjectZipStreamManager.createZipStreamForMultipleProjects(
-          this.project_ids,
-          (...args) => {
-            return this.callback(...Array.from(args || []))
+          ctx.ProjectGetter.getProject = (projectId, fields, callback) => {
+            return callback(null, { name: ctx.project_names[projectId] })
           }
-        )
+          sinon.spy(ctx.ProjectGetter, 'getProject')
 
-        return (this.archive.finalize = () => done())
+          ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects(
+            ctx.project_ids,
+            (...args) => {
+              return ctx.callback(...Array.from(args || []))
+            }
+          )
+
+          return (ctx.archive.finalize = () => resolve())
+        })
       })
 
-      it('should create a zip archive', function () {
-        return this.archiver.calledWith('zip').should.equal(true)
+      it('should create a zip archive', function (ctx) {
+        return ctx.archiver.calledWith('zip').should.equal(true)
       })
 
-      it('should return a stream before any processing is done', function () {
-        this.callback
-          .calledWith(sinon.match.falsy, this.archive)
+      it('should return a stream before any processing is done', function (ctx) {
+        ctx.callback
+          .calledWith(sinon.match.falsy, ctx.archive)
           .should.equal(true)
-        return this.callback
-          .calledBefore(this.ProjectZipStreamManager.createZipStreamForProject)
+        return ctx.callback
+          .calledBefore(ctx.ProjectZipStreamManager.createZipStreamForProject)
           .should.equal(true)
       })
 
-      it('should get a zip stream for all of the projects', function () {
-        return Array.from(this.project_ids).map(projectId =>
-          this.ProjectZipStreamManager.createZipStreamForProject
+      it('should get a zip stream for all of the projects', function (ctx) {
+        return Array.from(ctx.project_ids).map(projectId =>
+          ctx.ProjectZipStreamManager.createZipStreamForProject
             .calledWith(projectId)
             .should.equal(true)
         )
       })
 
-      it('should get the names of each project', function () {
-        return Array.from(this.project_ids).map(projectId =>
-          this.ProjectGetter.getProject
+      it('should get the names of each project', function (ctx) {
+        return Array.from(ctx.project_ids).map(projectId =>
+          ctx.ProjectGetter.getProject
             .calledWith(projectId, { name: true })
             .should.equal(true)
         )
       })
 
-      it('should add all of the projects to the zip', function () {
-        return Array.from(this.project_ids).map(projectId =>
-          this.archive.append
-            .calledWith(this.zip_streams[projectId], {
-              name: this.project_names[projectId] + '.zip',
+      it('should add all of the projects to the zip', function (ctx) {
+        return Array.from(ctx.project_ids).map(projectId =>
+          ctx.archive.append
+            .calledWith(ctx.zip_streams[projectId], {
+              name: ctx.project_names[projectId] + '.zip',
             })
             .should.equal(true)
         )
@@ -130,75 +154,77 @@ describe('ProjectZipStreamManager', function () {
     })
 
     describe('with a project not existing', function () {
-      beforeEach(function (done) {
-        this.project_ids = ['project-1', 'wrong-id']
-        this.project_names = {
-          'project-1': 'Project One Name',
-        }
-        this.zip_streams = {
-          'project-1': new EventEmitter(),
-        }
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.project_ids = ['project-1', 'wrong-id']
+          ctx.project_names = {
+            'project-1': 'Project One Name',
+          }
+          ctx.zip_streams = {
+            'project-1': new EventEmitter(),
+          }
 
-        this.ProjectZipStreamManager.createZipStreamForProject = (
-          projectId,
-          callback
-        ) => {
-          callback(null, this.zip_streams[projectId])
-          setTimeout(() => {
-            this.zip_streams[projectId].emit('end')
-          })
-        }
-        sinon.spy(this.ProjectZipStreamManager, 'createZipStreamForProject')
+          ctx.ProjectZipStreamManager.createZipStreamForProject = (
+            projectId,
+            callback
+          ) => {
+            callback(null, ctx.zip_streams[projectId])
+            setTimeout(() => {
+              ctx.zip_streams[projectId].emit('end')
+            })
+          }
+          sinon.spy(ctx.ProjectZipStreamManager, 'createZipStreamForProject')
 
-        this.ProjectGetter.getProject = (projectId, fields, callback) => {
-          const name = this.project_names[projectId]
-          callback(null, name ? { name } : undefined)
-        }
-        sinon.spy(this.ProjectGetter, 'getProject')
+          ctx.ProjectGetter.getProject = (projectId, fields, callback) => {
+            const name = ctx.project_names[projectId]
+            callback(null, name ? { name } : undefined)
+          }
+          sinon.spy(ctx.ProjectGetter, 'getProject')
 
-        this.ProjectZipStreamManager.createZipStreamForMultipleProjects(
-          this.project_ids,
-          this.callback
-        )
+          ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects(
+            ctx.project_ids,
+            ctx.callback
+          )
 
-        this.archive.finalize = () => done()
+          ctx.archive.finalize = () => resolve()
+        })
       })
 
-      it('should create a zip archive', function () {
-        this.archiver.calledWith('zip').should.equal(true)
+      it('should create a zip archive', function (ctx) {
+        ctx.archiver.calledWith('zip').should.equal(true)
       })
 
-      it('should return a stream before any processing is done', function () {
-        this.callback
-          .calledWith(sinon.match.falsy, this.archive)
+      it('should return a stream before any processing is done', function (ctx) {
+        ctx.callback
+          .calledWith(sinon.match.falsy, ctx.archive)
           .should.equal(true)
-        this.callback
-          .calledBefore(this.ProjectZipStreamManager.createZipStreamForProject)
+        ctx.callback
+          .calledBefore(ctx.ProjectZipStreamManager.createZipStreamForProject)
           .should.equal(true)
       })
 
-      it('should get the names of each project', function () {
-        this.project_ids.map(projectId =>
-          this.ProjectGetter.getProject
+      it('should get the names of each project', function (ctx) {
+        ctx.project_ids.map(projectId =>
+          ctx.ProjectGetter.getProject
             .calledWith(projectId, { name: true })
             .should.equal(true)
         )
       })
 
-      it('should get a zip stream only for the existing project', function () {
-        this.ProjectZipStreamManager.createZipStreamForProject
+      it('should get a zip stream only for the existing project', function (ctx) {
+        ctx.ProjectZipStreamManager.createZipStreamForProject
           .calledWith('project-1')
           .should.equal(true)
-        this.ProjectZipStreamManager.createZipStreamForProject
+        ctx.ProjectZipStreamManager.createZipStreamForProject
           .calledWith('wrong-id')
           .should.equal(false)
       })
 
-      it('should only add the existing project to the zip', function () {
-        sinon.assert.calledOnce(this.archive.append)
-        this.archive.append
-          .calledWith(this.zip_streams['project-1'], {
-            name: this.project_names['project-1'] + '.zip',
+      it('should only add the existing project to the zip', function (ctx) {
+        sinon.assert.calledOnce(ctx.archive.append)
+        ctx.archive.append
+          .calledWith(ctx.zip_streams['project-1'], {
+            name: ctx.project_names['project-1'] + '.zip',
           })
           .should.equal(true)
       })
@@ -207,160 +233,162 @@ describe('ProjectZipStreamManager', function () {
 
   describe('createZipStreamForProject', function () {
     describe('successfully', function () {
-      beforeEach(function () {
-        this.ProjectZipStreamManager.addAllDocsToArchive = sinon
+      beforeEach(function (ctx) {
+        ctx.ProjectZipStreamManager.addAllDocsToArchive = sinon
           .stub()
           .callsArg(2)
-        this.ProjectZipStreamManager.addAllFilesToArchive = sinon
+        ctx.ProjectZipStreamManager.addAllFilesToArchive = sinon
           .stub()
           .callsArg(2)
-        this.archive.finalize = sinon.stub()
-        return this.ProjectZipStreamManager.createZipStreamForProject(
-          this.project_id,
-          this.callback
+        ctx.archive.finalize = sinon.stub()
+        return ctx.ProjectZipStreamManager.createZipStreamForProject(
+          ctx.project_id,
+          ctx.callback
         )
       })
 
-      it('should create a zip archive', function () {
-        return this.archiver.calledWith('zip').should.equal(true)
+      it('should create a zip archive', function (ctx) {
+        return ctx.archiver.calledWith('zip').should.equal(true)
       })
 
-      it('should return a stream before any processing is done', function () {
-        this.callback
-          .calledWith(sinon.match.falsy, this.archive)
+      it('should return a stream before any processing is done', function (ctx) {
+        ctx.callback
+          .calledWith(sinon.match.falsy, ctx.archive)
           .should.equal(true)
-        this.callback
-          .calledBefore(this.ProjectZipStreamManager.addAllDocsToArchive)
+        ctx.callback
+          .calledBefore(ctx.ProjectZipStreamManager.addAllDocsToArchive)
           .should.equal(true)
-        return this.callback
-          .calledBefore(this.ProjectZipStreamManager.addAllFilesToArchive)
+        return ctx.callback
+          .calledBefore(ctx.ProjectZipStreamManager.addAllFilesToArchive)
           .should.equal(true)
       })
 
-      it('should add all of the project docs to the zip', function () {
-        return this.ProjectZipStreamManager.addAllDocsToArchive
-          .calledWith(this.project_id, this.archive)
+      it('should add all of the project docs to the zip', function (ctx) {
+        return ctx.ProjectZipStreamManager.addAllDocsToArchive
+          .calledWith(ctx.project_id, ctx.archive)
           .should.equal(true)
       })
 
-      it('should add all of the project files to the zip', function () {
-        return this.ProjectZipStreamManager.addAllFilesToArchive
-          .calledWith(this.project_id, this.archive)
+      it('should add all of the project files to the zip', function (ctx) {
+        return ctx.ProjectZipStreamManager.addAllFilesToArchive
+          .calledWith(ctx.project_id, ctx.archive)
           .should.equal(true)
       })
 
-      it('should finalise the stream', function () {
-        return this.archive.finalize.called.should.equal(true)
+      it('should finalise the stream', function (ctx) {
+        return ctx.archive.finalize.called.should.equal(true)
       })
     })
 
     describe('with an error adding docs', function () {
-      beforeEach(function () {
-        this.ProjectZipStreamManager.addAllDocsToArchive = sinon
+      beforeEach(function (ctx) {
+        ctx.ProjectZipStreamManager.addAllDocsToArchive = sinon
           .stub()
           .callsArgWith(2, new Error('something went wrong'))
-        this.ProjectZipStreamManager.addAllFilesToArchive = sinon
+        ctx.ProjectZipStreamManager.addAllFilesToArchive = sinon
           .stub()
           .callsArg(2)
-        this.archive.finalize = sinon.stub()
-        this.ProjectZipStreamManager.createZipStreamForProject(
-          this.project_id,
-          this.callback
+        ctx.archive.finalize = sinon.stub()
+        ctx.ProjectZipStreamManager.createZipStreamForProject(
+          ctx.project_id,
+          ctx.callback
         )
       })
 
-      it('should log out an error', function () {
-        return this.logger.error
+      it('should log out an error', function (ctx) {
+        return ctx.logger.error
           .calledWith(sinon.match.any, 'error adding docs to zip stream')
           .should.equal(true)
       })
 
-      it('should continue with the process', function () {
-        this.ProjectZipStreamManager.addAllDocsToArchive.called.should.equal(
+      it('should continue with the process', function (ctx) {
+        ctx.ProjectZipStreamManager.addAllDocsToArchive.called.should.equal(
           true
         )
-        this.ProjectZipStreamManager.addAllFilesToArchive.called.should.equal(
+        ctx.ProjectZipStreamManager.addAllFilesToArchive.called.should.equal(
           true
         )
-        return this.archive.finalize.called.should.equal(true)
+        return ctx.archive.finalize.called.should.equal(true)
       })
     })
 
     describe('with an error adding files', function () {
-      beforeEach(function () {
-        this.ProjectZipStreamManager.addAllDocsToArchive = sinon
+      beforeEach(function (ctx) {
+        ctx.ProjectZipStreamManager.addAllDocsToArchive = sinon
           .stub()
           .callsArg(2)
-        this.ProjectZipStreamManager.addAllFilesToArchive = sinon
+        ctx.ProjectZipStreamManager.addAllFilesToArchive = sinon
           .stub()
           .callsArgWith(2, new Error('something went wrong'))
-        this.archive.finalize = sinon.stub()
-        return this.ProjectZipStreamManager.createZipStreamForProject(
-          this.project_id,
-          this.callback
+        ctx.archive.finalize = sinon.stub()
+        return ctx.ProjectZipStreamManager.createZipStreamForProject(
+          ctx.project_id,
+          ctx.callback
         )
       })
 
-      it('should log out an error', function () {
-        return this.logger.error
+      it('should log out an error', function (ctx) {
+        return ctx.logger.error
           .calledWith(sinon.match.any, 'error adding files to zip stream')
           .should.equal(true)
       })
 
-      it('should continue with the process', function () {
-        this.ProjectZipStreamManager.addAllDocsToArchive.called.should.equal(
+      it('should continue with the process', function (ctx) {
+        ctx.ProjectZipStreamManager.addAllDocsToArchive.called.should.equal(
           true
         )
-        this.ProjectZipStreamManager.addAllFilesToArchive.called.should.equal(
+        ctx.ProjectZipStreamManager.addAllFilesToArchive.called.should.equal(
           true
         )
-        return this.archive.finalize.called.should.equal(true)
+        return ctx.archive.finalize.called.should.equal(true)
       })
     })
   })
 
   describe('addAllDocsToArchive', function () {
-    beforeEach(function (done) {
-      this.docs = {
-        '/main.tex': {
-          lines: [
-            '\\documentclass{article}',
-            '\\begin{document}',
-            'Hello world',
-            '\\end{document}',
-          ],
-        },
-        '/chapters/chapter1.tex': {
-          lines: ['chapter1', 'content'],
-        },
-      }
-      this.ProjectEntityHandler.getAllDocs = sinon
-        .stub()
-        .callsArgWith(1, null, this.docs)
-      return this.ProjectZipStreamManager.addAllDocsToArchive(
-        this.project_id,
-        this.archive,
-        error => {
-          this.callback(error)
-          return done()
+    beforeEach(function (ctx) {
+      return new Promise(resolve => {
+        ctx.docs = {
+          '/main.tex': {
+            lines: [
+              '\\documentclass{article}',
+              '\\begin{document}',
+              'Hello world',
+              '\\end{document}',
+            ],
+          },
+          '/chapters/chapter1.tex': {
+            lines: ['chapter1', 'content'],
+          },
         }
-      )
+        ctx.ProjectEntityHandler.getAllDocs = sinon
+          .stub()
+          .callsArgWith(1, null, ctx.docs)
+        return ctx.ProjectZipStreamManager.addAllDocsToArchive(
+          ctx.project_id,
+          ctx.archive,
+          error => {
+            ctx.callback(error)
+            return resolve()
+          }
+        )
+      })
     })
 
-    it('should get the docs for the project', function () {
-      return this.ProjectEntityHandler.getAllDocs
-        .calledWith(this.project_id)
+    it('should get the docs for the project', function (ctx) {
+      return ctx.ProjectEntityHandler.getAllDocs
+        .calledWith(ctx.project_id)
         .should.equal(true)
     })
 
-    it('should add each doc to the archive', function () {
+    it('should add each doc to the archive', function (ctx) {
       return (() => {
         const result = []
-        for (let path in this.docs) {
-          const doc = this.docs[path]
+        for (let path in ctx.docs) {
+          const doc = ctx.docs[path]
           path = path.slice(1) // remove "/"
           result.push(
-            this.archive.append
+            ctx.archive.append
               .calledWith(doc.lines.join('\n'), { name: path })
               .should.equal(true)
           )
@@ -371,8 +399,8 @@ describe('ProjectZipStreamManager', function () {
   })
 
   describe('addAllFilesToArchive', function () {
-    beforeEach(function () {
-      this.files = {
+    beforeEach(function (ctx) {
+      ctx.files = {
         '/image.png': {
           _id: 'file-id-1',
           hash: 'abc',
@@ -382,93 +410,91 @@ describe('ProjectZipStreamManager', function () {
           hash: 'def',
         },
       }
-      this.streams = {
+      ctx.streams = {
         'file-id-1': new EventEmitter(),
         'file-id-2': new EventEmitter(),
       }
-      this.ProjectEntityHandler.getAllFiles = sinon
+      ctx.ProjectEntityHandler.getAllFiles = sinon
         .stub()
-        .callsArgWith(1, null, this.files)
+        .callsArgWith(1, null, ctx.files)
     })
     describe('with project-history-blobs feature enabled', function () {
-      beforeEach(function () {
-        this.HistoryManager.requestBlobWithFallback = (
+      beforeEach(function (ctx) {
+        ctx.HistoryManager.requestBlobWithFallback = (
           projectId,
           hash,
           fileId,
           callback
         ) => {
-          return callback(null, { stream: this.streams[fileId] })
+          return callback(null, { stream: ctx.streams[fileId] })
         }
-        sinon.spy(this.HistoryManager, 'requestBlobWithFallback')
-        this.ProjectZipStreamManager.addAllFilesToArchive(
-          this.project_id,
-          this.archive,
-          this.callback
+        sinon.spy(ctx.HistoryManager, 'requestBlobWithFallback')
+        ctx.ProjectZipStreamManager.addAllFilesToArchive(
+          ctx.project_id,
+          ctx.archive,
+          ctx.callback
         )
-        for (const path in this.streams) {
-          const stream = this.streams[path]
+        for (const path in ctx.streams) {
+          const stream = ctx.streams[path]
           stream.emit('end')
         }
       })
 
-      it('should get the files for the project', function () {
-        return this.ProjectEntityHandler.getAllFiles
-          .calledWith(this.project_id)
+      it('should get the files for the project', function (ctx) {
+        return ctx.ProjectEntityHandler.getAllFiles
+          .calledWith(ctx.project_id)
           .should.equal(true)
       })
 
-      it('should get a stream for each file', function () {
-        for (const path in this.files) {
-          const file = this.files[path]
+      it('should get a stream for each file', function (ctx) {
+        for (const path in ctx.files) {
+          const file = ctx.files[path]
 
-          this.HistoryManager.requestBlobWithFallback
-            .calledWith(this.project_id, file.hash, file._id)
+          ctx.HistoryManager.requestBlobWithFallback
+            .calledWith(ctx.project_id, file.hash, file._id)
             .should.equal(true)
         }
       })
 
-      it('should add each file to the archive', function () {
-        for (let path in this.files) {
-          const file = this.files[path]
+      it('should add each file to the archive', function (ctx) {
+        for (let path in ctx.files) {
+          const file = ctx.files[path]
           path = path.slice(1) // remove "/"
-          this.archive.append
-            .calledWith(this.streams[file._id], { name: path })
+          ctx.archive.append
+            .calledWith(ctx.streams[file._id], { name: path })
             .should.equal(true)
         }
       })
     })
 
     describe('with project-history-blobs feature disabled', function () {
-      beforeEach(function () {
-        this.FileStoreHandler.getFileStream = (
+      beforeEach(function (ctx) {
+        ctx.FileStoreHandler.getFileStream = (
           projectId,
           fileId,
           query,
           callback
-        ) => callback(null, this.streams[fileId])
-
-        sinon.spy(this.FileStoreHandler, 'getFileStream')
-        this.Features.hasFeature
-          .withArgs('project-history-blobs')
-          .returns(false)
-        this.ProjectZipStreamManager.addAllFilesToArchive(
-          this.project_id,
-          this.archive,
-          this.callback
+        ) => callback(null, ctx.streams[fileId])
+
+        sinon.spy(ctx.FileStoreHandler, 'getFileStream')
+        ctx.Features.hasFeature.withArgs('project-history-blobs').returns(false)
+        ctx.ProjectZipStreamManager.addAllFilesToArchive(
+          ctx.project_id,
+          ctx.archive,
+          ctx.callback
         )
-        for (const path in this.streams) {
-          const stream = this.streams[path]
+        for (const path in ctx.streams) {
+          const stream = ctx.streams[path]
           stream.emit('end')
         }
       })
 
-      it('should get a stream for each file', function () {
-        for (const path in this.files) {
-          const file = this.files[path]
+      it('should get a stream for each file', function (ctx) {
+        for (const path in ctx.files) {
+          const file = ctx.files[path]
 
-          this.FileStoreHandler.getFileStream
-            .calledWith(this.project_id, file._id)
+          ctx.FileStoreHandler.getFileStream
+            .calledWith(ctx.project_id, file._id)
             .should.equal(true)
         }
       })

+ 149 - 129
services/web/test/unit/src/Exports/ExportsController.test.mjs

@@ -1,11 +1,4 @@
-// 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
- */
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import { expect } from 'chai'
 import sinon from 'sinon'
 const modulePath = new URL(
@@ -25,9 +18,9 @@ describe('ExportsController', function () {
   const license = 'other'
   const showSource = true
 
-  beforeEach(async function () {
-    this.handler = { getUserNotifications: sinon.stub().callsArgWith(1) }
-    this.req = {
+  beforeEach(async function (ctx) {
+    ctx.handler = { getUserNotifications: sinon.stub().callsArgWith(1) }
+    ctx.req = {
       params: {
         project_id: projectId,
         brand_variation_id: brandVariationId,
@@ -45,152 +38,179 @@ describe('ExportsController', function () {
         translate() {},
       },
     }
-    this.res = {
+    ctx.res = {
       json: sinon.stub(),
       status: sinon.stub(),
     }
-    this.res.status.returns(this.res)
-    this.next = sinon.stub()
-    this.AuthenticationController = {
-      getLoggedInUserId: sinon.stub().returns(this.req.session.user._id),
+    ctx.res.status.returns(ctx.res)
+    ctx.next = sinon.stub()
+    ctx.AuthenticationController = {
+      getLoggedInUserId: sinon.stub().returns(ctx.req.session.user._id),
     }
-    return (this.controller = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Exports/ExportsHandler.mjs': this.handler,
-      '../../../../app/src/Features/Authentication/AuthenticationController.js':
-        this.AuthenticationController,
-    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Exports/ExportsHandler.mjs',
+      () => ({
+        default: ctx.handler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/AuthenticationController.js',
+      () => ({
+        default: ctx.AuthenticationController,
+      })
+    )
+
+    ctx.controller = (await import(modulePath)).default
   })
 
   describe('without gallery fields', function () {
-    it('should ask the handler to perform the export', function (done) {
-      this.handler.exportProject = sinon
-        .stub()
-        .yields(null, { iAmAnExport: true, v1_id: 897 })
-      const expected = {
-        project_id: projectId,
-        user_id: userId,
-        brand_variation_id: brandVariationId,
-        first_name: firstName,
-        last_name: lastName,
-      }
-      return this.controller.exportProject(this.req, {
-        json: body => {
-          expect(this.handler.exportProject.args[0][0]).to.deep.equal(expected)
-          expect(body).to.deep.equal({ export_v1_id: 897, message: undefined })
-          return done()
-        },
+    it('should ask the handler to perform the export', function (ctx) {
+      return new Promise(resolve => {
+        ctx.handler.exportProject = sinon
+          .stub()
+          .yields(null, { iAmAnExport: true, v1_id: 897 })
+        const expected = {
+          project_id: projectId,
+          user_id: userId,
+          brand_variation_id: brandVariationId,
+          first_name: firstName,
+          last_name: lastName,
+        }
+        return ctx.controller.exportProject(ctx.req, {
+          json: body => {
+            expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected)
+            expect(body).to.deep.equal({
+              export_v1_id: 897,
+              message: undefined,
+            })
+            return resolve()
+          },
+        })
       })
     })
   })
 
   describe('with a message from v1', function () {
-    it('should ask the handler to perform the export', function (done) {
-      this.handler.exportProject = sinon.stub().yields(null, {
-        iAmAnExport: true,
-        v1_id: 897,
-        message: 'RESUBMISSION',
-      })
-      const expected = {
-        project_id: projectId,
-        user_id: userId,
-        brand_variation_id: brandVariationId,
-        first_name: firstName,
-        last_name: lastName,
-      }
-      return this.controller.exportProject(this.req, {
-        json: body => {
-          expect(this.handler.exportProject.args[0][0]).to.deep.equal(expected)
-          expect(body).to.deep.equal({
-            export_v1_id: 897,
-            message: 'RESUBMISSION',
-          })
-          return done()
-        },
+    it('should ask the handler to perform the export', function (ctx) {
+      return new Promise(resolve => {
+        ctx.handler.exportProject = sinon.stub().yields(null, {
+          iAmAnExport: true,
+          v1_id: 897,
+          message: 'RESUBMISSION',
+        })
+        const expected = {
+          project_id: projectId,
+          user_id: userId,
+          brand_variation_id: brandVariationId,
+          first_name: firstName,
+          last_name: lastName,
+        }
+        return ctx.controller.exportProject(ctx.req, {
+          json: body => {
+            expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected)
+            expect(body).to.deep.equal({
+              export_v1_id: 897,
+              message: 'RESUBMISSION',
+            })
+            return resolve()
+          },
+        })
       })
     })
   })
 
   describe('with gallery fields', function () {
-    beforeEach(function () {
-      this.req.body.title = title
-      this.req.body.description = description
-      this.req.body.author = author
-      this.req.body.license = license
-      return (this.req.body.showSource = true)
+    beforeEach(function (ctx) {
+      ctx.req.body.title = title
+      ctx.req.body.description = description
+      ctx.req.body.author = author
+      ctx.req.body.license = license
+      return (ctx.req.body.showSource = true)
     })
 
-    it('should ask the handler to perform the export', function (done) {
-      this.handler.exportProject = sinon
-        .stub()
-        .yields(null, { iAmAnExport: true, v1_id: 897 })
-      const expected = {
-        project_id: projectId,
-        user_id: userId,
-        brand_variation_id: brandVariationId,
-        first_name: firstName,
-        last_name: lastName,
-        title,
-        description,
-        author,
-        license,
-        show_source: showSource,
-      }
-      return this.controller.exportProject(this.req, {
-        json: body => {
-          expect(this.handler.exportProject.args[0][0]).to.deep.equal(expected)
-          expect(body).to.deep.equal({ export_v1_id: 897, message: undefined })
-          return done()
-        },
+    it('should ask the handler to perform the export', function (ctx) {
+      return new Promise(resolve => {
+        ctx.handler.exportProject = sinon
+          .stub()
+          .yields(null, { iAmAnExport: true, v1_id: 897 })
+        const expected = {
+          project_id: projectId,
+          user_id: userId,
+          brand_variation_id: brandVariationId,
+          first_name: firstName,
+          last_name: lastName,
+          title,
+          description,
+          author,
+          license,
+          show_source: showSource,
+        }
+        return ctx.controller.exportProject(ctx.req, {
+          json: body => {
+            expect(ctx.handler.exportProject.args[0][0]).to.deep.equal(expected)
+            expect(body).to.deep.equal({
+              export_v1_id: 897,
+              message: undefined,
+            })
+            return resolve()
+          },
+        })
       })
     })
   })
 
   describe('with an error return from v1 to forward to the publish modal', function () {
-    it('should forward the response onward', function (done) {
-      this.error_json = { status: 422, message: 'nope' }
-      this.handler.exportProject = sinon
-        .stub()
-        .yields({ forwardResponse: this.error_json })
-      this.controller.exportProject(this.req, this.res, this.next)
-      expect(this.res.json.args[0][0]).to.deep.equal(this.error_json)
-      expect(this.res.status.args[0][0]).to.equal(this.error_json.status)
-      return done()
+    it('should forward the response onward', function (ctx) {
+      return new Promise(resolve => {
+        ctx.error_json = { status: 422, message: 'nope' }
+        ctx.handler.exportProject = sinon
+          .stub()
+          .yields({ forwardResponse: ctx.error_json })
+        ctx.controller.exportProject(ctx.req, ctx.res, ctx.next)
+        expect(ctx.res.json.args[0][0]).to.deep.equal(ctx.error_json)
+        expect(ctx.res.status.args[0][0]).to.equal(ctx.error_json.status)
+        return resolve()
+      })
     })
   })
 
-  it('should ask the handler to return the status of an export', function (done) {
-    this.handler.fetchExport = sinon.stub().yields(
-      null,
-      `{
-"id":897,
-"status_summary":"completed",
-"status_detail":"all done",
-"partner_submission_id":"abc123",
-"v2_user_email":"la@tex.com",
-"v2_user_first_name":"Arthur",
-"v2_user_last_name":"Author",
-"title":"my project",
-"token":"token"
-}`
-    )
+  it('should ask the handler to return the status of an export', function (ctx) {
+    return new Promise(resolve => {
+      ctx.handler.fetchExport = sinon.stub().yields(
+        null,
+        `{
+  "id":897,
+  "status_summary":"completed",
+  "status_detail":"all done",
+  "partner_submission_id":"abc123",
+  "v2_user_email":"la@tex.com",
+  "v2_user_first_name":"Arthur",
+  "v2_user_last_name":"Author",
+  "title":"my project",
+  "token":"token"
+  }`
+      )
 
-    this.req.params = { project_id: projectId, export_id: 897 }
-    return this.controller.exportStatus(this.req, {
-      json: body => {
-        expect(body).to.deep.equal({
-          export_json: {
-            status_summary: 'completed',
-            status_detail: 'all done',
-            partner_submission_id: 'abc123',
-            v2_user_email: 'la@tex.com',
-            v2_user_first_name: 'Arthur',
-            v2_user_last_name: 'Author',
-            title: 'my project',
-            token: 'token',
-          },
-        })
-        return done()
-      },
+      ctx.req.params = { project_id: projectId, export_id: 897 }
+      return ctx.controller.exportStatus(ctx.req, {
+        json: body => {
+          expect(body).to.deep.equal({
+            export_json: {
+              status_summary: 'completed',
+              status_detail: 'all done',
+              partner_submission_id: 'abc123',
+              v2_user_email: 'la@tex.com',
+              v2_user_first_name: 'Arthur',
+              v2_user_last_name: 'Author',
+              title: 'my project',
+              token: 'token',
+            },
+          })
+          return resolve()
+        },
+      })
     })
   })
 })

+ 486 - 447
services/web/test/unit/src/Exports/ExportsHandler.test.mjs

@@ -1,697 +1,736 @@
-// 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
- */
+import { vi } from 'vitest'
 import sinon from 'sinon'
-import esmock from 'esmock'
 import { expect } from 'chai'
 const modulePath = '../../../../app/src/Features/Exports/ExportsHandler.mjs'
 
 describe('ExportsHandler', function () {
-  beforeEach(async function () {
-    this.stubRequest = {}
-    this.request = {
+  beforeEach(async function (ctx) {
+    ctx.stubRequest = {}
+    ctx.request = {
       defaults: () => {
-        return this.stubRequest
+        return ctx.stubRequest
       },
     }
-    this.ExportsHandler = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Project/ProjectGetter':
-        (this.ProjectGetter = {}),
-      '../../../../app/src/Features/Project/ProjectHistoryHandler':
-        (this.ProjectHistoryHandler = {}),
-      '../../../../app/src/Features/Project/ProjectLocator':
-        (this.ProjectLocator = {}),
-      '../../../../app/src/Features/Project/ProjectRootDocManager':
-        (this.ProjectRootDocManager = {}),
-      '../../../../app/src/Features/User/UserGetter': (this.UserGetter = {}),
-      '@overleaf/settings': (this.settings = {}),
-      request: this.request,
-    })
-    this.project_id = 'project-id-123'
-    this.project_history_id = 987
-    this.user_id = 'user-id-456'
-    this.brand_variation_id = 789
-    this.title = 'title'
-    this.description = 'description'
-    this.author = 'author'
-    this.license = 'other'
-    this.show_source = true
-    this.export_params = {
-      project_id: this.project_id,
-      brand_variation_id: this.brand_variation_id,
-      user_id: this.user_id,
-      title: this.title,
-      description: this.description,
-      author: this.author,
-      license: this.license,
-      show_source: this.show_source,
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: (ctx.ProjectGetter = {}),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectHistoryHandler',
+      () => ({
+        default: (ctx.ProjectHistoryHandler = {}),
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
+      default: (ctx.ProjectLocator = {}),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectRootDocManager',
+      () => ({
+        default: (ctx.ProjectRootDocManager = {}),
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: (ctx.UserGetter = {}),
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {}),
+    }))
+
+    vi.doMock('request', () => ({
+      default: ctx.request,
+    }))
+
+    ctx.ExportsHandler = (await import(modulePath)).default
+    ctx.project_id = 'project-id-123'
+    ctx.project_history_id = 987
+    ctx.user_id = 'user-id-456'
+    ctx.brand_variation_id = 789
+    ctx.title = 'title'
+    ctx.description = 'description'
+    ctx.author = 'author'
+    ctx.license = 'other'
+    ctx.show_source = true
+    ctx.export_params = {
+      project_id: ctx.project_id,
+      brand_variation_id: ctx.brand_variation_id,
+      user_id: ctx.user_id,
+      title: ctx.title,
+      description: ctx.description,
+      author: ctx.author,
+      license: ctx.license,
+      show_source: ctx.show_source,
     }
-    return (this.callback = sinon.stub())
+    ctx.callback = sinon.stub()
   })
 
   describe('exportProject', function () {
-    beforeEach(function () {
-      this.export_data = { iAmAnExport: true }
-      this.response_body = { iAmAResponseBody: true }
-      this.ExportsHandler._buildExport = sinon
+    beforeEach(function (ctx) {
+      ctx.export_data = { iAmAnExport: true }
+      ctx.response_body = { iAmAResponseBody: true }
+      ctx.ExportsHandler._buildExport = sinon
         .stub()
-        .yields(null, this.export_data)
-      return (this.ExportsHandler._requestExport = sinon
+        .yields(null, ctx.export_data)
+      ctx.ExportsHandler._requestExport = sinon
         .stub()
-        .yields(null, this.response_body))
+        .yields(null, ctx.response_body)
     })
 
     describe('when all goes well', function () {
-      beforeEach(function (done) {
-        return this.ExportsHandler.exportProject(
-          this.export_params,
-          (error, exportData) => {
-            this.callback(error, exportData)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.ExportsHandler.exportProject(
+            ctx.export_params,
+            (error, exportData) => {
+              ctx.callback(error, exportData)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should build the export', function () {
-        return this.ExportsHandler._buildExport
-          .calledWith(this.export_params)
+      it('should build the export', function (ctx) {
+        ctx.ExportsHandler._buildExport
+          .calledWith(ctx.export_params)
           .should.equal(true)
       })
 
-      it('should request the export', function () {
-        return this.ExportsHandler._requestExport
-          .calledWith(this.export_data)
+      it('should request the export', function (ctx) {
+        ctx.ExportsHandler._requestExport
+          .calledWith(ctx.export_data)
           .should.equal(true)
       })
 
-      it('should return the export', function () {
-        return this.callback
-          .calledWith(null, this.export_data)
-          .should.equal(true)
+      it('should return the export', function (ctx) {
+        ctx.callback.calledWith(null, ctx.export_data).should.equal(true)
       })
     })
 
     describe("when request can't be built", function () {
-      beforeEach(function (done) {
-        this.ExportsHandler._buildExport = sinon
-          .stub()
-          .yields(new Error('cannot export project without root doc'))
-        return this.ExportsHandler.exportProject(
-          this.export_params,
-          (error, exportData) => {
-            this.callback(error, exportData)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.ExportsHandler._buildExport = sinon
+            .stub()
+            .yields(new Error('cannot export project without root doc'))
+          ctx.ExportsHandler.exportProject(
+            ctx.export_params,
+            (error, exportData) => {
+              ctx.callback(error, exportData)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should return an error', function () {
-        return (this.callback.args[0][0] instanceof Error).should.equal(true)
+      it('should return an error', function (ctx) {
+        expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
       })
     })
 
     describe('when export request returns an error to forward to the user', function () {
-      beforeEach(function (done) {
-        this.error_json = { status: 422, message: 'nope' }
-        this.ExportsHandler._requestExport = sinon
-          .stub()
-          .yields(null, { forwardResponse: this.error_json })
-        return this.ExportsHandler.exportProject(
-          this.export_params,
-          (error, exportData) => {
-            this.callback(error, exportData)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.error_json = { status: 422, message: 'nope' }
+          ctx.ExportsHandler._requestExport = sinon
+            .stub()
+            .yields(null, { forwardResponse: ctx.error_json })
+          ctx.ExportsHandler.exportProject(
+            ctx.export_params,
+            (error, exportData) => {
+              ctx.callback(error, exportData)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should return success and the response to forward', function () {
-        ;(this.callback.args[0][0] instanceof Error).should.equal(false)
-        return this.callback.calledWith(null, {
-          forwardResponse: this.error_json,
+      it('should return success and the response to forward', function (ctx) {
+        expect(ctx.callback.args[0][0]).not.to.be.instanceOf(Error)
+        ctx.callback.calledWith(null, {
+          forwardResponse: ctx.error_json,
         })
       })
     })
   })
 
   describe('_buildExport', function () {
-    beforeEach(function (done) {
-      this.project = {
-        id: this.project_id,
-        rootDoc_id: 'doc1_id',
-        compiler: 'pdflatex',
-        imageName: 'mock-image-name',
-        overleaf: {
-          id: this.project_history_id, // for projects imported from v1
-          history: {
-            id: this.project_history_id,
+    beforeEach(function (ctx) {
+      return new Promise(resolve => {
+        ctx.project = {
+          id: ctx.project_id,
+          rootDoc_id: 'doc1_id',
+          compiler: 'pdflatex',
+          imageName: 'mock-image-name',
+          overleaf: {
+            id: ctx.project_history_id, // for projects imported from v1
+            history: {
+              id: ctx.project_history_id,
+            },
           },
-        },
-      }
-      this.user = {
-        id: this.user_id,
-        first_name: 'Arthur',
-        last_name: 'Author',
-        email: 'arthur.author@arthurauthoring.org',
-        overleaf: {
-          id: 876,
-        },
-      }
-      this.rootDocPath = 'main.tex'
-      this.historyVersion = 777
-      this.ProjectGetter.getProject = sinon.stub().yields(null, this.project)
-      this.ProjectHistoryHandler.ensureHistoryExistsForProject = sinon
-        .stub()
-        .yields(null)
-      this.ProjectLocator.findRootDoc = sinon
-        .stub()
-        .yields(null, [null, { fileSystem: 'main.tex' }])
-      this.ProjectRootDocManager.ensureRootDocumentIsValid = sinon
-        .stub()
-        .callsArgWith(1, null)
-      this.UserGetter.getUser = sinon.stub().yields(null, this.user)
-      this.ExportsHandler._requestVersion = sinon
-        .stub()
-        .yields(null, this.historyVersion)
-      return done()
+        }
+        ctx.user = {
+          id: ctx.user_id,
+          first_name: 'Arthur',
+          last_name: 'Author',
+          email: 'arthur.author@arthurauthoring.org',
+          overleaf: {
+            id: 876,
+          },
+        }
+        ctx.rootDocPath = 'main.tex'
+        ctx.historyVersion = 777
+        ctx.ProjectGetter.getProject = sinon.stub().yields(null, ctx.project)
+        ctx.ProjectHistoryHandler.ensureHistoryExistsForProject = sinon
+          .stub()
+          .yields(null)
+        ctx.ProjectLocator.findRootDoc = sinon
+          .stub()
+          .yields(null, [null, { fileSystem: 'main.tex' }])
+        ctx.ProjectRootDocManager.ensureRootDocumentIsValid = sinon
+          .stub()
+          .callsArgWith(1, null)
+        ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user)
+        ctx.ExportsHandler._requestVersion = sinon
+          .stub()
+          .yields(null, ctx.historyVersion)
+        resolve()
+      })
     })
 
     describe('when all goes well', function () {
-      beforeEach(function (done) {
-        return this.ExportsHandler._buildExport(
-          this.export_params,
-          (error, exportData) => {
-            this.callback(error, exportData)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.ExportsHandler._buildExport(
+            ctx.export_params,
+            (error, exportData) => {
+              ctx.callback(error, exportData)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should ensure the project has history', function () {
-        return this.ProjectHistoryHandler.ensureHistoryExistsForProject.called.should.equal(
+      it('should ensure the project has history', function (ctx) {
+        ctx.ProjectHistoryHandler.ensureHistoryExistsForProject.called.should.equal(
           true
         )
       })
 
-      it('should request the project history version', function () {
-        return this.ExportsHandler._requestVersion.called.should.equal(true)
+      it('should request the project history version', function (ctx) {
+        ctx.ExportsHandler._requestVersion.called.should.equal(true)
       })
 
-      it('should return export data', function () {
+      it('should return export data', function (ctx) {
         const expectedExportData = {
           project: {
-            id: this.project_id,
-            rootDocPath: this.rootDocPath,
-            historyId: this.project_history_id,
-            historyVersion: this.historyVersion,
-            v1ProjectId: this.project_history_id,
+            id: ctx.project_id,
+            rootDocPath: ctx.rootDocPath,
+            historyId: ctx.project_history_id,
+            historyVersion: ctx.historyVersion,
+            v1ProjectId: ctx.project_history_id,
             metadata: {
               compiler: 'pdflatex',
               imageName: 'mock-image-name',
-              title: this.title,
-              description: this.description,
-              author: this.author,
-              license: this.license,
-              showSource: this.show_source,
+              title: ctx.title,
+              description: ctx.description,
+              author: ctx.author,
+              license: ctx.license,
+              showSource: ctx.show_source,
             },
           },
           user: {
-            id: this.user_id,
-            firstName: this.user.first_name,
-            lastName: this.user.last_name,
-            email: this.user.email,
+            id: ctx.user_id,
+            firstName: ctx.user.first_name,
+            lastName: ctx.user.last_name,
+            email: ctx.user.email,
             orcidId: null,
             v1UserId: 876,
           },
           destination: {
-            brandVariationId: this.brand_variation_id,
+            brandVariationId: ctx.brand_variation_id,
           },
           options: {
             callbackUrl: null,
           },
         }
-        return this.callback
-          .calledWith(null, expectedExportData)
-          .should.equal(true)
+        ctx.callback.calledWith(null, expectedExportData).should.equal(true)
       })
     })
 
     describe('when we send replacement user first and last name', function () {
-      beforeEach(function (done) {
-        this.custom_first_name = 'FIRST'
-        this.custom_last_name = 'LAST'
-        this.export_params.first_name = this.custom_first_name
-        this.export_params.last_name = this.custom_last_name
-        return this.ExportsHandler._buildExport(
-          this.export_params,
-          (error, exportData) => {
-            this.callback(error, exportData)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.custom_first_name = 'FIRST'
+          ctx.custom_last_name = 'LAST'
+          ctx.export_params.first_name = ctx.custom_first_name
+          ctx.export_params.last_name = ctx.custom_last_name
+          ctx.ExportsHandler._buildExport(
+            ctx.export_params,
+            (error, exportData) => {
+              ctx.callback(error, exportData)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should send the data from the user input', function () {
+      it('should send the data from the user input', function (ctx) {
         const expectedExportData = {
           project: {
-            id: this.project_id,
-            rootDocPath: this.rootDocPath,
-            historyId: this.project_history_id,
-            historyVersion: this.historyVersion,
-            v1ProjectId: this.project_history_id,
+            id: ctx.project_id,
+            rootDocPath: ctx.rootDocPath,
+            historyId: ctx.project_history_id,
+            historyVersion: ctx.historyVersion,
+            v1ProjectId: ctx.project_history_id,
             metadata: {
               compiler: 'pdflatex',
               imageName: 'mock-image-name',
-              title: this.title,
-              description: this.description,
-              author: this.author,
-              license: this.license,
-              showSource: this.show_source,
+              title: ctx.title,
+              description: ctx.description,
+              author: ctx.author,
+              license: ctx.license,
+              showSource: ctx.show_source,
             },
           },
           user: {
-            id: this.user_id,
-            firstName: this.custom_first_name,
-            lastName: this.custom_last_name,
-            email: this.user.email,
+            id: ctx.user_id,
+            firstName: ctx.custom_first_name,
+            lastName: ctx.custom_last_name,
+            email: ctx.user.email,
             orcidId: null,
             v1UserId: 876,
           },
           destination: {
-            brandVariationId: this.brand_variation_id,
+            brandVariationId: ctx.brand_variation_id,
           },
           options: {
             callbackUrl: null,
           },
         }
-        return this.callback
-          .calledWith(null, expectedExportData)
-          .should.equal(true)
+        ctx.callback.calledWith(null, expectedExportData).should.equal(true)
       })
     })
 
     describe('when project is not found', function () {
-      beforeEach(function (done) {
-        this.ProjectGetter.getProject = sinon
-          .stub()
-          .yields(new Error('project not found'))
-        return this.ExportsHandler._buildExport(
-          this.export_params,
-          (error, exportData) => {
-            this.callback(error, exportData)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.ProjectGetter.getProject = sinon
+            .stub()
+            .yields(new Error('project not found'))
+          ctx.ExportsHandler._buildExport(
+            ctx.export_params,
+            (error, exportData) => {
+              ctx.callback(error, exportData)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should return an error', function () {
-        return (this.callback.args[0][0] instanceof Error).should.equal(true)
+      it('should return an error', function (ctx) {
+        expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
       })
     })
 
     describe('when project has no root doc', function () {
       describe('when a root doc can be set automatically', function () {
-        beforeEach(function (done) {
-          this.project.rootDoc_id = null
-          this.ProjectLocator.findRootDoc = sinon
-            .stub()
-            .yields(null, [null, { fileSystem: 'other.tex' }])
-          return this.ExportsHandler._buildExport(
-            this.export_params,
-            (error, exportData) => {
-              this.callback(error, exportData)
-              return done()
-            }
-          )
+        beforeEach(function (ctx) {
+          return new Promise(resolve => {
+            ctx.project.rootDoc_id = null
+            ctx.ProjectLocator.findRootDoc = sinon
+              .stub()
+              .yields(null, [null, { fileSystem: 'other.tex' }])
+            ctx.ExportsHandler._buildExport(
+              ctx.export_params,
+              (error, exportData) => {
+                ctx.callback(error, exportData)
+                resolve()
+              }
+            )
+          })
         })
 
-        it('should set a root doc', function () {
-          return this.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal(
+        it('should set a root doc', function (ctx) {
+          ctx.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal(
             true
           )
         })
 
-        it('should return export data', function () {
+        it('should return export data', function (ctx) {
           const expectedExportData = {
             project: {
-              id: this.project_id,
+              id: ctx.project_id,
               rootDocPath: 'other.tex',
-              historyId: this.project_history_id,
-              historyVersion: this.historyVersion,
-              v1ProjectId: this.project_history_id,
+              historyId: ctx.project_history_id,
+              historyVersion: ctx.historyVersion,
+              v1ProjectId: ctx.project_history_id,
               metadata: {
                 compiler: 'pdflatex',
                 imageName: 'mock-image-name',
-                title: this.title,
-                description: this.description,
-                author: this.author,
-                license: this.license,
-                showSource: this.show_source,
+                title: ctx.title,
+                description: ctx.description,
+                author: ctx.author,
+                license: ctx.license,
+                showSource: ctx.show_source,
               },
             },
             user: {
-              id: this.user_id,
-              firstName: this.user.first_name,
-              lastName: this.user.last_name,
-              email: this.user.email,
+              id: ctx.user_id,
+              firstName: ctx.user.first_name,
+              lastName: ctx.user.last_name,
+              email: ctx.user.email,
               orcidId: null,
               v1UserId: 876,
             },
             destination: {
-              brandVariationId: this.brand_variation_id,
+              brandVariationId: ctx.brand_variation_id,
             },
             options: {
               callbackUrl: null,
             },
           }
-          return this.callback
-            .calledWith(null, expectedExportData)
-            .should.equal(true)
+          ctx.callback.calledWith(null, expectedExportData).should.equal(true)
         })
       })
     })
 
     describe('when project has an invalid root doc', function () {
       describe('when a new root doc can be set automatically', function () {
-        beforeEach(function (done) {
-          this.fakeDoc_id = '1a2b3c4d5e6f'
-          this.project.rootDoc_id = this.fakeDoc_id
-          this.ProjectLocator.findRootDoc = sinon
-            .stub()
-            .yields(null, [null, { fileSystem: 'other.tex' }])
-          return this.ExportsHandler._buildExport(
-            this.export_params,
-            (error, exportData) => {
-              this.callback(error, exportData)
-              return done()
-            }
-          )
+        beforeEach(function (ctx) {
+          return new Promise(resolve => {
+            ctx.fakeDoc_id = '1a2b3c4d5e6f'
+            ctx.project.rootDoc_id = ctx.fakeDoc_id
+            ctx.ProjectLocator.findRootDoc = sinon
+              .stub()
+              .yields(null, [null, { fileSystem: 'other.tex' }])
+            ctx.ExportsHandler._buildExport(
+              ctx.export_params,
+              (error, exportData) => {
+                ctx.callback(error, exportData)
+                resolve()
+              }
+            )
+          })
         })
 
-        it('should set a valid root doc', function () {
-          return this.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal(
+        it('should set a valid root doc', function (ctx) {
+          ctx.ProjectRootDocManager.ensureRootDocumentIsValid.called.should.equal(
             true
           )
         })
 
-        it('should return export data', function () {
+        it('should return export data', function (ctx) {
           const expectedExportData = {
             project: {
-              id: this.project_id,
+              id: ctx.project_id,
               rootDocPath: 'other.tex',
-              historyId: this.project_history_id,
-              historyVersion: this.historyVersion,
-              v1ProjectId: this.project_history_id,
+              historyId: ctx.project_history_id,
+              historyVersion: ctx.historyVersion,
+              v1ProjectId: ctx.project_history_id,
               metadata: {
                 compiler: 'pdflatex',
                 imageName: 'mock-image-name',
-                title: this.title,
-                description: this.description,
-                author: this.author,
-                license: this.license,
-                showSource: this.show_source,
+                title: ctx.title,
+                description: ctx.description,
+                author: ctx.author,
+                license: ctx.license,
+                showSource: ctx.show_source,
               },
             },
             user: {
-              id: this.user_id,
-              firstName: this.user.first_name,
-              lastName: this.user.last_name,
-              email: this.user.email,
+              id: ctx.user_id,
+              firstName: ctx.user.first_name,
+              lastName: ctx.user.last_name,
+              email: ctx.user.email,
               orcidId: null,
               v1UserId: 876,
             },
             destination: {
-              brandVariationId: this.brand_variation_id,
+              brandVariationId: ctx.brand_variation_id,
             },
             options: {
               callbackUrl: null,
             },
           }
-          return this.callback
-            .calledWith(null, expectedExportData)
-            .should.equal(true)
+          ctx.callback.calledWith(null, expectedExportData).should.equal(true)
         })
       })
 
       describe('when no root doc can be identified', function () {
-        beforeEach(function (done) {
-          this.ProjectLocator.findRootDoc = sinon
-            .stub()
-            .yields(null, [null, null])
-          return this.ExportsHandler._buildExport(
-            this.export_params,
-            (error, exportData) => {
-              this.callback(error, exportData)
-              return done()
-            }
-          )
+        beforeEach(function (ctx) {
+          return new Promise(resolve => {
+            ctx.ProjectLocator.findRootDoc = sinon
+              .stub()
+              .yields(null, [null, null])
+            ctx.ExportsHandler._buildExport(
+              ctx.export_params,
+              (error, exportData) => {
+                ctx.callback(error, exportData)
+                resolve()
+              }
+            )
+          })
         })
 
-        it('should return an error', function () {
-          return (this.callback.args[0][0] instanceof Error).should.equal(true)
+        it('should return an error', function (ctx) {
+          expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
         })
       })
     })
 
     describe('when user is not found', function () {
-      beforeEach(function (done) {
-        this.UserGetter.getUser = sinon
-          .stub()
-          .yields(new Error('user not found'))
-        return this.ExportsHandler._buildExport(
-          this.export_params,
-          (error, exportData) => {
-            this.callback(error, exportData)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.UserGetter.getUser = sinon
+            .stub()
+            .yields(new Error('user not found'))
+          ctx.ExportsHandler._buildExport(
+            ctx.export_params,
+            (error, exportData) => {
+              ctx.callback(error, exportData)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should return an error', function () {
-        return (this.callback.args[0][0] instanceof Error).should.equal(true)
+      it('should return an error', function (ctx) {
+        expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
       })
     })
 
     describe('when project history request fails', function () {
-      beforeEach(function (done) {
-        this.ExportsHandler._requestVersion = sinon
-          .stub()
-          .yields(new Error('project history call failed'))
-        return this.ExportsHandler._buildExport(
-          this.export_params,
-          (error, exportData) => {
-            this.callback(error, exportData)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.ExportsHandler._requestVersion = sinon
+            .stub()
+            .yields(new Error('project history call failed'))
+          ctx.ExportsHandler._buildExport(
+            ctx.export_params,
+            (error, exportData) => {
+              ctx.callback(error, exportData)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should return an error', function () {
-        return (this.callback.args[0][0] instanceof Error).should.equal(true)
+      it('should return an error', function (ctx) {
+        expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
       })
     })
   })
 
   describe('_requestExport', function () {
-    beforeEach(function (done) {
-      this.settings.apis = {
-        v1: {
-          url: 'http://127.0.0.1:5000',
-          user: 'overleaf',
-          pass: 'pass',
-          timeout: 15000,
-        },
-      }
-      this.export_data = { iAmAnExport: true }
-      this.export_id = 4096
-      this.stubPost = sinon
-        .stub()
-        .yields(null, { statusCode: 200 }, { exportId: this.export_id })
-      return done()
+    beforeEach(function (ctx) {
+      return new Promise(resolve => {
+        ctx.settings.apis = {
+          v1: {
+            url: 'http://127.0.0.1:5000',
+            user: 'overleaf',
+            pass: 'pass',
+            timeout: 15000,
+          },
+        }
+        ctx.export_data = { iAmAnExport: true }
+        ctx.export_id = 4096
+        ctx.stubPost = sinon
+          .stub()
+          .yields(null, { statusCode: 200 }, { exportId: ctx.export_id })
+        resolve()
+      })
     })
 
     describe('when all goes well', function () {
-      beforeEach(function (done) {
-        this.stubRequest.post = this.stubPost
-        return this.ExportsHandler._requestExport(
-          this.export_data,
-          (error, exportV1Id) => {
-            this.callback(error, exportV1Id)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.stubRequest.post = ctx.stubPost
+          ctx.ExportsHandler._requestExport(
+            ctx.export_data,
+            (error, exportV1Id) => {
+              ctx.callback(error, exportV1Id)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should issue the request', function () {
-        return expect(this.stubPost.getCall(0).args[0]).to.deep.equal({
-          url: this.settings.apis.v1.url + '/api/v1/overleaf/exports',
+      it('should issue the request', function (ctx) {
+        expect(ctx.stubPost.getCall(0).args[0]).to.deep.equal({
+          url: ctx.settings.apis.v1.url + '/api/v1/overleaf/exports',
           auth: {
-            user: this.settings.apis.v1.user,
-            pass: this.settings.apis.v1.pass,
+            user: ctx.settings.apis.v1.user,
+            pass: ctx.settings.apis.v1.pass,
           },
-          json: this.export_data,
+          json: ctx.export_data,
           timeout: 15000,
         })
       })
 
-      it('should return the body with v1 export id', function () {
-        return this.callback
-          .calledWith(null, { exportId: this.export_id })
+      it('should return the body with v1 export id', function (ctx) {
+        ctx.callback
+          .calledWith(null, { exportId: ctx.export_id })
           .should.equal(true)
       })
     })
 
     describe('when the request fails', function () {
-      beforeEach(function (done) {
-        this.stubRequest.post = sinon
-          .stub()
-          .yields(new Error('export request failed'))
-        return this.ExportsHandler._requestExport(
-          this.export_data,
-          (error, exportV1Id) => {
-            this.callback(error, exportV1Id)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.stubRequest.post = sinon
+            .stub()
+            .yields(new Error('export request failed'))
+          ctx.ExportsHandler._requestExport(
+            ctx.export_data,
+            (error, exportV1Id) => {
+              ctx.callback(error, exportV1Id)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should return an error', function () {
-        return (this.callback.args[0][0] instanceof Error).should.equal(true)
+      it('should return an error', function (ctx) {
+        expect(ctx.callback.args[0][0]).to.be.instanceOf(Error)
       })
     })
 
     describe('when the request returns an error response to forward', function () {
-      beforeEach(function (done) {
-        this.error_code = 422
-        this.error_json = { status: this.error_code, message: 'nope' }
-        this.stubRequest.post = sinon
+      beforeEach(function (ctx) {
+        ctx.error_code = 422
+        ctx.error_json = { status: ctx.error_code, message: 'nope' }
+        ctx.stubRequest.post = sinon
           .stub()
-          .yields(null, { statusCode: this.error_code }, this.error_json)
-        return this.ExportsHandler._requestExport(
-          this.export_data,
-          (error, exportV1Id) => {
-            this.callback(error, exportV1Id)
-            return done()
-          }
-        )
+          .yields(null, { statusCode: ctx.error_code }, ctx.error_json)
+        return new Promise(resolve => {
+          ctx.ExportsHandler._requestExport(
+            ctx.export_data,
+            (error, exportV1Id) => {
+              ctx.callback(error, exportV1Id)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should return success and the response to forward', function () {
-        ;(this.callback.args[0][0] instanceof Error).should.equal(false)
-        return this.callback.calledWith(null, {
-          forwardResponse: this.error_json,
+      it('should return success and the response to forward', function (ctx) {
+        expect(ctx.callback.args[0][0]).not.to.be.instanceOf(Error)
+        ctx.callback.calledWith(null, {
+          forwardResponse: ctx.error_json,
         })
       })
     })
   })
 
   describe('fetchExport', function () {
-    beforeEach(function (done) {
-      this.settings.apis = {
-        v1: {
-          url: 'http://127.0.0.1:5000',
-          user: 'overleaf',
-          pass: 'pass',
-          timeout: 15000,
-        },
-      }
-      this.export_id = 897
-      this.body = '{"id":897, "status_summary":"completed"}'
-      this.stubGet = sinon
-        .stub()
-        .yields(null, { statusCode: 200 }, { body: this.body })
-      return done()
+    beforeEach(function (ctx) {
+      return new Promise(resolve => {
+        ctx.settings.apis = {
+          v1: {
+            url: 'http://127.0.0.1:5000',
+            user: 'overleaf',
+            pass: 'pass',
+            timeout: 15000,
+          },
+        }
+        ctx.export_id = 897
+        ctx.body = '{"id":897, "status_summary":"completed"}'
+        ctx.stubGet = sinon
+          .stub()
+          .yields(null, { statusCode: 200 }, { body: ctx.body })
+        resolve()
+      })
     })
 
     describe('when all goes well', function () {
-      beforeEach(function (done) {
-        this.stubRequest.get = this.stubGet
-        return this.ExportsHandler.fetchExport(
-          this.export_id,
-          (error, body) => {
-            this.callback(error, body)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.stubRequest.get = ctx.stubGet
+          ctx.ExportsHandler.fetchExport(ctx.export_id, (error, body) => {
+            ctx.callback(error, body)
+            resolve()
+          })
+        })
       })
 
-      it('should issue the request', function () {
-        return expect(this.stubGet.getCall(0).args[0]).to.deep.equal({
+      it('should issue the request', function (ctx) {
+        expect(ctx.stubGet.getCall(0).args[0]).to.deep.equal({
           url:
-            this.settings.apis.v1.url +
+            ctx.settings.apis.v1.url +
             '/api/v1/overleaf/exports/' +
-            this.export_id,
+            ctx.export_id,
           auth: {
-            user: this.settings.apis.v1.user,
-            pass: this.settings.apis.v1.pass,
+            user: ctx.settings.apis.v1.user,
+            pass: ctx.settings.apis.v1.pass,
           },
           timeout: 15000,
         })
       })
 
-      it('should return the v1 export id', function () {
-        return this.callback
-          .calledWith(null, { body: this.body })
-          .should.equal(true)
+      it('should return the v1 export id', function (ctx) {
+        ctx.callback.calledWith(null, { body: ctx.body }).should.equal(true)
       })
     })
   })
 
   describe('fetchDownload', function () {
-    beforeEach(function (done) {
-      this.settings.apis = {
-        v1: {
-          url: 'http://127.0.0.1:5000',
-          user: 'overleaf',
-          pass: 'pass',
-          timeout: 15000,
-        },
-      }
-      this.export_id = 897
-      this.body =
-        'https://writelatex-conversions-dev.s3.amazonaws.com/exports/ieee_latexqc/tnb/2912/xggmprcrpfwbsnqzqqmvktddnrbqkqkr.zip?X-Amz-Expires=14400&X-Amz-Date=20180730T181003Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAJDGDIJFGLNVGZH6A/20180730/us-east-1/s3/aws4_request&X-Amz-SignedHeaders=host&X-Amz-Signature=dec990336913cef9933f0e269afe99722d7ab2830ebf2c618a75673ee7159fee'
-      this.stubGet = sinon
-        .stub()
-        .yields(null, { statusCode: 200 }, { body: this.body })
-      return done()
+    beforeEach(function (ctx) {
+      return new Promise(resolve => {
+        ctx.settings.apis = {
+          v1: {
+            url: 'http://127.0.0.1:5000',
+            user: 'overleaf',
+            pass: 'pass',
+            timeout: 15000,
+          },
+        }
+        ctx.export_id = 897
+        ctx.body =
+          'https://writelatex-conversions-dev.s3.amazonaws.com/exports/ieee_latexqc/tnb/2912/xggmprcrpfwbsnqzqqmvktddnrbqkqkr.zip?X-Amz-Expires=14400&X-Amz-Date=20180730T181003Z&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAJDGDIJFGLNVGZH6A/20180730/us-east-1/s3/aws4_request&X-Amz-SignedHeaders=host&X-Amz-Signature=dec990336913cef9933f0e269afe99722d7ab2830ebf2c618a75673ee7159fee'
+        ctx.stubGet = sinon
+          .stub()
+          .yields(null, { statusCode: 200 }, { body: ctx.body })
+        resolve()
+      })
     })
 
     describe('when all goes well', function () {
-      beforeEach(function (done) {
-        this.stubRequest.get = this.stubGet
-        return this.ExportsHandler.fetchDownload(
-          this.export_id,
-          'zip',
-          (error, body) => {
-            this.callback(error, body)
-            return done()
-          }
-        )
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.stubRequest.get = ctx.stubGet
+          ctx.ExportsHandler.fetchDownload(
+            ctx.export_id,
+            'zip',
+            (error, body) => {
+              ctx.callback(error, body)
+              resolve()
+            }
+          )
+        })
       })
 
-      it('should issue the request', function () {
-        return expect(this.stubGet.getCall(0).args[0]).to.deep.equal({
+      it('should issue the request', function (ctx) {
+        expect(ctx.stubGet.getCall(0).args[0]).to.deep.equal({
           url:
-            this.settings.apis.v1.url +
+            ctx.settings.apis.v1.url +
             '/api/v1/overleaf/exports/' +
-            this.export_id +
+            ctx.export_id +
             '/zip_url',
           auth: {
-            user: this.settings.apis.v1.user,
-            pass: this.settings.apis.v1.pass,
+            user: ctx.settings.apis.v1.user,
+            pass: ctx.settings.apis.v1.pass,
           },
           timeout: 15000,
         })
       })
 
-      it('should return the v1 export id', function () {
-        return this.callback
-          .calledWith(null, { body: this.body })
-          .should.equal(true)
+      it('should return the v1 export id', function (ctx) {
+        ctx.callback.calledWith(null, { body: ctx.body }).should.equal(true)
       })
     })
   })

+ 127 - 106
services/web/test/unit/src/FileStore/FileStoreController.test.mjs

@@ -1,6 +1,6 @@
+import { vi } from 'vitest'
 import { expect } from 'chai'
 import sinon from 'sinon'
-import esmock from 'esmock'
 import Errors from '../../../../app/src/Features/Errors/Errors.js'
 import MockResponse from '../helpers/MockResponse.js'
 
@@ -12,34 +12,51 @@ const expectedFileHeaders = {
   'X-Served-By': 'filestore',
 }
 
+vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+)
+
 describe('FileStoreController', function () {
-  beforeEach(async function () {
-    this.FileStoreHandler = {
+  beforeEach(async function (ctx) {
+    ctx.FileStoreHandler = {
       promises: {
         getFileStream: sinon.stub(),
         getFileSize: sinon.stub(),
       },
     }
-    this.ProjectLocator = { promises: { findElement: sinon.stub() } }
-    this.Stream = { pipeline: sinon.stub().resolves() }
-    this.HistoryManager = {}
-    this.controller = await esmock.strict(MODULE_PATH, {
-      'node:stream/promises': this.Stream,
-      '@overleaf/settings': this.settings,
-      '../../../../app/src/Features/Project/ProjectLocator':
-        this.ProjectLocator,
-      '../../../../app/src/Features/FileStore/FileStoreHandler':
-        this.FileStoreHandler,
-      '../../../../app/src/Features/History/HistoryManager':
-        this.HistoryManager,
-    })
-    this.stream = {}
-    this.projectId = '2k3j1lk3j21lk3j'
-    this.fileId = '12321kklj1lk3jk12'
-    this.req = {
+    ctx.ProjectLocator = { promises: { findElement: sinon.stub() } }
+    ctx.Stream = { pipeline: sinon.stub().resolves() }
+    ctx.HistoryManager = {}
+
+    vi.doMock('node:stream/promises', () => ctx.Stream)
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
+      default: ctx.ProjectLocator,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/FileStore/FileStoreHandler',
+      () => ({
+        default: ctx.FileStoreHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/History/HistoryManager', () => ({
+      default: ctx.HistoryManager,
+    }))
+
+    ctx.controller = (await import(MODULE_PATH)).default
+    ctx.stream = {}
+    ctx.projectId = '2k3j1lk3j21lk3j'
+    ctx.fileId = '12321kklj1lk3jk12'
+    ctx.req = {
       params: {
-        Project_id: this.projectId,
-        File_id: this.fileId,
+        Project_id: ctx.projectId,
+        File_id: ctx.fileId,
       },
       query: 'query string here',
       get(key) {
@@ -49,61 +66,61 @@ describe('FileStoreController', function () {
         addFields: sinon.stub(),
       },
     }
-    this.res = new MockResponse()
-    this.next = sinon.stub()
-    this.file = { name: 'myfile.png' }
+    ctx.res = new MockResponse()
+    ctx.next = sinon.stub()
+    ctx.file = { name: 'myfile.png' }
   })
 
   describe('getFile', function () {
-    beforeEach(function () {
-      this.FileStoreHandler.promises.getFileStream.resolves(this.stream)
-      this.ProjectLocator.promises.findElement.resolves({ element: this.file })
+    beforeEach(function (ctx) {
+      ctx.FileStoreHandler.promises.getFileStream.resolves(ctx.stream)
+      ctx.ProjectLocator.promises.findElement.resolves({ element: ctx.file })
     })
 
-    it('should call the file store handler with the project_id file_id and any query string', async function () {
-      await this.controller.getFile(this.req, this.res)
-      this.FileStoreHandler.promises.getFileStream.should.have.been.calledWith(
-        this.req.params.Project_id,
-        this.req.params.File_id,
-        this.req.query
+    it('should call the file store handler with the project_id file_id and any query string', async function (ctx) {
+      await ctx.controller.getFile(ctx.req, ctx.res)
+      ctx.FileStoreHandler.promises.getFileStream.should.have.been.calledWith(
+        ctx.req.params.Project_id,
+        ctx.req.params.File_id,
+        ctx.req.query
       )
     })
 
-    it('should pipe to res', async function () {
-      await this.controller.getFile(this.req, this.res)
-      this.Stream.pipeline.should.have.been.calledWith(this.stream, this.res)
+    it('should pipe to res', async function (ctx) {
+      await ctx.controller.getFile(ctx.req, ctx.res)
+      ctx.Stream.pipeline.should.have.been.calledWith(ctx.stream, ctx.res)
     })
 
-    it('should get the file from the db', async function () {
-      await this.controller.getFile(this.req, this.res)
-      this.ProjectLocator.promises.findElement.should.have.been.calledWith({
-        project_id: this.projectId,
-        element_id: this.fileId,
+    it('should get the file from the db', async function (ctx) {
+      await ctx.controller.getFile(ctx.req, ctx.res)
+      ctx.ProjectLocator.promises.findElement.should.have.been.calledWith({
+        project_id: ctx.projectId,
+        element_id: ctx.fileId,
         type: 'file',
       })
     })
 
-    it('should set the Content-Disposition header', async function () {
-      await this.controller.getFile(this.req, this.res)
-      this.res.setContentDisposition.should.be.calledWith('attachment', {
-        filename: this.file.name,
+    it('should set the Content-Disposition header', async function (ctx) {
+      await ctx.controller.getFile(ctx.req, ctx.res)
+      ctx.res.setContentDisposition.should.be.calledWith('attachment', {
+        filename: ctx.file.name,
       })
     })
 
-    it('should return a 404 when not found', async function () {
-      this.ProjectLocator.promises.findElement.rejects(
+    it('should return a 404 when not found', async function (ctx) {
+      ctx.ProjectLocator.promises.findElement.rejects(
         new Errors.NotFoundError()
       )
-      await this.controller.getFile(this.req, this.res)
-      expect(this.res.statusCode).to.equal(404)
+      await ctx.controller.getFile(ctx.req, ctx.res)
+      expect(ctx.res.statusCode).to.equal(404)
     })
 
     // Test behaviour around handling html files
     ;['.html', '.htm', '.xhtml'].forEach(extension => {
       describe(`with a '${extension}' file extension`, function () {
-        beforeEach(function () {
-          this.file.name = `bad${extension}`
-          this.req.get = key => {
+        beforeEach(function (ctx) {
+          ctx.file.name = `bad${extension}`
+          ctx.req.get = key => {
             if (key === 'User-Agent') {
               return 'A generic browser'
             }
@@ -111,26 +128,26 @@ describe('FileStoreController', function () {
         })
 
         describe('from a non-ios browser', function () {
-          it('should not set Content-Type', async function () {
-            await this.controller.getFile(this.req, this.res)
-            this.res.headers.should.deep.equal({
+          it('should not set Content-Type', async function (ctx) {
+            await ctx.controller.getFile(ctx.req, ctx.res)
+            ctx.res.headers.should.deep.equal({
               ...expectedFileHeaders,
             })
           })
         })
 
         describe('from an iPhone', function () {
-          beforeEach(function () {
-            this.req.get = key => {
+          beforeEach(function (ctx) {
+            ctx.req.get = key => {
               if (key === 'User-Agent') {
                 return 'An iPhone browser'
               }
             }
           })
 
-          it("should set Content-Type to 'text/plain'", async function () {
-            await this.controller.getFile(this.req, this.res)
-            this.res.headers.should.deep.equal({
+          it("should set Content-Type to 'text/plain'", async function (ctx) {
+            await ctx.controller.getFile(ctx.req, ctx.res)
+            ctx.res.headers.should.deep.equal({
               ...expectedFileHeaders,
               'Content-Type': 'text/plain; charset=utf-8',
               'X-Content-Type-Options': 'nosniff',
@@ -139,17 +156,17 @@ describe('FileStoreController', function () {
         })
 
         describe('from an iPad', function () {
-          beforeEach(function () {
-            this.req.get = key => {
+          beforeEach(function (ctx) {
+            ctx.req.get = key => {
               if (key === 'User-Agent') {
                 return 'An iPad browser'
               }
             }
           })
 
-          it("should set Content-Type to 'text/plain'", async function () {
-            await this.controller.getFile(this.req, this.res)
-            this.res.headers.should.deep.equal({
+          it("should set Content-Type to 'text/plain'", async function (ctx) {
+            await ctx.controller.getFile(ctx.req, ctx.res)
+            ctx.res.headers.should.deep.equal({
               ...expectedFileHeaders,
               'Content-Type': 'text/plain; charset=utf-8',
               'X-Content-Type-Options': 'nosniff',
@@ -166,24 +183,24 @@ describe('FileStoreController', function () {
       'somefile',
     ].forEach(filename => {
       describe(`with filename as '${filename}'`, function () {
-        beforeEach(function () {
-          this.user_agent = 'A generic browser'
-          this.file.name = filename
-          this.req.get = key => {
+        beforeEach(function (ctx) {
+          ctx.user_agent = 'A generic browser'
+          ctx.file.name = filename
+          ctx.req.get = key => {
             if (key === 'User-Agent') {
-              return this.user_agent
+              return ctx.user_agent
             }
           }
         })
         ;['iPhone', 'iPad', 'Firefox', 'Chrome'].forEach(browser => {
           describe(`downloaded from ${browser}`, function () {
-            beforeEach(function () {
-              this.user_agent = `Some ${browser} thing`
+            beforeEach(function (ctx) {
+              ctx.user_agent = `Some ${browser} thing`
             })
 
-            it('Should not set the Content-type', async function () {
-              await this.controller.getFile(this.req, this.res)
-              this.res.headers.should.deep.equal({
+            it('Should not set the Content-type', async function (ctx) {
+              await ctx.controller.getFile(ctx.req, ctx.res)
+              ctx.res.headers.should.deep.equal({
                 ...expectedFileHeaders,
               })
             })
@@ -194,42 +211,46 @@ describe('FileStoreController', function () {
   })
 
   describe('getFileHead', function () {
-    beforeEach(function () {
-      this.ProjectLocator.promises.findElement.resolves({ element: this.file })
+    beforeEach(function (ctx) {
+      ctx.ProjectLocator.promises.findElement.resolves({ element: ctx.file })
     })
 
-    it('reports the file size', function (done) {
-      const expectedFileSize = 99393
-      this.FileStoreHandler.promises.getFileSize.rejects(
-        new Error('getFileSize: unexpected arguments')
-      )
-      this.FileStoreHandler.promises.getFileSize
-        .withArgs(this.projectId, this.fileId)
-        .resolves(expectedFileSize)
-
-      this.res.end = () => {
-        expect(this.res.status.lastCall.args).to.deep.equal([200])
-        expect(this.res.header.lastCall.args).to.deep.equal([
-          'Content-Length',
-          expectedFileSize,
-        ])
-        done()
-      }
-
-      this.controller.getFileHead(this.req, this.res)
+    it('reports the file size', function (ctx) {
+      return new Promise(resolve => {
+        const expectedFileSize = 99393
+        ctx.FileStoreHandler.promises.getFileSize.rejects(
+          new Error('getFileSize: unexpected arguments')
+        )
+        ctx.FileStoreHandler.promises.getFileSize
+          .withArgs(ctx.projectId, ctx.fileId)
+          .resolves(expectedFileSize)
+
+        ctx.res.end = () => {
+          expect(ctx.res.status.lastCall.args).to.deep.equal([200])
+          expect(ctx.res.header.lastCall.args).to.deep.equal([
+            'Content-Length',
+            expectedFileSize,
+          ])
+          resolve()
+        }
+
+        ctx.controller.getFileHead(ctx.req, ctx.res)
+      })
     })
 
-    it('returns 404 on NotFoundError', function (done) {
-      this.FileStoreHandler.promises.getFileSize.rejects(
-        new Errors.NotFoundError()
-      )
+    it('returns 404 on NotFoundError', function (ctx) {
+      return new Promise(resolve => {
+        ctx.FileStoreHandler.promises.getFileSize.rejects(
+          new Errors.NotFoundError()
+        )
 
-      this.res.end = () => {
-        expect(this.res.status.lastCall.args).to.deep.equal([404])
-        done()
-      }
+        ctx.res.end = () => {
+          expect(ctx.res.status.lastCall.args).to.deep.equal([404])
+          resolve()
+        }
 
-      this.controller.getFileHead(this.req, this.res)
+        ctx.controller.getFileHead(ctx.req, ctx.res)
+      })
     })
   })
 })

+ 155 - 105
services/web/test/unit/src/LinkedFiles/LinkedFilesController.test.mjs

@@ -1,155 +1,205 @@
+import { vi } from 'vitest'
 import { expect } from 'chai'
-import esmock from 'esmock'
 import sinon from 'sinon'
 const modulePath =
   '../../../../app/src/Features/LinkedFiles/LinkedFilesController.mjs'
 
 describe('LinkedFilesController', 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(async function () {
-    this.userId = 'user-id'
-    this.Agent = {
+  beforeEach(async function (ctx) {
+    ctx.userId = 'user-id'
+    ctx.Agent = {
       promises: {
         createLinkedFile: sinon.stub().resolves(),
         refreshLinkedFile: sinon.stub().resolves(),
       },
     }
-    this.projectId = 'projectId'
-    this.provider = 'provider'
-    this.name = 'linked-file-name'
-    this.data = { customAgentData: 'foo' }
-    this.LinkedFilesHandler = {
+    ctx.projectId = 'projectId'
+    ctx.provider = 'provider'
+    ctx.fileName = 'linked-file-name'
+    ctx.data = { customAgentData: 'foo' }
+    ctx.LinkedFilesHandler = {
       promises: {
         getFileById: sinon.stub(),
       },
     }
-    this.AnalyticsManager = {}
-    this.SessionManager = {
-      getLoggedInUserId: sinon.stub().returns(this.userId),
+    ctx.AnalyticsManager = {}
+    ctx.SessionManager = {
+      getLoggedInUserId: sinon.stub().returns(ctx.userId),
     }
-    this.EditorRealTimeController = {}
-    this.ReferencesHandler = {}
-    this.UrlAgent = {}
-    this.ProjectFileAgent = {}
-    this.ProjectOutputFileAgent = {}
-    this.EditorController = {}
-    this.ProjectLocator = {}
-    this.logger = {
+    ctx.EditorRealTimeController = {}
+    ctx.ReferencesHandler = {}
+    ctx.UrlAgent = {}
+    ctx.ProjectFileAgent = {}
+    ctx.ProjectOutputFileAgent = {}
+    ctx.EditorController = {}
+    ctx.ProjectLocator = {}
+    ctx.logger = {
       error: sinon.stub(),
     }
-    this.settings = { enabledLinkedFileTypes: [] }
-    this.LinkedFilesController = await esmock.strict(modulePath, {
-      '.../../../../app/src/Features/Authentication/SessionManager':
-        this.SessionManager,
-      '../../../../app/src/Features/Analytics/AnalyticsManager':
-        this.AnalyticsManager,
-      '../../../../app/src/Features/LinkedFiles/LinkedFilesHandler':
-        this.LinkedFilesHandler,
-      '../../../../app/src/Features/Editor/EditorRealTimeController':
-        this.EditorRealTimeController,
-      '../../../../app/src/Features/References/ReferencesHandler':
-        this.ReferencesHandler,
-      '../../../../app/src/Features/LinkedFiles/UrlAgent': this.UrlAgent,
-      '../../../../app/src/Features/LinkedFiles/ProjectFileAgent':
-        this.ProjectFileAgent,
-      '../../../../app/src/Features/LinkedFiles/ProjectOutputFileAgent':
-        this.ProjectOutputFileAgent,
-      '../../../../app/src/Features/Editor/EditorController':
-        this.EditorController,
-      '../../../../app/src/Features/Project/ProjectLocator':
-        this.ProjectLocator,
-      '@overleaf/logger': this.logger,
-      '@overleaf/settings': this.settings,
-    })
-    this.LinkedFilesController._getAgent = sinon.stub().resolves(this.Agent)
+    ctx.settings = { enabledLinkedFileTypes: [] }
+
+    vi.doMock(
+      '.../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager',
+      () => ({
+        default: ctx.AnalyticsManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/LinkedFiles/LinkedFilesHandler',
+      () => ({
+        default: ctx.LinkedFilesHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Editor/EditorRealTimeController',
+      () => ({
+        default: ctx.EditorRealTimeController,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/References/ReferencesHandler',
+      () => ({
+        default: ctx.ReferencesHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/LinkedFiles/UrlAgent', () => ({
+      default: ctx.UrlAgent,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/LinkedFiles/ProjectFileAgent',
+      () => ({
+        default: ctx.ProjectFileAgent,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/LinkedFiles/ProjectOutputFileAgent',
+      () => ({
+        default: ctx.ProjectOutputFileAgent,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({
+      default: ctx.EditorController,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
+      default: ctx.ProjectLocator,
+    }))
+
+    vi.doMock('@overleaf/logger', () => ({
+      default: ctx.logger,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    ctx.LinkedFilesController = (await import(modulePath)).default
+    ctx.LinkedFilesController._getAgent = sinon.stub().resolves(ctx.Agent)
   })
 
   describe('createLinkedFile', function () {
-    beforeEach(function () {
-      this.req = {
-        params: { project_id: this.projectId },
+    beforeEach(function (ctx) {
+      ctx.req = {
+        params: { project_id: ctx.projectId },
         body: {
-          name: this.name,
-          provider: this.provider,
-          data: this.data,
+          name: ctx.fileName,
+          provider: ctx.provider,
+          data: ctx.data,
         },
       }
-      this.next = sinon.stub()
+      ctx.next = sinon.stub()
     })
 
-    it('sets importedAt timestamp on linkedFileData', function (done) {
-      this.next = sinon.stub().callsFake(() => done('unexpected error'))
-      this.res = {
-        json: () => {
-          expect(this.Agent.promises.createLinkedFile).to.have.been.calledWith(
-            this.projectId,
-            { ...this.data, importedAt: this.fakeTime.toISOString() },
-            this.name,
-            undefined,
-            this.userId
-          )
-          done()
-        },
-      }
-      this.LinkedFilesController.createLinkedFile(this.req, this.res, this.next)
+    it('sets importedAt timestamp on linkedFileData', function (ctx) {
+      return new Promise(resolve => {
+        ctx.next = sinon.stub().callsFake(() => resolve('unexpected error'))
+        ctx.res = {
+          json: () => {
+            expect(ctx.Agent.promises.createLinkedFile).to.have.been.calledWith(
+              ctx.projectId,
+              { ...ctx.data, importedAt: ctx.fakeTime.toISOString() },
+              ctx.fileName,
+              undefined,
+              ctx.userId
+            )
+            resolve()
+          },
+        }
+        ctx.LinkedFilesController.createLinkedFile(ctx.req, ctx.res, ctx.next)
+      })
     })
   })
   describe('refreshLinkedFiles', function () {
-    beforeEach(function () {
-      this.data.provider = this.provider
-      this.file = {
-        name: this.name,
+    beforeEach(function (ctx) {
+      ctx.data.provider = ctx.provider
+      ctx.file = {
+        name: ctx.fileName,
         linkedFileData: {
-          ...this.data,
+          ...ctx.data,
           importedAt: new Date(2020, 1, 1).toISOString(),
         },
       }
-      this.LinkedFilesHandler.promises.getFileById
-        .withArgs(this.projectId, 'file-id')
+      ctx.LinkedFilesHandler.promises.getFileById
+        .withArgs(ctx.projectId, 'file-id')
         .resolves({
-          file: this.file,
+          file: ctx.file,
           path: 'fake-path',
           parentFolder: {
             _id: 'parent-folder-id',
           },
         })
-      this.req = {
-        params: { project_id: this.projectId, file_id: 'file-id' },
+      ctx.req = {
+        params: { project_id: ctx.projectId, file_id: 'file-id' },
         body: {},
       }
-      this.next = sinon.stub()
+      ctx.next = sinon.stub()
     })
 
-    it('resets importedAt timestamp on linkedFileData', function (done) {
-      this.next = sinon.stub().callsFake(() => done('unexpected error'))
-      this.res = {
-        json: () => {
-          expect(this.Agent.promises.refreshLinkedFile).to.have.been.calledWith(
-            this.projectId,
-            {
-              ...this.data,
-              importedAt: this.fakeTime.toISOString(),
-            },
-            this.name,
-            'parent-folder-id',
-            this.userId
-          )
-          done()
-        },
-      }
-      this.LinkedFilesController.refreshLinkedFile(
-        this.req,
-        this.res,
-        this.next
-      )
+    it('resets importedAt timestamp on linkedFileData', function (ctx) {
+      return new Promise(resolve => {
+        ctx.next = sinon.stub().callsFake(() => resolve('unexpected error'))
+        ctx.res = {
+          json: () => {
+            expect(
+              ctx.Agent.promises.refreshLinkedFile
+            ).to.have.been.calledWith(
+              ctx.projectId,
+              {
+                ...ctx.data,
+                importedAt: ctx.fakeTime.toISOString(),
+              },
+              ctx.name,
+              'parent-folder-id',
+              ctx.userId
+            )
+            resolve()
+          },
+        }
+        ctx.LinkedFilesController.refreshLinkedFile(ctx.req, ctx.res, ctx.next)
+      })
     })
   })
 })

+ 42 - 35
services/web/test/unit/src/Metadata/MetaController.test.mjs

@@ -1,31 +1,38 @@
+import { vi } from 'vitest'
 import { expect } from 'chai'
 import sinon from 'sinon'
-import esmock from 'esmock'
 import MockResponse from '../helpers/MockResponse.js'
 const modulePath = '../../../../app/src/Features/Metadata/MetaController.mjs'
 
 describe('MetaController', function () {
-  beforeEach(async function () {
-    this.EditorRealTimeController = {
+  beforeEach(async function (ctx) {
+    ctx.EditorRealTimeController = {
       emitToRoom: sinon.stub(),
     }
 
-    this.MetaHandler = {
+    ctx.MetaHandler = {
       promises: {
         getAllMetaForProject: sinon.stub(),
         getMetaForDoc: sinon.stub(),
       },
     }
 
-    this.MetadataController = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Editor/EditorRealTimeController':
-        this.EditorRealTimeController,
-      '../../../../app/src/Features/Metadata/MetaHandler': this.MetaHandler,
-    })
+    vi.doMock(
+      '../../../../app/src/Features/Editor/EditorRealTimeController',
+      () => ({
+        default: ctx.EditorRealTimeController,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Metadata/MetaHandler', () => ({
+      default: ctx.MetaHandler,
+    }))
+
+    ctx.MetadataController = (await import(modulePath)).default
   })
 
   describe('getMetadata', function () {
-    it('should respond with json', async function () {
+    it('should respond with json', async function (ctx) {
       const projectMeta = {
         'doc-id': {
           labels: ['foo'],
@@ -34,7 +41,7 @@ describe('MetaController', function () {
         },
       }
 
-      this.MetaHandler.promises.getAllMetaForProject = sinon
+      ctx.MetaHandler.promises.getAllMetaForProject = sinon
         .stub()
         .resolves(projectMeta)
 
@@ -42,9 +49,9 @@ describe('MetaController', function () {
       const res = new MockResponse()
       const next = sinon.stub()
 
-      await this.MetadataController.getMetadata(req, res, next)
+      await ctx.MetadataController.getMetadata(req, res, next)
 
-      this.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith(
+      ctx.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith(
         'project-id'
       )
       res.json.should.have.been.calledOnceWith({
@@ -54,8 +61,8 @@ describe('MetaController', function () {
       next.should.not.have.been.called
     })
 
-    it('should handle an error', async function () {
-      this.MetaHandler.promises.getAllMetaForProject = sinon
+    it('should handle an error', async function (ctx) {
+      ctx.MetaHandler.promises.getAllMetaForProject = sinon
         .stub()
         .throws(new Error('woops'))
 
@@ -63,9 +70,9 @@ describe('MetaController', function () {
       const res = new MockResponse()
       const next = sinon.stub()
 
-      await this.MetadataController.getMetadata(req, res, next)
+      await ctx.MetadataController.getMetadata(req, res, next)
 
-      this.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith(
+      ctx.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith(
         'project-id'
       )
       res.json.should.not.have.been.called
@@ -74,14 +81,14 @@ describe('MetaController', function () {
   })
 
   describe('broadcastMetadataForDoc', function () {
-    it('should broadcast on broadcast:true ', async function () {
-      this.MetaHandler.promises.getMetaForDoc = sinon.stub().resolves({
+    it('should broadcast on broadcast:true ', async function (ctx) {
+      ctx.MetaHandler.promises.getMetaForDoc = sinon.stub().resolves({
         labels: ['foo'],
         packages: { a: { commands: [] } },
         packageNames: ['a'],
       })
 
-      this.EditorRealTimeController.emitToRoom = sinon.stub()
+      ctx.EditorRealTimeController.emitToRoom = sinon.stub()
 
       const req = {
         params: { project_id: 'project-id', doc_id: 'doc-id' },
@@ -90,32 +97,32 @@ describe('MetaController', function () {
       const res = new MockResponse()
       const next = sinon.stub()
 
-      await this.MetadataController.broadcastMetadataForDoc(req, res, next)
+      await ctx.MetadataController.broadcastMetadataForDoc(req, res, next)
 
-      this.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
+      ctx.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
         'project-id'
       )
       res.json.should.not.have.been.called
       res.sendStatus.should.have.been.calledOnceWith(200)
       next.should.not.have.been.called
 
-      this.EditorRealTimeController.emitToRoom.should.have.been.calledOnce
-      const { lastCall } = this.EditorRealTimeController.emitToRoom
+      ctx.EditorRealTimeController.emitToRoom.should.have.been.calledOnce
+      const { lastCall } = ctx.EditorRealTimeController.emitToRoom
       expect(lastCall.args[0]).to.equal('project-id')
       expect(lastCall.args[1]).to.equal('broadcastDocMeta')
       expect(lastCall.args[2]).to.have.all.keys(['docId', 'meta'])
     })
 
-    it('should return json on broadcast:false ', async function () {
+    it('should return json on broadcast:false ', async function (ctx) {
       const docMeta = {
         labels: ['foo'],
         packages: { a: [] },
         packageNames: ['a'],
       }
 
-      this.MetaHandler.promises.getMetaForDoc = sinon.stub().resolves(docMeta)
+      ctx.MetaHandler.promises.getMetaForDoc = sinon.stub().resolves(docMeta)
 
-      this.EditorRealTimeController.emitToRoom = sinon.stub()
+      ctx.EditorRealTimeController.emitToRoom = sinon.stub()
 
       const req = {
         params: { project_id: 'project-id', doc_id: 'doc-id' },
@@ -124,12 +131,12 @@ describe('MetaController', function () {
       const res = new MockResponse()
       const next = sinon.stub()
 
-      await this.MetadataController.broadcastMetadataForDoc(req, res, next)
+      await ctx.MetadataController.broadcastMetadataForDoc(req, res, next)
 
-      this.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
+      ctx.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
         'project-id'
       )
-      this.EditorRealTimeController.emitToRoom.should.not.have.been.called
+      ctx.EditorRealTimeController.emitToRoom.should.not.have.been.called
       res.json.should.have.been.calledOnceWith({
         docId: 'doc-id',
         meta: docMeta,
@@ -137,12 +144,12 @@ describe('MetaController', function () {
       next.should.not.have.been.called
     })
 
-    it('should handle an error', async function () {
-      this.MetaHandler.promises.getMetaForDoc = sinon
+    it('should handle an error', async function (ctx) {
+      ctx.MetaHandler.promises.getMetaForDoc = sinon
         .stub()
         .throws(new Error('woops'))
 
-      this.EditorRealTimeController.emitToRoom = sinon.stub()
+      ctx.EditorRealTimeController.emitToRoom = sinon.stub()
 
       const req = {
         params: { project_id: 'project-id', doc_id: 'doc-id' },
@@ -151,9 +158,9 @@ describe('MetaController', function () {
       const res = new MockResponse()
       const next = sinon.stub()
 
-      await this.MetadataController.broadcastMetadataForDoc(req, res, next)
+      await ctx.MetadataController.broadcastMetadataForDoc(req, res, next)
 
-      this.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
+      ctx.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
         'project-id'
       )
       res.json.should.not.have.been.called

+ 53 - 42
services/web/test/unit/src/Metadata/MetaHandler.test.mjs

@@ -1,15 +1,15 @@
+import { vi } from 'vitest'
 import { expect } from 'chai'
 import sinon from 'sinon'
-import esmock from 'esmock'
 
 const modulePath = '../../../../app/src/Features/Metadata/MetaHandler.mjs'
 
 describe('MetaHandler', function () {
-  beforeEach(async function () {
-    this.projectId = 'someprojectid'
-    this.docId = 'somedocid'
+  beforeEach(async function (ctx) {
+    ctx.projectId = 'someprojectid'
+    ctx.docId = 'somedocid'
 
-    this.lines = [
+    ctx.lines = [
       '\\usepackage{ foo, bar }',
       '\\usepackage{baz}',
       'one',
@@ -23,28 +23,28 @@ describe('MetaHandler', function () {
       '\\begin{lstlisting}[label={lst:foo},caption={Test}]', // lst:foo should be in the returned labels
     ]
 
-    this.docs = {
-      [this.docId]: {
-        _id: this.docId,
-        lines: this.lines,
+    ctx.docs = {
+      [ctx.docId]: {
+        _id: ctx.docId,
+        lines: ctx.lines,
       },
     }
 
-    this.ProjectEntityHandler = {
+    ctx.ProjectEntityHandler = {
       promises: {
-        getAllDocs: sinon.stub().resolves(this.docs),
-        getDoc: sinon.stub().resolves(this.docs[this.docId]),
+        getAllDocs: sinon.stub().resolves(ctx.docs),
+        getDoc: sinon.stub().resolves(ctx.docs[ctx.docId]),
       },
     }
 
-    this.DocumentUpdaterHandler = {
+    ctx.DocumentUpdaterHandler = {
       promises: {
         flushDocToMongo: sinon.stub().resolves(),
         flushProjectToMongo: sinon.stub().resolves(),
       },
     }
 
-    this.packageMapping = {
+    ctx.packageMapping = {
       foo: [
         {
           caption: '\\bar',
@@ -69,47 +69,58 @@ describe('MetaHandler', function () {
       ],
     }
 
-    this.MetaHandler = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Project/ProjectEntityHandler':
-        this.ProjectEntityHandler,
-      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler':
-        this.DocumentUpdaterHandler,
-      '../../../../app/src/Features/Metadata/packageMapping':
-        this.packageMapping,
-    })
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityHandler',
+      () => ({
+        default: ctx.ProjectEntityHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
+      () => ({
+        default: ctx.DocumentUpdaterHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Metadata/packageMapping', () => ({
+      default: ctx.packageMapping,
+    }))
+
+    ctx.MetaHandler = (await import(modulePath)).default
   })
 
   describe('getMetaForDoc', function () {
-    it('should extract all the labels and packages', async function () {
-      const result = await this.MetaHandler.promises.getMetaForDoc(
-        this.projectId,
-        this.docId
+    it('should extract all the labels and packages', async function (ctx) {
+      const result = await ctx.MetaHandler.promises.getMetaForDoc(
+        ctx.projectId,
+        ctx.docId
       )
 
       expect(result).to.deep.equal({
         labels: ['aaa', 'ccc', 'ddd', 'e,f,g', 'foo', 'lst:foo'],
         packages: {
-          foo: this.packageMapping.foo,
-          baz: this.packageMapping.baz,
+          foo: ctx.packageMapping.foo,
+          baz: ctx.packageMapping.baz,
         },
         packageNames: ['foo', 'bar', 'baz'],
       })
 
-      this.DocumentUpdaterHandler.promises.flushDocToMongo.should.be.calledWith(
-        this.projectId,
-        this.docId
+      ctx.DocumentUpdaterHandler.promises.flushDocToMongo.should.be.calledWith(
+        ctx.projectId,
+        ctx.docId
       )
 
-      this.ProjectEntityHandler.promises.getDoc.should.be.calledWith(
-        this.projectId,
-        this.docId
+      ctx.ProjectEntityHandler.promises.getDoc.should.be.calledWith(
+        ctx.projectId,
+        ctx.docId
       )
     })
   })
 
   describe('getAllMetaForProject', function () {
-    it('should extract all metadata', async function () {
-      this.ProjectEntityHandler.promises.getAllDocs = sinon.stub().resolves({
+    it('should extract all metadata', async function (ctx) {
+      ctx.ProjectEntityHandler.promises.getAllDocs = sinon.stub().resolves({
         doc_one: {
           _id: 'id_one',
           lines: ['one', '\\label{aaa} two', 'three'],
@@ -142,8 +153,8 @@ describe('MetaHandler', function () {
         },
       })
 
-      const result = await this.MetaHandler.promises.getAllMetaForProject(
-        this.projectId
+      const result = await ctx.MetaHandler.promises.getAllMetaForProject(
+        ctx.projectId
       )
 
       expect(result).to.deep.equal({
@@ -206,12 +217,12 @@ describe('MetaHandler', function () {
         },
       })
 
-      this.DocumentUpdaterHandler.promises.flushProjectToMongo.should.be.calledWith(
-        this.projectId
+      ctx.DocumentUpdaterHandler.promises.flushProjectToMongo.should.be.calledWith(
+        ctx.projectId
       )
 
-      this.ProjectEntityHandler.promises.getAllDocs.should.be.calledWith(
-        this.projectId
+      ctx.ProjectEntityHandler.promises.getAllDocs.should.be.calledWith(
+        ctx.projectId
       )
     })
   })

+ 45 - 31
services/web/test/unit/src/Notifications/NotificationsController.test.mjs

@@ -1,4 +1,4 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 
 const modulePath = new URL(
@@ -10,12 +10,12 @@ describe('NotificationsController', function () {
   const userId = '123nd3ijdks'
   const notificationId = '123njdskj9jlk'
 
-  beforeEach(async function () {
-    this.handler = {
+  beforeEach(async function (ctx) {
+    ctx.handler = {
       getUserNotifications: sinon.stub().callsArgWith(1),
       markAsRead: sinon.stub().callsArgWith(2),
     }
-    this.req = {
+    ctx.req = {
       params: {
         notificationId,
       },
@@ -28,39 +28,53 @@ describe('NotificationsController', function () {
         translate() {},
       },
     }
-    this.AuthenticationController = {
-      getLoggedInUserId: sinon.stub().returns(this.req.session.user._id),
+    ctx.AuthenticationController = {
+      getLoggedInUserId: sinon.stub().returns(ctx.req.session.user._id),
     }
-    this.controller = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Notifications/NotificationsHandler':
-        this.handler,
-      '../../../../app/src/Features/Authentication/AuthenticationController':
-        this.AuthenticationController,
-    })
+
+    vi.doMock(
+      '../../../../app/src/Features/Notifications/NotificationsHandler',
+      () => ({
+        default: ctx.handler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/AuthenticationController',
+      () => ({
+        default: ctx.AuthenticationController,
+      })
+    )
+
+    ctx.controller = (await import(modulePath)).default
   })
 
-  it('should ask the handler for all unread notifications', function (done) {
-    const allNotifications = [{ _id: notificationId, user_id: userId }]
-    this.handler.getUserNotifications = sinon
-      .stub()
-      .callsArgWith(1, null, allNotifications)
-    this.controller.getAllUnreadNotifications(this.req, {
-      json: body => {
-        body.should.deep.equal(allNotifications)
-        this.handler.getUserNotifications.calledWith(userId).should.equal(true)
-        done()
-      },
+  it('should ask the handler for all unread notifications', function (ctx) {
+    return new Promise(resolve => {
+      const allNotifications = [{ _id: notificationId, user_id: userId }]
+      ctx.handler.getUserNotifications = sinon
+        .stub()
+        .callsArgWith(1, null, allNotifications)
+      ctx.controller.getAllUnreadNotifications(ctx.req, {
+        json: body => {
+          body.should.deep.equal(allNotifications)
+          ctx.handler.getUserNotifications.calledWith(userId).should.equal(true)
+          resolve()
+        },
+      })
     })
   })
 
-  it('should send a delete request when a delete has been received to mark a notification', function (done) {
-    this.controller.markNotificationAsRead(this.req, {
-      sendStatus: () => {
-        this.handler.markAsRead
-          .calledWith(userId, notificationId)
-          .should.equal(true)
-        done()
-      },
+  it('should send a delete request when a delete has been received to mark a notification', function (ctx) {
+    return new Promise(resolve => {
+      ctx.controller.markNotificationAsRead(ctx.req, {
+        sendStatus: () => {
+          ctx.handler.markAsRead
+            .calledWith(userId, notificationId)
+            .should.equal(true)
+          resolve()
+        },
+      })
     })
   })
 })

+ 414 - 330
services/web/test/unit/src/PasswordReset/PasswordResetController.test.mjs

@@ -1,4 +1,4 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import { expect } from 'chai'
 import MockResponse from '../helpers/MockResponse.js'
@@ -9,16 +9,16 @@ const MODULE_PATH = new URL(
 ).pathname
 
 describe('PasswordResetController', function () {
-  beforeEach(async function () {
-    this.email = 'bob@bob.com'
-    this.user_id = 'mock-user-id'
-    this.token = 'my security token that was emailed to me'
-    this.password = 'my new password'
-    this.req = {
+  beforeEach(async function (ctx) {
+    ctx.email = 'bob@bob.com'
+    ctx.user_id = 'mock-user-id'
+    ctx.token = 'my security token that was emailed to me'
+    ctx.password = 'my new password'
+    ctx.req = {
       body: {
-        email: this.email,
-        passwordResetToken: this.token,
-        password: this.password,
+        email: ctx.email,
+        passwordResetToken: ctx.token,
+        password: ctx.password,
       },
       i18n: {
         translate() {
@@ -28,456 +28,540 @@ describe('PasswordResetController', function () {
       session: {},
       query: {},
     }
-    this.res = new MockResponse()
+    ctx.res = new MockResponse()
 
-    this.settings = {}
-    this.PasswordResetHandler = {
+    ctx.settings = {}
+    ctx.PasswordResetHandler = {
       generateAndEmailResetToken: sinon.stub(),
       promises: {
         generateAndEmailResetToken: sinon.stub(),
         setNewUserPassword: sinon.stub().resolves({
           found: true,
           reset: true,
-          userID: this.user_id,
+          userID: ctx.user_id,
           mustReconfirm: true,
         }),
         getUserForPasswordResetToken: sinon
           .stub()
-          .withArgs(this.token)
+          .withArgs(ctx.token)
           .resolves({
-            user: { _id: this.user_id },
+            user: { _id: ctx.user_id },
             remainingPeeks: 1,
           }),
       },
     }
-    this.UserSessionsManager = {
+    ctx.UserSessionsManager = {
       promises: {
         removeSessionsFromRedis: sinon.stub().resolves(),
       },
     }
-    this.UserUpdater = {
+    ctx.UserUpdater = {
       promises: {
         removeReconfirmFlag: sinon.stub().resolves(),
       },
     }
-    this.SplitTestHandler = {
+    ctx.SplitTestHandler = {
       promises: {
         getAssignment: sinon.stub().resolves('default'),
       },
     }
-    this.PasswordResetController = await esmock.strict(MODULE_PATH, {
-      '@overleaf/settings': this.settings,
-      '../../../../app/src/Features/PasswordReset/PasswordResetHandler':
-        this.PasswordResetHandler,
-      '../../../../app/src/Features/Authentication/AuthenticationManager': {
-        validatePassword: sinon.stub().returns(null),
-      },
-      '../../../../app/src/Features/Authentication/AuthenticationController':
-        (this.AuthenticationController = {
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/PasswordReset/PasswordResetHandler',
+      () => ({
+        default: ctx.PasswordResetHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/AuthenticationManager',
+      () => ({
+        default: {
+          validatePassword: sinon.stub().returns(null),
+        },
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/AuthenticationController',
+      () => ({
+        default: (ctx.AuthenticationController = {
           getLoggedInUserId: sinon.stub(),
           finishLogin: sinon.stub(),
           setAuditInfo: sinon.stub(),
         }),
-      '../../../../app/src/Features/User/UserGetter': (this.UserGetter = {
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: (ctx.UserGetter = {
         promises: {
           getUser: sinon.stub(),
         },
       }),
-      '../../../../app/src/Features/User/UserSessionsManager':
-        this.UserSessionsManager,
-      '../../../../app/src/Features/User/UserUpdater': this.UserUpdater,
-      '../../../../app/src/Features/SplitTests/SplitTestHandler':
-        this.SplitTestHandler,
-    })
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({
+      default: ctx.UserSessionsManager,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
+      default: ctx.UserUpdater,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+
+    ctx.PasswordResetController = (await import(MODULE_PATH)).default
   })
 
   describe('requestReset', function () {
-    it('should tell the handler to process that email', function (done) {
-      this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
-        'primary'
-      )
-      this.res.callback = () => {
-        this.res.statusCode.should.equal(200)
-        this.res.json.calledWith(sinon.match.has('message')).should.equal(true)
-        expect(
-          this.PasswordResetHandler.promises.generateAndEmailResetToken.lastCall
-            .args[0]
-        ).equal(this.email)
-        done()
-      }
-      this.PasswordResetController.requestReset(this.req, this.res)
+    it('should tell the handler to process that email', function (ctx) {
+      return new Promise(resolve => {
+        ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
+          'primary'
+        )
+        ctx.res.callback = () => {
+          ctx.res.statusCode.should.equal(200)
+          ctx.res.json.calledWith(sinon.match.has('message')).should.equal(true)
+          expect(
+            ctx.PasswordResetHandler.promises.generateAndEmailResetToken
+              .lastCall.args[0]
+          ).equal(ctx.email)
+          resolve()
+        }
+        ctx.PasswordResetController.requestReset(ctx.req, ctx.res)
+      })
     })
 
-    it('should send a 500 if there is an error', function (done) {
-      this.PasswordResetHandler.promises.generateAndEmailResetToken.rejects(
-        new Error('error')
-      )
-      this.PasswordResetController.requestReset(this.req, this.res, error => {
-        expect(error).to.exist
-        done()
+    it('should send a 500 if there is an error', function (ctx) {
+      return new Promise(resolve => {
+        ctx.PasswordResetHandler.promises.generateAndEmailResetToken.rejects(
+          new Error('error')
+        )
+        ctx.PasswordResetController.requestReset(ctx.req, ctx.res, error => {
+          expect(error).to.exist
+          resolve()
+        })
       })
     })
 
-    it("should send a 404 if the email doesn't exist", function (done) {
-      this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
-        null
-      )
-      this.res.callback = () => {
-        this.res.statusCode.should.equal(404)
-        this.res.json.calledWith(sinon.match.has('message')).should.equal(true)
-        done()
-      }
-      this.PasswordResetController.requestReset(this.req, this.res)
+    it("should send a 404 if the email doesn't exist", function (ctx) {
+      return new Promise(resolve => {
+        ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
+          null
+        )
+        ctx.res.callback = () => {
+          ctx.res.statusCode.should.equal(404)
+          ctx.res.json.calledWith(sinon.match.has('message')).should.equal(true)
+          resolve()
+        }
+        ctx.PasswordResetController.requestReset(ctx.req, ctx.res)
+      })
     })
 
-    it('should send a 404 if the email is registered as a secondard email', function (done) {
-      this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
-        'secondary'
-      )
-      this.res.callback = () => {
-        this.res.statusCode.should.equal(404)
-        this.res.json.calledWith(sinon.match.has('message')).should.equal(true)
-        done()
-      }
-      this.PasswordResetController.requestReset(this.req, this.res)
+    it('should send a 404 if the email is registered as a secondard email', function (ctx) {
+      return new Promise(resolve => {
+        ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
+          'secondary'
+        )
+        ctx.res.callback = () => {
+          ctx.res.statusCode.should.equal(404)
+          ctx.res.json.calledWith(sinon.match.has('message')).should.equal(true)
+          resolve()
+        }
+        ctx.PasswordResetController.requestReset(ctx.req, ctx.res)
+      })
     })
 
-    it('should normalize the email address', function (done) {
-      this.email = '  UPperCaseEMAILWithSpacesAround@example.Com '
-      this.req.body.email = this.email
-      this.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
-        'primary'
-      )
-      this.res.callback = () => {
-        this.res.statusCode.should.equal(200)
-        this.res.json.calledWith(sinon.match.has('message')).should.equal(true)
-        done()
-      }
-      this.PasswordResetController.requestReset(this.req, this.res)
+    it('should normalize the email address', function (ctx) {
+      return new Promise(resolve => {
+        ctx.email = '  UPperCaseEMAILWithSpacesAround@example.Com '
+        ctx.req.body.email = ctx.email
+        ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
+          'primary'
+        )
+        ctx.res.callback = () => {
+          ctx.res.statusCode.should.equal(200)
+          ctx.res.json.calledWith(sinon.match.has('message')).should.equal(true)
+          resolve()
+        }
+        ctx.PasswordResetController.requestReset(ctx.req, ctx.res)
+      })
     })
   })
 
   describe('setNewUserPassword', function () {
-    beforeEach(function () {
-      this.req.session.resetToken = this.token
+    beforeEach(function (ctx) {
+      ctx.req.session.resetToken = ctx.token
     })
 
-    it('should tell the user handler to reset the password', function (done) {
-      this.res.sendStatus = code => {
-        code.should.equal(200)
-        this.PasswordResetHandler.promises.setNewUserPassword
-          .calledWith(this.token, this.password)
-          .should.equal(true)
-        done()
-      }
-      this.PasswordResetController.setNewUserPassword(this.req, this.res)
+    it('should tell the user handler to reset the password', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.sendStatus = code => {
+          code.should.equal(200)
+          ctx.PasswordResetHandler.promises.setNewUserPassword
+            .calledWith(ctx.token, ctx.password)
+            .should.equal(true)
+          resolve()
+        }
+        ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+      })
     })
 
-    it('should preserve spaces in the password', function (done) {
-      this.password = this.req.body.password = ' oh! clever! spaces around!   '
-      this.res.sendStatus = code => {
-        code.should.equal(200)
-        this.PasswordResetHandler.promises.setNewUserPassword.should.have.been.calledWith(
-          this.token,
-          this.password
-        )
-        done()
-      }
-      this.PasswordResetController.setNewUserPassword(this.req, this.res)
+    it('should preserve spaces in the password', function (ctx) {
+      return new Promise(resolve => {
+        ctx.password = ctx.req.body.password = ' oh! clever! spaces around!   '
+        ctx.res.sendStatus = code => {
+          code.should.equal(200)
+          ctx.PasswordResetHandler.promises.setNewUserPassword.should.have.been.calledWith(
+            ctx.token,
+            ctx.password
+          )
+          resolve()
+        }
+        ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+      })
     })
 
-    it('should send 404 if the token was not found', function (done) {
-      this.PasswordResetHandler.promises.setNewUserPassword.resolves({
-        found: false,
-        reset: false,
-        userId: this.user_id,
-      })
-      this.res.status = code => {
-        code.should.equal(404)
-        return this.res
-      }
-      this.res.json = data => {
-        data.message.key.should.equal('token-expired')
-        done()
-      }
-      this.PasswordResetController.setNewUserPassword(this.req, this.res)
+    it('should send 404 if the token was not found', function (ctx) {
+      return new Promise(resolve => {
+        ctx.PasswordResetHandler.promises.setNewUserPassword.resolves({
+          found: false,
+          reset: false,
+          userId: ctx.user_id,
+        })
+        ctx.res.status = code => {
+          code.should.equal(404)
+          return ctx.res
+        }
+        ctx.res.json = data => {
+          data.message.key.should.equal('token-expired')
+          resolve()
+        }
+        ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+      })
     })
 
-    it('should return 500 if not reset', function (done) {
-      this.PasswordResetHandler.promises.setNewUserPassword.resolves({
-        found: true,
-        reset: false,
-        userId: this.user_id,
-      })
-      this.res.status = code => {
-        code.should.equal(500)
-        return this.res
-      }
-      this.res.json = data => {
-        expect(data.message).to.exist
-        done()
-      }
-      this.PasswordResetController.setNewUserPassword(this.req, this.res)
+    it('should return 500 if not reset', function (ctx) {
+      return new Promise(resolve => {
+        ctx.PasswordResetHandler.promises.setNewUserPassword.resolves({
+          found: true,
+          reset: false,
+          userId: ctx.user_id,
+        })
+        ctx.res.status = code => {
+          code.should.equal(500)
+          return ctx.res
+        }
+        ctx.res.json = data => {
+          expect(data.message).to.exist
+          resolve()
+        }
+        ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+      })
     })
 
-    it('should return 400 (Bad Request) if there is no password', function (done) {
-      this.req.body.password = ''
-      this.res.status = code => {
-        code.should.equal(400)
-        return this.res
-      }
-      this.res.json = data => {
-        data.message.key.should.equal('invalid-password')
-        this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
-          false
-        )
-        done()
-      }
-      this.PasswordResetController.setNewUserPassword(this.req, this.res)
+    it('should return 400 (Bad Request) if there is no password', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body.password = ''
+        ctx.res.status = code => {
+          code.should.equal(400)
+          return ctx.res
+        }
+        ctx.res.json = data => {
+          data.message.key.should.equal('invalid-password')
+          ctx.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
+            false
+          )
+          resolve()
+        }
+        ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+      })
     })
 
-    it('should return 400 (Bad Request) if there is no passwordResetToken', function (done) {
-      this.req.body.passwordResetToken = ''
-      this.res.status = code => {
-        code.should.equal(400)
-        return this.res
-      }
-      this.res.json = data => {
-        data.message.key.should.equal('invalid-password')
-        this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
-          false
-        )
-        done()
-      }
-      this.PasswordResetController.setNewUserPassword(this.req, this.res)
+    it('should return 400 (Bad Request) if there is no passwordResetToken', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body.passwordResetToken = ''
+        ctx.res.status = code => {
+          code.should.equal(400)
+          return ctx.res
+        }
+        ctx.res.json = data => {
+          data.message.key.should.equal('invalid-password')
+          ctx.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
+            false
+          )
+          resolve()
+        }
+        ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+      })
     })
 
-    it('should return 400 (Bad Request) if the password is invalid', function (done) {
-      this.req.body.password = 'correct horse battery staple'
-      const err = new Error('bad')
-      err.name = 'InvalidPasswordError'
-      this.PasswordResetHandler.promises.setNewUserPassword.rejects(err)
-      this.res.status = code => {
-        code.should.equal(400)
-        return this.res
-      }
-      this.res.json = data => {
-        data.message.key.should.equal('invalid-password')
-        this.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
-          true
-        )
-        done()
-      }
-      this.PasswordResetController.setNewUserPassword(this.req, this.res)
+    it('should return 400 (Bad Request) if the password is invalid', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body.password = 'correct horse battery staple'
+        const err = new Error('bad')
+        err.name = 'InvalidPasswordError'
+        ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(err)
+        ctx.res.status = code => {
+          code.should.equal(400)
+          return ctx.res
+        }
+        ctx.res.json = data => {
+          data.message.key.should.equal('invalid-password')
+          ctx.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
+            true
+          )
+          resolve()
+        }
+        ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+      })
     })
 
-    it('should clear sessions', function (done) {
-      this.res.sendStatus = code => {
-        this.UserSessionsManager.promises.removeSessionsFromRedis.callCount.should.equal(
-          1
-        )
-        done()
-      }
-      this.PasswordResetController.setNewUserPassword(this.req, this.res)
+    it('should clear sessions', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.sendStatus = code => {
+          ctx.UserSessionsManager.promises.removeSessionsFromRedis.callCount.should.equal(
+            1
+          )
+          resolve()
+        }
+        ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+      })
     })
 
-    it('should call removeReconfirmFlag if user.must_reconfirm', function (done) {
-      this.res.sendStatus = code => {
-        this.UserUpdater.promises.removeReconfirmFlag.callCount.should.equal(1)
-        done()
-      }
-      this.PasswordResetController.setNewUserPassword(this.req, this.res)
+    it('should call removeReconfirmFlag if user.must_reconfirm', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.sendStatus = code => {
+          ctx.UserUpdater.promises.removeReconfirmFlag.callCount.should.equal(1)
+          resolve()
+        }
+        ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+      })
     })
 
     describe('catch errors', function () {
-      it('should return 404 for NotFoundError', function (done) {
-        const anError = new Error('oops')
-        anError.name = 'NotFoundError'
-        this.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
-        this.res.status = code => {
-          code.should.equal(404)
-          return this.res
-        }
-        this.res.json = data => {
-          data.message.key.should.equal('token-expired')
-          done()
-        }
-        this.PasswordResetController.setNewUserPassword(this.req, this.res)
+      it('should return 404 for NotFoundError', function (ctx) {
+        return new Promise(resolve => {
+          const anError = new Error('oops')
+          anError.name = 'NotFoundError'
+          ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
+          ctx.res.status = code => {
+            code.should.equal(404)
+            return ctx.res
+          }
+          ctx.res.json = data => {
+            data.message.key.should.equal('token-expired')
+            resolve()
+          }
+          ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+        })
       })
-      it('should return 400 for InvalidPasswordError', function (done) {
-        const anError = new Error('oops')
-        anError.name = 'InvalidPasswordError'
-        this.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
-        this.res.status = code => {
-          code.should.equal(400)
-          return this.res
-        }
-        this.res.json = data => {
-          data.message.key.should.equal('invalid-password')
-          done()
-        }
-        this.PasswordResetController.setNewUserPassword(this.req, this.res)
+      it('should return 400 for InvalidPasswordError', function (ctx) {
+        return new Promise(resolve => {
+          const anError = new Error('oops')
+          anError.name = 'InvalidPasswordError'
+          ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
+          ctx.res.status = code => {
+            code.should.equal(400)
+            return ctx.res
+          }
+          ctx.res.json = data => {
+            data.message.key.should.equal('invalid-password')
+            resolve()
+          }
+          ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+        })
       })
-      it('should return 500 for other errors', function (done) {
-        const anError = new Error('oops')
-        this.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
-        this.res.status = code => {
-          code.should.equal(500)
-          return this.res
-        }
-        this.res.json = data => {
-          expect(data.message).to.exist
-          done()
-        }
-        this.res.sendStatus = code => {
-          code.should.equal(500)
-          done()
-        }
-        this.PasswordResetController.setNewUserPassword(this.req, this.res)
+      it('should return 500 for other errors', function (ctx) {
+        return new Promise(resolve => {
+          const anError = new Error('oops')
+          ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
+          ctx.res.status = code => {
+            code.should.equal(500)
+            return ctx.res
+          }
+          ctx.res.json = data => {
+            expect(data.message).to.exist
+            resolve()
+          }
+          ctx.res.sendStatus = code => {
+            code.should.equal(500)
+            resolve()
+          }
+          ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
+        })
       })
     })
 
     describe('when doLoginAfterPasswordReset is set', function () {
-      beforeEach(function () {
-        this.user = {
-          _id: this.userId,
+      beforeEach(function (ctx) {
+        ctx.user = {
+          _id: ctx.userId,
           email: 'joe@example.com',
         }
-        this.UserGetter.promises.getUser.resolves(this.user)
-        this.req.session.doLoginAfterPasswordReset = 'true'
+        ctx.UserGetter.promises.getUser.resolves(ctx.user)
+        ctx.req.session.doLoginAfterPasswordReset = 'true'
       })
 
-      it('should login user', function (done) {
-        this.AuthenticationController.finishLogin.callsFake((...args) => {
-          expect(args[0]).to.equal(this.user)
-          done()
+      it('should login user', function (ctx) {
+        return new Promise(resolve => {
+          ctx.AuthenticationController.finishLogin.callsFake((...args) => {
+            expect(args[0]).to.equal(ctx.user)
+            resolve()
+          })
+          ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
         })
-        this.PasswordResetController.setNewUserPassword(this.req, this.res)
       })
     })
   })
 
   describe('renderSetPasswordForm', function () {
     describe('with token in query-string', function () {
-      beforeEach(function () {
-        this.req.query.passwordResetToken = this.token
+      beforeEach(function (ctx) {
+        ctx.req.query.passwordResetToken = ctx.token
       })
 
-      it('should set session.resetToken and redirect', function (done) {
-        this.req.session.should.not.have.property('resetToken')
-        this.res.redirect = path => {
-          path.should.equal('/user/password/set')
-          this.req.session.resetToken.should.equal(this.token)
-          done()
-        }
-        this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
+      it('should set session.resetToken and redirect', function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.session.should.not.have.property('resetToken')
+          ctx.res.redirect = path => {
+            path.should.equal('/user/password/set')
+            ctx.req.session.resetToken.should.equal(ctx.token)
+            resolve()
+          }
+          ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
+        })
       })
     })
 
     describe('with expired token in query', function () {
-      beforeEach(function () {
-        this.req.query.passwordResetToken = this.token
-        this.PasswordResetHandler.promises.getUserForPasswordResetToken = sinon
+      beforeEach(function (ctx) {
+        ctx.req.query.passwordResetToken = ctx.token
+        ctx.PasswordResetHandler.promises.getUserForPasswordResetToken = sinon
           .stub()
-          .withArgs(this.token)
-          .resolves({ user: { _id: this.user_id }, remainingPeeks: 0 })
+          .withArgs(ctx.token)
+          .resolves({ user: { _id: ctx.user_id }, remainingPeeks: 0 })
       })
 
-      it('should redirect to the reset request page with an error message', function (done) {
-        this.res.redirect = path => {
-          path.should.equal('/user/password/reset?error=token_expired')
-          this.req.session.should.not.have.property('resetToken')
-          done()
-        }
-        this.res.render = (templatePath, options) => {
-          done('should not render')
-        }
-        this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
+      it('should redirect to the reset request page with an error message', function (ctx) {
+        return new Promise(resolve => {
+          ctx.res.redirect = path => {
+            path.should.equal('/user/password/reset?error=token_expired')
+            ctx.req.session.should.not.have.property('resetToken')
+            resolve()
+          }
+          ctx.res.render = (templatePath, options) => {
+            resolve('should not render')
+          }
+          ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
+        })
       })
     })
 
     describe('with token and email in query-string', function () {
-      beforeEach(function () {
-        this.req.query.passwordResetToken = this.token
-        this.req.query.email = 'foo@bar.com'
+      beforeEach(function (ctx) {
+        ctx.req.query.passwordResetToken = ctx.token
+        ctx.req.query.email = 'foo@bar.com'
       })
 
-      it('should set session.resetToken and redirect with email', function (done) {
-        this.req.session.should.not.have.property('resetToken')
-        this.res.redirect = path => {
-          path.should.equal('/user/password/set?email=foo%40bar.com')
-          this.req.session.resetToken.should.equal(this.token)
-          done()
-        }
-        this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
+      it('should set session.resetToken and redirect with email', function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.session.should.not.have.property('resetToken')
+          ctx.res.redirect = path => {
+            path.should.equal('/user/password/set?email=foo%40bar.com')
+            ctx.req.session.resetToken.should.equal(ctx.token)
+            resolve()
+          }
+          ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
+        })
       })
     })
 
     describe('with token and invalid email in query-string', function () {
-      beforeEach(function () {
-        this.req.query.passwordResetToken = this.token
-        this.req.query.email = 'not-an-email'
+      beforeEach(function (ctx) {
+        ctx.req.query.passwordResetToken = ctx.token
+        ctx.req.query.email = 'not-an-email'
       })
 
-      it('should set session.resetToken and redirect without email', function (done) {
-        this.req.session.should.not.have.property('resetToken')
-        this.res.redirect = path => {
-          path.should.equal('/user/password/set')
-          this.req.session.resetToken.should.equal(this.token)
-          done()
-        }
-        this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
+      it('should set session.resetToken and redirect without email', function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.session.should.not.have.property('resetToken')
+          ctx.res.redirect = path => {
+            path.should.equal('/user/password/set')
+            ctx.req.session.resetToken.should.equal(ctx.token)
+            resolve()
+          }
+          ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
+        })
       })
     })
 
     describe('with token and non-string email in query-string', function () {
-      beforeEach(function () {
-        this.req.query.passwordResetToken = this.token
-        this.req.query.email = { foo: 'bar' }
+      beforeEach(function (ctx) {
+        ctx.req.query.passwordResetToken = ctx.token
+        ctx.req.query.email = { foo: 'bar' }
       })
 
-      it('should set session.resetToken and redirect without email', function (done) {
-        this.req.session.should.not.have.property('resetToken')
-        this.res.redirect = path => {
-          path.should.equal('/user/password/set')
-          this.req.session.resetToken.should.equal(this.token)
-          done()
-        }
-        this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
+      it('should set session.resetToken and redirect without email', function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.session.should.not.have.property('resetToken')
+          ctx.res.redirect = path => {
+            path.should.equal('/user/password/set')
+            ctx.req.session.resetToken.should.equal(ctx.token)
+            resolve()
+          }
+          ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
+        })
       })
     })
 
     describe('without a token in query-string', function () {
       describe('with token in session', function () {
-        beforeEach(function () {
-          this.req.session.resetToken = this.token
+        beforeEach(function (ctx) {
+          ctx.req.session.resetToken = ctx.token
         })
 
-        it('should render the page, passing the reset token', function (done) {
-          this.res.render = (templatePath, options) => {
-            options.passwordResetToken.should.equal(this.token)
-            done()
-          }
-          this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
+        it('should render the page, passing the reset token', function (ctx) {
+          return new Promise(resolve => {
+            ctx.res.render = (templatePath, options) => {
+              options.passwordResetToken.should.equal(ctx.token)
+              resolve()
+            }
+            ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
+          })
         })
 
-        it('should clear the req.session.resetToken', function (done) {
-          this.res.render = (templatePath, options) => {
-            this.req.session.should.not.have.property('resetToken')
-            done()
-          }
-          this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
+        it('should clear the req.session.resetToken', function (ctx) {
+          return new Promise(resolve => {
+            ctx.res.render = (templatePath, options) => {
+              ctx.req.session.should.not.have.property('resetToken')
+              resolve()
+            }
+            ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
+          })
         })
       })
 
       describe('without a token in session', function () {
-        it('should redirect to the reset request page', function (done) {
-          this.res.redirect = path => {
-            path.should.equal('/user/password/reset')
-            this.req.session.should.not.have.property('resetToken')
-            done()
-          }
-          this.PasswordResetController.renderSetPasswordForm(this.req, this.res)
+        it('should redirect to the reset request page', function (ctx) {
+          return new Promise(resolve => {
+            ctx.res.redirect = path => {
+              path.should.equal('/user/password/reset')
+              ctx.req.session.should.not.have.property('resetToken')
+              resolve()
+            }
+            ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
+          })
         })
       })
     })

+ 387 - 333
services/web/test/unit/src/PasswordReset/PasswordResetHandler.test.mjs

@@ -1,4 +1,4 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import { expect } from 'chai'
 const modulePath = new URL(
@@ -7,9 +7,9 @@ const modulePath = new URL(
 ).pathname
 
 describe('PasswordResetHandler', function () {
-  beforeEach(async function () {
-    this.settings = { siteUrl: 'https://www.overleaf.com' }
-    this.OneTimeTokenHandler = {
+  beforeEach(async function (ctx) {
+    ctx.settings = { siteUrl: 'https://www.overleaf.com' }
+    ctx.OneTimeTokenHandler = {
       promises: {
         getNewToken: sinon.stub(),
         peekValueFromToken: sinon.stub(),
@@ -17,7 +17,7 @@ describe('PasswordResetHandler', function () {
       peekValueFromToken: sinon.stub(),
       expireToken: sinon.stub(),
     }
-    this.UserGetter = {
+    ctx.UserGetter = {
       getUserByMainEmail: sinon.stub(),
       getUser: sinon.stub(),
       promises: {
@@ -25,123 +25,153 @@ describe('PasswordResetHandler', function () {
         getUserByMainEmail: sinon.stub(),
       },
     }
-    this.EmailHandler = { promises: { sendEmail: sinon.stub() } }
-    this.AuthenticationManager = {
+    ctx.EmailHandler = { promises: { sendEmail: sinon.stub() } }
+    ctx.AuthenticationManager = {
       setUserPasswordInV2: sinon.stub(),
       promises: {
         setUserPassword: sinon.stub().resolves(),
       },
     }
-    this.PasswordResetHandler = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/User/UserAuditLogHandler':
-        (this.UserAuditLogHandler = {
-          promises: {
-            addEntry: sinon.stub().resolves(),
-          },
-        }),
-      '../../../../app/src/Features/User/UserGetter': this.UserGetter,
-      '../../../../app/src/Features/Security/OneTimeTokenHandler':
-        this.OneTimeTokenHandler,
-      '../../../../app/src/Features/Email/EmailHandler': this.EmailHandler,
-      '../../../../app/src/Features/Authentication/AuthenticationManager':
-        this.AuthenticationManager,
-      '@overleaf/settings': this.settings,
-      '../../../../app/src/Features/Authorization/PermissionsManager':
-        (this.PermissionsManager = {
+
+    vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
+      default: (ctx.UserAuditLogHandler = {
+        promises: {
+          addEntry: sinon.stub().resolves(),
+        },
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Security/OneTimeTokenHandler',
+      () => ({
+        default: ctx.OneTimeTokenHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
+      default: ctx.EmailHandler,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/AuthenticationManager',
+      () => ({
+        default: ctx.AuthenticationManager,
+      })
+    )
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authorization/PermissionsManager',
+      () => ({
+        default: (ctx.PermissionsManager = {
           promises: {
             assertUserPermissions: sinon.stub(),
           },
         }),
-    })
-    this.token = '12312321i'
-    this.user_id = 'user_id_here'
-    this.user = { email: (this.email = 'bob@bob.com'), _id: this.user_id }
-    this.password = 'my great secret password'
-    this.callback = sinon.stub()
+      })
+    )
+
+    ctx.PasswordResetHandler = (await import(modulePath)).default
+    ctx.token = '12312321i'
+    ctx.user_id = 'user_id_here'
+    ctx.user = { email: (ctx.email = 'bob@bob.com'), _id: ctx.user_id }
+    ctx.password = 'my great secret password'
+    ctx.callback = sinon.stub()
     // this should not have any effect now
-    this.settings.overleaf = true
+    ctx.settings.overleaf = true
   })
 
-  afterEach(function () {
-    this.settings.overleaf = false
+  afterEach(function (ctx) {
+    ctx.settings.overleaf = false
   })
 
   describe('generateAndEmailResetToken', function () {
-    it('should check the user exists', function () {
-      this.UserGetter.promises.getUserByAnyEmail.resolves()
-      this.PasswordResetHandler.generateAndEmailResetToken(
-        this.user.email,
-        this.callback
+    it('should check the user exists', function (ctx) {
+      ctx.UserGetter.promises.getUserByAnyEmail.resolves()
+      ctx.PasswordResetHandler.generateAndEmailResetToken(
+        ctx.user.email,
+        ctx.callback
       )
-      this.UserGetter.promises.getUserByAnyEmail.should.have.been.calledWith(
-        this.user.email
+      ctx.UserGetter.promises.getUserByAnyEmail.should.have.been.calledWith(
+        ctx.user.email
       )
     })
 
-    it('should send the email with the token', function (done) {
-      this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
-      this.OneTimeTokenHandler.promises.getNewToken.resolves(this.token)
-      this.EmailHandler.promises.sendEmail.resolves()
-      this.PasswordResetHandler.generateAndEmailResetToken(
-        this.user.email,
-        (err, status) => {
-          expect(err).to.not.exist
-          this.EmailHandler.promises.sendEmail.called.should.equal(true)
-          status.should.equal('primary')
-          const args = this.EmailHandler.promises.sendEmail.args[0]
-          args[0].should.equal('passwordResetRequested')
-          args[1].setNewPasswordUrl.should.equal(
-            `${this.settings.siteUrl}/user/password/set?passwordResetToken=${
-              this.token
-            }&email=${encodeURIComponent(this.user.email)}`
-          )
-          done()
-        }
-      )
+    it('should send the email with the token', function (ctx) {
+      return new Promise(resolve => {
+        ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
+        ctx.OneTimeTokenHandler.promises.getNewToken.resolves(ctx.token)
+        ctx.EmailHandler.promises.sendEmail.resolves()
+        ctx.PasswordResetHandler.generateAndEmailResetToken(
+          ctx.user.email,
+          (err, status) => {
+            expect(err).to.not.exist
+            ctx.EmailHandler.promises.sendEmail.called.should.equal(true)
+            status.should.equal('primary')
+            const args = ctx.EmailHandler.promises.sendEmail.args[0]
+            args[0].should.equal('passwordResetRequested')
+            args[1].setNewPasswordUrl.should.equal(
+              `${ctx.settings.siteUrl}/user/password/set?passwordResetToken=${
+                ctx.token
+              }&email=${encodeURIComponent(ctx.user.email)}`
+            )
+            resolve()
+          }
+        )
+      })
     })
 
-    it('should return errors from getUserByAnyEmail', function (done) {
-      const err = new Error('oops')
-      this.UserGetter.promises.getUserByAnyEmail.rejects(err)
-      this.PasswordResetHandler.generateAndEmailResetToken(
-        this.user.email,
-        err => {
-          expect(err).to.equal(err)
-          done()
-        }
-      )
+    it('should return errors from getUserByAnyEmail', function (ctx) {
+      return new Promise(resolve => {
+        const err = new Error('oops')
+        ctx.UserGetter.promises.getUserByAnyEmail.rejects(err)
+        ctx.PasswordResetHandler.generateAndEmailResetToken(
+          ctx.user.email,
+          err => {
+            expect(err).to.equal(err)
+            resolve()
+          }
+        )
+      })
     })
 
     describe('when the email exists', function () {
       let result
-      beforeEach(async function () {
-        this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
-        this.OneTimeTokenHandler.promises.getNewToken.resolves(this.token)
-        this.EmailHandler.promises.sendEmail.resolves()
+      beforeEach(async function (ctx) {
+        ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
+        ctx.OneTimeTokenHandler.promises.getNewToken.resolves(ctx.token)
+        ctx.EmailHandler.promises.sendEmail.resolves()
         result =
-          await this.PasswordResetHandler.promises.generateAndEmailResetToken(
-            this.email
+          await ctx.PasswordResetHandler.promises.generateAndEmailResetToken(
+            ctx.email
           )
       })
 
-      it('should set the password token data to the user id and email', function () {
-        this.OneTimeTokenHandler.promises.getNewToken.should.have.been.calledWith(
+      it('should set the password token data to the user id and email', function (ctx) {
+        ctx.OneTimeTokenHandler.promises.getNewToken.should.have.been.calledWith(
           'password',
           {
-            email: this.email,
-            user_id: this.user._id,
+            email: ctx.email,
+            user_id: ctx.user._id,
           }
         )
       })
 
-      it('should send an email with the token', function () {
-        this.EmailHandler.promises.sendEmail.called.should.equal(true)
-        const args = this.EmailHandler.promises.sendEmail.args[0]
+      it('should send an email with the token', function (ctx) {
+        ctx.EmailHandler.promises.sendEmail.called.should.equal(true)
+        const args = ctx.EmailHandler.promises.sendEmail.args[0]
         args[0].should.equal('passwordResetRequested')
         args[1].setNewPasswordUrl.should.equal(
-          `${this.settings.siteUrl}/user/password/set?passwordResetToken=${
-            this.token
-          }&email=${encodeURIComponent(this.user.email)}`
+          `${ctx.settings.siteUrl}/user/password/set?passwordResetToken=${
+            ctx.token
+          }&email=${encodeURIComponent(ctx.user.email)}`
         )
       })
 
@@ -152,20 +182,20 @@ describe('PasswordResetHandler', function () {
 
     describe("when the email doesn't exist", function () {
       let result
-      beforeEach(async function () {
-        this.UserGetter.promises.getUserByAnyEmail.resolves(null)
+      beforeEach(async function (ctx) {
+        ctx.UserGetter.promises.getUserByAnyEmail.resolves(null)
         result =
-          await this.PasswordResetHandler.promises.generateAndEmailResetToken(
-            this.email
+          await ctx.PasswordResetHandler.promises.generateAndEmailResetToken(
+            ctx.email
           )
       })
 
-      it('should not set the password token data', function () {
-        this.OneTimeTokenHandler.promises.getNewToken.called.should.equal(false)
+      it('should not set the password token data', function (ctx) {
+        ctx.OneTimeTokenHandler.promises.getNewToken.called.should.equal(false)
       })
 
-      it('should send an email with the token', function () {
-        this.EmailHandler.promises.sendEmail.called.should.equal(false)
+      it('should send an email with the token', function (ctx) {
+        ctx.EmailHandler.promises.sendEmail.called.should.equal(false)
       })
 
       it('should return status == null', function () {
@@ -175,20 +205,20 @@ describe('PasswordResetHandler', function () {
 
     describe('when the email is a secondary email', function () {
       let result
-      beforeEach(async function () {
-        this.UserGetter.promises.getUserByAnyEmail.resolves(this.user)
+      beforeEach(async function (ctx) {
+        ctx.UserGetter.promises.getUserByAnyEmail.resolves(ctx.user)
         result =
-          await this.PasswordResetHandler.promises.generateAndEmailResetToken(
+          await ctx.PasswordResetHandler.promises.generateAndEmailResetToken(
             'secondary@email.com'
           )
       })
 
-      it('should not set the password token data', function () {
-        this.OneTimeTokenHandler.promises.getNewToken.called.should.equal(false)
+      it('should not set the password token data', function (ctx) {
+        ctx.OneTimeTokenHandler.promises.getNewToken.called.should.equal(false)
       })
 
-      it('should not send an email with the token', function () {
-        this.EmailHandler.promises.sendEmail.called.should.equal(false)
+      it('should not send an email with the token', function (ctx) {
+        ctx.EmailHandler.promises.sendEmail.called.should.equal(false)
       })
 
       it('should return status == secondary', function () {
@@ -198,19 +228,19 @@ describe('PasswordResetHandler', function () {
   })
 
   describe('setNewUserPassword', function () {
-    beforeEach(function () {
-      this.auditLog = { ip: '0:0:0:0' }
+    beforeEach(function (ctx) {
+      ctx.auditLog = { ip: '0:0:0:0' }
     })
     describe('when no data is found', function () {
-      beforeEach(function () {
-        this.OneTimeTokenHandler.promises.peekValueFromToken.resolves(null)
+      beforeEach(function (ctx) {
+        ctx.OneTimeTokenHandler.promises.peekValueFromToken.resolves(null)
       })
 
-      it('should return found == false and reset == false', function () {
-        this.PasswordResetHandler.setNewUserPassword(
-          this.token,
-          this.password,
-          this.auditLog,
+      it('should return found == false and reset == false', function (ctx) {
+        ctx.PasswordResetHandler.setNewUserPassword(
+          ctx.token,
+          ctx.password,
+          ctx.auditLog,
           (error, result) => {
             expect(error).to.not.exist
             expect(result).to.deep.equal({
@@ -224,202 +254,220 @@ describe('PasswordResetHandler', function () {
     })
 
     describe('when the token has a user_id and email', function () {
-      beforeEach(function () {
-        this.OneTimeTokenHandler.promises.peekValueFromToken.resolves({
+      beforeEach(function (ctx) {
+        ctx.OneTimeTokenHandler.promises.peekValueFromToken.resolves({
           data: {
-            user_id: this.user._id,
-            email: this.email,
+            user_id: ctx.user._id,
+            email: ctx.email,
           },
         })
-        this.AuthenticationManager.promises.setUserPassword
-          .withArgs(this.user, this.password)
+        ctx.AuthenticationManager.promises.setUserPassword
+          .withArgs(ctx.user, ctx.password)
           .resolves(true)
-        this.OneTimeTokenHandler.expireToken = sinon
-          .stub()
-          .callsArgWith(2, null)
+        ctx.OneTimeTokenHandler.expireToken = sinon.stub().callsArgWith(2, null)
       })
 
       describe('when no user is found with this email', function () {
-        beforeEach(function () {
-          this.UserGetter.getUserByMainEmail
-            .withArgs(this.email)
+        beforeEach(function (ctx) {
+          ctx.UserGetter.getUserByMainEmail
+            .withArgs(ctx.email)
             .yields(null, null)
         })
 
-        it('should return found == false and reset == false', function (done) {
-          this.PasswordResetHandler.setNewUserPassword(
-            this.token,
-            this.password,
-            this.auditLog,
-            (err, result) => {
-              const { found, reset } = result
-              expect(err).to.not.exist
-              expect(found).to.be.false
-              expect(reset).to.be.false
-              expect(this.OneTimeTokenHandler.expireToken.callCount).to.equal(0)
-              done()
-            }
-          )
+        it('should return found == false and reset == false', function (ctx) {
+          return new Promise(resolve => {
+            ctx.PasswordResetHandler.setNewUserPassword(
+              ctx.token,
+              ctx.password,
+              ctx.auditLog,
+              (err, result) => {
+                const { found, reset } = result
+                expect(err).to.not.exist
+                expect(found).to.be.false
+                expect(reset).to.be.false
+                expect(ctx.OneTimeTokenHandler.expireToken.callCount).to.equal(
+                  0
+                )
+                resolve()
+              }
+            )
+          })
         })
       })
 
       describe("when the email and user don't match", function () {
-        beforeEach(function () {
-          this.UserGetter.getUserByMainEmail
-            .withArgs(this.email)
-            .yields(null, { _id: 'not-the-same', email: this.email })
-          this.OneTimeTokenHandler.expireToken.callsArgWith(2, null)
+        beforeEach(function (ctx) {
+          ctx.UserGetter.getUserByMainEmail
+            .withArgs(ctx.email)
+            .yields(null, { _id: 'not-the-same', email: ctx.email })
+          ctx.OneTimeTokenHandler.expireToken.callsArgWith(2, null)
         })
 
-        it('should return found == false and reset == false', function (done) {
-          this.PasswordResetHandler.setNewUserPassword(
-            this.token,
-            this.password,
-            this.auditLog,
-            (err, result) => {
-              const { found, reset } = result
-              expect(err).to.not.exist
-              expect(found).to.be.false
-              expect(reset).to.be.false
-              expect(this.OneTimeTokenHandler.expireToken.callCount).to.equal(0)
-              done()
-            }
-          )
+        it('should return found == false and reset == false', function (ctx) {
+          return new Promise(resolve => {
+            ctx.PasswordResetHandler.setNewUserPassword(
+              ctx.token,
+              ctx.password,
+              ctx.auditLog,
+              (err, result) => {
+                const { found, reset } = result
+                expect(err).to.not.exist
+                expect(found).to.be.false
+                expect(reset).to.be.false
+                expect(ctx.OneTimeTokenHandler.expireToken.callCount).to.equal(
+                  0
+                )
+                resolve()
+              }
+            )
+          })
         })
       })
 
       describe('when the email and user match', function () {
         describe('success', function () {
-          beforeEach(function () {
-            this.UserGetter.promises.getUserByMainEmail.resolves(this.user)
-            this.OneTimeTokenHandler.expireToken = sinon
+          beforeEach(function (ctx) {
+            ctx.UserGetter.promises.getUserByMainEmail.resolves(ctx.user)
+            ctx.OneTimeTokenHandler.expireToken = sinon
               .stub()
               .callsArgWith(2, null)
           })
 
-          it('should update the user audit log', function (done) {
-            this.PasswordResetHandler.setNewUserPassword(
-              this.token,
-              this.password,
-              this.auditLog,
-              (error, result) => {
-                sinon.assert.calledWith(
-                  this.UserAuditLogHandler.promises.addEntry,
-                  this.user_id,
-                  'reset-password',
-                  undefined,
-                  this.auditLog.ip,
-                  { token: this.token.substring(0, 10) }
-                )
-                expect(error).to.not.exist
-                done()
-              }
-            )
+          it('should update the user audit log', function (ctx) {
+            return new Promise(resolve => {
+              ctx.PasswordResetHandler.setNewUserPassword(
+                ctx.token,
+                ctx.password,
+                ctx.auditLog,
+                (error, result) => {
+                  sinon.assert.calledWith(
+                    ctx.UserAuditLogHandler.promises.addEntry,
+                    ctx.user_id,
+                    'reset-password',
+                    undefined,
+                    ctx.auditLog.ip,
+                    { token: ctx.token.substring(0, 10) }
+                  )
+                  expect(error).to.not.exist
+                  resolve()
+                }
+              )
+            })
           })
 
-          it('should return reset == true and the user id', function (done) {
-            this.PasswordResetHandler.setNewUserPassword(
-              this.token,
-              this.password,
-              this.auditLog,
-              (err, result) => {
-                const { reset, userId } = result
-                expect(err).to.not.exist
-                expect(reset).to.be.true
-                expect(userId).to.equal(this.user._id)
-                done()
-              }
-            )
+          it('should return reset == true and the user id', function (ctx) {
+            return new Promise(resolve => {
+              ctx.PasswordResetHandler.setNewUserPassword(
+                ctx.token,
+                ctx.password,
+                ctx.auditLog,
+                (err, result) => {
+                  const { reset, userId } = result
+                  expect(err).to.not.exist
+                  expect(reset).to.be.true
+                  expect(userId).to.equal(ctx.user._id)
+                  resolve()
+                }
+              )
+            })
           })
 
-          it('should expire the token', function (done) {
-            this.PasswordResetHandler.setNewUserPassword(
-              this.token,
-              this.password,
-              this.auditLog,
-              (_err, _result) => {
-                expect(this.OneTimeTokenHandler.expireToken.called).to.equal(
-                  true
-                )
-                done()
-              }
-            )
+          it('should expire the token', function (ctx) {
+            return new Promise(resolve => {
+              ctx.PasswordResetHandler.setNewUserPassword(
+                ctx.token,
+                ctx.password,
+                ctx.auditLog,
+                (_err, _result) => {
+                  expect(ctx.OneTimeTokenHandler.expireToken.called).to.equal(
+                    true
+                  )
+                  resolve()
+                }
+              )
+            })
           })
 
           describe('when logged in', function () {
-            beforeEach(function () {
-              this.auditLog.initiatorId = this.user_id
+            beforeEach(function (ctx) {
+              ctx.auditLog.initiatorId = ctx.user_id
             })
-            it('should update the user audit log with initiatorId', function (done) {
-              this.PasswordResetHandler.setNewUserPassword(
-                this.token,
-                this.password,
-                this.auditLog,
-                (error, result) => {
-                  expect(error).to.not.exist
-                  sinon.assert.calledWith(
-                    this.UserAuditLogHandler.promises.addEntry,
-                    this.user_id,
-                    'reset-password',
-                    this.user_id,
-                    this.auditLog.ip,
-                    { token: this.token.substring(0, 10) }
-                  )
-                  done()
-                }
-              )
+            it('should update the user audit log with initiatorId', function (ctx) {
+              return new Promise(resolve => {
+                ctx.PasswordResetHandler.setNewUserPassword(
+                  ctx.token,
+                  ctx.password,
+                  ctx.auditLog,
+                  (error, result) => {
+                    expect(error).to.not.exist
+                    sinon.assert.calledWith(
+                      ctx.UserAuditLogHandler.promises.addEntry,
+                      ctx.user_id,
+                      'reset-password',
+                      ctx.user_id,
+                      ctx.auditLog.ip,
+                      { token: ctx.token.substring(0, 10) }
+                    )
+                    resolve()
+                  }
+                )
+              })
             })
           })
         })
 
         describe('errors', function () {
           describe('via setUserPassword', function () {
-            beforeEach(function () {
-              this.PasswordResetHandler.promises.getUserForPasswordResetToken =
-                sinon.stub().withArgs(this.token).resolves({ user: this.user })
-              this.AuthenticationManager.promises.setUserPassword
-                .withArgs(this.user, this.password)
+            beforeEach(function (ctx) {
+              ctx.PasswordResetHandler.promises.getUserForPasswordResetToken =
+                sinon.stub().withArgs(ctx.token).resolves({ user: ctx.user })
+              ctx.AuthenticationManager.promises.setUserPassword
+                .withArgs(ctx.user, ctx.password)
                 .rejects()
             })
-            it('should return the error', function (done) {
-              this.PasswordResetHandler.setNewUserPassword(
-                this.token,
-                this.password,
-                this.auditLog,
-                (error, _result) => {
-                  expect(error).to.exist
-                  expect(
-                    this.UserAuditLogHandler.promises.addEntry.callCount
-                  ).to.equal(1)
-                  done()
-                }
-              )
+            it('should return the error', function (ctx) {
+              return new Promise(resolve => {
+                ctx.PasswordResetHandler.setNewUserPassword(
+                  ctx.token,
+                  ctx.password,
+                  ctx.auditLog,
+                  (error, _result) => {
+                    expect(error).to.exist
+                    expect(
+                      ctx.UserAuditLogHandler.promises.addEntry.callCount
+                    ).to.equal(1)
+                    resolve()
+                  }
+                )
+              })
             })
           })
 
           describe('via UserAuditLogHandler', function () {
-            beforeEach(function () {
-              this.PasswordResetHandler.promises.getUserForPasswordResetToken =
-                sinon.stub().withArgs(this.token).resolves({ user: this.user })
-              this.UserAuditLogHandler.promises.addEntry.rejects(
+            beforeEach(function (ctx) {
+              ctx.PasswordResetHandler.promises.getUserForPasswordResetToken =
+                sinon.stub().withArgs(ctx.token).resolves({ user: ctx.user })
+              ctx.UserAuditLogHandler.promises.addEntry.rejects(
                 new Error('oops')
               )
             })
-            it('should return the error', function (done) {
-              this.PasswordResetHandler.setNewUserPassword(
-                this.token,
-                this.password,
-                this.auditLog,
-                (error, _result) => {
-                  expect(error).to.exist
-                  expect(
-                    this.UserAuditLogHandler.promises.addEntry.callCount
-                  ).to.equal(1)
-                  expect(this.AuthenticationManager.promises.setUserPassword).to
-                    .not.have.been.called
-                  done()
-                }
-              )
+            it('should return the error', function (ctx) {
+              return new Promise(resolve => {
+                ctx.PasswordResetHandler.setNewUserPassword(
+                  ctx.token,
+                  ctx.password,
+                  ctx.auditLog,
+                  (error, _result) => {
+                    expect(error).to.exist
+                    expect(
+                      ctx.UserAuditLogHandler.promises.addEntry.callCount
+                    ).to.equal(1)
+                    expect(ctx.AuthenticationManager.promises.setUserPassword)
+                      .to.not.have.been.called
+                    resolve()
+                  }
+                )
+              })
             })
           })
         })
@@ -427,120 +475,126 @@ describe('PasswordResetHandler', function () {
     })
 
     describe('when the token has a v1_user_id and email', function () {
-      beforeEach(function () {
-        this.user.overleaf = { id: 184 }
-        this.OneTimeTokenHandler.promises.peekValueFromToken.resolves({
+      beforeEach(function (ctx) {
+        ctx.user.overleaf = { id: 184 }
+        ctx.OneTimeTokenHandler.promises.peekValueFromToken.resolves({
           data: {
-            v1_user_id: this.user.overleaf.id,
-            email: this.email,
+            v1_user_id: ctx.user.overleaf.id,
+            email: ctx.email,
           },
         })
-        this.AuthenticationManager.promises.setUserPassword
-          .withArgs(this.user, this.password)
+        ctx.AuthenticationManager.promises.setUserPassword
+          .withArgs(ctx.user, ctx.password)
           .resolves(true)
-        this.OneTimeTokenHandler.expireToken = sinon
-          .stub()
-          .callsArgWith(2, null)
+        ctx.OneTimeTokenHandler.expireToken = sinon.stub().callsArgWith(2, null)
       })
 
       describe('when no user is reset with this email', function () {
-        beforeEach(function () {
-          this.UserGetter.getUserByMainEmail
-            .withArgs(this.email)
+        beforeEach(function (ctx) {
+          ctx.UserGetter.getUserByMainEmail
+            .withArgs(ctx.email)
             .yields(null, null)
         })
 
-        it('should return reset == false', function (done) {
-          this.PasswordResetHandler.setNewUserPassword(
-            this.token,
-            this.password,
-            this.auditLog,
-            (err, result) => {
-              const { reset } = result
-              expect(err).to.not.exist
-              expect(reset).to.be.false
-              expect(this.OneTimeTokenHandler.expireToken.called).to.equal(
-                false
-              )
-              done()
-            }
-          )
+        it('should return reset == false', function (ctx) {
+          return new Promise(resolve => {
+            ctx.PasswordResetHandler.setNewUserPassword(
+              ctx.token,
+              ctx.password,
+              ctx.auditLog,
+              (err, result) => {
+                const { reset } = result
+                expect(err).to.not.exist
+                expect(reset).to.be.false
+                expect(ctx.OneTimeTokenHandler.expireToken.called).to.equal(
+                  false
+                )
+                resolve()
+              }
+            )
+          })
         })
       })
 
       describe("when the email and user don't match", function () {
-        beforeEach(function () {
-          this.UserGetter.getUserByMainEmail.withArgs(this.email).yields(null, {
-            _id: this.user._id,
-            email: this.email,
+        beforeEach(function (ctx) {
+          ctx.UserGetter.getUserByMainEmail.withArgs(ctx.email).yields(null, {
+            _id: ctx.user._id,
+            email: ctx.email,
             overleaf: { id: 'not-the-same' },
           })
         })
 
-        it('should return reset == false', function (done) {
-          this.PasswordResetHandler.setNewUserPassword(
-            this.token,
-            this.password,
-            this.auditLog,
-            (err, result) => {
-              const { reset } = result
-              expect(err).to.not.exist
-              expect(reset).to.be.false
-              expect(this.OneTimeTokenHandler.expireToken.called).to.equal(
-                false
-              )
-              done()
-            }
-          )
+        it('should return reset == false', function (ctx) {
+          return new Promise(resolve => {
+            ctx.PasswordResetHandler.setNewUserPassword(
+              ctx.token,
+              ctx.password,
+              ctx.auditLog,
+              (err, result) => {
+                const { reset } = result
+                expect(err).to.not.exist
+                expect(reset).to.be.false
+                expect(ctx.OneTimeTokenHandler.expireToken.called).to.equal(
+                  false
+                )
+                resolve()
+              }
+            )
+          })
         })
       })
 
       describe('when the email and user match', function () {
-        beforeEach(function () {
-          this.UserGetter.promises.getUserByMainEmail.resolves(this.user)
+        beforeEach(function (ctx) {
+          ctx.UserGetter.promises.getUserByMainEmail.resolves(ctx.user)
         })
 
-        it('should return reset == true and the user id', function (done) {
-          this.PasswordResetHandler.setNewUserPassword(
-            this.token,
-            this.password,
-            this.auditLog,
-            (err, result) => {
-              const { reset, userId } = result
-              expect(err).to.not.exist
-              expect(reset).to.be.true
-              expect(userId).to.equal(this.user._id)
-              expect(this.OneTimeTokenHandler.expireToken.called).to.equal(true)
-              done()
-            }
-          )
+        it('should return reset == true and the user id', function (ctx) {
+          return new Promise(resolve => {
+            ctx.PasswordResetHandler.setNewUserPassword(
+              ctx.token,
+              ctx.password,
+              ctx.auditLog,
+              (err, result) => {
+                const { reset, userId } = result
+                expect(err).to.not.exist
+                expect(reset).to.be.true
+                expect(userId).to.equal(ctx.user._id)
+                expect(ctx.OneTimeTokenHandler.expireToken.called).to.equal(
+                  true
+                )
+                resolve()
+              }
+            )
+          })
         })
       })
     })
   })
 
   describe('getUserForPasswordResetToken', function () {
-    beforeEach(function () {
-      this.OneTimeTokenHandler.promises.peekValueFromToken.resolves({
+    beforeEach(function (ctx) {
+      ctx.OneTimeTokenHandler.promises.peekValueFromToken.resolves({
         data: {
-          user_id: this.user._id,
-          email: this.email,
+          user_id: ctx.user._id,
+          email: ctx.email,
         },
         remainingPeeks: 1,
       })
 
-      this.UserGetter.promises.getUserByMainEmail.resolves({
-        _id: this.user._id,
-        email: this.email,
+      ctx.UserGetter.promises.getUserByMainEmail.resolves({
+        _id: ctx.user._id,
+        email: ctx.email,
       })
     })
 
-    it('should returns errors from user permissions', async function () {
+    it('should returns errors from user permissions', async function (ctx) {
       let error
       const err = new Error('nope')
-      this.PermissionsManager.promises.assertUserPermissions.rejects(err)
+      ctx.PermissionsManager.promises.assertUserPermissions.rejects(err)
       try {
-        await this.PasswordResetHandler.promises.getUserForPasswordResetToken(
+        await ctx.PasswordResetHandler.promises.getUserForPasswordResetToken(
           'abc123'
         )
       } catch (e) {
@@ -549,13 +603,13 @@ describe('PasswordResetHandler', function () {
       expect(error).to.deep.equal(error)
     })
 
-    it('returns user when user has permissions and remaining peaks', async function () {
+    it('returns user when user has permissions and remaining peaks', async function (ctx) {
       const result =
-        await this.PasswordResetHandler.promises.getUserForPasswordResetToken(
+        await ctx.PasswordResetHandler.promises.getUserForPasswordResetToken(
           'abc123'
         )
       expect(result).to.deep.equal({
-        user: { _id: this.user._id, email: this.email },
+        user: { _id: ctx.user._id, email: ctx.email },
         remainingPeeks: 1,
       })
     })

+ 20 - 22
services/web/test/unit/src/Project/DocLinesComparitor.test.mjs

@@ -1,16 +1,14 @@
-import esmock from 'esmock'
-
 const modulePath = '../../../../app/src/Features/Project/DocLinesComparitor.mjs'
 
 describe('doc lines comparitor', function () {
-  beforeEach(async function () {
-    this.comparitor = await esmock.strict(modulePath, {})
+  beforeEach(async function (ctx) {
+    ctx.comparitor = (await import(modulePath)).default
   })
 
-  it('should return true when the lines are the same', function () {
+  it('should return true when the lines are the same', function (ctx) {
     const lines1 = ['hello', 'world']
     const lines2 = ['hello', 'world']
-    const result = this.comparitor.areSame(lines1, lines2)
+    const result = ctx.comparitor.areSame(lines1, lines2)
     result.should.equal(true)
   })
   ;[
@@ -23,58 +21,58 @@ describe('doc lines comparitor', function () {
       lines2: ['hello', 'wrld'],
     },
   ].forEach(({ lines1, lines2 }) => {
-    it('should return false when the lines are different', function () {
-      const result = this.comparitor.areSame(lines1, lines2)
+    it('should return false when the lines are different', function (ctx) {
+      const result = ctx.comparitor.areSame(lines1, lines2)
       result.should.equal(false)
     })
   })
 
-  it('should return true when the lines are same', function () {
+  it('should return true when the lines are same', function (ctx) {
     const lines1 = ['hello', 'world']
     const lines2 = ['hello', 'world']
-    const result = this.comparitor.areSame(lines1, lines2)
+    const result = ctx.comparitor.areSame(lines1, lines2)
     result.should.equal(true)
   })
 
-  it('should return false if the doc lines are different in length', function () {
+  it('should return false if the doc lines are different in length', function (ctx) {
     const lines1 = ['hello', 'world']
     const lines2 = ['hello', 'world', 'please']
-    const result = this.comparitor.areSame(lines1, lines2)
+    const result = ctx.comparitor.areSame(lines1, lines2)
     result.should.equal(false)
   })
 
-  it('should return false if the first array is undefined', function () {
+  it('should return false if the first array is undefined', function (ctx) {
     const lines1 = undefined
     const lines2 = ['hello', 'world']
-    const result = this.comparitor.areSame(lines1, lines2)
+    const result = ctx.comparitor.areSame(lines1, lines2)
     result.should.equal(false)
   })
 
-  it('should return false if the second array is undefined', function () {
+  it('should return false if the second array is undefined', function (ctx) {
     const lines1 = ['hello']
     const lines2 = undefined
-    const result = this.comparitor.areSame(lines1, lines2)
+    const result = ctx.comparitor.areSame(lines1, lines2)
     result.should.equal(false)
   })
 
-  it('should return false if the second array is not an array', function () {
+  it('should return false if the second array is not an array', function (ctx) {
     const lines1 = ['hello']
     const lines2 = ''
-    const result = this.comparitor.areSame(lines1, lines2)
+    const result = ctx.comparitor.areSame(lines1, lines2)
     result.should.equal(false)
   })
 
-  it('should return true when comparing equal orchard docs', function () {
+  it('should return true when comparing equal orchard docs', function (ctx) {
     const lines1 = [{ text: 'hello world' }]
     const lines2 = [{ text: 'hello world' }]
-    const result = this.comparitor.areSame(lines1, lines2)
+    const result = ctx.comparitor.areSame(lines1, lines2)
     result.should.equal(true)
   })
 
-  it('should return false when comparing different orchard docs', function () {
+  it('should return false when comparing different orchard docs', function (ctx) {
     const lines1 = [{ text: 'goodbye world' }]
     const lines2 = [{ text: 'hello world' }]
-    const result = this.comparitor.areSame(lines1, lines2)
+    const result = ctx.comparitor.areSame(lines1, lines2)
     result.should.equal(false)
   })
 })

+ 38 - 38
services/web/test/unit/src/Project/ProjectApiController.test.mjs

@@ -1,57 +1,57 @@
-// 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
- */
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 
 const modulePath = '../../../../app/src/Features/Project/ProjectApiController'
 
 describe('Project api controller', function () {
-  beforeEach(async function () {
-    this.ProjectDetailsHandler = { getDetails: sinon.stub() }
-    this.controller = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Project/ProjectDetailsHandler':
-        this.ProjectDetailsHandler,
-    })
-    this.project_id = '321l3j1kjkjl'
-    this.req = {
+  beforeEach(async function (ctx) {
+    ctx.ProjectDetailsHandler = { getDetails: sinon.stub() }
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectDetailsHandler',
+      () => ({
+        default: ctx.ProjectDetailsHandler,
+      })
+    )
+
+    ctx.controller = (await import(modulePath)).default
+    ctx.project_id = '321l3j1kjkjl'
+    ctx.req = {
       params: {
-        project_id: this.project_id,
+        project_id: ctx.project_id,
       },
       session: {
         destroy: sinon.stub(),
       },
     }
-    this.res = {}
-    this.next = sinon.stub()
-    return (this.projDetails = { name: 'something' })
+    ctx.res = {}
+    ctx.next = sinon.stub()
+    return (ctx.projDetails = { name: 'something' })
   })
 
   describe('getProjectDetails', function () {
-    it('should ask the project details handler for proj details', function (done) {
-      this.ProjectDetailsHandler.getDetails.callsArgWith(
-        1,
-        null,
-        this.projDetails
-      )
-      this.res.json = data => {
-        this.ProjectDetailsHandler.getDetails
-          .calledWith(this.project_id)
-          .should.equal(true)
-        data.should.deep.equal(this.projDetails)
-        return done()
-      }
-      return this.controller.getProjectDetails(this.req, this.res)
+    it('should ask the project details handler for proj details', function (ctx) {
+      return new Promise(resolve => {
+        ctx.ProjectDetailsHandler.getDetails.callsArgWith(
+          1,
+          null,
+          ctx.projDetails
+        )
+        ctx.res.json = data => {
+          ctx.ProjectDetailsHandler.getDetails
+            .calledWith(ctx.project_id)
+            .should.equal(true)
+          data.should.deep.equal(ctx.projDetails)
+          return resolve()
+        }
+        return ctx.controller.getProjectDetails(ctx.req, ctx.res)
+      })
     })
 
-    it('should send a 500 if there is an error', function () {
-      this.ProjectDetailsHandler.getDetails.callsArgWith(1, 'error')
-      this.controller.getProjectDetails(this.req, this.res, this.next)
-      return this.next.calledWith('error').should.equal(true)
+    it('should send a 500 if there is an error', function (ctx) {
+      ctx.ProjectDetailsHandler.getDetails.callsArgWith(1, 'error')
+      ctx.controller.getProjectDetails(ctx.req, ctx.res, ctx.next)
+      return ctx.next.calledWith('error').should.equal(true)
     })
   })
 })

File diff suppressed because it is too large
+ 418 - 317
services/web/test/unit/src/Project/ProjectListController.test.mjs


+ 112 - 91
services/web/test/unit/src/Referal/ReferalConnect.test.mjs

@@ -1,132 +1,153 @@
-import esmock from 'esmock'
 const modulePath = new URL(
   '../../../../app/src/Features/Referal/ReferalConnect.mjs',
   import.meta.url
 ).pathname
 
 describe('Referal connect middle wear', function () {
-  beforeEach(async function () {
-    this.connect = await esmock.strict(modulePath, {})
+  beforeEach(async function (ctx) {
+    ctx.connect = (await import(modulePath)).default
   })
 
-  it('should take a referal query string and put it on the session if it exists', function (done) {
-    const req = {
-      query: { referal: '12345' },
-      session: {},
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_id.should.equal(req.query.referal)
-      done()
+  it('should take a referal query string and put it on the session if it exists', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: { referal: '12345' },
+        session: {},
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_id.should.equal(req.query.referal)
+        resolve()
+      })
     })
   })
 
-  it('should not change the referal_id on the session if not in query', function (done) {
-    const req = {
-      query: {},
-      session: { referal_id: 'same' },
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_id.should.equal('same')
-      done()
+  it('should not change the referal_id on the session if not in query', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: {},
+        session: { referal_id: 'same' },
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_id.should.equal('same')
+        resolve()
+      })
     })
   })
 
-  it('should take a facebook referal query string and put it on the session if it exists', function (done) {
-    const req = {
-      query: { fb_ref: '12345' },
-      session: {},
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_id.should.equal(req.query.fb_ref)
-      done()
+  it('should take a facebook referal query string and put it on the session if it exists', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: { fb_ref: '12345' },
+        session: {},
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_id.should.equal(req.query.fb_ref)
+        resolve()
+      })
     })
   })
 
-  it('should map the facebook medium into the session', function (done) {
-    const req = {
-      query: { rm: 'fb' },
-      session: {},
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_medium.should.equal('facebook')
-      done()
+  it('should map the facebook medium into the session', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: { rm: 'fb' },
+        session: {},
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_medium.should.equal('facebook')
+        resolve()
+      })
     })
   })
 
-  it('should map the twitter medium into the session', function (done) {
-    const req = {
-      query: { rm: 't' },
-      session: {},
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_medium.should.equal('twitter')
-      done()
+  it('should map the twitter medium into the session', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: { rm: 't' },
+        session: {},
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_medium.should.equal('twitter')
+        resolve()
+      })
     })
   })
 
-  it('should map the google plus medium into the session', function (done) {
-    const req = {
-      query: { rm: 'gp' },
-      session: {},
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_medium.should.equal('google_plus')
-      done()
+  it('should map the google plus medium into the session', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: { rm: 'gp' },
+        session: {},
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_medium.should.equal('google_plus')
+        resolve()
+      })
     })
   })
 
-  it('should map the email medium into the session', function (done) {
-    const req = {
-      query: { rm: 'e' },
-      session: {},
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_medium.should.equal('email')
-      done()
+  it('should map the email medium into the session', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: { rm: 'e' },
+        session: {},
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_medium.should.equal('email')
+        resolve()
+      })
     })
   })
 
-  it('should map the direct medium into the session', function (done) {
-    const req = {
-      query: { rm: 'd' },
-      session: {},
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_medium.should.equal('direct')
-      done()
+  it('should map the direct medium into the session', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: { rm: 'd' },
+        session: {},
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_medium.should.equal('direct')
+        resolve()
+      })
     })
   })
 
-  it('should map the bonus source into the session', function (done) {
-    const req = {
-      query: { rs: 'b' },
-      session: {},
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_source.should.equal('bonus')
-      done()
+  it('should map the bonus source into the session', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: { rs: 'b' },
+        session: {},
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_source.should.equal('bonus')
+        resolve()
+      })
     })
   })
 
-  it('should map the public share source into the session', function (done) {
-    const req = {
-      query: { rs: 'ps' },
-      session: {},
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_source.should.equal('public_share')
-      done()
+  it('should map the public share source into the session', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: { rs: 'ps' },
+        session: {},
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_source.should.equal('public_share')
+        resolve()
+      })
     })
   })
 
-  it('should map the collaborator invite into the session', function (done) {
-    const req = {
-      query: { rs: 'ci' },
-      session: {},
-    }
-    this.connect.use(req, {}, () => {
-      req.session.referal_source.should.equal('collaborator_invite')
-      done()
+  it('should map the collaborator invite into the session', function (ctx) {
+    return new Promise(resolve => {
+      const req = {
+        query: { rs: 'ci' },
+        session: {},
+      }
+      ctx.connect.use(req, {}, () => {
+        req.session.referal_source.should.equal('collaborator_invite')
+        resolve()
+      })
     })
   })
 })

+ 4 - 8
services/web/test/unit/src/Referal/ReferalController.test.mjs

@@ -1,11 +1,7 @@
-import esmock from 'esmock'
-const modulePath = new URL(
-  '../../../../app/src/Features/Referal/ReferalController.js',
-  import.meta.url
-).pathname
+const modulePath = '../../../../app/src/Features/Referal/ReferalController.js'
 
-describe('Referal controller', function () {
-  beforeEach(async function () {
-    this.controller = await esmock.strict(modulePath, {})
+describe.skip('Referal controller', function () {
+  beforeEach(async function (ctx) {
+    ctx.controller = (await import(modulePath)).default
   })
 })

+ 26 - 28
services/web/test/unit/src/Referal/ReferalHandler.test.mjs

@@ -1,88 +1,86 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import { expect } from 'chai'
 import sinon from 'sinon'
-const modulePath = new URL(
-  '../../../../app/src/Features/Referal/ReferalHandler.mjs',
-  import.meta.url
-).pathname
+const modulePath = '../../../../app/src/Features/Referal/ReferalHandler.mjs'
 
 describe('Referal handler', function () {
-  beforeEach(async function () {
-    this.User = {
+  beforeEach(async function (ctx) {
+    ctx.User = {
       findById: sinon.stub().returns({
         exec: sinon.stub(),
       }),
     }
-    this.handler = await esmock.strict(modulePath, {
-      '../../../../app/src/models/User': {
-        User: this.User,
-      },
-    })
-    this.user_id = '12313'
+
+    vi.doMock('../../../../app/src/models/User', () => ({
+      User: ctx.User,
+    }))
+
+    ctx.handler = (await import(modulePath)).default
+    ctx.user_id = '12313'
   })
 
   describe('getting refered user_ids', function () {
-    it('should get the user from mongo and return the refered users array', async function () {
+    it('should get the user from mongo and return the refered users array', async function (ctx) {
       const user = {
         refered_users: ['1234', '312312', '3213129'],
         refered_user_count: 3,
       }
-      this.User.findById.returns({
+      ctx.User.findById.returns({
         exec: sinon.stub().resolves(user),
       })
 
       const {
         referedUsers: passedReferedUserIds,
         referedUserCount: passedReferedUserCount,
-      } = await this.handler.promises.getReferedUsers(this.user_id)
+      } = await ctx.handler.promises.getReferedUsers(ctx.user_id)
 
       passedReferedUserIds.should.deep.equal(user.refered_users)
       passedReferedUserCount.should.equal(3)
     })
 
-    it('should return an empty array if it is not set', async function () {
+    it('should return an empty array if it is not set', async function (ctx) {
       const user = {}
-      this.User.findById.returns({
+      ctx.User.findById.returns({
         exec: sinon.stub().resolves(user),
       })
 
       const { referedUsers: passedReferedUserIds } =
-        await this.handler.promises.getReferedUsers(this.user_id)
+        await ctx.handler.promises.getReferedUsers(ctx.user_id)
 
       passedReferedUserIds.length.should.equal(0)
     })
 
-    it('should return a zero count if neither it or the array are set', async function () {
+    it('should return a zero count if neither it or the array are set', async function (ctx) {
       const user = {}
-      this.User.findById.returns({
+      ctx.User.findById.returns({
         exec: sinon.stub().resolves(user),
       })
 
       const { referedUserCount: passedReferedUserCount } =
-        await this.handler.promises.getReferedUsers(this.user_id)
+        await ctx.handler.promises.getReferedUsers(ctx.user_id)
 
       passedReferedUserCount.should.equal(0)
     })
 
-    it('should return the array length if count is not set', async function () {
+    it('should return the array length if count is not set', async function (ctx) {
       const user = { refered_users: ['1234', '312312', '3213129'] }
-      this.User.findById.returns({
+      ctx.User.findById.returns({
         exec: sinon.stub().resolves(user),
       })
 
       const { referedUserCount: passedReferedUserCount } =
-        await this.handler.promises.getReferedUsers(this.user_id)
+        await ctx.handler.promises.getReferedUsers(ctx.user_id)
 
       passedReferedUserCount.should.equal(3)
     })
 
-    it('should error if finding the user fails', async function () {
-      this.User.findById.returns({
+    it('should error if finding the user fails', async function (ctx) {
+      ctx.User.findById.returns({
         exec: sinon.stub().rejects(new Error('user not found')),
       })
 
       expect(
-        this.handler.promises.getReferedUsers(this.user_id)
+        ctx.handler.promises.getReferedUsers(ctx.user_id)
       ).to.be.rejectedWith('user not found')
     })
   })

+ 141 - 116
services/web/test/unit/src/References/ReferencesController.test.mjs

@@ -1,4 +1,4 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import MockRequest from '../helpers/MockRequest.js'
 import MockResponse from '../helpers/MockResponse.js'
@@ -6,182 +6,207 @@ const modulePath =
   '../../../../app/src/Features/References/ReferencesController'
 
 describe('ReferencesController', function () {
-  beforeEach(async function () {
-    this.projectId = '2222'
-    this.controller = await esmock.strict(modulePath, {
-      '@overleaf/settings': (this.settings = {
+  beforeEach(async function (ctx) {
+    ctx.projectId = '2222'
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {
         apis: { web: { url: 'http://some.url' } },
       }),
-      '../../../../app/src/Features/References/ReferencesHandler':
-        (this.ReferencesHandler = {
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/References/ReferencesHandler',
+      () => ({
+        default: (ctx.ReferencesHandler = {
           index: sinon.stub(),
           indexAll: sinon.stub(),
         }),
-      '../../../../app/src/Features/Editor/EditorRealTimeController':
-        (this.EditorRealTimeController = {
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Editor/EditorRealTimeController',
+      () => ({
+        default: (ctx.EditorRealTimeController = {
           emitToRoom: sinon.stub(),
         }),
-    })
-    this.req = new MockRequest()
-    this.req.params.Project_id = this.projectId
-    this.req.body = {
-      docIds: (this.docIds = ['aaa', 'bbb']),
+      })
+    )
+
+    ctx.controller = (await import(modulePath)).default
+    ctx.req = new MockRequest()
+    ctx.req.params.Project_id = ctx.projectId
+    ctx.req.body = {
+      docIds: (ctx.docIds = ['aaa', 'bbb']),
       shouldBroadcast: false,
     }
-    this.res = new MockResponse()
-    this.res.json = sinon.stub()
-    this.res.sendStatus = sinon.stub()
-    this.next = sinon.stub()
-    this.fakeResponseData = {
-      projectId: this.projectId,
+    ctx.res = new MockResponse()
+    ctx.res.json = sinon.stub()
+    ctx.res.sendStatus = sinon.stub()
+    ctx.next = sinon.stub()
+    ctx.fakeResponseData = {
+      projectId: ctx.projectId,
       keys: ['one', 'two', 'three'],
     }
   })
 
   describe('indexAll', function () {
-    beforeEach(function () {
-      this.req.body = { shouldBroadcast: false }
-      this.ReferencesHandler.indexAll.callsArgWith(
-        1,
-        null,
-        this.fakeResponseData
-      )
-      this.call = callback => {
-        this.controller.indexAll(this.req, this.res, this.next)
+    beforeEach(function (ctx) {
+      ctx.req.body = { shouldBroadcast: false }
+      ctx.ReferencesHandler.indexAll.callsArgWith(1, null, ctx.fakeResponseData)
+      ctx.call = callback => {
+        ctx.controller.indexAll(ctx.req, ctx.res, ctx.next)
         return callback()
       }
     })
 
-    it('should not produce an error', function (done) {
-      this.call(() => {
-        this.res.sendStatus.callCount.should.equal(0)
-        this.res.sendStatus.calledWith(500).should.equal(false)
-        this.res.sendStatus.calledWith(400).should.equal(false)
-        done()
+    it('should not produce an error', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(() => {
+          ctx.res.sendStatus.callCount.should.equal(0)
+          ctx.res.sendStatus.calledWith(500).should.equal(false)
+          ctx.res.sendStatus.calledWith(400).should.equal(false)
+          resolve()
+        })
       })
     })
 
-    it('should return data', function (done) {
-      this.call(() => {
-        this.res.json.callCount.should.equal(1)
-        this.res.json.calledWith(this.fakeResponseData).should.equal(true)
-        done()
+    it('should return data', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(() => {
+          ctx.res.json.callCount.should.equal(1)
+          ctx.res.json.calledWith(ctx.fakeResponseData).should.equal(true)
+          resolve()
+        })
       })
     })
 
-    it('should call ReferencesHandler.indexAll', function (done) {
-      this.call(() => {
-        this.ReferencesHandler.indexAll.callCount.should.equal(1)
-        this.ReferencesHandler.indexAll
-          .calledWith(this.projectId)
-          .should.equal(true)
-        done()
+    it('should call ReferencesHandler.indexAll', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(() => {
+          ctx.ReferencesHandler.indexAll.callCount.should.equal(1)
+          ctx.ReferencesHandler.indexAll
+            .calledWith(ctx.projectId)
+            .should.equal(true)
+          resolve()
+        })
       })
     })
 
     describe('when shouldBroadcast is true', function () {
-      beforeEach(function () {
-        this.ReferencesHandler.index.callsArgWith(
-          2,
-          null,
-          this.fakeResponseData
-        )
-        this.req.body.shouldBroadcast = true
-      })
-
-      it('should call EditorRealTimeController.emitToRoom', function (done) {
-        this.call(() => {
-          this.EditorRealTimeController.emitToRoom.callCount.should.equal(1)
-          done()
+      beforeEach(function (ctx) {
+        ctx.ReferencesHandler.index.callsArgWith(2, null, ctx.fakeResponseData)
+        ctx.req.body.shouldBroadcast = true
+      })
+
+      it('should call EditorRealTimeController.emitToRoom', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(() => {
+            ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(1)
+            resolve()
+          })
         })
       })
 
-      it('should not produce an error', function (done) {
-        this.call(() => {
-          this.res.sendStatus.callCount.should.equal(0)
-          this.res.sendStatus.calledWith(500).should.equal(false)
-          this.res.sendStatus.calledWith(400).should.equal(false)
-          done()
+      it('should not produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(() => {
+            ctx.res.sendStatus.callCount.should.equal(0)
+            ctx.res.sendStatus.calledWith(500).should.equal(false)
+            ctx.res.sendStatus.calledWith(400).should.equal(false)
+            resolve()
+          })
         })
       })
 
-      it('should still return data', function (done) {
-        this.call(() => {
-          this.res.json.callCount.should.equal(1)
-          this.res.json.calledWith(this.fakeResponseData).should.equal(true)
-          done()
+      it('should still return data', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(() => {
+            ctx.res.json.callCount.should.equal(1)
+            ctx.res.json.calledWith(ctx.fakeResponseData).should.equal(true)
+            resolve()
+          })
         })
       })
     })
 
     describe('when shouldBroadcast is false', function () {
-      beforeEach(function () {
-        this.ReferencesHandler.index.callsArgWith(
-          2,
-          null,
-          this.fakeResponseData
-        )
-        this.req.body.shouldBroadcast = false
-      })
-
-      it('should not call EditorRealTimeController.emitToRoom', function (done) {
-        this.call(() => {
-          this.EditorRealTimeController.emitToRoom.callCount.should.equal(0)
-          done()
+      beforeEach(function (ctx) {
+        ctx.ReferencesHandler.index.callsArgWith(2, null, ctx.fakeResponseData)
+        ctx.req.body.shouldBroadcast = false
+      })
+
+      it('should not call EditorRealTimeController.emitToRoom', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(() => {
+            ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(0)
+            resolve()
+          })
         })
       })
 
-      it('should not produce an error', function (done) {
-        this.call(() => {
-          this.res.sendStatus.callCount.should.equal(0)
-          this.res.sendStatus.calledWith(500).should.equal(false)
-          this.res.sendStatus.calledWith(400).should.equal(false)
-          done()
+      it('should not produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(() => {
+            ctx.res.sendStatus.callCount.should.equal(0)
+            ctx.res.sendStatus.calledWith(500).should.equal(false)
+            ctx.res.sendStatus.calledWith(400).should.equal(false)
+            resolve()
+          })
         })
       })
 
-      it('should still return data', function (done) {
-        this.call(() => {
-          this.res.json.callCount.should.equal(1)
-          this.res.json.calledWith(this.fakeResponseData).should.equal(true)
-          done()
+      it('should still return data', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(() => {
+            ctx.res.json.callCount.should.equal(1)
+            ctx.res.json.calledWith(ctx.fakeResponseData).should.equal(true)
+            resolve()
+          })
         })
       })
     })
   })
 
   describe('there is no data', function () {
-    beforeEach(function () {
-      this.ReferencesHandler.indexAll.callsArgWith(1)
-      this.call = callback => {
-        this.controller.indexAll(this.req, this.res, this.next)
+    beforeEach(function (ctx) {
+      ctx.ReferencesHandler.indexAll.callsArgWith(1)
+      ctx.call = callback => {
+        ctx.controller.indexAll(ctx.req, ctx.res, ctx.next)
         callback()
       }
     })
 
-    it('should not call EditorRealTimeController.emitToRoom', function (done) {
-      this.call(() => {
-        this.EditorRealTimeController.emitToRoom.callCount.should.equal(0)
-        done()
+    it('should not call EditorRealTimeController.emitToRoom', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(() => {
+          ctx.EditorRealTimeController.emitToRoom.callCount.should.equal(0)
+          resolve()
+        })
       })
     })
 
-    it('should not produce an error', function (done) {
-      this.call(() => {
-        this.res.sendStatus.callCount.should.equal(0)
-        this.res.sendStatus.calledWith(500).should.equal(false)
-        this.res.sendStatus.calledWith(400).should.equal(false)
-        done()
+    it('should not produce an error', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(() => {
+          ctx.res.sendStatus.callCount.should.equal(0)
+          ctx.res.sendStatus.calledWith(500).should.equal(false)
+          ctx.res.sendStatus.calledWith(400).should.equal(false)
+          resolve()
+        })
       })
     })
 
-    it('should send a response with an empty keys list', function (done) {
-      this.call(() => {
-        this.res.json.called.should.equal(true)
-        this.res.json
-          .calledWith({ projectId: this.projectId, keys: [] })
-          .should.equal(true)
-        done()
+    it('should send a response with an empty keys list', function (ctx) {
+      return new Promise(resolve => {
+        ctx.call(() => {
+          ctx.res.json.called.should.equal(true)
+          ctx.res.json
+            .calledWith({ projectId: ctx.projectId, keys: [] })
+            .should.equal(true)
+          resolve()
+        })
       })
     })
   })

+ 244 - 196
services/web/test/unit/src/References/ReferencesHandler.test.mjs

@@ -1,11 +1,4 @@
-// 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
- */
-import esmock from 'esmock'
+import { vi } from 'vitest'
 
 import { expect } from 'chai'
 import sinon from 'sinon'
@@ -13,13 +6,17 @@ import Errors from '../../../../app/src/Features/Errors/Errors.js'
 const modulePath =
   '../../../../app/src/Features/References/ReferencesHandler.mjs'
 
+vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+)
+
 describe('ReferencesHandler', function () {
-  beforeEach(async function () {
-    this.projectId = '222'
-    this.historyId = 42
-    this.fakeProject = {
-      _id: this.projectId,
-      owner_ref: (this.fakeOwner = {
+  beforeEach(async function (ctx) {
+    ctx.projectId = '222'
+    ctx.historyId = 42
+    ctx.fakeProject = {
+      _id: ctx.projectId,
+      owner_ref: (ctx.fakeOwner = {
         _id: 'some_owner',
         features: {
           references: false,
@@ -43,11 +40,12 @@ describe('ReferencesHandler', function () {
           ],
         },
       ],
-      overleaf: { history: { id: this.historyId } },
+      overleaf: { history: { id: ctx.historyId } },
     }
-    this.docIds = ['aaa', 'ccc']
-    this.handler = await esmock.strict(modulePath, {
-      '@overleaf/settings': (this.settings = {
+    ctx.docIds = ['aaa', 'ccc']
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {
         apis: {
           references: { url: 'http://some.url/references' },
           docstore: { url: 'http://some.url/docstore' },
@@ -56,228 +54,278 @@ describe('ReferencesHandler', function () {
         },
         enableProjectHistoryBlobs: true,
       }),
-      request: (this.request = {
+    }))
+
+    vi.doMock('request', () => ({
+      default: (ctx.request = {
         get: sinon.stub(),
         post: sinon.stub(),
       }),
-      '../../../../app/src/Features/Project/ProjectGetter':
-        (this.ProjectGetter = {
-          getProject: sinon.stub().callsArgWith(2, null, this.fakeProject),
-        }),
-      '../../../../app/src/Features/User/UserGetter': (this.UserGetter = {
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: (ctx.ProjectGetter = {
+        getProject: sinon.stub().callsArgWith(2, null, ctx.fakeProject),
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: (ctx.UserGetter = {
         getUser: sinon.stub(),
       }),
-      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler':
-        (this.DocumentUpdaterHandler = {
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
+      () => ({
+        default: (ctx.DocumentUpdaterHandler = {
           flushDocToMongo: sinon.stub().callsArgWith(2, null),
         }),
-      '../../../../app/src/infrastructure/Features': (this.Features = {
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Features', () => ({
+      default: (ctx.Features = {
         hasFeature: sinon.stub().returns(true),
       }),
-    })
-    this.fakeResponseData = {
-      projectId: this.projectId,
+    }))
+
+    ctx.handler = (await import(modulePath)).default
+    ctx.fakeResponseData = {
+      projectId: ctx.projectId,
       keys: ['k1', 'k2'],
     }
   })
 
   describe('indexAll', function () {
-    beforeEach(function () {
-      sinon.stub(this.handler, '_findBibDocIds').returns(['aaa', 'ccc'])
+    beforeEach(function (ctx) {
+      sinon.stub(ctx.handler, '_findBibDocIds').returns(['aaa', 'ccc'])
       sinon
-        .stub(this.handler, '_findBibFileRefs')
+        .stub(ctx.handler, '_findBibFileRefs')
         .returns([{ _id: 'fff' }, { _id: 'ggg', hash: 'hash' }])
-      sinon.stub(this.handler, '_isFullIndex').callsArgWith(1, null, true)
-      this.request.post.callsArgWith(
+      sinon.stub(ctx.handler, '_isFullIndex').callsArgWith(1, null, true)
+      ctx.request.post.callsArgWith(
         1,
         null,
         { statusCode: 200 },
-        this.fakeResponseData
+        ctx.fakeResponseData
       )
-      return (this.call = callback => {
-        return this.handler.indexAll(this.projectId, callback)
+      return (ctx.call = callback => {
+        return ctx.handler.indexAll(ctx.projectId, callback)
       })
     })
 
-    it('should call _findBibDocIds', function (done) {
-      return this.call((err, data) => {
-        expect(err).to.be.null
-        this.handler._findBibDocIds.callCount.should.equal(1)
-        this.handler._findBibDocIds
-          .calledWith(this.fakeProject)
-          .should.equal(true)
-        return done()
+    it('should call _findBibDocIds', function (ctx) {
+      return new Promise(resolve => {
+        return ctx.call((err, data) => {
+          expect(err).to.be.null
+          ctx.handler._findBibDocIds.callCount.should.equal(1)
+          ctx.handler._findBibDocIds
+            .calledWith(ctx.fakeProject)
+            .should.equal(true)
+          return resolve()
+        })
       })
     })
 
-    it('should call _findBibFileRefs', function (done) {
-      return this.call((err, data) => {
-        expect(err).to.be.null
-        this.handler._findBibDocIds.callCount.should.equal(1)
-        this.handler._findBibDocIds
-          .calledWith(this.fakeProject)
-          .should.equal(true)
-        return done()
+    it('should call _findBibFileRefs', function (ctx) {
+      return new Promise(resolve => {
+        return ctx.call((err, data) => {
+          expect(err).to.be.null
+          ctx.handler._findBibDocIds.callCount.should.equal(1)
+          ctx.handler._findBibDocIds
+            .calledWith(ctx.fakeProject)
+            .should.equal(true)
+          return resolve()
+        })
       })
     })
 
-    it('should call DocumentUpdaterHandler.flushDocToMongo', function (done) {
-      return this.call((err, data) => {
-        expect(err).to.be.null
-        this.DocumentUpdaterHandler.flushDocToMongo.callCount.should.equal(2)
-        return done()
+    it('should call DocumentUpdaterHandler.flushDocToMongo', function (ctx) {
+      return new Promise(resolve => {
+        return ctx.call((err, data) => {
+          expect(err).to.be.null
+          ctx.DocumentUpdaterHandler.flushDocToMongo.callCount.should.equal(2)
+          return resolve()
+        })
       })
     })
 
-    it('should make a request to references service', function (done) {
-      return this.call((err, data) => {
-        expect(err).to.be.null
-        this.request.post.callCount.should.equal(1)
-        const arg = this.request.post.firstCall.args[0]
-        expect(arg.json).to.have.all.keys('docUrls', 'sourceURLs', 'fullIndex')
-        expect(arg.json.docUrls.length).to.equal(4)
-        expect(arg.json.docUrls).to.deep.equal([
-          `${this.settings.apis.docstore.url}/project/${this.projectId}/doc/aaa/raw`,
-          `${this.settings.apis.docstore.url}/project/${this.projectId}/doc/ccc/raw`,
-          `${this.settings.apis.filestore.url}/project/${this.projectId}/file/fff?from=bibFileUrls`,
-          `${this.settings.apis.filestore.url}/project/${this.projectId}/file/ggg?from=bibFileUrls`,
-        ])
-        expect(arg.json.sourceURLs.length).to.equal(4)
-        expect(arg.json.sourceURLs).to.deep.equal([
-          {
-            url: `${this.settings.apis.docstore.url}/project/${this.projectId}/doc/aaa/raw`,
-          },
-          {
-            url: `${this.settings.apis.docstore.url}/project/${this.projectId}/doc/ccc/raw`,
-          },
-          {
-            url: `${this.settings.apis.filestore.url}/project/${this.projectId}/file/fff?from=bibFileUrls`,
-          },
-          {
-            url: `${this.settings.apis.project_history.url}/project/${this.historyId}/blob/hash`,
-            fallbackURL: `${this.settings.apis.filestore.url}/project/${this.projectId}/file/ggg?from=bibFileUrls`,
-          },
-        ])
-        expect(arg.json.fullIndex).to.equal(true)
-        return done()
+    it('should make a request to references service', function (ctx) {
+      return new Promise(resolve => {
+        return ctx.call((err, data) => {
+          expect(err).to.be.null
+          ctx.request.post.callCount.should.equal(1)
+          const arg = ctx.request.post.firstCall.args[0]
+          expect(arg.json).to.have.all.keys(
+            'docUrls',
+            'sourceURLs',
+            'fullIndex'
+          )
+          expect(arg.json.docUrls.length).to.equal(4)
+          expect(arg.json.docUrls).to.deep.equal([
+            `${ctx.settings.apis.docstore.url}/project/${ctx.projectId}/doc/aaa/raw`,
+            `${ctx.settings.apis.docstore.url}/project/${ctx.projectId}/doc/ccc/raw`,
+            `${ctx.settings.apis.filestore.url}/project/${ctx.projectId}/file/fff?from=bibFileUrls`,
+            `${ctx.settings.apis.filestore.url}/project/${ctx.projectId}/file/ggg?from=bibFileUrls`,
+          ])
+          expect(arg.json.sourceURLs.length).to.equal(4)
+          expect(arg.json.sourceURLs).to.deep.equal([
+            {
+              url: `${ctx.settings.apis.docstore.url}/project/${ctx.projectId}/doc/aaa/raw`,
+            },
+            {
+              url: `${ctx.settings.apis.docstore.url}/project/${ctx.projectId}/doc/ccc/raw`,
+            },
+            {
+              url: `${ctx.settings.apis.filestore.url}/project/${ctx.projectId}/file/fff?from=bibFileUrls`,
+            },
+            {
+              url: `${ctx.settings.apis.project_history.url}/project/${ctx.historyId}/blob/hash`,
+              fallbackURL: `${ctx.settings.apis.filestore.url}/project/${ctx.projectId}/file/ggg?from=bibFileUrls`,
+            },
+          ])
+          expect(arg.json.fullIndex).to.equal(true)
+          return resolve()
+        })
       })
     })
 
-    it('should not produce an error', function (done) {
-      return this.call((err, data) => {
-        expect(err).to.equal(null)
-        return done()
+    it('should not produce an error', function (ctx) {
+      return new Promise(resolve => {
+        return ctx.call((err, data) => {
+          expect(err).to.equal(null)
+          return resolve()
+        })
       })
     })
 
-    it('should return data', function (done) {
-      return this.call((err, data) => {
-        expect(err).to.be.null
-        expect(data).to.not.equal(null)
-        expect(data).to.not.equal(undefined)
-        expect(data).to.equal(this.fakeResponseData)
-        return done()
+    it('should return data', function (ctx) {
+      return new Promise(resolve => {
+        return ctx.call((err, data) => {
+          expect(err).to.be.null
+          expect(data).to.not.equal(null)
+          expect(data).to.not.equal(undefined)
+          expect(data).to.equal(ctx.fakeResponseData)
+          return resolve()
+        })
       })
     })
 
     describe('when ProjectGetter.getProject produces an error', function () {
-      beforeEach(function () {
-        return this.ProjectGetter.getProject.callsArgWith(2, new Error('woops'))
+      beforeEach(function (ctx) {
+        ctx.ProjectGetter.getProject.callsArgWith(2, new Error('woops'))
       })
 
-      it('should produce an error', function (done) {
-        return this.call((err, data) => {
-          expect(err).to.not.equal(null)
-          expect(err).to.be.instanceof(Error)
-          expect(data).to.equal(undefined)
-          return done()
+      it('should produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call((err, data) => {
+            expect(err).to.not.equal(null)
+            expect(err).to.be.instanceof(Error)
+            expect(data).to.equal(undefined)
+            resolve()
+          })
         })
       })
 
-      it('should not send request', function (done) {
-        return this.call(() => {
-          this.request.post.callCount.should.equal(0)
-          return done()
+      it('should not send request', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(() => {
+            ctx.request.post.callCount.should.equal(0)
+            resolve()
+          })
         })
       })
     })
 
     describe('when ProjectGetter.getProject returns null', function () {
-      beforeEach(function () {
-        return this.ProjectGetter.getProject.callsArgWith(2, null)
+      beforeEach(function (ctx) {
+        ctx.ProjectGetter.getProject.callsArgWith(2, null)
       })
 
-      it('should produce an error', function (done) {
-        return this.call((err, data) => {
-          expect(err).to.not.equal(null)
-          expect(err).to.be.instanceof(Errors.NotFoundError)
-          expect(data).to.equal(undefined)
-          return done()
+      it('should produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call((err, data) => {
+            expect(err).to.not.equal(null)
+            expect(err).to.be.instanceof(Errors.NotFoundError)
+            expect(data).to.equal(undefined)
+            resolve()
+          })
         })
       })
 
-      it('should not send request', function (done) {
-        return this.call(() => {
-          this.request.post.callCount.should.equal(0)
-          return done()
+      it('should not send request', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(() => {
+            ctx.request.post.callCount.should.equal(0)
+            resolve()
+          })
         })
       })
     })
 
     describe('when _isFullIndex produces an error', function () {
-      beforeEach(function () {
-        this.ProjectGetter.getProject.callsArgWith(2, null, this.fakeProject)
-        return this.handler._isFullIndex.callsArgWith(1, new Error('woops'))
+      beforeEach(function (ctx) {
+        ctx.ProjectGetter.getProject.callsArgWith(2, null, ctx.fakeProject)
+        ctx.handler._isFullIndex.callsArgWith(1, new Error('woops'))
       })
 
-      it('should produce an error', function (done) {
-        return this.call((err, data) => {
-          expect(err).to.not.equal(null)
-          expect(err).to.be.instanceof(Error)
-          expect(data).to.equal(undefined)
-          return done()
+      it('should produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call((err, data) => {
+            expect(err).to.not.equal(null)
+            expect(err).to.be.instanceof(Error)
+            expect(data).to.equal(undefined)
+            resolve()
+          })
         })
       })
 
-      it('should not send request', function (done) {
-        return this.call(() => {
-          this.request.post.callCount.should.equal(0)
-          return done()
+      it('should not send request', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(() => {
+            ctx.request.post.callCount.should.equal(0)
+            resolve()
+          })
         })
       })
     })
 
     describe('when flushDocToMongo produces an error', function () {
-      beforeEach(function () {
-        this.ProjectGetter.getProject.callsArgWith(2, null, this.fakeProject)
-        this.handler._isFullIndex.callsArgWith(1, false)
-        return this.DocumentUpdaterHandler.flushDocToMongo.callsArgWith(
+      beforeEach(function (ctx) {
+        ctx.ProjectGetter.getProject.callsArgWith(2, null, ctx.fakeProject)
+        ctx.handler._isFullIndex.callsArgWith(1, false)
+        ctx.DocumentUpdaterHandler.flushDocToMongo.callsArgWith(
           2,
           new Error('woops')
         )
       })
 
-      it('should produce an error', function (done) {
-        return this.call((err, data) => {
-          expect(err).to.not.equal(null)
-          expect(err).to.be.instanceof(Error)
-          expect(data).to.equal(undefined)
-          return done()
+      it('should produce an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call((err, data) => {
+            expect(err).to.not.equal(null)
+            expect(err).to.be.instanceof(Error)
+            expect(data).to.equal(undefined)
+            resolve()
+          })
         })
       })
 
-      it('should not send request', function (done) {
-        return this.call(() => {
-          this.request.post.callCount.should.equal(0)
-          return done()
+      it('should not send request', function (ctx) {
+        return new Promise(resolve => {
+          ctx.call(() => {
+            ctx.request.post.callCount.should.equal(0)
+            resolve()
+          })
         })
       })
     })
   })
 
   describe('_findBibDocIds', function () {
-    beforeEach(function () {
-      this.fakeProject = {
+    beforeEach(function (ctx) {
+      ctx.fakeProject = {
         rootFolder: [
           {
             docs: [
@@ -290,24 +338,24 @@ describe('ReferencesHandler', function () {
           },
         ],
       }
-      return (this.expectedIds = ['aaa', 'ccc'])
+      ctx.expectedIds = ['aaa', 'ccc']
     })
 
-    it('should select the correct docIds', function () {
-      const result = this.handler._findBibDocIds(this.fakeProject)
-      return expect(result).to.deep.equal(this.expectedIds)
+    it('should select the correct docIds', function (ctx) {
+      const result = ctx.handler._findBibDocIds(ctx.fakeProject)
+      expect(result).to.deep.equal(ctx.expectedIds)
     })
 
-    it('should not error with a non array of folders from dirty data', function () {
-      this.fakeProject.rootFolder[0].folders[0].folders = {}
-      const result = this.handler._findBibDocIds(this.fakeProject)
-      return expect(result).to.deep.equal(this.expectedIds)
+    it('should not error with a non array of folders from dirty data', function (ctx) {
+      ctx.fakeProject.rootFolder[0].folders[0].folders = {}
+      const result = ctx.handler._findBibDocIds(ctx.fakeProject)
+      expect(result).to.deep.equal(ctx.expectedIds)
     })
   })
 
   describe('_findBibFileRefs', function () {
-    beforeEach(function () {
-      this.fakeProject = {
+    beforeEach(function (ctx) {
+      ctx.fakeProject = {
         rootFolder: [
           {
             docs: [
@@ -325,73 +373,73 @@ describe('ReferencesHandler', function () {
           },
         ],
       }
-      this.expectedIds = [
-        this.fakeProject.rootFolder[0].fileRefs[0],
-        this.fakeProject.rootFolder[0].folders[0].fileRefs[0],
+      ctx.expectedIds = [
+        ctx.fakeProject.rootFolder[0].fileRefs[0],
+        ctx.fakeProject.rootFolder[0].folders[0].fileRefs[0],
       ]
     })
 
-    it('should select the correct docIds', function () {
-      const result = this.handler._findBibFileRefs(this.fakeProject)
-      return expect(result).to.deep.equal(this.expectedIds)
+    it('should select the correct docIds', function (ctx) {
+      const result = ctx.handler._findBibFileRefs(ctx.fakeProject)
+      expect(result).to.deep.equal(ctx.expectedIds)
     })
   })
 
   describe('_isFullIndex', function () {
-    beforeEach(function () {
-      this.fakeProject = { owner_ref: (this.owner_ref = 'owner-ref-123') }
-      this.owner = {
+    beforeEach(function (ctx) {
+      ctx.fakeProject = { owner_ref: (ctx.owner_ref = 'owner-ref-123') }
+      ctx.owner = {
         features: {
           references: false,
         },
       }
-      this.UserGetter.getUser = sinon.stub()
-      this.UserGetter.getUser
-        .withArgs(this.owner_ref, { features: true })
-        .yields(null, this.owner)
-      return (this.call = callback => {
-        return this.handler._isFullIndex(this.fakeProject, callback)
-      })
+      ctx.UserGetter.getUser = sinon.stub()
+      ctx.UserGetter.getUser
+        .withArgs(ctx.owner_ref, { features: true })
+        .yields(null, ctx.owner)
+      ctx.call = callback => {
+        ctx.handler._isFullIndex(ctx.fakeProject, callback)
+      }
     })
 
     describe('with references feature on', function () {
-      beforeEach(function () {
-        return (this.owner.features.references = true)
+      beforeEach(function (ctx) {
+        ctx.owner.features.references = true
       })
 
-      it('should return true', function () {
-        return this.call((err, isFullIndex) => {
+      it('should return true', function (ctx) {
+        ctx.call((err, isFullIndex) => {
           expect(err).to.equal(null)
-          return expect(isFullIndex).to.equal(true)
+          expect(isFullIndex).to.equal(true)
         })
       })
     })
 
     describe('with references feature off', function () {
-      beforeEach(function () {
-        return (this.owner.features.references = false)
+      beforeEach(function (ctx) {
+        ctx.owner.features.references = false
       })
 
-      it('should return false', function () {
-        return this.call((err, isFullIndex) => {
+      it('should return false', function (ctx) {
+        ctx.call((err, isFullIndex) => {
           expect(err).to.equal(null)
-          return expect(isFullIndex).to.equal(false)
+          expect(isFullIndex).to.equal(false)
         })
       })
     })
 
     describe('with referencesSearch', function () {
-      beforeEach(function () {
-        return (this.owner.features = {
+      beforeEach(function (ctx) {
+        ctx.owner.features = {
           referencesSearch: true,
           references: false,
-        })
+        }
       })
 
-      it('should return true', function () {
-        return this.call((err, isFullIndex) => {
+      it('should return true', function (ctx) {
+        ctx.call((err, isFullIndex) => {
           expect(err).to.equal(null)
-          return expect(isFullIndex).to.equal(true)
+          expect(isFullIndex).to.equal(true)
         })
       })
     })

+ 663 - 552
services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs

@@ -1,68 +1,68 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 
 const modulePath =
   '../../../../app/src/Features/Subscription/SubscriptionGroupController'
 
 describe('SubscriptionGroupController', function () {
-  beforeEach(async function () {
-    this.user = { _id: '!@312431', email: 'user@email.com' }
-    this.adminUserId = '123jlkj'
-    this.subscriptionId = '123434325412'
-    this.user_email = 'bob@gmail.com'
-    this.req = {
+  beforeEach(async function (ctx) {
+    ctx.user = { _id: '!@312431', email: 'user@email.com' }
+    ctx.adminUserId = '123jlkj'
+    ctx.subscriptionId = '123434325412'
+    ctx.user_email = 'bob@gmail.com'
+    ctx.req = {
       session: {
         user: {
-          _id: this.adminUserId,
-          email: this.user_email,
+          _id: ctx.adminUserId,
+          email: ctx.user_email,
         },
       },
       params: {
-        subscriptionId: this.subscriptionId,
+        subscriptionId: ctx.subscriptionId,
       },
       query: {},
     }
 
-    this.subscription = {
-      _id: this.subscriptionId,
+    ctx.subscription = {
+      _id: ctx.subscriptionId,
       teamName: 'Cool group',
       groupPlan: true,
       membersLimit: 5,
     }
 
-    this.plan = {
+    ctx.plan = {
       canUseFlexibleLicensing: true,
     }
 
-    this.recurlySubscription = {
+    ctx.recurlySubscription = {
       get isCollectionMethodManual() {
         return true
       },
     }
 
-    this.previewSubscriptionChangeData = {
+    ctx.previewSubscriptionChangeData = {
       change: {},
       currency: 'USD',
     }
 
-    this.createSubscriptionChangeData = { adding: 1 }
+    ctx.createSubscriptionChangeData = { adding: 1 }
 
-    this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
+    ctx.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
 
-    this.SubscriptionGroupHandler = {
+    ctx.SubscriptionGroupHandler = {
       promises: {
         removeUserFromGroup: sinon.stub().resolves(),
         getUsersGroupSubscriptionDetails: sinon.stub().resolves({
-          subscription: this.subscription,
-          plan: this.plan,
-          recurlySubscription: this.recurlySubscription,
+          subscription: ctx.subscription,
+          plan: ctx.plan,
+          recurlySubscription: ctx.recurlySubscription,
         }),
         previewAddSeatsSubscriptionChange: sinon
           .stub()
-          .resolves(this.previewSubscriptionChangeData),
+          .resolves(ctx.previewSubscriptionChangeData),
         createAddSeatsSubscriptionChange: sinon
           .stub()
-          .resolves(this.createSubscriptionChangeData),
+          .resolves(ctx.createSubscriptionChangeData),
         ensureFlexibleLicensingEnabled: sinon.stub().resolves(),
         ensureSubscriptionIsActive: sinon.stub().resolves(),
         ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(),
@@ -70,19 +70,19 @@ describe('SubscriptionGroupController', function () {
         ensureSubscriptionHasNoPastDueInvoice: sinon.stub().resolves(),
         getGroupPlanUpgradePreview: sinon
           .stub()
-          .resolves(this.previewSubscriptionChangeData),
-        checkBillingInfoExistence: sinon.stub().resolves(this.paymentMethod),
+          .resolves(ctx.previewSubscriptionChangeData),
+        checkBillingInfoExistence: sinon.stub().resolves(ctx.paymentMethod),
         updateSubscriptionPaymentTerms: sinon.stub().resolves(),
       },
     }
 
-    this.SubscriptionLocator = {
+    ctx.SubscriptionLocator = {
       promises: {
-        getSubscription: sinon.stub().resolves(this.subscription),
+        getSubscription: sinon.stub().resolves(ctx.subscription),
       },
     }
 
-    this.SessionManager = {
+    ctx.SessionManager = {
       getLoggedInUserId(session) {
         return session.user._id
       },
@@ -91,13 +91,13 @@ describe('SubscriptionGroupController', function () {
       },
     }
 
-    this.UserAuditLogHandler = {
+    ctx.UserAuditLogHandler = {
       promises: {
         addEntry: sinon.stub().resolves(),
       },
     }
 
-    this.Modules = {
+    ctx.Modules = {
       promises: {
         hooks: {
           fire: sinon.stub().resolves(),
@@ -105,35 +105,35 @@ describe('SubscriptionGroupController', function () {
       },
     }
 
-    this.SplitTestHandler = {
+    ctx.SplitTestHandler = {
       promises: {
         getAssignment: sinon.stub().resolves({ variant: 'enabled' }),
       },
     }
 
-    this.UserGetter = {
+    ctx.UserGetter = {
       promises: {
-        getUserEmail: sinon.stub().resolves(this.user.email),
+        getUserEmail: sinon.stub().resolves(ctx.user.email),
       },
     }
 
-    this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
+    ctx.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
 
-    this.RecurlyClient = {
+    ctx.RecurlyClient = {
       promises: {
-        getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
+        getPaymentMethod: sinon.stub().resolves(ctx.paymentMethod),
       },
     }
 
-    this.SubscriptionController = {}
+    ctx.SubscriptionController = {}
 
-    this.SubscriptionModel = { Subscription: {} }
+    ctx.SubscriptionModel = { Subscription: {} }
 
-    this.PlansHelper = {
+    ctx.PlansHelper = {
       isProfessionalGroupPlan: sinon.stub().returns(false),
     }
 
-    this.Errors = {
+    ctx.Errors = {
       MissingBillingInfoError: class extends Error {},
       ManuallyCollectedError: class extends Error {},
       PendingChangeError: class extends Error {},
@@ -142,632 +142,743 @@ describe('SubscriptionGroupController', function () {
       HasPastDueInvoiceError: class extends Error {},
     }
 
-    this.Controller = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Subscription/SubscriptionGroupHandler':
-        this.SubscriptionGroupHandler,
-      '../../../../app/src/Features/Subscription/SubscriptionLocator':
-        this.SubscriptionLocator,
-      '../../../../app/src/Features/Authentication/SessionManager':
-        this.SessionManager,
-      '../../../../app/src/Features/User/UserAuditLogHandler':
-        this.UserAuditLogHandler,
-      '../../../../app/src/infrastructure/Modules': this.Modules,
-      '../../../../app/src/Features/SplitTests/SplitTestHandler':
-        this.SplitTestHandler,
-      '../../../../app/src/Features/User/UserGetter': this.UserGetter,
-      '../../../../app/src/Features/Errors/ErrorController':
-        (this.ErrorController = {
-          notFound: sinon.stub(),
-        }),
-      '../../../../app/src/Features/Subscription/SubscriptionController':
-        this.SubscriptionController,
-      '../../../../app/src/Features/Subscription/RecurlyClient':
-        this.RecurlyClient,
-      '../../../../app/src/Features/Subscription/PlansHelper': this.PlansHelper,
-      '../../../../app/src/Features/Subscription/Errors': this.Errors,
-      '../../../../app/src/models/Subscription': this.SubscriptionModel,
-      '@overleaf/logger': {
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionGroupHandler',
+      () => ({
+        default: ctx.SubscriptionGroupHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: ctx.SubscriptionLocator,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
+      default: ctx.UserAuditLogHandler,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: ctx.Modules,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Errors/ErrorController', () => ({
+      default: (ctx.ErrorController = {
+        notFound: sinon.stub(),
+      }),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionController',
+      () => ({
+        default: ctx.SubscriptionController,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/RecurlyClient',
+      () => ({
+        default: ctx.RecurlyClient,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/PlansHelper',
+      () => ctx.PlansHelper
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/Errors',
+      () => ctx.Errors
+    )
+
+    vi.doMock(
+      '../../../../app/src/models/Subscription',
+      () => ctx.SubscriptionModel
+    )
+
+    vi.doMock('@overleaf/logger', () => ({
+      default: {
         err: sinon.stub(),
         error: sinon.stub(),
         warn: sinon.stub(),
         log: sinon.stub(),
         debug: sinon.stub(),
       },
-    })
+    }))
+
+    ctx.Controller = (await import(modulePath)).default
   })
 
   describe('removeUserFromGroup', function () {
-    it('should use the subscription id for the logged in user and take the user id from the params', function (done) {
-      const userIdToRemove = '31231'
-      this.req.params = { user_id: userIdToRemove }
-      this.req.entity = this.subscription
-
-      const res = {
-        sendStatus: () => {
-          this.SubscriptionGroupHandler.promises.removeUserFromGroup
-            .calledWith(this.subscriptionId, userIdToRemove, {
-              initiatorId: this.req.session.user._id,
-              ipAddress: this.req.ip,
-            })
-            .should.equal(true)
-          done()
-        },
-      }
-      this.Controller.removeUserFromGroup(this.req, res, done)
+    it('should use the subscription id for the logged in user and take the user id from the params', function (ctx) {
+      return new Promise(resolve => {
+        const userIdToRemove = '31231'
+        ctx.req.params = { user_id: userIdToRemove }
+        ctx.req.entity = ctx.subscription
+
+        const res = {
+          sendStatus: () => {
+            ctx.SubscriptionGroupHandler.promises.removeUserFromGroup
+              .calledWith(ctx.subscriptionId, userIdToRemove, {
+                initiatorId: ctx.req.session.user._id,
+                ipAddress: ctx.req.ip,
+              })
+              .should.equal(true)
+            resolve()
+          },
+        }
+        ctx.Controller.removeUserFromGroup(ctx.req, res, resolve)
+      })
     })
 
-    it('should log that the user has been removed', function (done) {
-      const userIdToRemove = '31231'
-      this.req.params = { user_id: userIdToRemove }
-      this.req.entity = this.subscription
-
-      const res = {
-        sendStatus: () => {
-          sinon.assert.calledWith(
-            this.UserAuditLogHandler.promises.addEntry,
-            userIdToRemove,
-            'remove-from-group-subscription',
-            this.adminUserId,
-            this.req.ip,
-            { subscriptionId: this.subscriptionId }
-          )
-          done()
-        },
-      }
-      this.Controller.removeUserFromGroup(this.req, res, done)
-    })
+    it('should log that the user has been removed', function (ctx) {
+      return new Promise(resolve => {
+        const userIdToRemove = '31231'
+        ctx.req.params = { user_id: userIdToRemove }
+        ctx.req.entity = ctx.subscription
 
-    it('should call the group SSO hooks with group SSO enabled', function (done) {
-      const userIdToRemove = '31231'
-      this.req.params = { user_id: userIdToRemove }
-      this.req.entity = this.subscription
-      this.Modules.promises.hooks.fire
-        .withArgs('hasGroupSSOEnabled', this.subscription)
-        .resolves([true])
-
-      const res = {
-        sendStatus: () => {
-          this.Modules.promises.hooks.fire
-            .calledWith('hasGroupSSOEnabled', this.subscription)
-            .should.equal(true)
-          this.Modules.promises.hooks.fire
-            .calledWith(
-              'unlinkUserFromGroupSSO',
+        const res = {
+          sendStatus: () => {
+            sinon.assert.calledWith(
+              ctx.UserAuditLogHandler.promises.addEntry,
               userIdToRemove,
-              this.subscriptionId
+              'remove-from-group-subscription',
+              ctx.adminUserId,
+              ctx.req.ip,
+              { subscriptionId: ctx.subscriptionId }
             )
-            .should.equal(true)
-          sinon.assert.calledTwice(this.Modules.promises.hooks.fire)
-          done()
-        },
-      }
-      this.Controller.removeUserFromGroup(this.req, res, done)
+            resolve()
+          },
+        }
+        ctx.Controller.removeUserFromGroup(ctx.req, res, resolve)
+      })
     })
 
-    it('should call the group SSO hooks with group SSO disabled', function (done) {
-      const userIdToRemove = '31231'
-      this.req.params = { user_id: userIdToRemove }
-      this.req.entity = this.subscription
-      this.Modules.promises.hooks.fire
-        .withArgs('hasGroupSSOEnabled', this.subscription)
-        .resolves([false])
-
-      const res = {
-        sendStatus: () => {
-          this.Modules.promises.hooks.fire
-            .calledWith('hasGroupSSOEnabled', this.subscription)
-            .should.equal(true)
-          sinon.assert.calledOnce(this.Modules.promises.hooks.fire)
-          done()
-        },
-      }
-      this.Controller.removeUserFromGroup(this.req, res, done)
+    it('should call the group SSO hooks with group SSO enabled', function (ctx) {
+      return new Promise(resolve => {
+        const userIdToRemove = '31231'
+        ctx.req.params = { user_id: userIdToRemove }
+        ctx.req.entity = ctx.subscription
+        ctx.Modules.promises.hooks.fire
+          .withArgs('hasGroupSSOEnabled', ctx.subscription)
+          .resolves([true])
+
+        const res = {
+          sendStatus: () => {
+            ctx.Modules.promises.hooks.fire
+              .calledWith('hasGroupSSOEnabled', ctx.subscription)
+              .should.equal(true)
+            ctx.Modules.promises.hooks.fire
+              .calledWith(
+                'unlinkUserFromGroupSSO',
+                userIdToRemove,
+                ctx.subscriptionId
+              )
+              .should.equal(true)
+            sinon.assert.calledTwice(ctx.Modules.promises.hooks.fire)
+            resolve()
+          },
+        }
+        ctx.Controller.removeUserFromGroup(ctx.req, res, resolve)
+      })
+    })
+
+    it('should call the group SSO hooks with group SSO disabled', function (ctx) {
+      return new Promise(resolve => {
+        const userIdToRemove = '31231'
+        ctx.req.params = { user_id: userIdToRemove }
+        ctx.req.entity = ctx.subscription
+        ctx.Modules.promises.hooks.fire
+          .withArgs('hasGroupSSOEnabled', ctx.subscription)
+          .resolves([false])
+
+        const res = {
+          sendStatus: () => {
+            ctx.Modules.promises.hooks.fire
+              .calledWith('hasGroupSSOEnabled', ctx.subscription)
+              .should.equal(true)
+            sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire)
+            resolve()
+          },
+        }
+        ctx.Controller.removeUserFromGroup(ctx.req, res, resolve)
+      })
     })
   })
 
   describe('removeSelfFromGroup', function () {
-    it('gets subscription and remove user', function (done) {
-      this.req.query = { subscriptionId: this.subscriptionId }
-      const memberUserIdToremove = 123456789
-      this.req.session.user._id = memberUserIdToremove
-
-      const res = {
-        sendStatus: () => {
-          sinon.assert.calledWith(
-            this.SubscriptionLocator.promises.getSubscription,
-            this.subscriptionId
-          )
-          sinon.assert.calledWith(
-            this.SubscriptionGroupHandler.promises.removeUserFromGroup,
-            this.subscriptionId,
-            memberUserIdToremove,
-            {
-              initiatorId: this.req.session.user._id,
-              ipAddress: this.req.ip,
-            }
-          )
-          done()
-        },
-      }
-      this.Controller.removeSelfFromGroup(this.req, res, done)
+    it('gets subscription and remove user', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.query = { subscriptionId: ctx.subscriptionId }
+        const memberUserIdToremove = 123456789
+        ctx.req.session.user._id = memberUserIdToremove
+
+        const res = {
+          sendStatus: () => {
+            sinon.assert.calledWith(
+              ctx.SubscriptionLocator.promises.getSubscription,
+              ctx.subscriptionId
+            )
+            sinon.assert.calledWith(
+              ctx.SubscriptionGroupHandler.promises.removeUserFromGroup,
+              ctx.subscriptionId,
+              memberUserIdToremove,
+              {
+                initiatorId: ctx.req.session.user._id,
+                ipAddress: ctx.req.ip,
+              }
+            )
+            resolve()
+          },
+        }
+        ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve)
+      })
     })
 
-    it('should log that the user has left the subscription', function (done) {
-      this.req.query = { subscriptionId: this.subscriptionId }
-      const memberUserIdToremove = '123456789'
-      this.req.session.user._id = memberUserIdToremove
-
-      const res = {
-        sendStatus: () => {
-          sinon.assert.calledWith(
-            this.UserAuditLogHandler.promises.addEntry,
-            memberUserIdToremove,
-            'remove-from-group-subscription',
-            memberUserIdToremove,
-            this.req.ip,
-            { subscriptionId: this.subscriptionId }
-          )
-          done()
-        },
-      }
-      this.Controller.removeSelfFromGroup(this.req, res, done)
-    })
+    it('should log that the user has left the subscription', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.query = { subscriptionId: ctx.subscriptionId }
+        const memberUserIdToremove = '123456789'
+        ctx.req.session.user._id = memberUserIdToremove
 
-    it('should call the group SSO hooks with group SSO enabled', function (done) {
-      this.req.query = { subscriptionId: this.subscriptionId }
-      const memberUserIdToremove = '123456789'
-      this.req.session.user._id = memberUserIdToremove
-
-      this.Modules.promises.hooks.fire
-        .withArgs('hasGroupSSOEnabled', this.subscription)
-        .resolves([true])
-
-      const res = {
-        sendStatus: () => {
-          this.Modules.promises.hooks.fire
-            .calledWith('hasGroupSSOEnabled', this.subscription)
-            .should.equal(true)
-          this.Modules.promises.hooks.fire
-            .calledWith(
-              'unlinkUserFromGroupSSO',
+        const res = {
+          sendStatus: () => {
+            sinon.assert.calledWith(
+              ctx.UserAuditLogHandler.promises.addEntry,
+              memberUserIdToremove,
+              'remove-from-group-subscription',
               memberUserIdToremove,
-              this.subscriptionId
+              ctx.req.ip,
+              { subscriptionId: ctx.subscriptionId }
             )
-            .should.equal(true)
-          sinon.assert.calledTwice(this.Modules.promises.hooks.fire)
-          done()
-        },
-      }
-      this.Controller.removeSelfFromGroup(this.req, res, done)
+            resolve()
+          },
+        }
+        ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve)
+      })
     })
 
-    it('should call the group SSO hooks with group SSO disabled', function (done) {
-      const userIdToRemove = '31231'
-      this.req.session.user._id = userIdToRemove
-      this.req.params = { user_id: userIdToRemove }
-      this.req.entity = this.subscription
-      this.Modules.promises.hooks.fire
-        .withArgs('hasGroupSSOEnabled', this.subscription)
-        .resolves([false])
-
-      const res = {
-        sendStatus: () => {
-          this.Modules.promises.hooks.fire
-            .calledWith('hasGroupSSOEnabled', this.subscription)
-            .should.equal(true)
-          sinon.assert.calledOnce(this.Modules.promises.hooks.fire)
-          done()
-        },
-      }
-      this.Controller.removeSelfFromGroup(this.req, res, done)
+    it('should call the group SSO hooks with group SSO enabled', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.query = { subscriptionId: ctx.subscriptionId }
+        const memberUserIdToremove = '123456789'
+        ctx.req.session.user._id = memberUserIdToremove
+
+        ctx.Modules.promises.hooks.fire
+          .withArgs('hasGroupSSOEnabled', ctx.subscription)
+          .resolves([true])
+
+        const res = {
+          sendStatus: () => {
+            ctx.Modules.promises.hooks.fire
+              .calledWith('hasGroupSSOEnabled', ctx.subscription)
+              .should.equal(true)
+            ctx.Modules.promises.hooks.fire
+              .calledWith(
+                'unlinkUserFromGroupSSO',
+                memberUserIdToremove,
+                ctx.subscriptionId
+              )
+              .should.equal(true)
+            sinon.assert.calledTwice(ctx.Modules.promises.hooks.fire)
+            resolve()
+          },
+        }
+        ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve)
+      })
+    })
+
+    it('should call the group SSO hooks with group SSO disabled', function (ctx) {
+      return new Promise(resolve => {
+        const userIdToRemove = '31231'
+        ctx.req.session.user._id = userIdToRemove
+        ctx.req.params = { user_id: userIdToRemove }
+        ctx.req.entity = ctx.subscription
+        ctx.Modules.promises.hooks.fire
+          .withArgs('hasGroupSSOEnabled', ctx.subscription)
+          .resolves([false])
+
+        const res = {
+          sendStatus: () => {
+            ctx.Modules.promises.hooks.fire
+              .calledWith('hasGroupSSOEnabled', ctx.subscription)
+              .should.equal(true)
+            sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire)
+            resolve()
+          },
+        }
+        ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve)
+      })
     })
   })
 
   describe('addSeatsToGroupSubscription', function () {
-    it('should render the "add seats" page', function (done) {
-      const res = {
-        render: (page, props) => {
-          this.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails
-            .calledWith(this.req.session.user._id)
-            .should.equal(true)
-          this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
-            .calledWith(this.plan)
-            .should.equal(true)
-          this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges
-            .calledWith(this.recurlySubscription)
-            .should.equal(true)
-          this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
-            .calledWith(this.subscription)
-            .should.equal(true)
-          this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice
-            .calledWith(this.subscription)
-            .should.equal(true)
-          this.SubscriptionGroupHandler.promises.checkBillingInfoExistence
-            .calledWith(this.recurlySubscription, this.adminUserId)
-            .should.equal(true)
-          page.should.equal('subscriptions/add-seats')
-          props.subscriptionId.should.equal(this.subscriptionId)
-          props.groupName.should.equal(this.subscription.teamName)
-          props.totalLicenses.should.equal(this.subscription.membersLimit)
-          props.isProfessional.should.equal(false)
-          props.isCollectionMethodManual.should.equal(true)
-          done()
-        },
-      }
+    it('should render the "add seats" page', function (ctx) {
+      return new Promise((resolve, reject) => {
+        const res = {
+          render: (page, props) => {
+            ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails
+              .calledWith(ctx.req.session.user._id)
+              .should.equal(true)
+            ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
+              .calledWith(ctx.plan)
+              .should.equal(true)
+            ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges
+              .calledWith(ctx.recurlySubscription)
+              .should.equal(true)
+            ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive
+              .calledWith(ctx.subscription)
+              .should.equal(true)
+            ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice
+              .calledWith(ctx.subscription)
+              .should.equal(true)
+            ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence
+              .calledWith(ctx.recurlySubscription, ctx.adminUserId)
+              .should.equal(true)
+            page.should.equal('subscriptions/add-seats')
+            props.subscriptionId.should.equal(ctx.subscriptionId)
+            props.groupName.should.equal(ctx.subscription.teamName)
+            props.totalLicenses.should.equal(ctx.subscription.membersLimit)
+            props.isProfessional.should.equal(false)
+            props.isCollectionMethodManual.should.equal(true)
+            resolve()
+          },
+        }
 
-      this.Controller.addSeatsToGroupSubscription(this.req, res)
+        ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
+      })
     })
 
-    it('should redirect to subscription page when getting subscription details fails', function (done) {
-      this.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails =
-        sinon.stub().rejects()
+    it('should redirect to subscription page when getting subscription details fails', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails =
+          sinon.stub().rejects()
 
-      const res = {
-        redirect: url => {
-          url.should.equal('/user/subscription')
-          done()
-        },
-      }
+        const res = {
+          redirect: url => {
+            url.should.equal('/user/subscription')
+            resolve()
+          },
+        }
 
-      this.Controller.addSeatsToGroupSubscription(this.req, res)
+        ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
+      })
     })
 
-    it('should redirect to subscription page when flexible licensing is not enabled', function (done) {
-      this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled =
-        sinon.stub().rejects()
+    it('should redirect to subscription page when flexible licensing is not enabled', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled =
+          sinon.stub().rejects()
 
-      const res = {
-        redirect: url => {
-          url.should.equal('/user/subscription')
-          done()
-        },
-      }
+        const res = {
+          redirect: url => {
+            url.should.equal('/user/subscription')
+            resolve()
+          },
+        }
 
-      this.Controller.addSeatsToGroupSubscription(this.req, res)
+        ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
+      })
     })
 
-    it('should redirect to missing billing information page when billing information is missing', function (done) {
-      this.SubscriptionGroupHandler.promises.checkBillingInfoExistence = sinon
-        .stub()
-        .throws(new this.Errors.MissingBillingInfoError())
-
-      const res = {
-        redirect: url => {
-          url.should.equal(
-            '/user/subscription/group/missing-billing-information'
-          )
-          done()
-        },
-      }
+    it('should redirect to missing billing information page when billing information is missing', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence = sinon
+          .stub()
+          .throws(new ctx.Errors.MissingBillingInfoError())
+
+        const res = {
+          redirect: url => {
+            url.should.equal(
+              '/user/subscription/group/missing-billing-information'
+            )
+            resolve()
+          },
+        }
 
-      this.Controller.addSeatsToGroupSubscription(this.req, res)
+        ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
+      })
     })
 
-    it('should redirect to subscription page when there is a pending change', function (done) {
-      this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges =
-        sinon.stub().throws(new this.Errors.PendingChangeError())
+    it('should redirect to subscription page when there is a pending change', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges =
+          sinon.stub().throws(new ctx.Errors.PendingChangeError())
 
-      const res = {
-        redirect: url => {
-          url.should.equal('/user/subscription')
-          done()
-        },
-      }
+        const res = {
+          redirect: url => {
+            url.should.equal('/user/subscription')
+            resolve()
+          },
+        }
 
-      this.Controller.addSeatsToGroupSubscription(this.req, res)
+        ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
+      })
     })
 
-    it('should redirect to subscription page when subscription is not active', function (done) {
-      this.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon
-        .stub()
-        .rejects()
+    it('should redirect to subscription page when subscription is not active', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon
+          .stub()
+          .rejects()
 
-      const res = {
-        redirect: url => {
-          url.should.equal('/user/subscription')
-          done()
-        },
-      }
+        const res = {
+          redirect: url => {
+            url.should.equal('/user/subscription')
+            resolve()
+          },
+        }
 
-      this.Controller.addSeatsToGroupSubscription(this.req, res)
+        ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
+      })
     })
 
-    it('should redirect to subscription page when subscription has pending invoice', function (done) {
-      this.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice =
+    it('should redirect to subscription page when subscription has pending invoice', function (ctx) {
+      ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice =
         sinon.stub().rejects()
+      return new Promise(resolve => {
+        const res = {
+          redirect: url => {
+            url.should.equal('/user/subscription')
+            resolve()
+          },
+        }
 
-      const res = {
-        redirect: url => {
-          url.should.equal('/user/subscription')
-          done()
-        },
-      }
-
-      this.Controller.addSeatsToGroupSubscription(this.req, res)
+        ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
+      })
     })
   })
 
   describe('previewAddSeatsSubscriptionChange', function () {
-    it('should preview "add seats" change', function (done) {
-      this.req.body = { adding: 2 }
-
-      const res = {
-        json: data => {
-          this.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange
-            .calledWith(this.req.session.user._id, this.req.body.adding)
-            .should.equal(true)
-          data.should.deep.equal(this.previewSubscriptionChangeData)
-          done()
-        },
-      }
+    it('should preview "add seats" change', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body = { adding: 2 }
+
+        const res = {
+          json: data => {
+            ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange
+              .calledWith(ctx.req.session.user._id, ctx.req.body.adding)
+              .should.equal(true)
+            data.should.deep.equal(ctx.previewSubscriptionChangeData)
+            resolve()
+          },
+        }
 
-      this.Controller.previewAddSeatsSubscriptionChange(this.req, res)
+        ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res)
+      })
     })
 
-    it('should fail previewing "add seats" change', function (done) {
-      this.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange =
-        sinon.stub().rejects()
+    it('should fail previewing "add seats" change', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange =
+          sinon.stub().rejects()
 
-      const res = {
-        status: statusCode => {
-          statusCode.should.equal(500)
+        const res = {
+          status: statusCode => {
+            statusCode.should.equal(500)
 
-          return {
-            end: () => {
-              done()
-            },
-          }
-        },
-      }
+            return {
+              end: () => {
+                resolve()
+              },
+            }
+          },
+        }
 
-      this.Controller.previewAddSeatsSubscriptionChange(this.req, res)
+        ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res)
+      })
     })
 
-    it('should fail previewing "add seats" change with SubtotalLimitExceededError', function (done) {
-      this.req.body = { adding: 2 }
-      this.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange =
-        sinon.stub().throws(new this.Errors.SubtotalLimitExceededError())
-
-      const res = {
-        status: statusCode => {
-          statusCode.should.equal(422)
-
-          return {
-            json: data => {
-              data.should.deep.equal({
-                code: 'subtotal_limit_exceeded',
-                adding: this.req.body.adding,
-              })
-              done()
-            },
-          }
-        },
-      }
+    it('should fail previewing "add seats" change with SubtotalLimitExceededError', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body = { adding: 2 }
+        ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange =
+          sinon.stub().throws(new ctx.Errors.SubtotalLimitExceededError())
+
+        const res = {
+          status: statusCode => {
+            statusCode.should.equal(422)
+
+            return {
+              json: data => {
+                data.should.deep.equal({
+                  code: 'subtotal_limit_exceeded',
+                  adding: ctx.req.body.adding,
+                })
+                resolve()
+              },
+            }
+          },
+        }
 
-      this.Controller.previewAddSeatsSubscriptionChange(this.req, res)
+        ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res)
+      })
     })
   })
 
   describe('createAddSeatsSubscriptionChange', function () {
-    it('should apply "add seats" change', function (done) {
-      this.req.body = { adding: 2 }
-
-      const res = {
-        json: data => {
-          this.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange
-            .calledWith(this.req.session.user._id, this.req.body.adding)
-            .should.equal(true)
-          data.should.deep.equal(this.createSubscriptionChangeData)
-          done()
-        },
-      }
+    it('should apply "add seats" change', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body = { adding: 2 }
+
+        const res = {
+          json: data => {
+            ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange
+              .calledWith(ctx.req.session.user._id, ctx.req.body.adding)
+              .should.equal(true)
+            data.should.deep.equal(ctx.createSubscriptionChangeData)
+            resolve()
+          },
+        }
 
-      this.Controller.createAddSeatsSubscriptionChange(this.req, res)
+        ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
+      })
     })
 
-    it('should fail applying "add seats" change', function (done) {
-      this.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
-        sinon.stub().rejects()
+    it('should fail applying "add seats" change', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
+          sinon.stub().rejects()
 
-      const res = {
-        status: statusCode => {
-          statusCode.should.equal(500)
+        const res = {
+          status: statusCode => {
+            statusCode.should.equal(500)
 
-          return {
-            end: () => {
-              done()
-            },
-          }
-        },
-      }
+            return {
+              end: () => {
+                resolve()
+              },
+            }
+          },
+        }
 
-      this.Controller.createAddSeatsSubscriptionChange(this.req, res)
+        ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
+      })
     })
 
-    it('should fail applying "add seats" change with SubtotalLimitExceededError', function (done) {
-      this.req.body = { adding: 2 }
-      this.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
-        sinon.stub().throws(new this.Errors.SubtotalLimitExceededError())
-
-      const res = {
-        status: statusCode => {
-          statusCode.should.equal(422)
-
-          return {
-            json: data => {
-              data.should.deep.equal({
-                code: 'subtotal_limit_exceeded',
-                adding: this.req.body.adding,
-              })
-              done()
-            },
-          }
-        },
-      }
+    it('should fail applying "add seats" change with SubtotalLimitExceededError', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body = { adding: 2 }
+        ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
+          sinon.stub().throws(new ctx.Errors.SubtotalLimitExceededError())
+
+        const res = {
+          status: statusCode => {
+            statusCode.should.equal(422)
+
+            return {
+              json: data => {
+                data.should.deep.equal({
+                  code: 'subtotal_limit_exceeded',
+                  adding: ctx.req.body.adding,
+                })
+                resolve()
+              },
+            }
+          },
+        }
 
-      this.Controller.createAddSeatsSubscriptionChange(this.req, res)
+        ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
+      })
     })
   })
 
   describe('submitForm', function () {
-    it('should build and pass the request body to the sales submit handler', function (done) {
-      const adding = 100
-      const poNumber = 'PO123456'
-      this.req.body = { adding, poNumber }
-
-      const res = {
-        sendStatus: code => {
-          this.SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
-            this.adminUserId,
-            this.recurlySubscription,
-            poNumber
-          )
-          this.Modules.promises.hooks.fire
-            .calledWith('sendSupportRequest', {
-              email: this.user.email,
-              subject: 'Sales Contact Form',
-              message:
-                '\n' +
-                '**Overleaf Sales Contact Form:**\n' +
-                '\n' +
-                '**Subject:** Self-Serve Group User Increase Request\n' +
-                '\n' +
-                `**Estimated Number of Users:** ${adding}\n` +
-                '\n' +
-                `**PO Number:** ${poNumber}\n` +
-                '\n' +
-                `**Message:** This email has been generated on behalf of user with email **${this.user.email}** to request an increase in the total number of users for their subscription.`,
-              inbox: 'sales',
-            })
-            .should.equal(true)
-          sinon.assert.calledOnce(this.Modules.promises.hooks.fire)
-          code.should.equal(204)
-          done()
-        },
-      }
-      this.Controller.submitForm(this.req, res, done)
+    it('should build and pass the request body to the sales submit handler', function (ctx) {
+      return new Promise(resolve => {
+        const adding = 100
+        const poNumber = 'PO123456'
+        ctx.req.body = { adding, poNumber }
+
+        const res = {
+          sendStatus: code => {
+            ctx.SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
+              ctx.adminUserId,
+              ctx.recurlySubscription,
+              poNumber
+            )
+            ctx.Modules.promises.hooks.fire
+              .calledWith('sendSupportRequest', {
+                email: ctx.user.email,
+                subject: 'Sales Contact Form',
+                message:
+                  '\n' +
+                  '**Overleaf Sales Contact Form:**\n' +
+                  '\n' +
+                  '**Subject:** Self-Serve Group User Increase Request\n' +
+                  '\n' +
+                  `**Estimated Number of Users:** ${adding}\n` +
+                  '\n' +
+                  `**PO Number:** ${poNumber}\n` +
+                  '\n' +
+                  `**Message:** This email has been generated on behalf of user with email **${ctx.user.email}** to request an increase in the total number of users for their subscription.`,
+                inbox: 'sales',
+              })
+              .should.equal(true)
+            sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire)
+            code.should.equal(204)
+            resolve()
+          },
+        }
+        ctx.Controller.submitForm(ctx.req, res, resolve)
+      })
     })
   })
 
   describe('subscriptionUpgradePage', function () {
-    it('should render "subscription upgrade" page', function (done) {
-      const olSubscription = { membersLimit: 1, teamName: 'test team' }
-      this.SubscriptionModel.Subscription.findOne = () => {
-        return {
-          exec: () => olSubscription,
+    it('should render "subscription upgrade" page', function (ctx) {
+      return new Promise(resolve => {
+        const olSubscription = { membersLimit: 1, teamName: 'test team' }
+        ctx.SubscriptionModel.Subscription.findOne = () => {
+          return {
+            exec: () => olSubscription,
+          }
         }
-      }
-
-      const res = {
-        render: (page, data) => {
-          this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview
-            .calledWith(this.req.session.user._id)
-            .should.equal(true)
-          page.should.equal('subscriptions/upgrade-group-subscription-react')
-          data.totalLicenses.should.equal(olSubscription.membersLimit)
-          data.groupName.should.equal(olSubscription.teamName)
-          data.changePreview.should.equal(this.previewSubscriptionChangeData)
-          done()
-        },
-      }
 
-      this.Controller.subscriptionUpgradePage(this.req, res)
+        const res = {
+          render: (page, data) => {
+            ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview
+              .calledWith(ctx.req.session.user._id)
+              .should.equal(true)
+            page.should.equal('subscriptions/upgrade-group-subscription-react')
+            data.totalLicenses.should.equal(olSubscription.membersLimit)
+            data.groupName.should.equal(olSubscription.teamName)
+            data.changePreview.should.equal(ctx.previewSubscriptionChangeData)
+            resolve()
+          },
+        }
+
+        ctx.Controller.subscriptionUpgradePage(ctx.req, res)
+      })
     })
 
-    it('should redirect if failed to generate preview', function (done) {
-      this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
-        .stub()
-        .rejects()
+    it('should redirect if failed to generate preview', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
+          .stub()
+          .rejects()
 
-      const res = {
-        redirect: url => {
-          url.should.equal('/user/subscription')
-          done()
-        },
-      }
+        const res = {
+          redirect: url => {
+            url.should.equal('/user/subscription')
+            resolve()
+          },
+        }
 
-      this.Controller.subscriptionUpgradePage(this.req, res)
+        ctx.Controller.subscriptionUpgradePage(ctx.req, res)
+      })
     })
 
-    it('should redirect to missing billing information page when billing information is missing', function (done) {
-      this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
-        .stub()
-        .throws(new this.Errors.MissingBillingInfoError())
-
-      const res = {
-        redirect: url => {
-          url.should.equal(
-            '/user/subscription/group/missing-billing-information'
-          )
-          done()
-        },
-      }
+    it('should redirect to missing billing information page when billing information is missing', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
+          .stub()
+          .throws(new ctx.Errors.MissingBillingInfoError())
 
-      this.Controller.subscriptionUpgradePage(this.req, res)
+        const res = {
+          redirect: url => {
+            url.should.equal(
+              '/user/subscription/group/missing-billing-information'
+            )
+            resolve()
+          },
+        }
+
+        ctx.Controller.subscriptionUpgradePage(ctx.req, res)
+      })
     })
 
-    it('should redirect to manually collected subscription error page when collection method is manual', function (done) {
-      this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
-        .stub()
-        .throws(new this.Errors.ManuallyCollectedError())
-
-      const res = {
-        redirect: url => {
-          url.should.equal(
-            '/user/subscription/group/manually-collected-subscription'
-          )
-          done()
-        },
-      }
+    it('should redirect to manually collected subscription error page when collection method is manual', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
+          .stub()
+          .throws(new ctx.Errors.ManuallyCollectedError())
+
+        const res = {
+          redirect: url => {
+            url.should.equal(
+              '/user/subscription/group/manually-collected-subscription'
+            )
+            resolve()
+          },
+        }
 
-      this.Controller.subscriptionUpgradePage(this.req, res)
+        ctx.Controller.subscriptionUpgradePage(ctx.req, res)
+      })
     })
 
-    it('should redirect to subtotal limit exceeded page', function (done) {
-      this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
-        .stub()
-        .throws(new this.Errors.SubtotalLimitExceededError())
+    it('should redirect to subtotal limit exceeded page', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
+          .stub()
+          .throws(new ctx.Errors.SubtotalLimitExceededError())
 
-      const res = {
-        redirect: url => {
-          url.should.equal('/user/subscription/group/subtotal-limit-exceeded')
-          done()
-        },
-      }
+        const res = {
+          redirect: url => {
+            url.should.equal('/user/subscription/group/subtotal-limit-exceeded')
+            resolve()
+          },
+        }
 
-      this.Controller.subscriptionUpgradePage(this.req, res)
+        ctx.Controller.subscriptionUpgradePage(ctx.req, res)
+      })
     })
   })
 
   describe('upgradeSubscription', function () {
-    it('should send 200 response', function (done) {
-      this.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
-        .stub()
-        .resolves()
-
-      const res = {
-        sendStatus: code => {
-          code.should.equal(200)
-          done()
-        },
-      }
+    it('should send 200 response', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
+          .stub()
+          .resolves()
+
+        const res = {
+          sendStatus: code => {
+            code.should.equal(200)
+            resolve()
+          },
+        }
 
-      this.Controller.upgradeSubscription(this.req, res)
+        ctx.Controller.upgradeSubscription(ctx.req, res)
+      })
     })
 
-    it('should send 500 response', function (done) {
-      this.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
-        .stub()
-        .rejects()
+    it('should send 500 response', function (ctx) {
+      return new Promise(resolve => {
+        ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon
+          .stub()
+          .rejects()
 
-      const res = {
-        sendStatus: code => {
-          code.should.equal(500)
-          done()
-        },
-      }
+        const res = {
+          sendStatus: code => {
+            code.should.equal(500)
+            resolve()
+          },
+        }
 
-      this.Controller.upgradeSubscription(this.req, res)
+        ctx.Controller.upgradeSubscription(ctx.req, res)
+      })
     })
   })
 })

+ 178 - 128
services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs

@@ -1,20 +1,20 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import { expect } from 'chai'
 const modulePath =
   '../../../../app/src/Features/Subscription/TeamInvitesController'
 
 describe('TeamInvitesController', function () {
-  beforeEach(async function () {
-    this.user = { _id: '!@312431', email: 'user@email.com' }
-    this.adminUserId = '123jlkj'
-    this.subscriptionId = '123434325412'
-    this.user_email = 'bob@gmail.com'
-    this.req = {
+  beforeEach(async function (ctx) {
+    ctx.user = { _id: '!@312431', email: 'user@email.com' }
+    ctx.adminUserId = '123jlkj'
+    ctx.subscriptionId = '123434325412'
+    ctx.user_email = 'bob@gmail.com'
+    ctx.req = {
       session: {
         user: {
-          _id: this.adminUserId,
-          email: this.user_email,
+          _id: ctx.adminUserId,
+          email: ctx.user_email,
         },
       },
       params: {},
@@ -22,33 +22,33 @@ describe('TeamInvitesController', function () {
       ip: '0.0.0.0',
     }
 
-    this.subscription = {
-      _id: this.subscriptionId,
+    ctx.subscription = {
+      _id: ctx.subscriptionId,
     }
 
-    this.TeamInvitesHandler = {
+    ctx.TeamInvitesHandler = {
       promises: {
-        acceptInvite: sinon.stub().resolves(this.subscription),
+        acceptInvite: sinon.stub().resolves(ctx.subscription),
         getInvite: sinon.stub().resolves({
           invite: {
-            email: this.user.email,
+            email: ctx.user.email,
             token: 'token123',
-            inviterName: this.user_email,
+            inviterName: ctx.user_email,
           },
-          subscription: this.subscription,
+          subscription: ctx.subscription,
         }),
       },
     }
 
-    this.SubscriptionLocator = {
+    ctx.SubscriptionLocator = {
       promises: {
         hasSSOEnabled: sinon.stub().resolves(true),
         getUsersSubscription: sinon.stub().resolves(),
       },
     }
-    this.ErrorController = { notFound: sinon.stub() }
+    ctx.ErrorController = { notFound: sinon.stub() }
 
-    this.SessionManager = {
+    ctx.SessionManager = {
       getLoggedInUserId(session) {
         return session.user?._id
       },
@@ -57,74 +57,112 @@ describe('TeamInvitesController', function () {
       },
     }
 
-    this.UserAuditLogHandler = {
+    ctx.UserAuditLogHandler = {
       promises: {
         addEntry: sinon.stub().resolves(),
       },
     }
-    this.UserGetter = {
+    ctx.UserGetter = {
       promises: {
-        getUser: sinon.stub().resolves(this.user),
-        getUserByMainEmail: sinon.stub().resolves(this.user),
-        getUserByAnyEmail: sinon.stub().resolves(this.user),
+        getUser: sinon.stub().resolves(ctx.user),
+        getUserByMainEmail: sinon.stub().resolves(ctx.user),
+        getUserByAnyEmail: sinon.stub().resolves(ctx.user),
       },
     }
-    this.EmailHandler = {
+    ctx.EmailHandler = {
       sendDeferredEmail: sinon.stub().resolves(),
     }
 
-    this.RateLimiter = {
+    ctx.RateLimiter = {
       RateLimiter: class {},
     }
 
-    this.Controller = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Subscription/TeamInvitesHandler':
-        this.TeamInvitesHandler,
-      '../../../../app/src/Features/Authentication/SessionManager':
-        this.SessionManager,
-      '../../../../app/src/Features/Subscription/SubscriptionLocator':
-        this.SubscriptionLocator,
-      '../../../../app/src/Features/User/UserAuditLogHandler':
-        this.UserAuditLogHandler,
-      '../../../../app/src/Features/Errors/ErrorController':
-        this.ErrorController,
-      '../../../../app/src/Features/User/UserGetter': this.UserGetter,
-      '../../../../app/src/Features/Email/EmailHandler': this.EmailHandler,
-      '../../../../app/src/infrastructure/RateLimiter': this.RateLimiter,
-      '../../../../app/src/infrastructure/Modules': (this.Modules = {
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/TeamInvitesHandler',
+      () => ({
+        default: ctx.TeamInvitesHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: ctx.SubscriptionLocator,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
+      default: ctx.UserAuditLogHandler,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Errors/ErrorController', () => ({
+      default: ctx.ErrorController,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
+      default: ctx.EmailHandler,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/infrastructure/RateLimiter',
+      () => ctx.RateLimiter
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: (ctx.Modules = {
         promises: {
           hooks: {
             fire: sinon.stub().resolves([]),
           },
         },
       }),
-      '../../../../app/src/Features/SplitTests/SplitTestHandler':
-        (this.SplitTestHandler = {
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: (ctx.SplitTestHandler = {
           promises: {
             getAssignment: sinon.stub().resolves({}),
           },
         }),
-    })
+      })
+    )
+
+    ctx.Controller = (await import(modulePath)).default
   })
 
   describe('acceptInvite', function () {
-    it('should add an audit log entry', function (done) {
-      this.req.params.token = 'foo'
-      this.req.session.user = this.user
-      const res = {
-        json: () => {
-          sinon.assert.calledWith(
-            this.UserAuditLogHandler.promises.addEntry,
-            this.user._id,
-            'accept-group-invitation',
-            this.user._id,
-            this.req.ip,
-            { subscriptionId: this.subscriptionId }
-          )
-          done()
-        },
-      }
-      this.Controller.acceptInvite(this.req, res)
+    it('should add an audit log entry', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.params.token = 'foo'
+        ctx.req.session.user = ctx.user
+        const res = {
+          json: () => {
+            sinon.assert.calledWith(
+              ctx.UserAuditLogHandler.promises.addEntry,
+              ctx.user._id,
+              'accept-group-invitation',
+              ctx.user._id,
+              ctx.req.ip,
+              { subscriptionId: ctx.subscriptionId }
+            )
+            resolve()
+          },
+        }
+        ctx.Controller.acceptInvite(ctx.req, res)
+      })
     })
   })
 
@@ -138,90 +176,102 @@ describe('TeamInvitesController', function () {
     }
 
     describe('hasIndividualRecurlySubscription', function () {
-      it('is true for personal subscription', function (done) {
-        this.SubscriptionLocator.promises.getUsersSubscription.resolves({
-          recurlySubscription_id: 'subscription123',
-          groupPlan: false,
+      it('is true for personal subscription', function (ctx) {
+        return new Promise(resolve => {
+          ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({
+            recurlySubscription_id: 'subscription123',
+            groupPlan: false,
+          })
+          const res = {
+            render: (template, data) => {
+              expect(data.hasIndividualRecurlySubscription).to.be.true
+              resolve()
+            },
+          }
+          ctx.Controller.viewInvite(req, res)
         })
-        const res = {
-          render: (template, data) => {
-            expect(data.hasIndividualRecurlySubscription).to.be.true
-            done()
-          },
-        }
-        this.Controller.viewInvite(req, res)
       })
 
-      it('is true for group subscriptions', function (done) {
-        this.SubscriptionLocator.promises.getUsersSubscription.resolves({
-          recurlySubscription_id: 'subscription123',
-          groupPlan: true,
+      it('is true for group subscriptions', function (ctx) {
+        return new Promise(resolve => {
+          ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({
+            recurlySubscription_id: 'subscription123',
+            groupPlan: true,
+          })
+          const res = {
+            render: (template, data) => {
+              expect(data.hasIndividualRecurlySubscription).to.be.false
+              resolve()
+            },
+          }
+          ctx.Controller.viewInvite(req, res)
         })
-        const res = {
-          render: (template, data) => {
-            expect(data.hasIndividualRecurlySubscription).to.be.false
-            done()
-          },
-        }
-        this.Controller.viewInvite(req, res)
       })
 
-      it('is false for canceled subscriptions', function (done) {
-        this.SubscriptionLocator.promises.getUsersSubscription.resolves({
-          recurlySubscription_id: 'subscription123',
-          groupPlan: false,
-          recurlyStatus: {
-            state: 'canceled',
-          },
+      it('is false for canceled subscriptions', function (ctx) {
+        return new Promise(resolve => {
+          ctx.SubscriptionLocator.promises.getUsersSubscription.resolves({
+            recurlySubscription_id: 'subscription123',
+            groupPlan: false,
+            recurlyStatus: {
+              state: 'canceled',
+            },
+          })
+          const res = {
+            render: (template, data) => {
+              expect(data.hasIndividualRecurlySubscription).to.be.false
+              resolve()
+            },
+          }
+          ctx.Controller.viewInvite(req, res)
         })
-        const res = {
-          render: (template, data) => {
-            expect(data.hasIndividualRecurlySubscription).to.be.false
-            done()
-          },
-        }
-        this.Controller.viewInvite(req, res)
       })
     })
 
     describe('when user is logged out', function () {
-      it('renders logged out invite page', function (done) {
-        const res = {
-          render: (template, data) => {
-            expect(template).to.equal('subscriptions/team/invite_logged_out')
-            expect(data.groupSSOActive).to.be.undefined
-            done()
-          },
-        }
-        this.Controller.viewInvite(
-          { params: { token: 'token123' }, session: {} },
-          res
-        )
+      it('renders logged out invite page', function (ctx) {
+        return new Promise(resolve => {
+          const res = {
+            render: (template, data) => {
+              expect(template).to.equal('subscriptions/team/invite_logged_out')
+              expect(data.groupSSOActive).to.be.undefined
+              resolve()
+            },
+          }
+          ctx.Controller.viewInvite(
+            { params: { token: 'token123' }, session: {} },
+            res
+          )
+        })
       })
 
-      it('includes groupSSOActive flag when the group has SSO enabled', function (done) {
-        this.Modules.promises.hooks.fire = sinon.stub().resolves([true])
+      it('includes groupSSOActive flag when the group has SSO enabled', function (ctx) {
+        return new Promise(resolve => {
+          ctx.Modules.promises.hooks.fire = sinon.stub().resolves([true])
+          const res = {
+            render: (template, data) => {
+              expect(data.groupSSOActive).to.be.true
+              resolve()
+            },
+          }
+          ctx.Controller.viewInvite(
+            { params: { token: 'token123' }, session: {} },
+            res
+          )
+        })
+      })
+    })
+
+    it('renders the view', function (ctx) {
+      return new Promise(resolve => {
         const res = {
-          render: (template, data) => {
-            expect(data.groupSSOActive).to.be.true
-            done()
+          render: template => {
+            expect(template).to.equal('subscriptions/team/invite')
+            resolve()
           },
         }
-        this.Controller.viewInvite(
-          { params: { token: 'token123' }, session: {} },
-          res
-        )
+        ctx.Controller.viewInvite(req, res)
       })
     })
-
-    it('renders the view', function (done) {
-      const res = {
-        render: template => {
-          expect(template).to.equal('subscriptions/team/invite')
-          done()
-        },
-      }
-      this.Controller.viewInvite(req, res)
-    })
   })
 })

+ 224 - 197
services/web/test/unit/src/Tags/TagsController.test.mjs

@@ -1,17 +1,14 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import { assert } from 'chai'
-const modulePath = new URL(
-  '../../../../app/src/Features/Tags/TagsController.mjs',
-  import.meta.url
-).pathname
+const modulePath = '../../../../app/src/Features/Tags/TagsController.mjs'
 
 describe('TagsController', function () {
   const userId = '123nd3ijdks'
   const projectId = '123njdskj9jlk'
 
-  beforeEach(async function () {
-    this.TagsHandler = {
+  beforeEach(async function (ctx) {
+    ctx.TagsHandler = {
       promises: {
         addProjectToTag: sinon.stub().resolves(),
         addProjectsToTag: sinon.stub().resolves(),
@@ -23,17 +20,25 @@ describe('TagsController', function () {
         createTag: sinon.stub().resolves(),
       },
     }
-    this.SessionManager = {
+    ctx.SessionManager = {
       getLoggedInUserId: session => {
         return session.user._id
       },
     }
-    this.TagsController = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/Tags/TagsHandler': this.TagsHandler,
-      '../../../../app/src/Features/Authentication/SessionManager':
-        this.SessionManager,
-    })
-    this.req = {
+
+    vi.doMock('../../../../app/src/Features/Tags/TagsHandler', () => ({
+      default: ctx.TagsHandler,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    ctx.TagsController = (await import(modulePath)).default
+    ctx.req = {
       params: {
         projectId,
       },
@@ -45,65 +50,92 @@ describe('TagsController', function () {
       body: {},
     }
 
-    this.res = {}
-    this.res.status = sinon.stub().returns(this.res)
-    this.res.end = sinon.stub()
-    this.res.json = sinon.stub()
-  })
-
-  it('get all tags', function (done) {
-    const allTags = [{ name: 'tag', projects: ['123423', '423423'] }]
-    this.TagsHandler.promises.getAllTags = sinon.stub().resolves(allTags)
-    this.TagsController.getAllTags(this.req, {
-      json: body => {
-        body.should.equal(allTags)
-        sinon.assert.calledWith(this.TagsHandler.promises.getAllTags, userId)
-        done()
-        return {
-          end: () => {},
-        }
-      },
-    })
+    ctx.res = {}
+    ctx.res.status = sinon.stub().returns(ctx.res)
+    ctx.res.end = sinon.stub()
+    ctx.res.json = sinon.stub()
   })
 
-  describe('create a tag', function (done) {
-    it('without a color', function (done) {
-      this.tag = { mock: 'tag' }
-      this.TagsHandler.promises.createTag = sinon.stub().resolves(this.tag)
-      this.req.session.user._id = this.userId = 'user-id-123'
-      this.req.body = { name: (this.name = 'tag-name') }
-      this.TagsController.createTag(this.req, {
-        json: () => {
-          sinon.assert.calledWith(
-            this.TagsHandler.promises.createTag,
-            this.userId,
-            this.name
-          )
-          done()
+  it('get all tags', function (ctx) {
+    return new Promise(resolve => {
+      const allTags = [{ name: 'tag', projects: ['123423', '423423'] }]
+      ctx.TagsHandler.promises.getAllTags = sinon.stub().resolves(allTags)
+      ctx.TagsController.getAllTags(ctx.req, {
+        json: body => {
+          body.should.equal(allTags)
+          sinon.assert.calledWith(ctx.TagsHandler.promises.getAllTags, userId)
+          resolve()
           return {
             end: () => {},
           }
         },
       })
     })
+  })
+
+  describe('create a tag', function (done) {
+    it('without a color', function (ctx) {
+      return new Promise(resolve => {
+        ctx.tag = { mock: 'tag' }
+        ctx.TagsHandler.promises.createTag = sinon.stub().resolves(ctx.tag)
+        ctx.req.session.user._id = ctx.userId = 'user-id-123'
+        ctx.req.body = { name: (ctx.tagName = 'tag-name') }
+        ctx.TagsController.createTag(ctx.req, {
+          json: () => {
+            sinon.assert.calledWith(
+              ctx.TagsHandler.promises.createTag,
+              ctx.userId,
+              ctx.tagName
+            )
+            resolve()
+            return {
+              end: () => {},
+            }
+          },
+        })
+      })
+    })
 
-    it('with a color', function (done) {
-      this.tag = { mock: 'tag' }
-      this.TagsHandler.promises.createTag = sinon.stub().resolves(this.tag)
-      this.req.session.user._id = this.userId = 'user-id-123'
-      this.req.body = {
-        name: (this.name = 'tag-name'),
-        color: (this.color = '#123456'),
-      }
-      this.TagsController.createTag(this.req, {
-        json: () => {
+    it('with a color', function (ctx) {
+      return new Promise(resolve => {
+        ctx.tag = { mock: 'tag' }
+        ctx.TagsHandler.promises.createTag = sinon.stub().resolves(ctx.tag)
+        ctx.req.session.user._id = ctx.userId = 'user-id-123'
+        ctx.req.body = {
+          name: (ctx.tagName = 'tag-name'),
+          color: (ctx.color = '#123456'),
+        }
+        ctx.TagsController.createTag(ctx.req, {
+          json: () => {
+            sinon.assert.calledWith(
+              ctx.TagsHandler.promises.createTag,
+              ctx.userId,
+              ctx.tagName,
+              ctx.color
+            )
+            resolve()
+            return {
+              end: () => {},
+            }
+          },
+        })
+      })
+    })
+  })
+
+  it('delete a tag', function (ctx) {
+    return new Promise(resolve => {
+      ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
+      ctx.req.session.user._id = ctx.userId = 'user-id-123'
+      ctx.TagsController.deleteTag(ctx.req, {
+        status: code => {
+          assert.equal(code, 204)
           sinon.assert.calledWith(
-            this.TagsHandler.promises.createTag,
-            this.userId,
-            this.name,
-            this.color
+            ctx.TagsHandler.promises.deleteTag,
+            ctx.userId,
+            ctx.tagId
           )
-          done()
+          resolve()
           return {
             end: () => {},
           }
@@ -112,82 +144,141 @@ describe('TagsController', function () {
     })
   })
 
-  it('delete a tag', function (done) {
-    this.req.params.tagId = this.tagId = 'tag-id-123'
-    this.req.session.user._id = this.userId = 'user-id-123'
-    this.TagsController.deleteTag(this.req, {
-      status: code => {
-        assert.equal(code, 204)
-        sinon.assert.calledWith(
-          this.TagsHandler.promises.deleteTag,
-          this.userId,
-          this.tagId
-        )
-        done()
-        return {
-          end: () => {},
+  describe('edit a tag', function () {
+    beforeEach(function (ctx) {
+      ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
+      ctx.req.session.user._id = ctx.userId = 'user-id-123'
+    })
+
+    it('with a name and no color', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body = {
+          name: (ctx.tagName = 'new-name'),
         }
-      },
+        ctx.TagsController.editTag(ctx.req, {
+          status: code => {
+            assert.equal(code, 204)
+            sinon.assert.calledWith(
+              ctx.TagsHandler.promises.editTag,
+              ctx.userId,
+              ctx.tagId,
+              ctx.tagName
+            )
+            resolve()
+            return {
+              end: () => {},
+            }
+          },
+        })
+      })
     })
-  })
 
-  describe('edit a tag', function () {
-    beforeEach(function () {
-      this.req.params.tagId = this.tagId = 'tag-id-123'
-      this.req.session.user._id = this.userId = 'user-id-123'
+    it('with a name and color', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body = {
+          name: (ctx.tagName = 'new-name'),
+          color: (ctx.color = '#FF0011'),
+        }
+        ctx.TagsController.editTag(ctx.req, {
+          status: code => {
+            assert.equal(code, 204)
+            sinon.assert.calledWith(
+              ctx.TagsHandler.promises.editTag,
+              ctx.userId,
+              ctx.tagId,
+              ctx.tagName,
+              ctx.color
+            )
+            resolve()
+            return {
+              end: () => {},
+            }
+          },
+        })
+      })
     })
 
-    it('with a name and no color', function (done) {
-      this.req.body = {
-        name: (this.name = 'new-name'),
-      }
-      this.TagsController.editTag(this.req, {
+    it('without a name', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body = { name: undefined }
+        ctx.TagsController.renameTag(ctx.req, {
+          status: code => {
+            assert.equal(code, 400)
+            sinon.assert.notCalled(ctx.TagsHandler.promises.renameTag)
+            resolve()
+            return {
+              end: () => {},
+            }
+          },
+        })
+      })
+    })
+  })
+
+  it('add a project to a tag', function (ctx) {
+    return new Promise(resolve => {
+      ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
+      ctx.req.params.projectId = ctx.projectId = 'project-id-123'
+      ctx.req.session.user._id = ctx.userId = 'user-id-123'
+      ctx.TagsController.addProjectToTag(ctx.req, {
         status: code => {
           assert.equal(code, 204)
           sinon.assert.calledWith(
-            this.TagsHandler.promises.editTag,
-            this.userId,
-            this.tagId,
-            this.name
+            ctx.TagsHandler.promises.addProjectToTag,
+            ctx.userId,
+            ctx.tagId,
+            ctx.projectId
           )
-          done()
+          resolve()
           return {
             end: () => {},
           }
         },
       })
     })
+  })
 
-    it('with a name and color', function (done) {
-      this.req.body = {
-        name: (this.name = 'new-name'),
-        color: (this.color = '#FF0011'),
-      }
-      this.TagsController.editTag(this.req, {
+  it('add projects to a tag', function (ctx) {
+    return new Promise(resolve => {
+      ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
+      ctx.req.body.projectIds = ctx.projectIds = [
+        'project-id-123',
+        'project-id-234',
+      ]
+      ctx.req.session.user._id = ctx.userId = 'user-id-123'
+      ctx.TagsController.addProjectsToTag(ctx.req, {
         status: code => {
           assert.equal(code, 204)
           sinon.assert.calledWith(
-            this.TagsHandler.promises.editTag,
-            this.userId,
-            this.tagId,
-            this.name,
-            this.color
+            ctx.TagsHandler.promises.addProjectsToTag,
+            ctx.userId,
+            ctx.tagId,
+            ctx.projectIds
           )
-          done()
+          resolve()
           return {
             end: () => {},
           }
         },
       })
     })
+  })
 
-    it('without a name', function (done) {
-      this.req.body = { name: undefined }
-      this.TagsController.renameTag(this.req, {
+  it('remove a project from a tag', function (ctx) {
+    return new Promise(resolve => {
+      ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
+      ctx.req.params.projectId = ctx.projectId = 'project-id-123'
+      ctx.req.session.user._id = ctx.userId = 'user-id-123'
+      ctx.TagsController.removeProjectFromTag(ctx.req, {
         status: code => {
-          assert.equal(code, 400)
-          sinon.assert.notCalled(this.TagsHandler.promises.renameTag)
-          done()
+          assert.equal(code, 204)
+          sinon.assert.calledWith(
+            ctx.TagsHandler.promises.removeProjectFromTag,
+            ctx.userId,
+            ctx.tagId,
+            ctx.projectId
+          )
+          resolve()
           return {
             end: () => {},
           }
@@ -196,93 +287,29 @@ describe('TagsController', function () {
     })
   })
 
-  it('add a project to a tag', function (done) {
-    this.req.params.tagId = this.tagId = 'tag-id-123'
-    this.req.params.projectId = this.projectId = 'project-id-123'
-    this.req.session.user._id = this.userId = 'user-id-123'
-    this.TagsController.addProjectToTag(this.req, {
-      status: code => {
-        assert.equal(code, 204)
-        sinon.assert.calledWith(
-          this.TagsHandler.promises.addProjectToTag,
-          this.userId,
-          this.tagId,
-          this.projectId
-        )
-        done()
-        return {
-          end: () => {},
-        }
-      },
-    })
-  })
-
-  it('add projects to a tag', function (done) {
-    this.req.params.tagId = this.tagId = 'tag-id-123'
-    this.req.body.projectIds = this.projectIds = [
-      'project-id-123',
-      'project-id-234',
-    ]
-    this.req.session.user._id = this.userId = 'user-id-123'
-    this.TagsController.addProjectsToTag(this.req, {
-      status: code => {
-        assert.equal(code, 204)
-        sinon.assert.calledWith(
-          this.TagsHandler.promises.addProjectsToTag,
-          this.userId,
-          this.tagId,
-          this.projectIds
-        )
-        done()
-        return {
-          end: () => {},
-        }
-      },
-    })
-  })
-
-  it('remove a project from a tag', function (done) {
-    this.req.params.tagId = this.tagId = 'tag-id-123'
-    this.req.params.projectId = this.projectId = 'project-id-123'
-    this.req.session.user._id = this.userId = 'user-id-123'
-    this.TagsController.removeProjectFromTag(this.req, {
-      status: code => {
-        assert.equal(code, 204)
-        sinon.assert.calledWith(
-          this.TagsHandler.promises.removeProjectFromTag,
-          this.userId,
-          this.tagId,
-          this.projectId
-        )
-        done()
-        return {
-          end: () => {},
-        }
-      },
-    })
-  })
-
-  it('remove projects from a tag', function (done) {
-    this.req.params.tagId = this.tagId = 'tag-id-123'
-    this.req.body.projectIds = this.projectIds = [
-      'project-id-123',
-      'project-id-234',
-    ]
-    this.req.session.user._id = this.userId = 'user-id-123'
-    this.TagsController.removeProjectsFromTag(this.req, {
-      status: code => {
-        assert.equal(code, 204)
-        sinon.assert.calledWith(
-          this.TagsHandler.promises.removeProjectsFromTag,
-          this.userId,
-          this.tagId,
-          this.projectIds
-        )
-        done()
-        return {
-          end: () => {},
-        }
-      },
+  it('remove projects from a tag', function (ctx) {
+    return new Promise(resolve => {
+      ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
+      ctx.req.body.projectIds = ctx.projectIds = [
+        'project-id-123',
+        'project-id-234',
+      ]
+      ctx.req.session.user._id = ctx.userId = 'user-id-123'
+      ctx.TagsController.removeProjectsFromTag(ctx.req, {
+        status: code => {
+          assert.equal(code, 204)
+          sinon.assert.calledWith(
+            ctx.TagsHandler.promises.removeProjectsFromTag,
+            ctx.userId,
+            ctx.tagId,
+            ctx.projectIds
+          )
+          resolve()
+          return {
+            end: () => {},
+          }
+        },
+      })
     })
   })
 })

+ 386 - 327
services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs

@@ -1,6 +1,6 @@
+import { vi } from 'vitest'
 import mongodb from 'mongodb-legacy'
 import { expect } from 'chai'
-import esmock from 'esmock'
 import sinon from 'sinon'
 import Errors from '../../../../app/src/Features/Errors/Errors.js'
 import MockResponse from '../helpers/MockResponse.js'
@@ -12,498 +12,557 @@ const MODULE_PATH =
   '../../../../app/src/Features/ThirdPartyDataStore/TpdsController.mjs'
 
 describe('TpdsController', function () {
-  beforeEach(async function () {
-    this.metadata = {
+  beforeEach(async function (ctx) {
+    ctx.metadata = {
       projectId: new ObjectId(),
       entityId: new ObjectId(),
       folderId: new ObjectId(),
       entityType: 'doc',
       rev: 2,
     }
-    this.TpdsUpdateHandler = {
+    ctx.TpdsUpdateHandler = {
       promises: {
-        newUpdate: sinon.stub().resolves(this.metadata),
-        deleteUpdate: sinon.stub().resolves(this.metadata.entityId),
+        newUpdate: sinon.stub().resolves(ctx.metadata),
+        deleteUpdate: sinon.stub().resolves(ctx.metadata.entityId),
         createFolder: sinon.stub().resolves(),
       },
     }
-    this.UpdateMerger = {
+    ctx.UpdateMerger = {
       promises: {
-        mergeUpdate: sinon.stub().resolves(this.metadata),
-        deleteUpdate: sinon.stub().resolves(this.metadata.entityId),
+        mergeUpdate: sinon.stub().resolves(ctx.metadata),
+        deleteUpdate: sinon.stub().resolves(ctx.metadata.entityId),
       },
     }
-    this.NotificationsBuilder = {
+    ctx.NotificationsBuilder = {
       tpdsFileLimit: sinon.stub().returns({ create: sinon.stub() }),
     }
-    this.SessionManager = {
+    ctx.SessionManager = {
       getLoggedInUserId: sinon.stub().returns('user-id'),
     }
-    this.TpdsQueueManager = {
+    ctx.TpdsQueueManager = {
       promises: {
         getQueues: sinon.stub().resolves('queues'),
       },
     }
-    this.HttpErrorHandler = {
+    ctx.HttpErrorHandler = {
       conflict: sinon.stub(),
     }
 
-    this.newProject = { _id: new ObjectId() }
-    this.ProjectCreationHandler = {
-      promises: { createBlankProject: sinon.stub().resolves(this.newProject) },
+    ctx.newProject = { _id: new ObjectId() }
+    ctx.ProjectCreationHandler = {
+      promises: { createBlankProject: sinon.stub().resolves(ctx.newProject) },
     }
-    this.ProjectDetailsHandler = {
+    ctx.ProjectDetailsHandler = {
       promises: {
         generateUniqueName: sinon.stub().resolves('unique'),
       },
     }
-    this.TpdsController = await esmock.strict(MODULE_PATH, {
-      '../../../../app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler':
-        this.TpdsUpdateHandler,
-      '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger':
-        this.UpdateMerger,
-      '../../../../app/src/Features/Notifications/NotificationsBuilder':
-        this.NotificationsBuilder,
-      '../../../../app/src/Features/Authentication/SessionManager':
-        this.SessionManager,
-      '../../../../app/src/Features/Errors/HttpErrorHandler':
-        this.HttpErrorHandler,
-      '../../../../app/src/Features/ThirdPartyDataStore/TpdsQueueManager':
-        this.TpdsQueueManager,
-      '../../../../app/src/Features/Project/ProjectCreationHandler':
-        this.ProjectCreationHandler,
-      '../../../../app/src/Features/Project/ProjectDetailsHandler':
-        this.ProjectDetailsHandler,
-    })
 
-    this.user_id = 'dsad29jlkjas'
+    vi.doMock(
+      '../../../../app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler',
+      () => ({
+        default: ctx.TpdsUpdateHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger',
+      () => ({
+        default: ctx.UpdateMerger,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Notifications/NotificationsBuilder',
+      () => ({
+        default: ctx.NotificationsBuilder,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
+      default: ctx.HttpErrorHandler,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/ThirdPartyDataStore/TpdsQueueManager',
+      () => ({
+        default: ctx.TpdsQueueManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectCreationHandler',
+      () => ({
+        default: ctx.ProjectCreationHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectDetailsHandler',
+      () => ({
+        default: ctx.ProjectDetailsHandler,
+      })
+    )
+
+    ctx.TpdsController = (await import(MODULE_PATH)).default
+
+    ctx.user_id = 'dsad29jlkjas'
   })
 
   describe('creating a project', function () {
-    it('should yield the new projects id', function (done) {
-      const res = new MockResponse()
-      const req = new MockRequest()
-      req.params.user_id = this.user_id
-      req.body = { projectName: 'foo' }
-      res.callback = err => {
-        if (err) done(err)
-        expect(res.body).to.equal(
-          JSON.stringify({ projectId: this.newProject._id.toString() })
-        )
-        expect(
-          this.ProjectDetailsHandler.promises.generateUniqueName
-        ).to.have.been.calledWith(this.user_id, 'foo')
-        expect(
-          this.ProjectCreationHandler.promises.createBlankProject
-        ).to.have.been.calledWith(
-          this.user_id,
-          'unique',
-          {},
-          { skipCreatingInTPDS: true }
-        )
-        done()
-      }
-      this.TpdsController.createProject(req, res)
+    it('should yield the new projects id', function (ctx) {
+      return new Promise(resolve => {
+        const res = new MockResponse()
+        const req = new MockRequest()
+        req.params.user_id = ctx.user_id
+        req.body = { projectName: 'foo' }
+        res.callback = err => {
+          if (err) resolve(err)
+          expect(res.body).to.equal(
+            JSON.stringify({ projectId: ctx.newProject._id.toString() })
+          )
+          expect(
+            ctx.ProjectDetailsHandler.promises.generateUniqueName
+          ).to.have.been.calledWith(ctx.user_id, 'foo')
+          expect(
+            ctx.ProjectCreationHandler.promises.createBlankProject
+          ).to.have.been.calledWith(
+            ctx.user_id,
+            'unique',
+            {},
+            { skipCreatingInTPDS: true }
+          )
+          resolve()
+        }
+        ctx.TpdsController.createProject(req, res)
+      })
     })
   })
 
   describe('getting an update', function () {
-    beforeEach(function () {
-      this.projectName = 'projectName'
-      this.path = '/here.txt'
-      this.req = {
+    beforeEach(function (ctx) {
+      ctx.projectName = 'projectName'
+      ctx.path = '/here.txt'
+      ctx.req = {
         params: {
-          0: `${this.projectName}${this.path}`,
-          user_id: this.user_id,
+          0: `${ctx.projectName}${ctx.path}`,
+          user_id: ctx.user_id,
           project_id: '',
         },
         headers: {
-          'x-update-source': (this.source = 'dropbox'),
+          'x-update-source': (ctx.source = 'dropbox'),
         },
       }
     })
 
-    it('should process the update with the update receiver by name', function (done) {
-      const res = {
-        json: payload => {
-          expect(payload).to.deep.equal({
-            status: 'applied',
-            projectId: this.metadata.projectId.toString(),
-            entityId: this.metadata.entityId.toString(),
-            folderId: this.metadata.folderId.toString(),
-            entityType: this.metadata.entityType,
-            rev: this.metadata.rev.toString(),
-          })
-          this.TpdsUpdateHandler.promises.newUpdate
-            .calledWith(
-              this.user_id,
-              '', // projectId
-              this.projectName,
-              this.path,
-              this.req,
-              this.source
-            )
-            .should.equal(true)
-          done()
-        },
-      }
-      this.TpdsController.mergeUpdate(this.req, res)
+    it('should process the update with the update receiver by name', function (ctx) {
+      return new Promise(resolve => {
+        const res = {
+          json: payload => {
+            expect(payload).to.deep.equal({
+              status: 'applied',
+              projectId: ctx.metadata.projectId.toString(),
+              entityId: ctx.metadata.entityId.toString(),
+              folderId: ctx.metadata.folderId.toString(),
+              entityType: ctx.metadata.entityType,
+              rev: ctx.metadata.rev.toString(),
+            })
+            ctx.TpdsUpdateHandler.promises.newUpdate
+              .calledWith(
+                ctx.user_id,
+                '', // projectId
+                ctx.projectName,
+                ctx.path,
+                ctx.req,
+                ctx.source
+              )
+              .should.equal(true)
+            resolve()
+          },
+        }
+        ctx.TpdsController.mergeUpdate(ctx.req, res)
+      })
     })
 
-    it('should indicate in the response when the update was rejected', function (done) {
-      this.TpdsUpdateHandler.promises.newUpdate.resolves(null)
-      const res = {
-        json: payload => {
-          expect(payload).to.deep.equal({ status: 'rejected' })
-          done()
-        },
-      }
-      this.TpdsController.mergeUpdate(this.req, res)
+    it('should indicate in the response when the update was rejected', function (ctx) {
+      return new Promise(resolve => {
+        ctx.TpdsUpdateHandler.promises.newUpdate.resolves(null)
+        const res = {
+          json: payload => {
+            expect(payload).to.deep.equal({ status: 'rejected' })
+            resolve()
+          },
+        }
+        ctx.TpdsController.mergeUpdate(ctx.req, res)
+      })
     })
 
-    it('should process the update with the update receiver by id', function (done) {
-      const path = '/here.txt'
-      const req = {
-        pause() {},
-        params: { 0: path, user_id: this.user_id, project_id: '123' },
-        session: {
-          destroy() {},
-        },
-        headers: {
-          'x-update-source': (this.source = 'dropbox'),
-        },
-      }
-      const res = {
-        json: () => {
-          this.TpdsUpdateHandler.promises.newUpdate.should.have.been.calledWith(
-            this.user_id,
-            '123',
-            '', // projectName
-            '/here.txt',
-            req,
-            this.source
-          )
-          done()
-        },
-      }
-      this.TpdsController.mergeUpdate(req, res)
+    it('should process the update with the update receiver by id', function (ctx) {
+      return new Promise(resolve => {
+        const path = '/here.txt'
+        const req = {
+          pause() {},
+          params: { 0: path, user_id: ctx.user_id, project_id: '123' },
+          session: {
+            destroy() {},
+          },
+          headers: {
+            'x-update-source': (ctx.source = 'dropbox'),
+          },
+        }
+        const res = {
+          json: () => {
+            ctx.TpdsUpdateHandler.promises.newUpdate.should.have.been.calledWith(
+              ctx.user_id,
+              '123',
+              '', // projectName
+              '/here.txt',
+              req,
+              ctx.source
+            )
+            resolve()
+          },
+        }
+        ctx.TpdsController.mergeUpdate(req, res)
+      })
     })
 
-    it('should return a 500 error when the update receiver fails', function (done) {
-      this.TpdsUpdateHandler.promises.newUpdate.rejects(new Error())
-      const res = {
-        json: sinon.stub(),
-      }
-      this.TpdsController.mergeUpdate(this.req, res, err => {
-        expect(err).to.exist
-        expect(res.json).not.to.have.been.called
-        done()
+    it('should return a 500 error when the update receiver fails', function (ctx) {
+      return new Promise(resolve => {
+        ctx.TpdsUpdateHandler.promises.newUpdate.rejects(new Error())
+        const res = {
+          json: sinon.stub(),
+        }
+        ctx.TpdsController.mergeUpdate(ctx.req, res, err => {
+          expect(err).to.exist
+          expect(res.json).not.to.have.been.called
+          resolve()
+        })
       })
     })
 
-    it('should return a 400 error when the project is too big', function (done) {
-      this.TpdsUpdateHandler.promises.newUpdate.rejects({
-        message: 'project_has_too_many_files',
+    it('should return a 400 error when the project is too big', function (ctx) {
+      return new Promise(resolve => {
+        ctx.TpdsUpdateHandler.promises.newUpdate.rejects({
+          message: 'project_has_too_many_files',
+        })
+        const res = {
+          sendStatus: status => {
+            expect(status).to.equal(400)
+            ctx.NotificationsBuilder.tpdsFileLimit.should.have.been.calledWith(
+              ctx.user_id
+            )
+            resolve()
+          },
+        }
+        ctx.TpdsController.mergeUpdate(ctx.req, res)
       })
-      const res = {
-        sendStatus: status => {
-          expect(status).to.equal(400)
-          this.NotificationsBuilder.tpdsFileLimit.should.have.been.calledWith(
-            this.user_id
-          )
-          done()
-        },
-      }
-      this.TpdsController.mergeUpdate(this.req, res)
     })
 
-    it('should return a 429 error when the update receiver fails due to too many requests error', function (done) {
-      this.TpdsUpdateHandler.promises.newUpdate.rejects(
-        new Errors.TooManyRequestsError('project on cooldown')
-      )
-      const res = {
-        sendStatus: status => {
-          expect(status).to.equal(429)
-          done()
-        },
-      }
-      this.TpdsController.mergeUpdate(this.req, res)
+    it('should return a 429 error when the update receiver fails due to too many requests error', function (ctx) {
+      return new Promise(resolve => {
+        ctx.TpdsUpdateHandler.promises.newUpdate.rejects(
+          new Errors.TooManyRequestsError('project on cooldown')
+        )
+        const res = {
+          sendStatus: status => {
+            expect(status).to.equal(429)
+            resolve()
+          },
+        }
+        ctx.TpdsController.mergeUpdate(ctx.req, res)
+      })
     })
   })
 
   describe('getting a delete update', function () {
-    it('should process the delete with the update receiver by name', function (done) {
-      const path = '/projectName/here.txt'
-      const req = {
-        params: { 0: path, user_id: this.user_id, project_id: '' },
-        session: {
-          destroy() {},
-        },
-        headers: {
-          'x-update-source': (this.source = 'dropbox'),
-        },
-      }
-      const res = {
-        sendStatus: () => {
-          this.TpdsUpdateHandler.promises.deleteUpdate
-            .calledWith(
-              this.user_id,
-              '',
-              'projectName',
-              '/here.txt',
-              this.source
-            )
-            .should.equal(true)
-          done()
-        },
-      }
-      this.TpdsController.deleteUpdate(req, res)
+    it('should process the delete with the update receiver by name', function (ctx) {
+      return new Promise(resolve => {
+        const path = '/projectName/here.txt'
+        const req = {
+          params: { 0: path, user_id: ctx.user_id, project_id: '' },
+          session: {
+            destroy() {},
+          },
+          headers: {
+            'x-update-source': (ctx.source = 'dropbox'),
+          },
+        }
+        const res = {
+          sendStatus: () => {
+            ctx.TpdsUpdateHandler.promises.deleteUpdate
+              .calledWith(
+                ctx.user_id,
+                '',
+                'projectName',
+                '/here.txt',
+                ctx.source
+              )
+              .should.equal(true)
+            resolve()
+          },
+        }
+        ctx.TpdsController.deleteUpdate(req, res)
+      })
     })
 
-    it('should process the delete with the update receiver by id', function (done) {
-      const path = '/here.txt'
-      const req = {
-        params: { 0: path, user_id: this.user_id, project_id: '123' },
-        session: {
-          destroy() {},
-        },
-        headers: {
-          'x-update-source': (this.source = 'dropbox'),
-        },
-      }
-      const res = {
-        sendStatus: () => {
-          this.TpdsUpdateHandler.promises.deleteUpdate.should.have.been.calledWith(
-            this.user_id,
-            '123',
-            '', // projectName
-            '/here.txt',
-            this.source
-          )
-          done()
-        },
-      }
-      this.TpdsController.deleteUpdate(req, res)
+    it('should process the delete with the update receiver by id', function (ctx) {
+      return new Promise(resolve => {
+        const path = '/here.txt'
+        const req = {
+          params: { 0: path, user_id: ctx.user_id, project_id: '123' },
+          session: {
+            destroy() {},
+          },
+          headers: {
+            'x-update-source': (ctx.source = 'dropbox'),
+          },
+        }
+        const res = {
+          sendStatus: () => {
+            ctx.TpdsUpdateHandler.promises.deleteUpdate.should.have.been.calledWith(
+              ctx.user_id,
+              '123',
+              '', // projectName
+              '/here.txt',
+              ctx.source
+            )
+            resolve()
+          },
+        }
+        ctx.TpdsController.deleteUpdate(req, res)
+      })
     })
   })
 
   describe('updateFolder', function () {
-    beforeEach(function () {
-      this.req = {
-        body: { userId: this.user_id, path: '/abc/def/ghi.txt' },
+    beforeEach(function (ctx) {
+      ctx.req = {
+        body: { userId: ctx.user_id, path: '/abc/def/ghi.txt' },
       }
-      this.res = {
+      ctx.res = {
         json: sinon.stub(),
       }
     })
 
-    it("creates a folder if it doesn't exist", function (done) {
-      const metadata = {
-        folderId: new ObjectId(),
-        projectId: new ObjectId(),
-        path: '/def/ghi.txt',
-        parentFolderId: new ObjectId(),
-      }
-      this.TpdsUpdateHandler.promises.createFolder.resolves(metadata)
-      this.res.json.callsFake(body => {
-        expect(body).to.deep.equal({
-          entityId: metadata.folderId.toString(),
-          projectId: metadata.projectId.toString(),
-          path: metadata.path,
-          folderId: metadata.parentFolderId.toString(),
+    it("creates a folder if it doesn't exist", function (ctx) {
+      return new Promise(resolve => {
+        const metadata = {
+          folderId: new ObjectId(),
+          projectId: new ObjectId(),
+          path: '/def/ghi.txt',
+          parentFolderId: new ObjectId(),
+        }
+        ctx.TpdsUpdateHandler.promises.createFolder.resolves(metadata)
+        ctx.res.json.callsFake(body => {
+          expect(body).to.deep.equal({
+            entityId: metadata.folderId.toString(),
+            projectId: metadata.projectId.toString(),
+            path: metadata.path,
+            folderId: metadata.parentFolderId.toString(),
+          })
+          resolve()
         })
-        done()
+        ctx.TpdsController.updateFolder(ctx.req, ctx.res)
       })
-      this.TpdsController.updateFolder(this.req, this.res)
     })
 
-    it('supports top level folders', function (done) {
-      const metadata = {
-        folderId: new ObjectId(),
-        projectId: new ObjectId(),
-        path: '/',
-        parentFolderId: null,
-      }
-      this.TpdsUpdateHandler.promises.createFolder.resolves(metadata)
-      this.res.json.callsFake(body => {
-        expect(body).to.deep.equal({
-          entityId: metadata.folderId.toString(),
-          projectId: metadata.projectId.toString(),
-          path: metadata.path,
-          folderId: null,
+    it('supports top level folders', function (ctx) {
+      return new Promise(resolve => {
+        const metadata = {
+          folderId: new ObjectId(),
+          projectId: new ObjectId(),
+          path: '/',
+          parentFolderId: null,
+        }
+        ctx.TpdsUpdateHandler.promises.createFolder.resolves(metadata)
+        ctx.res.json.callsFake(body => {
+          expect(body).to.deep.equal({
+            entityId: metadata.folderId.toString(),
+            projectId: metadata.projectId.toString(),
+            path: metadata.path,
+            folderId: null,
+          })
+          resolve()
         })
-        done()
+        ctx.TpdsController.updateFolder(ctx.req, ctx.res)
       })
-      this.TpdsController.updateFolder(this.req, this.res)
     })
 
-    it("returns a 409 if the folder couldn't be created", function (done) {
-      this.TpdsUpdateHandler.promises.createFolder.resolves(null)
-      this.HttpErrorHandler.conflict.callsFake((req, res) => {
-        expect(req).to.equal(this.req)
-        expect(res).to.equal(this.res)
-        done()
+    it("returns a 409 if the folder couldn't be created", function (ctx) {
+      return new Promise(resolve => {
+        ctx.TpdsUpdateHandler.promises.createFolder.resolves(null)
+        ctx.HttpErrorHandler.conflict.callsFake((req, res) => {
+          expect(req).to.equal(ctx.req)
+          expect(res).to.equal(ctx.res)
+          resolve()
+        })
+        ctx.TpdsController.updateFolder(ctx.req, ctx.res)
       })
-      this.TpdsController.updateFolder(this.req, this.res)
     })
   })
 
   describe('parseParams', function () {
-    it('should take the project name off the start and replace with slash', function () {
+    it('should take the project name off the start and replace with slash', function (ctx) {
       const path = 'noSlashHere'
-      const req = { params: { 0: path, user_id: this.user_id } }
-      const result = this.TpdsController.parseParams(req)
-      result.userId.should.equal(this.user_id)
+      const req = { params: { 0: path, user_id: ctx.user_id } }
+      const result = ctx.TpdsController.parseParams(req)
+      result.userId.should.equal(ctx.user_id)
       result.filePath.should.equal('/')
       result.projectName.should.equal(path)
     })
 
-    it('should take the project name off the start and it with no slashes in', function () {
+    it('should take the project name off the start and it with no slashes in', function (ctx) {
       const path = '/project/file.tex'
-      const req = { params: { 0: path, user_id: this.user_id } }
-      const result = this.TpdsController.parseParams(req)
-      result.userId.should.equal(this.user_id)
+      const req = { params: { 0: path, user_id: ctx.user_id } }
+      const result = ctx.TpdsController.parseParams(req)
+      result.userId.should.equal(ctx.user_id)
       result.filePath.should.equal('/file.tex')
       result.projectName.should.equal('project')
     })
 
-    it('should take the project name of and return a slash for the file path', function () {
+    it('should take the project name of and return a slash for the file path', function (ctx) {
       const path = '/project_name'
-      const req = { params: { 0: path, user_id: this.user_id } }
-      const result = this.TpdsController.parseParams(req)
+      const req = { params: { 0: path, user_id: ctx.user_id } }
+      const result = ctx.TpdsController.parseParams(req)
       result.projectName.should.equal('project_name')
       result.filePath.should.equal('/')
     })
   })
 
   describe('updateProjectContents', function () {
-    beforeEach(async function () {
-      this.req = {
+    beforeEach(async function (ctx) {
+      ctx.req = {
         params: {
-          0: (this.path = 'chapters/main.tex'),
-          project_id: (this.project_id = 'project-id-123'),
+          0: (ctx.path = 'chapters/main.tex'),
+          project_id: (ctx.project_id = 'project-id-123'),
         },
         session: {
           destroy: sinon.stub(),
         },
         headers: {
-          'x-update-source': (this.source = 'github'),
+          'x-update-source': (ctx.source = 'github'),
         },
       }
 
-      this.res = {
+      ctx.res = {
         json: sinon.stub(),
         sendStatus: sinon.stub(),
       }
 
-      await this.TpdsController.promises.updateProjectContents(
-        this.req,
-        this.res
-      )
+      await ctx.TpdsController.promises.updateProjectContents(ctx.req, ctx.res)
     })
 
-    it('should merge the update', function () {
-      this.UpdateMerger.promises.mergeUpdate.should.be.calledWith(
+    it('should merge the update', function (ctx) {
+      ctx.UpdateMerger.promises.mergeUpdate.should.be.calledWith(
         null,
-        this.project_id,
-        `/${this.path}`,
-        this.req,
-        this.source
+        ctx.project_id,
+        `/${ctx.path}`,
+        ctx.req,
+        ctx.source
       )
     })
 
-    it('should return a success', function () {
-      this.res.json.should.be.calledWith({
-        entityId: this.metadata.entityId.toString(),
-        rev: this.metadata.rev,
+    it('should return a success', function (ctx) {
+      ctx.res.json.should.be.calledWith({
+        entityId: ctx.metadata.entityId.toString(),
+        rev: ctx.metadata.rev,
       })
     })
   })
 
   describe('deleteProjectContents', function () {
-    beforeEach(async function () {
-      this.req = {
+    beforeEach(async function (ctx) {
+      ctx.req = {
         params: {
-          0: (this.path = 'chapters/main.tex'),
-          project_id: (this.project_id = 'project-id-123'),
+          0: (ctx.path = 'chapters/main.tex'),
+          project_id: (ctx.project_id = 'project-id-123'),
         },
         session: {
           destroy: sinon.stub(),
         },
         headers: {
-          'x-update-source': (this.source = 'github'),
+          'x-update-source': (ctx.source = 'github'),
         },
       }
-      this.res = {
+      ctx.res = {
         sendStatus: sinon.stub(),
         json: sinon.stub(),
       }
 
-      await this.TpdsController.promises.deleteProjectContents(
-        this.req,
-        this.res
-      )
+      await ctx.TpdsController.promises.deleteProjectContents(ctx.req, ctx.res)
     })
 
-    it('should delete the file', function () {
-      this.UpdateMerger.promises.deleteUpdate.should.be.calledWith(
+    it('should delete the file', function (ctx) {
+      ctx.UpdateMerger.promises.deleteUpdate.should.be.calledWith(
         null,
-        this.project_id,
-        `/${this.path}`,
-        this.source
+        ctx.project_id,
+        `/${ctx.path}`,
+        ctx.source
       )
     })
 
-    it('should return a success', function () {
-      this.res.json.should.be.calledWith({
-        entityId: this.metadata.entityId,
+    it('should return a success', function (ctx) {
+      ctx.res.json.should.be.calledWith({
+        entityId: ctx.metadata.entityId,
       })
     })
   })
 
   describe('getQueues', function () {
-    beforeEach(function () {
-      this.req = {}
-      this.res = { json: sinon.stub() }
-      this.next = sinon.stub()
+    beforeEach(function (ctx) {
+      ctx.req = {}
+      ctx.res = { json: sinon.stub() }
+      ctx.next = sinon.stub()
     })
 
     describe('success', function () {
-      beforeEach(function (done) {
-        this.res.json.callsFake(() => {
-          done()
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.res.json.callsFake(() => {
+            resolve()
+          })
+          ctx.TpdsController.getQueues(ctx.req, ctx.res, ctx.next)
         })
-        this.TpdsController.getQueues(this.req, this.res, this.next)
       })
 
-      it('should use userId from session', function () {
-        this.SessionManager.getLoggedInUserId.should.have.been.calledOnce
-        this.TpdsQueueManager.promises.getQueues.should.have.been.calledWith(
+      it('should use userId from session', function (ctx) {
+        ctx.SessionManager.getLoggedInUserId.should.have.been.calledOnce
+        ctx.TpdsQueueManager.promises.getQueues.should.have.been.calledWith(
           'user-id'
         )
       })
 
-      it('should call json with response', function () {
-        this.res.json.should.have.been.calledWith('queues')
-        this.next.should.not.have.been.called
+      it('should call json with response', function (ctx) {
+        ctx.res.json.should.have.been.calledWith('queues')
+        ctx.next.should.not.have.been.called
       })
     })
 
     describe('error', function () {
-      beforeEach(function (done) {
-        this.err = new Error()
-        this.TpdsQueueManager.promises.getQueues = sinon
-          .stub()
-          .rejects(this.err)
-        this.next.callsFake(() => {
-          done()
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.err = new Error()
+          ctx.TpdsQueueManager.promises.getQueues = sinon
+            .stub()
+            .rejects(ctx.err)
+          ctx.next.callsFake(() => {
+            resolve()
+          })
+          ctx.TpdsController.getQueues(ctx.req, ctx.res, ctx.next)
         })
-        this.TpdsController.getQueues(this.req, this.res, this.next)
       })
 
-      it('should call next with error', function () {
-        this.res.json.should.not.have.been.called
-        this.next.should.have.been.calledWith(this.err)
+      it('should call next with error', function (ctx) {
+        ctx.res.json.should.not.have.been.called
+        ctx.next.should.have.been.calledWith(ctx.err)
       })
     })
   })

+ 214 - 175
services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs

@@ -1,4 +1,4 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import { expect } from 'chai'
 import mongodb from 'mongodb-legacy'
@@ -9,120 +9,158 @@ const ObjectId = mongodb.ObjectId
 const MODULE_PATH =
   '../../../../app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs'
 
+vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+)
+
 describe('TpdsUpdateHandler', function () {
-  beforeEach(async function () {
-    this.projectName = 'My recipes'
-    this.projects = {
-      active1: { _id: new ObjectId(), name: this.projectName },
-      active2: { _id: new ObjectId(), name: this.projectName },
+  beforeEach(async function (ctx) {
+    ctx.projectName = 'My recipes'
+    ctx.projects = {
+      active1: { _id: new ObjectId(), name: ctx.projectName },
+      active2: { _id: new ObjectId(), name: ctx.projectName },
       archived1: {
         _id: new ObjectId(),
-        name: this.projectName,
-        archived: [this.userId],
+        name: ctx.projectName,
+        archived: [ctx.userId],
       },
       archived2: {
         _id: new ObjectId(),
-        name: this.projectName,
-        archived: [this.userId],
+        name: ctx.projectName,
+        archived: [ctx.userId],
       },
     }
-    this.userId = new ObjectId()
-    this.source = 'dropbox'
-    this.path = `/some/file`
-    this.update = {}
-    this.folderPath = '/some/folder'
-    this.folder = {
+    ctx.userId = new ObjectId()
+    ctx.source = 'dropbox'
+    ctx.path = `/some/file`
+    ctx.update = {}
+    ctx.folderPath = '/some/folder'
+    ctx.folder = {
       _id: new ObjectId(),
       parentFolder_id: new ObjectId(),
     }
 
-    this.CooldownManager = {
+    ctx.CooldownManager = {
       promises: {
         isProjectOnCooldown: sinon.stub().resolves(false),
       },
     }
-    this.FileTypeManager = {
+    ctx.FileTypeManager = {
       promises: {
         shouldIgnore: sinon.stub().resolves(false),
       },
     }
-    this.Modules = {
+    ctx.Modules = {
       promises: {
         hooks: { fire: sinon.stub().resolves() },
       },
     }
-    this.notification = {
+    ctx.notification = {
       create: sinon.stub().resolves(),
     }
-    this.NotificationsBuilder = {
+    ctx.NotificationsBuilder = {
       promises: {
-        dropboxDuplicateProjectNames: sinon.stub().returns(this.notification),
+        dropboxDuplicateProjectNames: sinon.stub().returns(ctx.notification),
       },
     }
-    this.ProjectCreationHandler = {
+    ctx.ProjectCreationHandler = {
       promises: {
-        createBlankProject: sinon.stub().resolves(this.projects.active1),
+        createBlankProject: sinon.stub().resolves(ctx.projects.active1),
       },
     }
-    this.ProjectDeleter = {
+    ctx.ProjectDeleter = {
       promises: {
         markAsDeletedByExternalSource: sinon.stub().resolves(),
       },
     }
-    this.ProjectGetter = {
+    ctx.ProjectGetter = {
       promises: {
         findUsersProjectsByName: sinon.stub(),
         findAllUsersProjects: sinon
           .stub()
-          .resolves({ owned: [this.projects.active1], readAndWrite: [] }),
+          .resolves({ owned: [ctx.projects.active1], readAndWrite: [] }),
       },
     }
-    this.ProjectHelper = {
+    ctx.ProjectHelper = {
       isArchivedOrTrashed: sinon.stub().returns(false),
     }
-    this.ProjectHelper.isArchivedOrTrashed
-      .withArgs(this.projects.archived1, this.userId)
+    ctx.ProjectHelper.isArchivedOrTrashed
+      .withArgs(ctx.projects.archived1, ctx.userId)
       .returns(true)
-    this.ProjectHelper.isArchivedOrTrashed
-      .withArgs(this.projects.archived2, this.userId)
+    ctx.ProjectHelper.isArchivedOrTrashed
+      .withArgs(ctx.projects.archived2, ctx.userId)
       .returns(true)
-    this.RootDocManager = {
+    ctx.RootDocManager = {
       setRootDocAutomaticallyInBackground: sinon.stub(),
     }
-    this.UpdateMerger = {
+    ctx.UpdateMerger = {
       promises: {
         deleteUpdate: sinon.stub().resolves(),
         mergeUpdate: sinon.stub().resolves(),
-        createFolder: sinon.stub().resolves(this.folder),
+        createFolder: sinon.stub().resolves(ctx.folder),
       },
     }
 
-    this.TpdsUpdateHandler = await esmock.strict(MODULE_PATH, {
-      '.../../../../app/src/Features/Cooldown/CooldownManager':
-        this.CooldownManager,
-      '../../../../app/src/Features/Uploads/FileTypeManager':
-        this.FileTypeManager,
-      '../../../../app/src/infrastructure/Modules': this.Modules,
-      '../../../../app/src/Features/Notifications/NotificationsBuilder':
-        this.NotificationsBuilder,
-      '../../../../app/src/Features/Project/ProjectCreationHandler':
-        this.ProjectCreationHandler,
-      '../../../../app/src/Features/Project/ProjectDeleter':
-        this.ProjectDeleter,
-      '../../../../app/src/Features/Project/ProjectGetter': this.ProjectGetter,
-      '../../../../app/src/Features/Project/ProjectHelper': this.ProjectHelper,
-      '../../../../app/src/Features/Project/ProjectRootDocManager':
-        this.RootDocManager,
-      '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger':
-        this.UpdateMerger,
-    })
+    vi.doMock('../../../../app/src/Features/Cooldown/CooldownManager', () => ({
+      default: ctx.CooldownManager,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Uploads/FileTypeManager', () => ({
+      default: ctx.FileTypeManager,
+    }))
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: ctx.Modules,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Notifications/NotificationsBuilder',
+      () => ({
+        default: ctx.NotificationsBuilder,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectCreationHandler',
+      () => ({
+        default: ctx.ProjectCreationHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectDeleter', () => ({
+      default: ctx.ProjectDeleter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
+      default: ctx.ProjectGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectHelper', () => ({
+      default: ctx.ProjectHelper,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectRootDocManager',
+      () => ({
+        default: ctx.RootDocManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/ThirdPartyDataStore/UpdateMerger',
+      () => ({
+        default: ctx.UpdateMerger,
+      })
+    )
+
+    ctx.TpdsUpdateHandler = (await import(MODULE_PATH)).default
   })
 
   describe('getting an update', function () {
     describe('byId', function () {
       describe('with no matching project', function () {
-        beforeEach(function () {
-          this.projectId = new ObjectId().toString()
+        beforeEach(function (ctx) {
+          ctx.projectId = new ObjectId().toString()
         })
         receiveUpdateById()
         expectProjectNotCreated()
@@ -130,8 +168,8 @@ describe('TpdsUpdateHandler', function () {
       })
 
       describe('with one matching active project', function () {
-        beforeEach(function () {
-          this.projectId = this.projects.active1._id.toString()
+        beforeEach(function (ctx) {
+          ctx.projectId = ctx.projects.active1._id.toString()
         })
         receiveUpdateById()
         expectProjectNotCreated()
@@ -187,8 +225,8 @@ describe('TpdsUpdateHandler', function () {
 
     describe('update to a file that should be ignored', async function () {
       setupMatchingProjects(['active1'])
-      beforeEach(function () {
-        this.FileTypeManager.promises.shouldIgnore.resolves(true)
+      beforeEach(function (ctx) {
+        ctx.FileTypeManager.promises.shouldIgnore.resolves(true)
       })
       receiveUpdate()
       expectProjectNotCreated()
@@ -199,15 +237,15 @@ describe('TpdsUpdateHandler', function () {
     describe('update to a project on cooldown', async function () {
       setupMatchingProjects(['active1'])
       setupProjectOnCooldown()
-      beforeEach(async function () {
+      beforeEach(async function (ctx) {
         await expect(
-          this.TpdsUpdateHandler.promises.newUpdate(
-            this.userId,
+          ctx.TpdsUpdateHandler.promises.newUpdate(
+            ctx.userId,
             '', // projectId
-            this.projectName,
-            this.path,
-            this.update,
-            this.source
+            ctx.projectName,
+            ctx.path,
+            ctx.update,
+            ctx.source
           )
         ).to.be.rejectedWith(Errors.TooManyRequestsError)
       })
@@ -218,8 +256,8 @@ describe('TpdsUpdateHandler', function () {
   describe('getting a file delete', function () {
     describe('byId', function () {
       describe('with no matching project', function () {
-        beforeEach(function () {
-          this.projectId = new ObjectId().toString()
+        beforeEach(function (ctx) {
+          ctx.projectId = new ObjectId().toString()
         })
         receiveFileDeleteById()
         expectDeleteNotProcessed()
@@ -227,8 +265,8 @@ describe('TpdsUpdateHandler', function () {
       })
 
       describe('with one matching active project', function () {
-        beforeEach(function () {
-          this.projectId = this.projects.active1._id.toString()
+        beforeEach(function (ctx) {
+          ctx.projectId = ctx.projects.active1._id.toString()
         })
         receiveFileDeleteById()
         expectDeleteProcessed()
@@ -379,13 +417,13 @@ describe('TpdsUpdateHandler', function () {
     describe('update to a project on cooldown', async function () {
       setupMatchingProjects(['active1'])
       setupProjectOnCooldown()
-      beforeEach(async function () {
+      beforeEach(async function (ctx) {
         await expect(
-          this.TpdsUpdateHandler.promises.createFolder(
-            this.userId,
-            this.projectId,
-            this.projectName,
-            this.path
+          ctx.TpdsUpdateHandler.promises.createFolder(
+            ctx.userId,
+            ctx.projectId,
+            ctx.projectName,
+            ctx.path
           )
         ).to.be.rejectedWith(Errors.TooManyRequestsError)
       })
@@ -397,18 +435,18 @@ describe('TpdsUpdateHandler', function () {
 /* Setup helpers */
 
 function setupMatchingProjects(projectKeys) {
-  beforeEach(function () {
-    const projects = projectKeys.map(key => this.projects[key])
-    this.ProjectGetter.promises.findUsersProjectsByName
-      .withArgs(this.userId, this.projectName)
+  beforeEach(function (ctx) {
+    const projects = projectKeys.map(key => ctx.projects[key])
+    ctx.ProjectGetter.promises.findUsersProjectsByName
+      .withArgs(ctx.userId, ctx.projectName)
       .resolves(projects)
   })
 }
 
 function setupProjectOnCooldown() {
-  beforeEach(function () {
-    this.CooldownManager.promises.isProjectOnCooldown
-      .withArgs(this.projects.active1._id)
+  beforeEach(function (ctx) {
+    ctx.CooldownManager.promises.isProjectOnCooldown
+      .withArgs(ctx.projects.active1._id)
       .resolves(true)
   })
 }
@@ -416,76 +454,77 @@ function setupProjectOnCooldown() {
 /* Test helpers */
 
 function receiveUpdate() {
-  beforeEach(async function () {
-    await this.TpdsUpdateHandler.promises.newUpdate(
-      this.userId,
+  beforeEach(async function (ctx) {
+    await ctx.TpdsUpdateHandler.promises.newUpdate(
+      ctx.userId,
       '', // projectId
-      this.projectName,
-      this.path,
-      this.update,
-      this.source
+      ctx.projectName,
+      ctx.path,
+      ctx.update,
+      ctx.source
     )
   })
 }
 
 function receiveUpdateById() {
-  beforeEach(function (done) {
-    this.TpdsUpdateHandler.newUpdate(
-      this.userId,
-      this.projectId,
+  beforeEach(async function (ctx) {
+    await ctx.TpdsUpdateHandler.promises.newUpdate(
+      ctx.userId,
+      ctx.projectId,
       '', // projectName
-      this.path,
-      this.update,
-      this.source,
-      done
+      ctx.path,
+      ctx.update,
+      ctx.source
     )
   })
 }
 
 function receiveFileDelete() {
-  beforeEach(async function () {
-    await this.TpdsUpdateHandler.promises.deleteUpdate(
-      this.userId,
+  beforeEach(async function (ctx) {
+    await ctx.TpdsUpdateHandler.promises.deleteUpdate(
+      ctx.userId,
       '', // projectId
-      this.projectName,
-      this.path,
-      this.source
+      ctx.projectName,
+      ctx.path,
+      ctx.source
     )
   })
 }
 
 function receiveFileDeleteById() {
-  beforeEach(function (done) {
-    this.TpdsUpdateHandler.deleteUpdate(
-      this.userId,
-      this.projectId,
-      '', // projectName
-      this.path,
-      this.source,
-      done
-    )
+  beforeEach(function (ctx) {
+    return new Promise(resolve => {
+      ctx.TpdsUpdateHandler.deleteUpdate(
+        ctx.userId,
+        ctx.projectId,
+        '', // projectName
+        ctx.path,
+        ctx.source,
+        resolve
+      )
+    })
   })
 }
 
 function receiveProjectDelete() {
-  beforeEach(async function () {
-    await this.TpdsUpdateHandler.promises.deleteUpdate(
-      this.userId,
+  beforeEach(async function (ctx) {
+    await ctx.TpdsUpdateHandler.promises.deleteUpdate(
+      ctx.userId,
       '', // projectId
-      this.projectName,
+      ctx.projectName,
       '/',
-      this.source
+      ctx.source
     )
   })
 }
 
 function receiveFolderUpdate() {
-  beforeEach(async function () {
-    await this.TpdsUpdateHandler.promises.createFolder(
-      this.userId,
-      this.projectId,
-      this.projectName,
-      this.folderPath
+  beforeEach(async function (ctx) {
+    await ctx.TpdsUpdateHandler.promises.createFolder(
+      ctx.userId,
+      ctx.projectId,
+      ctx.projectName,
+      ctx.folderPath
     )
   })
 }
@@ -493,121 +532,121 @@ function receiveFolderUpdate() {
 /* Expectations */
 
 function expectProjectCreated() {
-  it('creates a project', function () {
+  it('creates a project', function (ctx) {
     expect(
-      this.ProjectCreationHandler.promises.createBlankProject
-    ).to.have.been.calledWith(this.userId, this.projectName)
+      ctx.ProjectCreationHandler.promises.createBlankProject
+    ).to.have.been.calledWith(ctx.userId, ctx.projectName)
   })
 
-  it('sets the root doc', function () {
+  it('sets the root doc', function (ctx) {
     expect(
-      this.RootDocManager.setRootDocAutomaticallyInBackground
-    ).to.have.been.calledWith(this.projects.active1._id)
+      ctx.RootDocManager.setRootDocAutomaticallyInBackground
+    ).to.have.been.calledWith(ctx.projects.active1._id)
   })
 }
 
 function expectProjectNotCreated() {
-  it('does not create a project', function () {
-    expect(this.ProjectCreationHandler.promises.createBlankProject).not.to.have
+  it('does not create a project', function (ctx) {
+    expect(ctx.ProjectCreationHandler.promises.createBlankProject).not.to.have
       .been.called
   })
 
-  it('does not set the root doc', function () {
-    expect(this.RootDocManager.setRootDocAutomaticallyInBackground).not.to.have
+  it('does not set the root doc', function (ctx) {
+    expect(ctx.RootDocManager.setRootDocAutomaticallyInBackground).not.to.have
       .been.called
   })
 }
 
 function expectUpdateProcessed() {
-  it('processes the update', function () {
-    expect(this.UpdateMerger.promises.mergeUpdate).to.have.been.calledWith(
-      this.userId,
-      this.projects.active1._id,
-      this.path,
-      this.update,
-      this.source
+  it('processes the update', function (ctx) {
+    expect(ctx.UpdateMerger.promises.mergeUpdate).to.have.been.calledWith(
+      ctx.userId,
+      ctx.projects.active1._id,
+      ctx.path,
+      ctx.update,
+      ctx.source
     )
   })
 }
 
 function expectUpdateNotProcessed() {
-  it('does not process the update', function () {
-    expect(this.UpdateMerger.promises.mergeUpdate).not.to.have.been.called
+  it('does not process the update', function (ctx) {
+    expect(ctx.UpdateMerger.promises.mergeUpdate).not.to.have.been.called
   })
 }
 
 function expectFolderUpdateProcessed() {
-  it('processes the folder update', function () {
-    expect(this.UpdateMerger.promises.createFolder).to.have.been.calledWith(
-      this.projects.active1._id,
-      this.folderPath,
-      this.userId
+  it('processes the folder update', function (ctx) {
+    expect(ctx.UpdateMerger.promises.createFolder).to.have.been.calledWith(
+      ctx.projects.active1._id,
+      ctx.folderPath,
+      ctx.userId
     )
   })
 }
 
 function expectFolderUpdateNotProcessed() {
-  it("doesn't process the folder update", function () {
-    expect(this.UpdateMerger.promises.createFolder).not.to.have.been.called
+  it("doesn't process the folder update", function (ctx) {
+    expect(ctx.UpdateMerger.promises.createFolder).not.to.have.been.called
   })
 }
 
 function expectDropboxUnlinked() {
-  it('unlinks Dropbox', function () {
-    expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
+  it('unlinks Dropbox', function (ctx) {
+    expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
       'removeDropbox',
-      this.userId,
+      ctx.userId,
       'duplicate-projects'
     )
   })
 
-  it('creates a notification that dropbox was unlinked', function () {
+  it('creates a notification that dropbox was unlinked', function (ctx) {
     expect(
-      this.NotificationsBuilder.promises.dropboxDuplicateProjectNames
-    ).to.have.been.calledWith(this.userId)
-    expect(this.notification.create).to.have.been.calledWith(this.projectName)
+      ctx.NotificationsBuilder.promises.dropboxDuplicateProjectNames
+    ).to.have.been.calledWith(ctx.userId)
+    expect(ctx.notification.create).to.have.been.calledWith(ctx.projectName)
   })
 }
 
 function expectDropboxNotUnlinked() {
-  it('does not unlink Dropbox', function () {
-    expect(this.Modules.promises.hooks.fire).not.to.have.been.called
+  it('does not unlink Dropbox', function (ctx) {
+    expect(ctx.Modules.promises.hooks.fire).not.to.have.been.called
   })
 
-  it('does not create a notification that dropbox was unlinked', function () {
-    expect(this.NotificationsBuilder.promises.dropboxDuplicateProjectNames).not
+  it('does not create a notification that dropbox was unlinked', function (ctx) {
+    expect(ctx.NotificationsBuilder.promises.dropboxDuplicateProjectNames).not
       .to.have.been.called
   })
 }
 
 function expectDeleteProcessed() {
-  it('processes the delete', function () {
-    expect(this.UpdateMerger.promises.deleteUpdate).to.have.been.calledWith(
-      this.userId,
-      this.projects.active1._id,
-      this.path,
-      this.source
+  it('processes the delete', function (ctx) {
+    expect(ctx.UpdateMerger.promises.deleteUpdate).to.have.been.calledWith(
+      ctx.userId,
+      ctx.projects.active1._id,
+      ctx.path,
+      ctx.source
     )
   })
 }
 
 function expectDeleteNotProcessed() {
-  it('does not process the delete', function () {
-    expect(this.UpdateMerger.promises.deleteUpdate).not.to.have.been.called
+  it('does not process the delete', function (ctx) {
+    expect(ctx.UpdateMerger.promises.deleteUpdate).not.to.have.been.called
   })
 }
 
 function expectProjectDeleted() {
-  it('deletes the project', function () {
+  it('deletes the project', function (ctx) {
     expect(
-      this.ProjectDeleter.promises.markAsDeletedByExternalSource
-    ).to.have.been.calledWith(this.projects.active1._id)
+      ctx.ProjectDeleter.promises.markAsDeletedByExternalSource
+    ).to.have.been.calledWith(ctx.projects.active1._id)
   })
 }
 
 function expectProjectNotDeleted() {
-  it('does not delete the project', function () {
-    expect(this.ProjectDeleter.promises.markAsDeletedByExternalSource).not.to
+  it('does not delete the project', function (ctx) {
+    expect(ctx.ProjectDeleter.promises.markAsDeletedByExternalSource).not.to
       .have.been.called
   })
 }

File diff suppressed because it is too large
+ 441 - 331
services/web/test/unit/src/TokenAccess/TokenAccessController.test.mjs


+ 185 - 152
services/web/test/unit/src/Uploads/ProjectUploadController.test.mjs

@@ -2,15 +2,12 @@
 // Fix any style issues and re-enable lint.
 /*
  * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
+import { vi } from 'vitest'
 import sinon from 'sinon'
-
 import { expect } from 'chai'
-
-import esmock from 'esmock'
 import MockRequest from '../helpers/MockRequest.js'
 import MockResponse from '../helpers/MockResponse.js'
 import ArchiveErrors from '../../../../app/src/Features/Uploads/ArchiveErrors.js'
@@ -19,12 +16,12 @@ const modulePath =
   '../../../../app/src/Features/Uploads/ProjectUploadController.mjs'
 
 describe('ProjectUploadController', function () {
-  beforeEach(async function () {
+  beforeEach(async function (ctx) {
     let Timer
-    this.req = new MockRequest()
-    this.res = new MockResponse()
-    this.user_id = 'user-id-123'
-    this.metrics = {
+    ctx.req = new MockRequest()
+    ctx.res = new MockResponse()
+    ctx.user_id = 'user-id-123'
+    ctx.metrics = {
       Timer: (Timer = (function () {
         Timer = class Timer {
           static initClass() {
@@ -35,262 +32,298 @@ describe('ProjectUploadController', function () {
         return Timer
       })()),
     }
-    this.SessionManager = {
-      getLoggedInUserId: sinon.stub().returns(this.user_id),
+    ctx.SessionManager = {
+      getLoggedInUserId: sinon.stub().returns(ctx.user_id),
     }
-    this.ProjectLocator = {
+    ctx.ProjectLocator = {
       promises: {},
     }
-    this.EditorController = {
+    ctx.EditorController = {
       promises: {},
     }
 
-    return (this.ProjectUploadController = await esmock.strict(modulePath, {
-      multer: sinon.stub(),
-      '@overleaf/settings': { path: {} },
-      '../../../../app/src/Features/Uploads/ProjectUploadManager':
-        (this.ProjectUploadManager = {}),
-      '../../../../app/src/Features/Uploads/FileSystemImportManager':
-        (this.FileSystemImportManager = {}),
-      '@overleaf/metrics': this.metrics,
-      '../../../../app/src/Features/Authentication/SessionManager':
-        this.SessionManager,
-      '../../../../app/src/Features/Uploads/ArchiveErrors': ArchiveErrors,
-      '../../../../app/src/Features/Project/ProjectLocator':
-        this.ProjectLocator,
-      '../../../../app/src/Features/Editor/EditorController':
-        this.EditorController,
-      fs: (this.fs = {}),
+    vi.doMock('multer', () => ({
+      default: sinon.stub(),
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: { path: {} },
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Uploads/ProjectUploadManager',
+      () => ({
+        default: (ctx.ProjectUploadManager = {}),
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Uploads/FileSystemImportManager',
+      () => ({
+        default: (ctx.FileSystemImportManager = {}),
+      })
+    )
+
+    vi.doMock('@overleaf/metrics', () => ({
+      default: ctx.metrics,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Uploads/ArchiveErrors',
+      () => ArchiveErrors
+    )
+
+    vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
+      default: ctx.ProjectLocator,
     }))
+
+    vi.doMock('../../../../app/src/Features/Editor/EditorController', () => ({
+      default: ctx.EditorController,
+    }))
+
+    vi.doMock('fs', () => ({
+      default: (ctx.fs = {}),
+    }))
+
+    ctx.ProjectUploadController = (await import(modulePath)).default
   })
 
   describe('uploadProject', function () {
-    beforeEach(function () {
-      this.path = '/path/to/file/on/disk.zip'
-      this.name = 'filename.zip'
-      this.req.file = {
-        path: this.path,
+    beforeEach(function (ctx) {
+      ctx.path = '/path/to/file/on/disk.zip'
+      ctx.fileName = 'filename.zip'
+      ctx.req.file = {
+        path: ctx.path,
       }
-      this.req.body = {
-        name: this.name,
+      ctx.req.body = {
+        name: ctx.fileName,
       }
-      this.req.session = {
+      ctx.req.session = {
         user: {
-          _id: this.user_id,
+          _id: ctx.user_id,
         },
       }
-      this.project = { _id: (this.project_id = 'project-id-123') }
+      ctx.project = { _id: (ctx.project_id = 'project-id-123') }
 
-      return (this.fs.unlink = sinon.stub())
+      ctx.fs.unlink = sinon.stub()
     })
 
     describe('successfully', function () {
-      beforeEach(function () {
-        this.ProjectUploadManager.createProjectFromZipArchive = sinon
+      beforeEach(function (ctx) {
+        ctx.ProjectUploadManager.createProjectFromZipArchive = sinon
           .stub()
-          .callsArgWith(3, null, this.project)
-        return this.ProjectUploadController.uploadProject(this.req, this.res)
+          .callsArgWith(3, null, ctx.project)
+        ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res)
       })
 
-      it('should create a project owned by the logged in user', function () {
-        return this.ProjectUploadManager.createProjectFromZipArchive
-          .calledWith(this.user_id)
+      it('should create a project owned by the logged in user', function (ctx) {
+        ctx.ProjectUploadManager.createProjectFromZipArchive
+          .calledWith(ctx.user_id)
           .should.equal(true)
       })
 
-      it('should create a project with the same name as the zip archive', function () {
-        return this.ProjectUploadManager.createProjectFromZipArchive
+      it('should create a project with the same name as the zip archive', function (ctx) {
+        ctx.ProjectUploadManager.createProjectFromZipArchive
           .calledWith(sinon.match.any, 'filename', sinon.match.any)
           .should.equal(true)
       })
 
-      it('should create a project from the zip archive', function () {
-        return this.ProjectUploadManager.createProjectFromZipArchive
-          .calledWith(sinon.match.any, sinon.match.any, this.path)
+      it('should create a project from the zip archive', function (ctx) {
+        ctx.ProjectUploadManager.createProjectFromZipArchive
+          .calledWith(sinon.match.any, sinon.match.any, ctx.path)
           .should.equal(true)
       })
 
-      it('should return a successful response to the FileUploader client', function () {
-        return expect(this.res.body).to.deep.equal(
+      it('should return a successful response to the FileUploader client', function (ctx) {
+        expect(ctx.res.body).to.deep.equal(
           JSON.stringify({
             success: true,
-            project_id: this.project_id,
+            project_id: ctx.project_id,
           })
         )
       })
 
-      it('should record the time taken to do the upload', function () {
-        return this.metrics.Timer.prototype.done.called.should.equal(true)
+      it('should record the time taken to do the upload', function (ctx) {
+        ctx.metrics.Timer.prototype.done.called.should.equal(true)
       })
 
-      it('should remove the uploaded file', function () {
-        return this.fs.unlink.calledWith(this.path).should.equal(true)
+      it('should remove the uploaded file', function (ctx) {
+        ctx.fs.unlink.calledWith(ctx.path).should.equal(true)
       })
     })
 
     describe('when ProjectUploadManager.createProjectFromZipArchive fails', function () {
-      beforeEach(function () {
-        this.ProjectUploadManager.createProjectFromZipArchive = sinon
+      beforeEach(function (ctx) {
+        ctx.ProjectUploadManager.createProjectFromZipArchive = sinon
           .stub()
-          .callsArgWith(3, new Error('Something went wrong'), this.project)
-        return this.ProjectUploadController.uploadProject(this.req, this.res)
+          .callsArgWith(3, new Error('Something went wrong'), ctx.project)
+        ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res)
       })
 
-      it('should return a failed response to the FileUploader client', function () {
-        return expect(this.res.body).to.deep.equal(
+      it('should return a failed response to the FileUploader client', function (ctx) {
+        expect(ctx.res.body).to.deep.equal(
           JSON.stringify({ success: false, error: 'upload_failed' })
         )
       })
     })
 
     describe('when ProjectUploadManager.createProjectFromZipArchive reports the file as invalid', function () {
-      beforeEach(function () {
-        this.ProjectUploadManager.createProjectFromZipArchive = sinon
+      beforeEach(function (ctx) {
+        ctx.ProjectUploadManager.createProjectFromZipArchive = sinon
           .stub()
           .callsArgWith(
             3,
             new ArchiveErrors.ZipContentsTooLargeError(),
-            this.project
+            ctx.project
           )
-        return this.ProjectUploadController.uploadProject(this.req, this.res)
+        ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res)
       })
 
-      it('should return the reported error to the FileUploader client', function () {
-        expect(JSON.parse(this.res.body)).to.deep.equal({
+      it('should return the reported error to the FileUploader client', function (ctx) {
+        expect(JSON.parse(ctx.res.body)).to.deep.equal({
           success: false,
           error: 'zip_contents_too_large',
         })
       })
 
-      it("should return an 'unprocessable entity' status code", function () {
-        return expect(this.res.statusCode).to.equal(422)
+      it("should return an 'unprocessable entity' status code", function (ctx) {
+        expect(ctx.res.statusCode).to.equal(422)
       })
     })
   })
 
   describe('uploadFile', function () {
-    beforeEach(function () {
-      this.project_id = 'project-id-123'
-      this.folder_id = 'folder-id-123'
-      this.path = '/path/to/file/on/disk.png'
-      this.name = 'filename.png'
-      this.req.file = {
-        path: this.path,
+    beforeEach(function (ctx) {
+      ctx.project_id = 'project-id-123'
+      ctx.folder_id = 'folder-id-123'
+      ctx.path = '/path/to/file/on/disk.png'
+      ctx.fileName = 'filename.png'
+      ctx.req.file = {
+        path: ctx.path,
       }
-      this.req.body = {
-        name: this.name,
+      ctx.req.body = {
+        name: ctx.fileName,
       }
-      this.req.session = {
+      ctx.req.session = {
         user: {
-          _id: this.user_id,
+          _id: ctx.user_id,
         },
       }
-      this.req.params = { Project_id: this.project_id }
-      this.req.query = { folder_id: this.folder_id }
-      return (this.fs.unlink = sinon.stub())
+      ctx.req.params = { Project_id: ctx.project_id }
+      ctx.req.query = { folder_id: ctx.folder_id }
+      ctx.fs.unlink = sinon.stub()
     })
 
     describe('successfully', function () {
-      beforeEach(function () {
-        this.entity = {
+      beforeEach(function (ctx) {
+        ctx.entity = {
           _id: '1234',
           type: 'file',
         }
-        this.FileSystemImportManager.addEntity = sinon
+        ctx.FileSystemImportManager.addEntity = sinon
           .stub()
-          .callsArgWith(6, null, this.entity)
-        return this.ProjectUploadController.uploadFile(this.req, this.res)
+          .callsArgWith(6, null, ctx.entity)
+        ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
       })
 
-      it('should insert the file', function () {
-        return this.FileSystemImportManager.addEntity
+      it('should insert the file', function (ctx) {
+        return ctx.FileSystemImportManager.addEntity
           .calledWith(
-            this.user_id,
-            this.project_id,
-            this.folder_id,
-            this.name,
-            this.path
+            ctx.user_id,
+            ctx.project_id,
+            ctx.folder_id,
+            ctx.fileName,
+            ctx.path
           )
           .should.equal(true)
       })
 
-      it('should return a successful response to the FileUploader client', function () {
-        return expect(this.res.body).to.deep.equal(
+      it('should return a successful response to the FileUploader client', function (ctx) {
+        expect(ctx.res.body).to.deep.equal(
           JSON.stringify({
             success: true,
-            entity_id: this.entity._id,
+            entity_id: ctx.entity._id,
             entity_type: 'file',
           })
         )
       })
 
-      it('should time the request', function () {
-        return this.metrics.Timer.prototype.done.called.should.equal(true)
+      it('should time the request', function (ctx) {
+        ctx.metrics.Timer.prototype.done.called.should.equal(true)
       })
 
-      it('should remove the uploaded file', function () {
-        return this.fs.unlink.calledWith(this.path).should.equal(true)
+      it('should remove the uploaded file', function (ctx) {
+        ctx.fs.unlink.calledWith(ctx.path).should.equal(true)
       })
     })
 
     describe('with folder structure', function () {
-      beforeEach(function (done) {
-        this.entity = {
-          _id: '1234',
-          type: 'file',
-        }
-        this.FileSystemImportManager.addEntity = sinon
-          .stub()
-          .callsArgWith(6, null, this.entity)
-        this.ProjectLocator.promises.findElement = sinon.stub().resolves({
-          path: { fileSystem: '/test' },
-        })
-        this.EditorController.promises.mkdirp = sinon.stub().resolves({
-          lastFolder: { _id: 'folder-id' },
+      beforeEach(function (ctx) {
+        return new Promise(resolve => {
+          ctx.entity = {
+            _id: '1234',
+            type: 'file',
+          }
+          ctx.FileSystemImportManager.addEntity = sinon
+            .stub()
+            .callsArgWith(6, null, ctx.entity)
+          ctx.ProjectLocator.promises.findElement = sinon.stub().resolves({
+            path: { fileSystem: '/test' },
+          })
+          ctx.EditorController.promises.mkdirp = sinon.stub().resolves({
+            lastFolder: { _id: 'folder-id' },
+          })
+          ctx.req.body.relativePath = 'foo/bar/' + ctx.fileName
+          ctx.res.json = data => {
+            expect(data.success).to.be.true
+            resolve()
+          }
+          ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
         })
-        this.req.body.relativePath = 'foo/bar/' + this.name
-        this.res.json = data => {
-          expect(data.success).to.be.true
-          done()
-        }
-        this.ProjectUploadController.uploadFile(this.req, this.res)
       })
 
-      it('should insert the file', function () {
-        this.ProjectLocator.promises.findElement.should.be.calledOnceWithExactly(
+      it('should insert the file', function (ctx) {
+        ctx.ProjectLocator.promises.findElement.should.be.calledOnceWithExactly(
           {
-            project_id: this.project_id,
-            element_id: this.folder_id,
+            project_id: ctx.project_id,
+            element_id: ctx.folder_id,
             type: 'folder',
           }
         )
 
-        this.EditorController.promises.mkdirp.should.be.calledWith(
-          this.project_id,
+        ctx.EditorController.promises.mkdirp.should.be.calledWith(
+          ctx.project_id,
           '/test/foo/bar',
-          this.user_id
+          ctx.user_id
         )
 
-        this.FileSystemImportManager.addEntity.should.be.calledOnceWith(
-          this.user_id,
-          this.project_id,
+        ctx.FileSystemImportManager.addEntity.should.be.calledOnceWith(
+          ctx.user_id,
+          ctx.project_id,
           'folder-id',
-          this.name,
-          this.path
+          ctx.fileName,
+          ctx.path
         )
       })
     })
 
     describe('when FileSystemImportManager.addEntity returns a generic error', function () {
-      beforeEach(function () {
-        this.FileSystemImportManager.addEntity = sinon
+      beforeEach(function (ctx) {
+        ctx.FileSystemImportManager.addEntity = sinon
           .stub()
           .callsArgWith(6, new Error('Sorry something went wrong'))
-        return this.ProjectUploadController.uploadFile(this.req, this.res)
+        ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
       })
 
-      it('should return an unsuccessful response to the FileUploader client', function () {
-        return expect(this.res.body).to.deep.equal(
+      it('should return an unsuccessful response to the FileUploader client', function (ctx) {
+        expect(ctx.res.body).to.deep.equal(
           JSON.stringify({
             success: false,
           })
@@ -299,15 +332,15 @@ describe('ProjectUploadController', function () {
     })
 
     describe('when FileSystemImportManager.addEntity returns a too many files error', function () {
-      beforeEach(function () {
-        this.FileSystemImportManager.addEntity = sinon
+      beforeEach(function (ctx) {
+        ctx.FileSystemImportManager.addEntity = sinon
           .stub()
           .callsArgWith(6, new Error('project_has_too_many_files'))
-        return this.ProjectUploadController.uploadFile(this.req, this.res)
+        ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
       })
 
-      it('should return an unsuccessful response to the FileUploader client', function () {
-        return expect(this.res.body).to.deep.equal(
+      it('should return an unsuccessful response to the FileUploader client', function (ctx) {
+        expect(ctx.res.body).to.deep.equal(
           JSON.stringify({
             success: false,
             error: 'project_has_too_many_files',
@@ -317,13 +350,13 @@ describe('ProjectUploadController', function () {
     })
 
     describe('with an invalid filename', function () {
-      beforeEach(function () {
-        this.req.body.name = ''
-        return this.ProjectUploadController.uploadFile(this.req, this.res)
+      beforeEach(function (ctx) {
+        ctx.req.body.name = ''
+        ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
       })
 
-      it('should return a a non success response', function () {
-        return expect(this.res.body).to.deep.equal(
+      it('should return a a non success response', function (ctx) {
+        expect(ctx.res.body).to.deep.equal(
           JSON.stringify({
             success: false,
             error: 'invalid_filename',

+ 387 - 297
services/web/test/unit/src/User/UserPagesController.test.mjs

@@ -1,18 +1,15 @@
-import esmock from 'esmock'
+import { vi } from 'vitest'
 import assert from 'assert'
 import sinon from 'sinon'
 import { expect } from 'chai'
 import MockResponse from '../helpers/MockResponse.js'
 import MockRequest from '../helpers/MockRequest.js'
 
-const modulePath = new URL(
-  '../../../../app/src/Features/User/UserPagesController',
-  import.meta.url
-).pathname
+const modulePath = '../../../../app/src/Features/User/UserPagesController'
 
 describe('UserPagesController', function () {
-  beforeEach(async function () {
-    this.settings = {
+  beforeEach(async function (ctx) {
+    ctx.settings = {
       apis: {
         v1: {
           url: 'some.host',
@@ -21,8 +18,8 @@ describe('UserPagesController', function () {
         },
       },
     }
-    this.user = {
-      _id: (this.user_id = 'kwjewkl'),
+    ctx.user = {
+      _id: (ctx.user_id = 'kwjewkl'),
       features: {},
       email: 'joe@example.com',
       ip_address: '1.1.1.1',
@@ -39,414 +36,507 @@ describe('UserPagesController', function () {
         papers: { encrypted: 'cccc' },
       },
     }
-    this.adminEmail = 'group-admin-email@overleaf.com'
-    this.subscriptionViewModel = {
+    ctx.adminEmail = 'group-admin-email@overleaf.com'
+    ctx.subscriptionViewModel = {
       memberGroupSubscriptions: [],
     }
 
-    this.UserGetter = {
+    ctx.UserGetter = {
       getUser: sinon.stub(),
       promises: { getUser: sinon.stub() },
     }
-    this.UserSessionsManager = { getAllUserSessions: sinon.stub() }
-    this.dropboxStatus = {}
-    this.ErrorController = { notFound: sinon.stub() }
-    this.SessionManager = {
-      getLoggedInUserId: sinon.stub().returns(this.user._id),
-      getSessionUser: sinon.stub().returns(this.user),
+    ctx.UserSessionsManager = { getAllUserSessions: sinon.stub() }
+    ctx.dropboxStatus = {}
+    ctx.ErrorController = { notFound: sinon.stub() }
+    ctx.SessionManager = {
+      getLoggedInUserId: sinon.stub().returns(ctx.user._id),
+      getSessionUser: sinon.stub().returns(ctx.user),
     }
-    this.NewsletterManager = {
+    ctx.NewsletterManager = {
       subscribed: sinon.stub().yields(),
     }
-    this.AuthenticationController = {
+    ctx.AuthenticationController = {
       getRedirectFromSession: sinon.stub(),
       setRedirectInSession: sinon.stub(),
     }
-    this.Features = {
+    ctx.Features = {
       hasFeature: sinon.stub().returns(false),
     }
-    this.PersonalAccessTokenManager = {
+    ctx.PersonalAccessTokenManager = {
       listTokens: sinon.stub().returns([]),
     }
-    this.SubscriptionLocator = {
+    ctx.SubscriptionLocator = {
       promises: {
-        getAdminEmail: sinon.stub().returns(this.adminEmail),
+        getAdminEmail: sinon.stub().returns(ctx.adminEmail),
         getMemberSubscriptions: sinon.stub().resolves(),
       },
     }
-    this.SplitTestHandler = {
+    ctx.SplitTestHandler = {
       promises: {
         getAssignment: sinon.stub().returns('default'),
       },
     }
-    this.Modules = {
+    ctx.Modules = {
       promises: {
         hooks: {
           fire: sinon.stub().resolves(),
         },
       },
     }
-    this.UserPagesController = await esmock.strict(modulePath, {
-      '@overleaf/settings': this.settings,
-      '../../../../app/src/Features/User/UserGetter': this.UserGetter,
-      '../../../../app/src/Features/User/UserSessionsManager':
-        this.UserSessionsManager,
-      '../../../../app/src/Features/Newsletter/NewsletterManager':
-        this.NewsletterManager,
-      '../../../../app/src/Features/Errors/ErrorController':
-        this.ErrorController,
-      '../../../../app/src/Features/Authentication/AuthenticationController':
-        this.AuthenticationController,
-      '../../../../app/src/Features/Subscription/SubscriptionLocator':
-        this.SubscriptionLocator,
-      '../../../../app/src/infrastructure/Features': this.Features,
-      '../../../../modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager':
-        this.PersonalAccessTokenManager,
-      '../../../../app/src/Features/Authentication/SessionManager':
-        this.SessionManager,
-      '../../../../app/src/Features/SplitTests/SplitTestHandler':
-        this.SplitTestHandler,
-      '../../../../app/src/infrastructure/Modules': this.Modules,
-      request: (this.request = sinon.stub()),
-    })
-    this.req = new MockRequest()
-    this.req.session.user = this.user
-    this.res = new MockResponse()
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({
+      default: ctx.UserSessionsManager,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Newsletter/NewsletterManager',
+      () => ({
+        default: ctx.NewsletterManager,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/Errors/ErrorController', () => ({
+      default: ctx.ErrorController,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/AuthenticationController',
+      () => ({
+        default: ctx.AuthenticationController,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: ctx.SubscriptionLocator,
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Features', () => ({
+      default: ctx.Features,
+    }))
+
+    vi.doMock(
+      '../../../../modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager',
+      () => ({
+        default: ctx.PersonalAccessTokenManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+
+    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
+      default: ctx.Modules,
+    }))
+    ctx.request = sinon.stub()
+    vi.doMock('request', () => ({
+      default: ctx.request,
+    }))
+
+    ctx.UserPagesController = (await import(modulePath)).default
+    ctx.req = new MockRequest()
+    ctx.req.session.user = ctx.user
+    ctx.res = new MockResponse()
   })
 
   describe('registerPage', function () {
-    it('should render the register page', function (done) {
-      this.res.callback = () => {
-        this.res.renderedTemplate.should.equal('user/register')
-        done()
-      }
-      this.UserPagesController.registerPage(this.req, this.res, done)
+    it('should render the register page', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.res.renderedTemplate.should.equal('user/register')
+          resolve()
+        }
+        ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should set sharedProjectData', function (done) {
-      this.req.session.sharedProjectData = {
-        project_name: 'myProject',
-        user_first_name: 'user_first_name_here',
-      }
+    it('should set sharedProjectData', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.session.sharedProjectData = {
+          project_name: 'myProject',
+          user_first_name: 'user_first_name_here',
+        }
 
-      this.res.callback = () => {
-        this.res.renderedVariables.sharedProjectData.project_name.should.equal(
-          'myProject'
-        )
-        this.res.renderedVariables.sharedProjectData.user_first_name.should.equal(
-          'user_first_name_here'
-        )
-        done()
-      }
-      this.UserPagesController.registerPage(this.req, this.res, done)
+        ctx.res.callback = () => {
+          ctx.res.renderedVariables.sharedProjectData.project_name.should.equal(
+            'myProject'
+          )
+          ctx.res.renderedVariables.sharedProjectData.user_first_name.should.equal(
+            'user_first_name_here'
+          )
+          resolve()
+        }
+        ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should set newTemplateData', function (done) {
-      this.req.session.templateData = { templateName: 'templateName' }
+    it('should set newTemplateData', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.session.templateData = { templateName: 'templateName' }
 
-      this.res.callback = () => {
-        this.res.renderedVariables.newTemplateData.templateName.should.equal(
-          'templateName'
-        )
-        done()
-      }
-      this.UserPagesController.registerPage(this.req, this.res, done)
+        ctx.res.callback = () => {
+          ctx.res.renderedVariables.newTemplateData.templateName.should.equal(
+            'templateName'
+          )
+          resolve()
+        }
+        ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should not set the newTemplateData if there is nothing in the session', function (done) {
-      this.res.callback = () => {
-        assert.equal(
-          this.res.renderedVariables.newTemplateData.templateName,
-          undefined
-        )
-        done()
-      }
-      this.UserPagesController.registerPage(this.req, this.res, done)
+    it('should not set the newTemplateData if there is nothing in the session', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          assert.equal(
+            ctx.res.renderedVariables.newTemplateData.templateName,
+            undefined
+          )
+          resolve()
+        }
+        ctx.UserPagesController.registerPage(ctx.req, ctx.res, resolve)
+      })
     })
   })
 
   describe('loginForm', function () {
-    it('should render the login page', function (done) {
-      this.res.callback = () => {
-        this.res.renderedTemplate.should.equal('user/login')
-        done()
-      }
-      this.UserPagesController.loginPage(this.req, this.res, done)
+    it('should render the login page', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.res.renderedTemplate.should.equal('user/login')
+          resolve()
+        }
+        ctx.UserPagesController.loginPage(ctx.req, ctx.res, resolve)
+      })
     })
 
     describe('when an explicit redirect is set via query string', function () {
-      beforeEach(function () {
-        this.AuthenticationController.getRedirectFromSession = sinon
+      beforeEach(function (ctx) {
+        ctx.AuthenticationController.getRedirectFromSession = sinon
           .stub()
           .returns(null)
-        this.AuthenticationController.setRedirectInSession = sinon.stub()
-        this.req.query.redir = '/somewhere/in/particular'
+        ctx.AuthenticationController.setRedirectInSession = sinon.stub()
+        ctx.req.query.redir = '/somewhere/in/particular'
       })
 
-      it('should set a redirect', function (done) {
-        this.res.callback = page => {
-          this.AuthenticationController.setRedirectInSession.callCount.should.equal(
-            1
-          )
-          expect(
-            this.AuthenticationController.setRedirectInSession.lastCall.args[1]
-          ).to.equal(this.req.query.redir)
-          done()
-        }
-        this.UserPagesController.loginPage(this.req, this.res, done)
+      it('should set a redirect', function (ctx) {
+        return new Promise(resolve => {
+          ctx.res.callback = page => {
+            ctx.AuthenticationController.setRedirectInSession.callCount.should.equal(
+              1
+            )
+            expect(
+              ctx.AuthenticationController.setRedirectInSession.lastCall.args[1]
+            ).to.equal(ctx.req.query.redir)
+            resolve()
+          }
+          ctx.UserPagesController.loginPage(ctx.req, ctx.res, resolve)
+        })
       })
     })
   })
 
   describe('sessionsPage', function () {
-    beforeEach(function () {
-      this.UserSessionsManager.getAllUserSessions.callsArgWith(2, null, [])
+    beforeEach(function (ctx) {
+      ctx.UserSessionsManager.getAllUserSessions.callsArgWith(2, null, [])
     })
 
-    it('should render user/sessions', function (done) {
-      this.res.callback = () => {
-        this.res.renderedTemplate.should.equal('user/sessions')
-        done()
-      }
-      this.UserPagesController.sessionsPage(this.req, this.res, done)
+    it('should render user/sessions', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.res.renderedTemplate.should.equal('user/sessions')
+          resolve()
+        }
+        ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should include current session data in the view', function (done) {
-      this.res.callback = () => {
-        expect(this.res.renderedVariables.currentSession).to.deep.equal({
-          ip_address: '1.1.1.1',
-          session_created: 'timestamp',
-        })
-        done()
-      }
-      this.UserPagesController.sessionsPage(this.req, this.res, done)
+    it('should include current session data in the view', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          expect(ctx.res.renderedVariables.currentSession).to.deep.equal({
+            ip_address: '1.1.1.1',
+            session_created: 'timestamp',
+          })
+          resolve()
+        }
+        ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should have called getAllUserSessions', function (done) {
-      this.res.callback = page => {
-        this.UserSessionsManager.getAllUserSessions.callCount.should.equal(1)
-        done()
-      }
-      this.UserPagesController.sessionsPage(this.req, this.res, done)
+    it('should have called getAllUserSessions', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = page => {
+          ctx.UserSessionsManager.getAllUserSessions.callCount.should.equal(1)
+          resolve()
+        }
+        ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
     describe('when getAllUserSessions produces an error', function () {
-      beforeEach(function () {
-        this.UserSessionsManager.getAllUserSessions.callsArgWith(
+      beforeEach(function (ctx) {
+        ctx.UserSessionsManager.getAllUserSessions.callsArgWith(
           2,
           new Error('woops')
         )
       })
 
-      it('should call next with an error', function (done) {
-        this.next = err => {
-          assert(err !== null)
-          assert(err instanceof Error)
-          done()
-        }
-        this.UserPagesController.sessionsPage(this.req, this.res, this.next)
+      it('should call next with an error', function (ctx) {
+        return new Promise(resolve => {
+          ctx.next = err => {
+            assert(err !== null)
+            assert(err instanceof Error)
+            resolve()
+          }
+          ctx.UserPagesController.sessionsPage(ctx.req, ctx.res, ctx.next)
+        })
       })
     })
   })
 
   describe('emailPreferencesPage', function () {
-    beforeEach(function () {
-      this.UserGetter.getUser = sinon.stub().yields(null, this.user)
+    beforeEach(function (ctx) {
+      ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user)
     })
 
-    it('render page with subscribed status', function (done) {
-      this.NewsletterManager.subscribed.yields(null, true)
-      this.res.callback = () => {
-        this.res.renderedTemplate.should.equal('user/email-preferences')
-        this.res.renderedVariables.title.should.equal('newsletter_info_title')
-        this.res.renderedVariables.subscribed.should.equal(true)
-        done()
-      }
-      this.UserPagesController.emailPreferencesPage(this.req, this.res, done)
+    it('render page with subscribed status', function (ctx) {
+      return new Promise(resolve => {
+        ctx.NewsletterManager.subscribed.yields(null, true)
+        ctx.res.callback = () => {
+          ctx.res.renderedTemplate.should.equal('user/email-preferences')
+          ctx.res.renderedVariables.title.should.equal('newsletter_info_title')
+          ctx.res.renderedVariables.subscribed.should.equal(true)
+          resolve()
+        }
+        ctx.UserPagesController.emailPreferencesPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('render page with unsubscribed status', function (done) {
-      this.NewsletterManager.subscribed.yields(null, false)
-      this.res.callback = () => {
-        this.res.renderedTemplate.should.equal('user/email-preferences')
-        this.res.renderedVariables.title.should.equal('newsletter_info_title')
-        this.res.renderedVariables.subscribed.should.equal(false)
-        done()
-      }
-      this.UserPagesController.emailPreferencesPage(this.req, this.res, done)
+    it('render page with unsubscribed status', function (ctx) {
+      return new Promise(resolve => {
+        ctx.NewsletterManager.subscribed.yields(null, false)
+        ctx.res.callback = () => {
+          ctx.res.renderedTemplate.should.equal('user/email-preferences')
+          ctx.res.renderedVariables.title.should.equal('newsletter_info_title')
+          ctx.res.renderedVariables.subscribed.should.equal(false)
+          resolve()
+        }
+        ctx.UserPagesController.emailPreferencesPage(ctx.req, ctx.res, resolve)
+      })
     })
   })
 
   describe('settingsPage', function () {
-    beforeEach(function () {
-      this.request.get = sinon
+    beforeEach(function (ctx) {
+      ctx.request.get = sinon
         .stub()
         .callsArgWith(1, null, { statusCode: 200 }, { has_password: true })
-      this.UserGetter.promises.getUser = sinon.stub().resolves(this.user)
+      ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.user)
     })
 
-    it('should render user/settings', function (done) {
-      this.res.callback = () => {
-        this.res.renderedTemplate.should.equal('user/settings')
-        done()
-      }
-      this.UserPagesController.settingsPage(this.req, this.res, done)
+    it('should render user/settings', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.res.renderedTemplate.should.equal('user/settings')
+          resolve()
+        }
+        ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should send user', function (done) {
-      this.res.callback = () => {
-        this.res.renderedVariables.user.id.should.equal(this.user._id)
-        this.res.renderedVariables.user.email.should.equal(this.user.email)
-        done()
-      }
-      this.UserPagesController.settingsPage(this.req, this.res, done)
+    it('should send user', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.res.renderedVariables.user.id.should.equal(ctx.user._id)
+          ctx.res.renderedVariables.user.email.should.equal(ctx.user.email)
+          resolve()
+        }
+        ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it("should set 'shouldAllowEditingDetails' to true", function (done) {
-      this.res.callback = () => {
-        this.res.renderedVariables.shouldAllowEditingDetails.should.equal(true)
-        done()
-      }
-      this.UserPagesController.settingsPage(this.req, this.res, done)
+    it("should set 'shouldAllowEditingDetails' to true", function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          ctx.res.renderedVariables.shouldAllowEditingDetails.should.equal(true)
+          resolve()
+        }
+        ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should restructure thirdPartyIdentifiers data for template use', function (done) {
-      const expectedResult = {
-        google: 'testId',
-      }
-      this.res.callback = () => {
-        expect(this.res.renderedVariables.thirdPartyIds).to.include(
-          expectedResult
-        )
-        done()
-      }
-      this.UserPagesController.settingsPage(this.req, this.res, done)
+    it('should restructure thirdPartyIdentifiers data for template use', function (ctx) {
+      return new Promise(resolve => {
+        const expectedResult = {
+          google: 'testId',
+        }
+        ctx.res.callback = () => {
+          expect(ctx.res.renderedVariables.thirdPartyIds).to.include(
+            expectedResult
+          )
+          resolve()
+        }
+        ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it("should set and clear 'projectSyncSuccessMessage'", function (done) {
-      this.req.session.projectSyncSuccessMessage = 'Some Sync Success'
-      this.res.callback = () => {
-        this.res.renderedVariables.projectSyncSuccessMessage.should.equal(
-          'Some Sync Success'
-        )
-        expect(this.req.session.projectSyncSuccessMessage).to.not.exist
-        done()
-      }
-      this.UserPagesController.settingsPage(this.req, this.res, done)
+    it("should set and clear 'projectSyncSuccessMessage'", function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.session.projectSyncSuccessMessage = 'Some Sync Success'
+        ctx.res.callback = () => {
+          ctx.res.renderedVariables.projectSyncSuccessMessage.should.equal(
+            'Some Sync Success'
+          )
+          expect(ctx.req.session.projectSyncSuccessMessage).to.not.exist
+          resolve()
+        }
+        ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should cast refProviders to booleans', function (done) {
-      this.res.callback = () => {
-        expect(this.res.renderedVariables.user.refProviders).to.deep.equal({
-          mendeley: true,
-          papers: true,
-          zotero: true,
-        })
-        done()
-      }
-      this.UserPagesController.settingsPage(this.req, this.res, done)
+    it('should cast refProviders to booleans', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          expect(ctx.res.renderedVariables.user.refProviders).to.deep.equal({
+            mendeley: true,
+            papers: true,
+            zotero: true,
+          })
+          resolve()
+        }
+        ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should send the correct managed user admin email', function (done) {
-      this.res.callback = () => {
-        expect(
-          this.res.renderedVariables.currentManagedUserAdminEmail
-        ).to.equal(this.adminEmail)
-        done()
-      }
-      this.UserPagesController.settingsPage(this.req, this.res, done)
+    it('should send the correct managed user admin email', function (ctx) {
+      return new Promise(resolve => {
+        ctx.res.callback = () => {
+          expect(
+            ctx.res.renderedVariables.currentManagedUserAdminEmail
+          ).to.equal(ctx.adminEmail)
+          resolve()
+        }
+        ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
-    it('should send info for groups with SSO enabled', function (done) {
-      this.user.enrollment = {
-        sso: [
-          {
-            groupId: 'abc123abc123',
-            primary: true,
-            linkedAt: new Date(),
-          },
-        ],
-      }
-      const group1 = {
-        _id: 'abc123abc123',
-        teamName: 'Group SSO Rulz',
-        admin_id: {
-          email: 'admin.email@ssolove.com',
-        },
-        linked: true,
-      }
-      const group2 = {
-        _id: 'def456def456',
-        admin_id: {
-          email: 'someone.else@noname.co.uk',
-        },
-        linked: false,
-      }
-
-      this.Modules.promises.hooks.fire
-        .withArgs('getUserGroupsSSOEnrollmentStatus')
-        .resolves([[group1, group2]])
-
-      this.res.callback = () => {
-        expect(
-          this.res.renderedVariables.memberOfSSOEnabledGroups
-        ).to.deep.equal([
-          {
-            groupId: 'abc123abc123',
-            groupName: 'Group SSO Rulz',
-            adminEmail: 'admin.email@ssolove.com',
-            linked: true,
+    it('should send info for groups with SSO enabled', function (ctx) {
+      return new Promise(resolve => {
+        ctx.user.enrollment = {
+          sso: [
+            {
+              groupId: 'abc123abc123',
+              primary: true,
+              linkedAt: new Date(),
+            },
+          ],
+        }
+        const group1 = {
+          _id: 'abc123abc123',
+          teamName: 'Group SSO Rulz',
+          admin_id: {
+            email: 'admin.email@ssolove.com',
           },
-          {
-            groupId: 'def456def456',
-            groupName: undefined,
-            adminEmail: 'someone.else@noname.co.uk',
-            linked: false,
+          linked: true,
+        }
+        const group2 = {
+          _id: 'def456def456',
+          admin_id: {
+            email: 'someone.else@noname.co.uk',
           },
-        ])
-        done()
-      }
+          linked: false,
+        }
 
-      this.UserPagesController.settingsPage(this.req, this.res, done)
+        ctx.Modules.promises.hooks.fire
+          .withArgs('getUserGroupsSSOEnrollmentStatus')
+          .resolves([[group1, group2]])
+
+        ctx.res.callback = () => {
+          expect(
+            ctx.res.renderedVariables.memberOfSSOEnabledGroups
+          ).to.deep.equal([
+            {
+              groupId: 'abc123abc123',
+              groupName: 'Group SSO Rulz',
+              adminEmail: 'admin.email@ssolove.com',
+              linked: true,
+            },
+            {
+              groupId: 'def456def456',
+              groupName: undefined,
+              adminEmail: 'someone.else@noname.co.uk',
+              linked: false,
+            },
+          ])
+          resolve()
+        }
+
+        ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve)
+      })
     })
 
     describe('when ldap.updateUserDetailsOnLogin is true', function () {
-      beforeEach(function () {
-        this.settings.ldap = { updateUserDetailsOnLogin: true }
+      beforeEach(function (ctx) {
+        ctx.settings.ldap = { updateUserDetailsOnLogin: true }
       })
 
-      afterEach(function () {
-        delete this.settings.ldap
+      afterEach(function (ctx) {
+        delete ctx.settings.ldap
       })
 
-      it('should set "shouldAllowEditingDetails" to false', function (done) {
-        this.res.callback = () => {
-          this.res.renderedVariables.shouldAllowEditingDetails.should.equal(
-            false
-          )
-          done()
-        }
-        this.UserPagesController.settingsPage(this.req, this.res, done)
+      it('should set "shouldAllowEditingDetails" to false', function (ctx) {
+        return new Promise(resolve => {
+          ctx.res.callback = () => {
+            ctx.res.renderedVariables.shouldAllowEditingDetails.should.equal(
+              false
+            )
+            resolve()
+          }
+          ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve)
+        })
       })
     })
 
     describe('when saml.updateUserDetailsOnLogin is true', function () {
-      beforeEach(function () {
-        this.settings.saml = { updateUserDetailsOnLogin: true }
+      beforeEach(function (ctx) {
+        ctx.settings.saml = { updateUserDetailsOnLogin: true }
       })
 
-      afterEach(function () {
-        delete this.settings.saml
+      afterEach(function (ctx) {
+        delete ctx.settings.saml
       })
 
-      it('should set "shouldAllowEditingDetails" to false', function (done) {
-        this.res.callback = () => {
-          this.res.renderedVariables.shouldAllowEditingDetails.should.equal(
-            false
-          )
-          done()
-        }
-        this.UserPagesController.settingsPage(this.req, this.res, done)
+      it('should set "shouldAllowEditingDetails" to false', function (ctx) {
+        return new Promise(resolve => {
+          ctx.res.callback = () => {
+            ctx.res.renderedVariables.shouldAllowEditingDetails.should.equal(
+              false
+            )
+            resolve()
+          }
+          ctx.UserPagesController.settingsPage(ctx.req, ctx.res, resolve)
+        })
       })
     })
   })

+ 262 - 194
services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs

@@ -1,6 +1,6 @@
+import { vi } from 'vitest'
 import sinon from 'sinon'
 import { expect } from 'chai'
-import esmock from 'esmock'
 import MockRequest from '../helpers/MockRequest.js'
 import MockResponse from '../helpers/MockResponse.js'
 import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js'
@@ -15,27 +15,39 @@ const assertCalledWith = sinon.assert.calledWith
 const modulePath =
   '../../../../app/src/Features/UserMembership/UserMembershipController.mjs'
 
+vi.mock(
+  '../../../../app/src/Features/UserMembership/UserMembershipErrors.js',
+  () =>
+    vi.importActual(
+      '../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
+    )
+)
+
+vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
+  vi.importActual('../../../../app/src/Features/Errors/Errors.js')
+)
+
 describe('UserMembershipController', function () {
-  beforeEach(async function () {
-    this.req = new MockRequest()
-    this.req.params.id = 'mock-entity-id'
-    this.user = { _id: 'mock-user-id' }
-    this.newUser = { _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' }
-    this.subscription = {
+  beforeEach(async function (ctx) {
+    ctx.req = new MockRequest()
+    ctx.req.params.id = 'mock-entity-id'
+    ctx.user = { _id: 'mock-user-id' }
+    ctx.newUser = { _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' }
+    ctx.subscription = {
       _id: 'mock-subscription-id',
       admin_id: 'mock-admin-id',
-      fetchV1Data: callback => callback(null, this.subscription),
+      fetchV1Data: callback => callback(null, ctx.subscription),
     }
-    this.institution = {
+    ctx.institution = {
       _id: 'mock-institution-id',
       v1Id: 123,
       fetchV1Data: callback => {
-        const institution = Object.assign({}, this.institution)
+        const institution = Object.assign({}, ctx.institution)
         institution.name = 'Test Institution Name'
         callback(null, institution)
       },
     }
-    this.users = [
+    ctx.users = [
       {
         _id: 'mock-member-id-1',
         email: 'mock-email-1@foo.com',
@@ -50,106 +62,136 @@ describe('UserMembershipController', function () {
       },
     ]
 
-    this.Settings = {
+    ctx.Settings = {
       managedUsers: {
         enabled: false,
       },
     }
 
-    this.SessionManager = {
-      getSessionUser: sinon.stub().returns(this.user),
-      getLoggedInUserId: sinon.stub().returns(this.user._id),
+    ctx.SessionManager = {
+      getSessionUser: sinon.stub().returns(ctx.user),
+      getLoggedInUserId: sinon.stub().returns(ctx.user._id),
     }
-    this.SSOConfig = {
+    ctx.SSOConfig = {
       findById: sinon
         .stub()
         .returns({ exec: sinon.stub().resolves({ enabled: true }) }),
     }
-    this.UserMembershipHandler = {
-      getEntity: sinon.stub().yields(null, this.subscription),
-      createEntity: sinon.stub().yields(null, this.institution),
-      getUsers: sinon.stub().yields(null, this.users),
-      addUser: sinon.stub().yields(null, this.newUser),
+    ctx.UserMembershipHandler = {
+      getEntity: sinon.stub().yields(null, ctx.subscription),
+      createEntity: sinon.stub().yields(null, ctx.institution),
+      getUsers: sinon.stub().yields(null, ctx.users),
+      addUser: sinon.stub().yields(null, ctx.newUser),
       removeUser: sinon.stub().yields(null),
       promises: {
-        getUsers: sinon.stub().resolves(this.users),
+        getUsers: sinon.stub().resolves(ctx.users),
       },
     }
-    this.SplitTestHandler = {
+    ctx.SplitTestHandler = {
       promises: {
         getAssignment: sinon.stub().resolves({ variant: 'default' }),
       },
       getAssignment: sinon.stub().yields(null, { variant: 'default' }),
     }
-    this.RecurlyClient = {
+    ctx.RecurlyClient = {
       promises: {
         getSubscription: sinon.stub().resolves({}),
       },
     }
-    this.UserMembershipController = await esmock.strict(modulePath, {
-      '../../../../app/src/Features/UserMembership/UserMembershipErrors': {
+
+    vi.doMock(
+      '../../../../app/src/Features/UserMembership/UserMembershipErrors',
+      () => ({
         UserIsManagerError,
         UserNotFoundError,
         UserAlreadyAddedError,
-      },
-      '../../../../app/src/Features/Authentication/SessionManager':
-        this.SessionManager,
-      '../../../../app/src/Features/SplitTests/SplitTestHandler':
-        this.SplitTestHandler,
-      '../../../../app/src/Features/UserMembership/UserMembershipHandler':
-        this.UserMembershipHandler,
-      '../../../../app/src/Features/Subscription/RecurlyClient':
-        this.RecurlyClient,
-      '@overleaf/settings': this.Settings,
-      '../../../../app/src/models/SSOConfig': { SSOConfig: this.SSOConfig },
-    })
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Authentication/SessionManager',
+      () => ({
+        default: ctx.SessionManager,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/UserMembership/UserMembershipHandler',
+      () => ({
+        default: ctx.UserMembershipHandler,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/RecurlyClient',
+      () => ({
+        default: ctx.RecurlyClient,
+      })
+    )
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.Settings,
+    }))
+
+    vi.doMock('../../../../app/src/models/SSOConfig', () => ({
+      SSOConfig: ctx.SSOConfig,
+    }))
+
+    ctx.UserMembershipController = (await import(modulePath)).default
   })
 
   describe('index', function () {
-    beforeEach(function () {
-      this.req.entity = this.subscription
-      this.req.entityConfig = EntityConfigs.group
+    beforeEach(function (ctx) {
+      ctx.req.entity = ctx.subscription
+      ctx.req.entityConfig = EntityConfigs.group
     })
 
-    it('get users', async function () {
-      await this.UserMembershipController.manageGroupMembers(this.req, {
+    it('get users', async function (ctx) {
+      await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
         render: () => {
           sinon.assert.calledWithMatch(
-            this.UserMembershipHandler.promises.getUsers,
-            this.subscription,
+            ctx.UserMembershipHandler.promises.getUsers,
+            ctx.subscription,
             { modelName: 'Subscription' }
           )
         },
       })
     })
 
-    it('render group view', async function () {
-      this.subscription.managedUsersEnabled = false
-      await this.UserMembershipController.manageGroupMembers(this.req, {
+    it('render group view', async function (ctx) {
+      ctx.subscription.managedUsersEnabled = false
+      await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
         render: (viewPath, viewParams) => {
           expect(viewPath).to.equal('user_membership/group-members-react')
-          expect(viewParams.users).to.deep.equal(this.users)
-          expect(viewParams.groupSize).to.equal(this.subscription.membersLimit)
+          expect(viewParams.users).to.deep.equal(ctx.users)
+          expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
           expect(viewParams.managedUsersActive).to.equal(false)
         },
       })
     })
 
-    it('render group view with managed users', async function () {
-      this.subscription.managedUsersEnabled = true
-      await this.UserMembershipController.manageGroupMembers(this.req, {
+    it('render group view with managed users', async function (ctx) {
+      ctx.subscription.managedUsersEnabled = true
+      await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
         render: (viewPath, viewParams) => {
           expect(viewPath).to.equal('user_membership/group-members-react')
-          expect(viewParams.users).to.deep.equal(this.users)
-          expect(viewParams.groupSize).to.equal(this.subscription.membersLimit)
+          expect(viewParams.users).to.deep.equal(ctx.users)
+          expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
           expect(viewParams.managedUsersActive).to.equal(true)
         },
       })
     })
 
-    it('render group managers view', async function () {
-      this.req.entityConfig = EntityConfigs.groupManagers
-      await this.UserMembershipController.manageGroupManagers(this.req, {
+    it('render group managers view', async function (ctx) {
+      ctx.req.entityConfig = EntityConfigs.groupManagers
+      await ctx.UserMembershipController.manageGroupManagers(ctx.req, {
         render: (viewPath, viewParams) => {
           expect(viewPath).to.equal('user_membership/group-managers-react')
           expect(viewParams.groupSize).to.equal(undefined)
@@ -157,10 +199,10 @@ describe('UserMembershipController', function () {
       })
     })
 
-    it('render institution view', async function () {
-      this.req.entity = this.institution
-      this.req.entityConfig = EntityConfigs.institution
-      await this.UserMembershipController.manageInstitutionManagers(this.req, {
+    it('render institution view', async function (ctx) {
+      ctx.req.entity = ctx.institution
+      ctx.req.entityConfig = EntityConfigs.institution
+      await ctx.UserMembershipController.manageInstitutionManagers(ctx.req, {
         render: (viewPath, viewParams) => {
           expect(viewPath).to.equal(
             'user_membership/institution-managers-react'
@@ -173,207 +215,233 @@ describe('UserMembershipController', function () {
   })
 
   describe('add', function () {
-    beforeEach(function () {
-      this.req.body.email = this.newUser.email
-      this.req.entity = this.subscription
-      this.req.entityConfig = EntityConfigs.groupManagers
+    beforeEach(function (ctx) {
+      ctx.req.body.email = ctx.newUser.email
+      ctx.req.entity = ctx.subscription
+      ctx.req.entityConfig = EntityConfigs.groupManagers
     })
 
-    it('add user', function (done) {
-      this.UserMembershipController.add(this.req, {
-        json: () => {
-          sinon.assert.calledWithMatch(
-            this.UserMembershipHandler.addUser,
-            this.subscription,
-            { modelName: 'Subscription' },
-            this.newUser.email
-          )
-          done()
-        },
+    it('add user', function (ctx) {
+      return new Promise(resolve => {
+        ctx.UserMembershipController.add(ctx.req, {
+          json: () => {
+            sinon.assert.calledWithMatch(
+              ctx.UserMembershipHandler.addUser,
+              ctx.subscription,
+              { modelName: 'Subscription' },
+              ctx.newUser.email
+            )
+            resolve()
+          },
+        })
       })
     })
 
-    it('return user object', function (done) {
-      this.UserMembershipController.add(this.req, {
-        json: payload => {
-          payload.user.should.equal(this.newUser)
-          done()
-        },
+    it('return user object', function (ctx) {
+      return new Promise(resolve => {
+        ctx.UserMembershipController.add(ctx.req, {
+          json: payload => {
+            payload.user.should.equal(ctx.newUser)
+            resolve()
+          },
+        })
       })
     })
 
-    it('handle readOnly entity', function (done) {
-      this.req.entityConfig = EntityConfigs.group
-      this.UserMembershipController.add(this.req, null, error => {
-        expect(error).to.exist
-        expect(error).to.be.an.instanceof(Errors.NotFoundError)
-        done()
+    it('handle readOnly entity', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.entityConfig = EntityConfigs.group
+        ctx.UserMembershipController.add(ctx.req, null, error => {
+          expect(error).to.exist
+          expect(error).to.be.an.instanceof(Errors.NotFoundError)
+          resolve()
+        })
       })
     })
 
-    it('handle user already added', function (done) {
-      this.UserMembershipHandler.addUser.yields(new UserAlreadyAddedError())
-      this.UserMembershipController.add(this.req, {
-        status: () => ({
-          json: payload => {
-            expect(payload.error.code).to.equal('user_already_added')
-            done()
-          },
-        }),
+    it('handle user already added', function (ctx) {
+      return new Promise(resolve => {
+        ctx.UserMembershipHandler.addUser.yields(new UserAlreadyAddedError())
+        ctx.UserMembershipController.add(ctx.req, {
+          status: () => ({
+            json: payload => {
+              expect(payload.error.code).to.equal('user_already_added')
+              resolve()
+            },
+          }),
+        })
       })
     })
 
-    it('handle user not found', function (done) {
-      this.UserMembershipHandler.addUser.yields(new UserNotFoundError())
-      this.UserMembershipController.add(this.req, {
-        status: () => ({
-          json: payload => {
-            expect(payload.error.code).to.equal('user_not_found')
-            done()
-          },
-        }),
+    it('handle user not found', function (ctx) {
+      return new Promise(resolve => {
+        ctx.UserMembershipHandler.addUser.yields(new UserNotFoundError())
+        ctx.UserMembershipController.add(ctx.req, {
+          status: () => ({
+            json: payload => {
+              expect(payload.error.code).to.equal('user_not_found')
+              resolve()
+            },
+          }),
+        })
       })
     })
 
-    it('handle invalid email', function (done) {
-      this.req.body.email = 'not_valid_email'
-      this.UserMembershipController.add(this.req, {
-        status: () => ({
-          json: payload => {
-            expect(payload.error.code).to.equal('invalid_email')
-            done()
-          },
-        }),
+    it('handle invalid email', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.body.email = 'not_valid_email'
+        ctx.UserMembershipController.add(ctx.req, {
+          status: () => ({
+            json: payload => {
+              expect(payload.error.code).to.equal('invalid_email')
+              resolve()
+            },
+          }),
+        })
       })
     })
   })
 
   describe('remove', function () {
-    beforeEach(function () {
-      this.req.params.userId = this.newUser._id
-      this.req.entity = this.subscription
-      this.req.entityConfig = EntityConfigs.groupManagers
+    beforeEach(function (ctx) {
+      ctx.req.params.userId = ctx.newUser._id
+      ctx.req.entity = ctx.subscription
+      ctx.req.entityConfig = EntityConfigs.groupManagers
     })
 
-    it('remove user', function (done) {
-      this.UserMembershipController.remove(this.req, {
-        sendStatus: () => {
-          sinon.assert.calledWithMatch(
-            this.UserMembershipHandler.removeUser,
-            this.subscription,
-            { modelName: 'Subscription' },
-            this.newUser._id
-          )
-          done()
-        },
+    it('remove user', function (ctx) {
+      return new Promise(resolve => {
+        ctx.UserMembershipController.remove(ctx.req, {
+          sendStatus: () => {
+            sinon.assert.calledWithMatch(
+              ctx.UserMembershipHandler.removeUser,
+              ctx.subscription,
+              { modelName: 'Subscription' },
+              ctx.newUser._id
+            )
+            resolve()
+          },
+        })
       })
     })
 
-    it('handle readOnly entity', function (done) {
-      this.req.entityConfig = EntityConfigs.group
-      this.UserMembershipController.remove(this.req, null, error => {
-        expect(error).to.exist
-        expect(error).to.be.an.instanceof(Errors.NotFoundError)
-        done()
+    it('handle readOnly entity', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.entityConfig = EntityConfigs.group
+        ctx.UserMembershipController.remove(ctx.req, null, error => {
+          expect(error).to.exist
+          expect(error).to.be.an.instanceof(Errors.NotFoundError)
+          resolve()
+        })
       })
     })
 
-    it('prevent self removal', function (done) {
-      this.req.params.userId = this.user._id
-      this.UserMembershipController.remove(this.req, {
-        status: () => ({
-          json: payload => {
-            expect(payload.error.code).to.equal('managers_cannot_remove_self')
-            done()
-          },
-        }),
+    it('prevent self removal', function (ctx) {
+      return new Promise(resolve => {
+        ctx.req.params.userId = ctx.user._id
+        ctx.UserMembershipController.remove(ctx.req, {
+          status: () => ({
+            json: payload => {
+              expect(payload.error.code).to.equal('managers_cannot_remove_self')
+              resolve()
+            },
+          }),
+        })
       })
     })
 
-    it('prevent admin removal', function (done) {
-      this.UserMembershipHandler.removeUser.yields(new UserIsManagerError())
-      this.UserMembershipController.remove(this.req, {
-        status: () => ({
-          json: payload => {
-            expect(payload.error.code).to.equal('managers_cannot_remove_admin')
-            done()
-          },
-        }),
+    it('prevent admin removal', function (ctx) {
+      return new Promise(resolve => {
+        ctx.UserMembershipHandler.removeUser.yields(new UserIsManagerError())
+        ctx.UserMembershipController.remove(ctx.req, {
+          status: () => ({
+            json: payload => {
+              expect(payload.error.code).to.equal(
+                'managers_cannot_remove_admin'
+              )
+              resolve()
+            },
+          }),
+        })
       })
     })
   })
 
   describe('exportCsv', function () {
-    beforeEach(function () {
-      this.req.entity = this.subscription
-      this.req.entityConfig = EntityConfigs.groupManagers
-      this.res = new MockResponse()
-      this.UserMembershipController.exportCsv(this.req, this.res)
+    beforeEach(function (ctx) {
+      ctx.req.entity = ctx.subscription
+      ctx.req.entityConfig = EntityConfigs.groupManagers
+      ctx.res = new MockResponse()
+      ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
     })
 
-    it('get users', function () {
+    it('get users', function (ctx) {
       sinon.assert.calledWithMatch(
-        this.UserMembershipHandler.getUsers,
-        this.subscription,
+        ctx.UserMembershipHandler.getUsers,
+        ctx.subscription,
         { modelName: 'Subscription' }
       )
     })
 
-    it('should set the correct content type on the request', function () {
-      assertCalledWith(this.res.contentType, 'text/csv; charset=utf-8')
+    it('should set the correct content type on the request', function (ctx) {
+      assertCalledWith(ctx.res.contentType, 'text/csv; charset=utf-8')
     })
 
-    it('should name the exported csv file', function () {
+    it('should name the exported csv file', function (ctx) {
       assertCalledWith(
-        this.res.header,
+        ctx.res.header,
         'Content-Disposition',
         'attachment; filename="Group.csv"'
       )
     })
 
-    it('should export the correct csv', function () {
+    it('should export the correct csv', function (ctx) {
       assertCalledWith(
-        this.res.send,
+        ctx.res.send,
         '"email","last_logged_in_at","last_active_at"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z"\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z"'
       )
     })
   })
 
   describe('new', function () {
-    beforeEach(function () {
-      this.req.params.name = 'publisher'
-      this.req.params.id = 'abc'
+    beforeEach(function (ctx) {
+      ctx.req.params.name = 'publisher'
+      ctx.req.params.id = 'abc'
     })
 
-    it('renders view', function (done) {
-      this.UserMembershipController.new(this.req, {
-        render: (viewPath, data) => {
-          expect(data.entityName).to.eq('publisher')
-          expect(data.entityId).to.eq('abc')
-          done()
-        },
+    it('renders view', function (ctx) {
+      return new Promise(resolve => {
+        ctx.UserMembershipController.new(ctx.req, {
+          render: (viewPath, data) => {
+            expect(data.entityName).to.eq('publisher')
+            expect(data.entityId).to.eq('abc')
+            resolve()
+          },
+        })
       })
     })
   })
 
   describe('create', function () {
-    beforeEach(function () {
-      this.req.params.name = 'institution'
-      this.req.entityConfig = EntityConfigs.institution
-      this.req.params.id = 123
+    beforeEach(function (ctx) {
+      ctx.req.params.name = 'institution'
+      ctx.req.entityConfig = EntityConfigs.institution
+      ctx.req.params.id = 123
     })
 
-    it('creates institution', function (done) {
-      this.UserMembershipController.create(this.req, {
-        redirect: path => {
-          expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index)
-          sinon.assert.calledWithMatch(
-            this.UserMembershipHandler.createEntity,
-            123,
-            { modelName: 'Institution' }
-          )
-          done()
-        },
+    it('creates institution', function (ctx) {
+      return new Promise(resolve => {
+        ctx.UserMembershipController.create(ctx.req, {
+          redirect: path => {
+            expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index)
+            sinon.assert.calledWithMatch(
+              ctx.UserMembershipHandler.createEntity,
+              123,
+              { modelName: 'Institution' }
+            )
+            resolve()
+          },
+        })
       })
     })
   })

+ 22 - 21
services/web/test/unit/src/infrastructure/ServeStaticWrapper.test.mjs

@@ -1,24 +1,22 @@
-import { strict as esmock } from 'esmock'
+import { vi } from 'vitest'
 import { expect } from 'chai'
 import Path from 'node:path'
-import { fileURLToPath } from 'node:url'
 import sinon from 'sinon'
 import MockResponse from '../helpers/MockResponse.js'
 import MockRequest from '../helpers/MockRequest.js'
 
-const __dirname = fileURLToPath(new URL('.', import.meta.url))
 const modulePath = Path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/infrastructure/ServeStaticWrapper'
 )
 
 describe('ServeStaticWrapperTests', function () {
   let error = null
 
-  beforeEach(async function () {
-    this.req = new MockRequest()
-    this.res = new MockResponse()
-    this.express = {
+  beforeEach(async function (ctx) {
+    ctx.req = new MockRequest()
+    ctx.res = new MockResponse()
+    ctx.express = {
       static: () => (req, res, next) => {
         if (error) {
           next(error)
@@ -27,36 +25,39 @@ describe('ServeStaticWrapperTests', function () {
         }
       },
     }
-    this.serveStaticWrapper = await esmock(modulePath, {
-      express: this.express,
-    })
+
+    vi.doMock('express', () => ({
+      default: ctx.express,
+    }))
+
+    ctx.serveStaticWrapper = (await import(modulePath)).default
   })
 
-  this.afterEach(() => {
+  afterEach(() => {
     error = null
   })
 
-  it('Premature close error thrown', async function () {
+  it('Premature close error thrown', async function (ctx) {
     error = new Error()
     error.code = 'ERR_STREAM_PREMATURE_CLOSE'
-    const middleware = this.serveStaticWrapper('test_folder', {})
+    const middleware = ctx.serveStaticWrapper('test_folder', {})
     const next = sinon.stub()
-    middleware(this.req, this.res, next)
+    middleware(ctx.req, ctx.res, next)
     expect(next.called).to.be.false
   })
 
-  it('No error thrown', async function () {
-    const middleware = this.serveStaticWrapper('test_folder', {})
+  it('No error thrown', async function (ctx) {
+    const middleware = ctx.serveStaticWrapper('test_folder', {})
     const next = sinon.stub()
-    middleware(this.req, this.res, next)
+    middleware(ctx.req, ctx.res, next)
     expect(next).to.be.calledWith()
   })
 
-  it('Other error thrown', async function () {
+  it('Other error thrown', async function (ctx) {
     error = new Error()
-    const middleware = this.serveStaticWrapper('test_folder', {})
+    const middleware = ctx.serveStaticWrapper('test_folder', {})
     const next = sinon.stub()
-    middleware(this.req, this.res, next)
+    middleware(ctx.req, ctx.res, next)
     expect(next).to.be.calledWith(error)
   })
 })

Some files were not shown because too many files changed in this diff