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

Features ESM conversion

GitOrigin-RevId: d659326723a90ac0789f4f7acc7f00aa9eaa63e2
Andrew Rumble 1 год назад
Родитель
Сommit
3d3be18f57

+ 10 - 10
services/web/app/src/Features/Compile/ClsiCookieManager.mjs

@@ -1,15 +1,15 @@
-const { URL, URLSearchParams } = require('url')
-const OError = require('@overleaf/o-error')
-const Settings = require('@overleaf/settings')
-const {
+import { URL, URLSearchParams } from 'node:url'
+import OError from '@overleaf/o-error'
+import Settings from '@overleaf/settings'
+import {
   fetchNothing,
   fetchStringWithResponse,
   RequestFailedError,
-} = require('@overleaf/fetch-utils')
-const RedisWrapper = require('../../infrastructure/RedisWrapper')
-const Cookie = require('cookie')
-const logger = require('@overleaf/logger')
-const Metrics = require('@overleaf/metrics')
+} from '@overleaf/fetch-utils'
+import RedisWrapper from '../../infrastructure/RedisWrapper.js'
+import Cookie from 'cookie'
+import logger from '@overleaf/logger'
+import Metrics from '@overleaf/metrics'
 
 const clsiCookiesEnabled = (Settings.clsiCookie?.key ?? '') !== ''
 
@@ -256,4 +256,4 @@ const ClsiCookieManagerFactory = function (backendGroup) {
   return cookieManager
 }
 
-module.exports = ClsiCookieManagerFactory
+export default ClsiCookieManagerFactory

+ 3 - 3
services/web/app/src/Features/Compile/ClsiFormatChecker.mjs

@@ -1,5 +1,5 @@
-const _ = require('lodash')
-const settings = require('@overleaf/settings')
+import _ from 'lodash'
+import settings from '@overleaf/settings'
 
 const ClsiFormatChecker = {
   checkRecoursesForProblems(resources) {
@@ -56,4 +56,4 @@ const ClsiFormatChecker = {
   },
 }
 
-module.exports = ClsiFormatChecker
+export default ClsiFormatChecker

+ 3 - 11
services/web/app/src/Features/Compile/ClsiStateManager.mjs

@@ -1,8 +1,3 @@
-/* eslint-disable
-    n/handle-callback-err,
-    max-len,
-    no-unused-vars,
-*/
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
 /*
@@ -13,11 +8,8 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-let ClsiStateManager
-const Settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-const crypto = require('crypto')
-const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
+import crypto from 'node:crypto'
+import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
 
 // The "state" of a project is a hash of the relevant attributes in the
 // project object in this case we only need the rootFolder.
@@ -36,7 +28,7 @@ const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
 const buildState = s =>
   crypto.createHash('sha1').update(s, 'utf8').digest('hex')
 
-module.exports = ClsiStateManager = {
+export default {
   computeHash(project, options) {
     const { docs, files } =
       ProjectEntityHandler.getAllEntitiesFromProject(project)

+ 5 - 5
services/web/app/src/Features/Institutions/InstitutionsGetter.mjs

@@ -1,7 +1,7 @@
-const { promisify, callbackify } = require('util')
-const UserGetter = require('../User/UserGetter')
-const UserMembershipsHandler = require('../UserMembership/UserMembershipsHandler')
-const UserMembershipEntityConfigs = require('../UserMembership/UserMembershipEntityConfigs')
+import { promisify, callbackify } from 'node:util'
+import UserGetter from '../User/UserGetter.js'
+import UserMembershipsHandler from '../UserMembership/UserMembershipsHandler.js'
+import UserMembershipEntityConfigs from '../UserMembership/UserMembershipEntityConfigs.js'
 
 async function getCurrentAffiliations(userId) {
   const fullEmails = await UserGetter.promises.getUserFullEmails(userId)
@@ -98,4 +98,4 @@ InstitutionsGetter.promises = {
   getManagedInstitutions: promisify(InstitutionsGetter.getManagedInstitutions),
 }
 
-module.exports = InstitutionsGetter
+export default InstitutionsGetter

+ 18 - 19
services/web/app/src/Features/Institutions/InstitutionsManager.mjs

@@ -1,21 +1,20 @@
-const {
-  callbackifyAll,
-  promiseMapWithLimit,
-} = require('@overleaf/promise-utils')
-const { ObjectId } = require('mongodb-legacy')
-const Settings = require('@overleaf/settings')
-const logger = require('@overleaf/logger')
-const { fetchJson } = require('@overleaf/fetch-utils')
-const InstitutionsAPI = require('./InstitutionsAPI')
-const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
-const FeaturesHelper = require('../Subscription/FeaturesHelper')
-const UserGetter = require('../User/UserGetter')
-const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
-const NotificationsHandler = require('../Notifications/NotificationsHandler')
-const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
-const { Institution } = require('../../models/Institution')
-const { Subscription } = require('../../models/Subscription')
-const OError = require('@overleaf/o-error')
+import { callbackifyAll, promiseMapWithLimit } from '@overleaf/promise-utils'
+import mongodb from 'mongodb-legacy'
+import Settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import { fetchJson } from '@overleaf/fetch-utils'
+import InstitutionsAPI from './InstitutionsAPI.js'
+import FeaturesUpdater from '../Subscription/FeaturesUpdater.js'
+import FeaturesHelper from '../Subscription/FeaturesHelper.js'
+import UserGetter from '../User/UserGetter.js'
+import NotificationsBuilder from '../Notifications/NotificationsBuilder.js'
+import NotificationsHandler from '../Notifications/NotificationsHandler.js'
+import SubscriptionLocator from '../Subscription/SubscriptionLocator.js'
+import { Institution } from '../../models/Institution.js'
+import { Subscription } from '../../models/Subscription.js'
+import OError from '@overleaf/o-error'
+
+const { ObjectId } = mongodb
 
 const ASYNC_LIMIT = parseInt(process.env.ASYNC_LIMIT, 10) || 5
 
@@ -355,7 +354,7 @@ async function affiliateUserByReversedHostname(user, reversedHostname) {
   )
 }
 
-module.exports = {
+export default {
   ...callbackifyAll(InstitutionsManager),
   promises: InstitutionsManager,
 }

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

@@ -1,6 +1,6 @@
 import EmailHandler from '../Email/EmailHandler.js'
 import UserGetter from '../User/UserGetter.js'
-import './SubscriptionEmailBuilder.js'
+import './SubscriptionEmailBuilder.mjs'
 import PlansLocator from './PlansLocator.js'
 import Settings from '@overleaf/settings'
 

+ 2 - 2
services/web/app/src/Features/Subscription/SubscriptionFormatters.mjs

@@ -1,4 +1,4 @@
-import dateformat from 'dateformat';
+import dateformat from 'dateformat'
 
 function formatDateTime(date) {
   if (!date) {
@@ -17,4 +17,4 @@ function formatDate(date) {
 export default {
   formatDateTime,
   formatDate,
-};
+}

+ 179 - 175
services/web/test/unit/src/Compile/ClsiCookieManager.test.mjs

@@ -1,24 +1,23 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../../app/src/Features/Compile/ClsiCookieManager.js'
-const SandboxedModule = require('sandboxed-module')
+import sinon from 'sinon'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+const modulePath = '../../../../app/src/Features/Compile/ClsiCookieManager.mjs'
 
 describe('ClsiCookieManager', function () {
-  beforeEach(function () {
-    this.redis = {
+  beforeEach(async function (ctx) {
+    ctx.redis = {
       auth() {},
       del: sinon.stub(),
       get: sinon.stub(),
       setex: sinon.stub().resolves(),
     }
-    this.project_id = '123423431321-proj-id'
-    this.user_id = 'abc-user-id'
-    this.fetchUtils = {
+    ctx.project_id = '123423431321-proj-id'
+    ctx.user_id = 'abc-user-id'
+    ctx.fetchUtils = {
       fetchNothing: sinon.stub().returns(Promise.resolve()),
       fetchStringWithResponse: sinon.stub().returns(Promise.resolve()),
     }
-    this.metrics = { inc: sinon.stub() }
-    this.settings = {
+    ctx.metrics = { inc: sinon.stub() }
+    ctx.settings = {
       redis: {
         web: 'redis.something',
       },
@@ -33,314 +32,319 @@ describe('ClsiCookieManager', function () {
         key: 'coooookie',
       },
     }
-    this.requires = {
-      '../../infrastructure/RedisWrapper': (this.RedisWrapper = {
-        client: () => this.redis,
+    vi.doMock('../../../../app/src/infrastructure/RedisWrapper', () => ({
+      default: (ctx.RedisWrapper = {
+        client: () => ctx.redis,
       }),
-      '@overleaf/settings': this.settings,
-      '@overleaf/fetch-utils': this.fetchUtils,
-      '@overleaf/metrics': this.metrics,
-    }
-    this.ClsiCookieManager = SandboxedModule.require(modulePath, {
-      requires: this.requires,
-    })()
+    }))
+    vi.doMock('@overleaf/settings', () => ({
+      default: ctx.settings,
+    }))
+    vi.doMock('@overleaf/fetch-utils', () => ctx.fetchUtils)
+    vi.doMock('@overleaf/metrics', () => ({
+      default: ctx.metrics,
+    }))
+
+    ctx.ClsiCookieManager = (await import(modulePath)).default()
   })
 
   describe('getServerId', function () {
-    it('should call get for the key', async function () {
-      this.redis.get.resolves('clsi-7')
-      const serverId = await this.ClsiCookieManager.promises.getServerId(
-        this.project_id,
-        this.user_id,
+    it('should call get for the key', async function (ctx) {
+      ctx.redis.get.resolves('clsi-7')
+      const serverId = await ctx.ClsiCookieManager.promises.getServerId(
+        ctx.project_id,
+        ctx.user_id,
         '',
         'n2d'
       )
-      this.redis.get
-        .calledWith(`clsiserver:n2d:${this.project_id}:${this.user_id}`)
+      ctx.redis.get
+        .calledWith(`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`)
         .should.equal(true)
       serverId.should.equal('clsi-7')
     })
 
-    it('should fallback to old key', async function () {
-      this.redis.get
-        .withArgs(`clsiserver:n2d:${this.project_id}:${this.user_id}`)
+    it('should fallback to old key', async function (ctx) {
+      ctx.redis.get
+        .withArgs(`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`)
         .resolves(null)
-      this.redis.get
-        .withArgs(`clsiserver:${this.project_id}:${this.user_id}`)
+      ctx.redis.get
+        .withArgs(`clsiserver:${ctx.project_id}:${ctx.user_id}`)
         .resolves('clsi-7')
-      const serverId = await this.ClsiCookieManager.promises.getServerId(
-        this.project_id,
-        this.user_id,
+      const serverId = await ctx.ClsiCookieManager.promises.getServerId(
+        ctx.project_id,
+        ctx.user_id,
         '',
         'n2d'
       )
-      this.redis.get
-        .calledWith(`clsiserver:n2d:${this.project_id}:${this.user_id}`)
+      ctx.redis.get
+        .calledWith(`clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`)
         .should.equal(true)
-      this.redis.get
-        .calledWith(`clsiserver:${this.project_id}:${this.user_id}`)
+      ctx.redis.get
+        .calledWith(`clsiserver:${ctx.project_id}:${ctx.user_id}`)
         .should.equal(true)
       serverId.should.equal('clsi-7')
     })
 
-    it('should _populateServerIdViaRequest if no key is found', async function () {
-      this.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
+    it('should _populateServerIdViaRequest if no key is found', async function (ctx) {
+      ctx.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
         .stub()
         .resolves()
-      this.redis.get.resolves(null)
-      await this.ClsiCookieManager.promises.getServerId(
-        this.project_id,
-        this.user_id,
+      ctx.redis.get.resolves(null)
+      await ctx.ClsiCookieManager.promises.getServerId(
+        ctx.project_id,
+        ctx.user_id,
         ''
       )
-      this.ClsiCookieManager.promises._populateServerIdViaRequest
-        .calledWith(this.project_id, this.user_id)
+      ctx.ClsiCookieManager.promises._populateServerIdViaRequest
+        .calledWith(ctx.project_id, ctx.user_id)
         .should.equal(true)
     })
 
-    it('should _populateServerIdViaRequest if no key is blank', async function () {
-      this.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
+    it('should _populateServerIdViaRequest if no key is blank', async function (ctx) {
+      ctx.ClsiCookieManager.promises._populateServerIdViaRequest = sinon
         .stub()
         .resolves(null)
-      this.redis.get.resolves('')
-      await this.ClsiCookieManager.promises.getServerId(
-        this.project_id,
-        this.user_id,
+      ctx.redis.get.resolves('')
+      await ctx.ClsiCookieManager.promises.getServerId(
+        ctx.project_id,
+        ctx.user_id,
         '',
         'n2d'
       )
-      this.ClsiCookieManager.promises._populateServerIdViaRequest
-        .calledWith(this.project_id, this.user_id)
+      ctx.ClsiCookieManager.promises._populateServerIdViaRequest
+        .calledWith(ctx.project_id, ctx.user_id)
         .should.equal(true)
     })
   })
 
   describe('_populateServerIdViaRequest', function () {
-    beforeEach(function () {
-      this.clsiServerId = 'server-id'
-      this.ClsiCookieManager.promises.setServerId = sinon.stub().resolves()
+    beforeEach(function (ctx) {
+      ctx.clsiServerId = 'server-id'
+      ctx.ClsiCookieManager.promises.setServerId = sinon.stub().resolves()
     })
 
     describe('with a server id in the response', function () {
-      beforeEach(function () {
-        this.response = {
+      beforeEach(function (ctx) {
+        ctx.response = {
           headers: {
             'set-cookie': [
-              `${this.settings.clsiCookie.key}=${this.clsiServerId}`,
+              `${ctx.settings.clsiCookie.key}=${ctx.clsiServerId}`,
             ],
           },
         }
-        this.fetchUtils.fetchNothing.returns(this.response)
+        ctx.fetchUtils.fetchNothing.returns(ctx.response)
       })
 
-      it('should make a request to the clsi', async function () {
-        await this.ClsiCookieManager.promises._populateServerIdViaRequest(
-          this.project_id,
-          this.user_id,
+      it('should make a request to the clsi', async function (ctx) {
+        await ctx.ClsiCookieManager.promises._populateServerIdViaRequest(
+          ctx.project_id,
+          ctx.user_id,
           'standard',
           'n2d'
         )
-        const args = this.ClsiCookieManager.promises.setServerId.args[0]
-        args[0].should.equal(this.project_id)
-        args[1].should.equal(this.user_id)
+        const args = ctx.ClsiCookieManager.promises.setServerId.args[0]
+        args[0].should.equal(ctx.project_id)
+        args[1].should.equal(ctx.user_id)
         args[2].should.equal('standard')
         args[3].should.equal('n2d')
-        args[4].should.deep.equal(this.clsiServerId)
+        args[4].should.deep.equal(ctx.clsiServerId)
       })
 
-      it('should return the server id', async function () {
+      it('should return the server id', async function (ctx) {
         const serverId =
-          await this.ClsiCookieManager.promises._populateServerIdViaRequest(
-            this.project_id,
-            this.user_id,
+          await ctx.ClsiCookieManager.promises._populateServerIdViaRequest(
+            ctx.project_id,
+            ctx.user_id,
             '',
             'n2d'
           )
-        serverId.should.equal(this.clsiServerId)
+        serverId.should.equal(ctx.clsiServerId)
       })
     })
 
     describe('without a server id in the response', function () {
-      beforeEach(function () {
-        this.response = { headers: {} }
-        this.fetchUtils.fetchNothing.returns(this.response)
+      beforeEach(function (ctx) {
+        ctx.response = { headers: {} }
+        ctx.fetchUtils.fetchNothing.returns(ctx.response)
       })
-      it('should not set the server id there is no server id in the response', async function () {
-        this.ClsiCookieManager._parseServerIdFromResponse = sinon
+      it('should not set the server id there is no server id in the response', async function (ctx) {
+        ctx.ClsiCookieManager._parseServerIdFromResponse = sinon
           .stub()
           .returns(null)
-        await this.ClsiCookieManager.promises.setServerId(
-          this.project_id,
-          this.user_id,
+        await ctx.ClsiCookieManager.promises.setServerId(
+          ctx.project_id,
+          ctx.user_id,
           'standard',
           'n2d',
-          this.clsiServerId,
+          ctx.clsiServerId,
           null
         )
-        this.redis.setex.called.should.equal(false)
+        ctx.redis.setex.called.should.equal(false)
       })
     })
   })
 
   describe('clearServerId', function () {
-    it('should clear both keys', async function () {
-      await this.ClsiCookieManager.promises.clearServerId(
-        this.project_id,
-        this.user_id,
+    it('should clear both keys', async function (ctx) {
+      await ctx.ClsiCookieManager.promises.clearServerId(
+        ctx.project_id,
+        ctx.user_id,
         'n2d'
       )
-      this.redis.del.should.have.been.calledWith(
-        `clsiserver:n2d:${this.project_id}:${this.user_id}`,
-        `clsiserver:${this.project_id}:${this.user_id}`
+      ctx.redis.del.should.have.been.calledWith(
+        `clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
+        `clsiserver:${ctx.project_id}:${ctx.user_id}`
       )
     })
   })
 
   describe('setServerId', function () {
-    beforeEach(function () {
-      this.clsiServerId = 'server-id'
-      this.ClsiCookieManager._parseServerIdFromResponse = sinon
+    beforeEach(function (ctx) {
+      ctx.clsiServerId = 'server-id'
+      ctx.ClsiCookieManager._parseServerIdFromResponse = sinon
         .stub()
         .returns('clsi-8')
     })
 
-    it('should set the server id with a ttl', async function () {
-      await this.ClsiCookieManager.promises.setServerId(
-        this.project_id,
-        this.user_id,
+    it('should set the server id with a ttl', async function (ctx) {
+      await ctx.ClsiCookieManager.promises.setServerId(
+        ctx.project_id,
+        ctx.user_id,
         'standard',
         'n2d',
-        this.clsiServerId,
+        ctx.clsiServerId,
         null
       )
-      this.redis.setex.should.have.been.calledWith(
-        `clsiserver:n2d:${this.project_id}:${this.user_id}`,
-        this.settings.clsiCookie.ttlInSeconds,
-        this.clsiServerId
+      ctx.redis.setex.should.have.been.calledWith(
+        `clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
+        ctx.settings.clsiCookie.ttlInSeconds,
+        ctx.clsiServerId
       )
     })
 
-    it('should set the server id with the regular ttl for reg instance', async function () {
-      this.clsiServerId = 'clsi-reg-8'
-      await this.ClsiCookieManager.promises.setServerId(
-        this.project_id,
-        this.user_id,
+    it('should set the server id with the regular ttl for reg instance', async function (ctx) {
+      ctx.clsiServerId = 'clsi-reg-8'
+      await ctx.ClsiCookieManager.promises.setServerId(
+        ctx.project_id,
+        ctx.user_id,
         'standard',
         'n2d',
-        this.clsiServerId,
+        ctx.clsiServerId,
         null
       )
-      expect(this.redis.setex).to.have.been.calledWith(
-        `clsiserver:n2d:${this.project_id}:${this.user_id}`,
-        this.settings.clsiCookie.ttlInSecondsRegular,
-        this.clsiServerId
+      expect(ctx.redis.setex).to.have.been.calledWith(
+        `clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
+        ctx.settings.clsiCookie.ttlInSecondsRegular,
+        ctx.clsiServerId
       )
     })
 
-    it('should not set the server id if clsiCookies are not enabled', async function () {
-      delete this.settings.clsiCookie.key
-      this.ClsiCookieManager2 = SandboxedModule.require(modulePath, {
-        globals: {
-          console,
-        },
-        requires: this.requires,
-      })()
-      await this.ClsiCookieManager2.promises.setServerId(
-        this.project_id,
-        this.user_id,
-        'standard',
-        'n2d',
-        this.clsiServerId,
-        null
-      )
-      this.redis.setex.called.should.equal(false)
+    describe('when clsiCookies are not enabled', function (ctx) {
+      let oldKey
+      beforeEach(async function (ctx) {
+        oldKey = ctx.settings.clsiCookie.key
+        delete ctx.settings.clsiCookie.key
+        vi.resetModules()
+        ctx.ClsiCookieManager2 = (await import(modulePath)).default()
+      })
+      afterEach(function (ctx) {
+        ctx.settings.clsiCookie.key = oldKey
+      })
+
+      it('should not set the server id if clsiCookies are not enabled', async function (ctx) {
+        await ctx.ClsiCookieManager2.promises.setServerId(
+          ctx.project_id,
+          ctx.user_id,
+          'standard',
+          'n2d',
+          ctx.clsiServerId,
+          null
+        )
+        ctx.redis.setex.called.should.equal(false)
+      })
     })
 
-    it('should also set in the secondary if secondary redis is enabled', async function () {
-      this.redis_secondary = { setex: sinon.stub().resolves() }
-      this.settings.redis.clsi_cookie_secondary = {}
-      this.RedisWrapper.client = sinon.stub()
-      this.RedisWrapper.client.withArgs('clsi_cookie').returns(this.redis)
-      this.RedisWrapper.client
+    it('should also set in the secondary if secondary redis is enabled', async function (ctx) {
+      ctx.redis_secondary = { setex: sinon.stub().resolves() }
+      ctx.settings.redis.clsi_cookie_secondary = {}
+      ctx.RedisWrapper.client = sinon.stub()
+      ctx.RedisWrapper.client.withArgs('clsi_cookie').returns(ctx.redis)
+      ctx.RedisWrapper.client
         .withArgs('clsi_cookie_secondary')
-        .returns(this.redis_secondary)
-      this.ClsiCookieManager2 = SandboxedModule.require(modulePath, {
-        globals: {
-          console,
-        },
-        requires: this.requires,
-      })()
-      this.ClsiCookieManager2._parseServerIdFromResponse = sinon
+        .returns(ctx.redis_secondary)
+      vi.resetModules()
+      ctx.ClsiCookieManager2 = (await import(modulePath)).default()
+      ctx.ClsiCookieManager2._parseServerIdFromResponse = sinon
         .stub()
         .returns('clsi-8')
-      await this.ClsiCookieManager2.promises.setServerId(
-        this.project_id,
-        this.user_id,
+      await ctx.ClsiCookieManager2.promises.setServerId(
+        ctx.project_id,
+        ctx.user_id,
         'standard',
         'n2d',
-        this.clsiServerId,
+        ctx.clsiServerId,
         null
       )
-      this.redis_secondary.setex.should.have.been.calledWith(
-        `clsiserver:n2d:${this.project_id}:${this.user_id}`,
-        this.settings.clsiCookie.ttlInSeconds,
-        this.clsiServerId
+      ctx.redis_secondary.setex.should.have.been.calledWith(
+        `clsiserver:n2d:${ctx.project_id}:${ctx.user_id}`,
+        ctx.settings.clsiCookie.ttlInSeconds,
+        ctx.clsiServerId
       )
     })
 
     describe('checkIsLoadSheddingEvent', function () {
-      beforeEach(function () {
-        this.fetchUtils.fetchStringWithResponse.reset()
-        this.call = async () => {
-          await this.ClsiCookieManager.promises.setServerId(
-            this.project_id,
-            this.user_id,
+      beforeEach(function (ctx) {
+        ctx.fetchUtils.fetchStringWithResponse.reset()
+        ctx.call = async () => {
+          await ctx.ClsiCookieManager.promises.setServerId(
+            ctx.project_id,
+            ctx.user_id,
             'standard',
             'n2d',
-            this.clsiServerId,
+            ctx.clsiServerId,
             'previous-clsi-server-id'
           )
           expect(
-            this.fetchUtils.fetchStringWithResponse
+            ctx.fetchUtils.fetchStringWithResponse
           ).to.have.been.calledWith(
-            `${this.settings.apis.clsi.url}/instance-state?clsiserverid=previous-clsi-server-id&compileGroup=standard&compileBackendClass=n2d`,
+            `${ctx.settings.apis.clsi.url}/instance-state?clsiserverid=previous-clsi-server-id&compileGroup=standard&compileBackendClass=n2d`,
             { method: 'GET', signal: sinon.match.instanceOf(AbortSignal) }
           )
         }
       })
 
-      it('should report "load-shedding" when previous is UP', async function () {
-        this.fetchUtils.fetchStringWithResponse.resolves({
+      it('should report "load-shedding" when previous is UP', async function (ctx) {
+        ctx.fetchUtils.fetchStringWithResponse.resolves({
           response: { status: 200 },
           body: 'previous-clsi-server-id,UP\n',
         })
-        await this.call()
-        expect(this.metrics.inc).to.have.been.calledWith(
+        await ctx.call()
+        expect(ctx.metrics.inc).to.have.been.calledWith(
           'clsi-lb-switch-backend',
           1,
           { status: 'load-shedding' }
         )
       })
 
-      it('should report "cycle" when other is UP', async function () {
-        this.fetchUtils.fetchStringWithResponse.resolves({
+      it('should report "cycle" when other is UP', async function (ctx) {
+        ctx.fetchUtils.fetchStringWithResponse.resolves({
           response: { status: 200 },
           body: 'other-clsi-server-id,UP\n',
         })
-        await this.call()
-        expect(this.metrics.inc).to.have.been.calledWith(
+        await ctx.call()
+        expect(ctx.metrics.inc).to.have.been.calledWith(
           'clsi-lb-switch-backend',
           1,
           { status: 'cycle' }
         )
       })
 
-      it('should report "cycle" when previous is 404', async function () {
-        this.fetchUtils.fetchStringWithResponse.resolves({
+      it('should report "cycle" when previous is 404', async function (ctx) {
+        ctx.fetchUtils.fetchStringWithResponse.resolves({
           response: { status: 404 },
         })
-        await this.call()
-        expect(this.metrics.inc).to.have.been.calledWith(
+        await ctx.call()
+        expect(ctx.metrics.inc).to.have.been.calledWith(
           'clsi-lb-switch-backend',
           1,
           { status: 'cycle' }

+ 65 - 73
services/web/test/unit/src/Compile/ClsiFormatChecker.test.mjs

@@ -1,23 +1,23 @@
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../../app/src/Features/Compile/ClsiFormatChecker.js'
-const SandboxedModule = require('sandboxed-module')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
+const modulePath = '../../../../app/src/Features/Compile/ClsiFormatChecker.mjs'
 
 describe('ClsiFormatChecker', function () {
-  beforeEach(function () {
-    this.ClsiFormatChecker = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': (this.settings = {
-          compileBodySizeLimitMb: 5,
-        }),
-      },
-    })
-    return (this.project_id = 'project-id')
+  beforeEach(async function (ctx) {
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {
+        compileBodySizeLimitMb: 5,
+      }),
+    }))
+
+    ctx.ClsiFormatChecker = (await import(modulePath)).default
+    ctx.project_id = 'project-id'
   })
 
   describe('checkRecoursesForProblems', function () {
-    beforeEach(function () {
-      return (this.resources = [
+    beforeEach(function (ctx) {
+      ctx.resources = [
         {
           path: 'main.tex',
           content: 'stuff',
@@ -28,65 +28,59 @@ describe('ClsiFormatChecker', function () {
         },
         {
           path: 'stuff/image/image.png',
-          url: `http:somewhere.com/project/${this.project_id}/file/1234124321312`,
+          url: `http:somewhere.com/project/${ctx.project_id}/file/1234124321312`,
           modified: 'more stuff',
         },
-      ])
+      ]
     })
 
-    it('should call _checkDocsAreUnderSizeLimit and _checkForConflictingPaths', async function () {
-      this.ClsiFormatChecker._checkForConflictingPaths = sinon
+    it('should call _checkDocsAreUnderSizeLimit and _checkForConflictingPaths', async function (ctx) {
+      ctx.ClsiFormatChecker._checkForConflictingPaths = sinon
         .stub()
         .returns(null)
-      this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
+      ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
         .stub()
         .returns(null)
-      this.ClsiFormatChecker.checkRecoursesForProblems(this.resources)
-      this.ClsiFormatChecker._checkForConflictingPaths.called.should.equal(true)
-      this.ClsiFormatChecker._checkDocsAreUnderSizeLimit.called.should.equal(
+      await ctx.ClsiFormatChecker.checkRecoursesForProblems(ctx.resources)
+      ctx.ClsiFormatChecker._checkForConflictingPaths.called.should.equal(true)
+      ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit.called.should.equal(
         true
       )
     })
 
-    it('should remove undefined errors', async function () {
-      this.ClsiFormatChecker._checkForConflictingPaths = sinon
-        .stub()
-        .returns([])
-      this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
+    it('should remove undefined errors', async function (ctx) {
+      ctx.ClsiFormatChecker._checkForConflictingPaths = sinon.stub().returns([])
+      ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
         .stub()
         .returns({})
-      const problems = this.ClsiFormatChecker.checkRecoursesForProblems(
-        this.resources
+      const problems = await ctx.ClsiFormatChecker.checkRecoursesForProblems(
+        ctx.resources
       )
       expect(problems).to.not.exist
     })
 
-    it('should keep populated arrays', async function () {
-      this.ClsiFormatChecker._checkForConflictingPaths = sinon
+    it('should keep populated arrays', async function (ctx) {
+      ctx.ClsiFormatChecker._checkForConflictingPaths = sinon
         .stub()
         .returns([{ path: 'somewhere/main.tex' }])
-      this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
+      ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
         .stub()
         .returns({})
-      const problems = this.ClsiFormatChecker.checkRecoursesForProblems(
-        this.resources
+      const problems = await ctx.ClsiFormatChecker.checkRecoursesForProblems(
+        ctx.resources
       )
       problems.conflictedPaths[0].path.should.equal('somewhere/main.tex')
       expect(problems.sizeCheck).to.not.exist
     })
 
-    it('should keep populated object', async function () {
-      this.ClsiFormatChecker._checkForConflictingPaths = sinon
-        .stub()
-        .returns([])
-      this.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon
-        .stub()
-        .returns({
-          resources: [{ 'a.tex': 'a.tex' }, { 'b.tex': 'b.tex' }],
-          totalSize: 1000000,
-        })
-      const problems = this.ClsiFormatChecker.checkRecoursesForProblems(
-        this.resources
+    it('should keep populated object', async function (ctx) {
+      ctx.ClsiFormatChecker._checkForConflictingPaths = sinon.stub().returns([])
+      ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit = sinon.stub().returns({
+        resources: [{ 'a.tex': 'a.tex' }, { 'b.tex': 'b.tex' }],
+        totalSize: 1000000,
+      })
+      const problems = await ctx.ClsiFormatChecker.checkRecoursesForProblems(
+        ctx.resources
       )
       problems.sizeCheck.resources.length.should.equal(2)
       problems.sizeCheck.totalSize.should.equal(1000000)
@@ -94,93 +88,91 @@ describe('ClsiFormatChecker', function () {
     })
 
     describe('_checkForConflictingPaths', function () {
-      beforeEach(function () {
-        this.resources.push({
+      beforeEach(function (ctx) {
+        ctx.resources.push({
           path: 'chapters/chapter1.tex',
           content: 'other stuff',
         })
 
-        return this.resources.push({
+        ctx.resources.push({
           path: 'chapters.tex',
           content: 'other stuff',
         })
       })
 
-      it('should flag up when a nested file has folder with same subpath as file elsewhere', async function () {
-        this.resources.push({
+      it('should flag up when a nested file has folder with same subpath as file elsewhere', async function (ctx) {
+        ctx.resources.push({
           path: 'stuff/image',
           url: 'http://somwhere.com',
         })
 
         const conflictPathErrors =
-          this.ClsiFormatChecker._checkForConflictingPaths(this.resources)
+          await ctx.ClsiFormatChecker._checkForConflictingPaths(ctx.resources)
         conflictPathErrors.length.should.equal(1)
         conflictPathErrors[0].path.should.equal('stuff/image')
       })
 
-      it('should flag up when a root level file has folder with same subpath as file elsewhere', async function () {
-        this.resources.push({
+      it('should flag up when a root level file has folder with same subpath as file elsewhere', async function (ctx) {
+        ctx.resources.push({
           path: 'stuff',
           content: 'other stuff',
         })
 
         const conflictPathErrors =
-          this.ClsiFormatChecker._checkForConflictingPaths(this.resources)
+          await ctx.ClsiFormatChecker._checkForConflictingPaths(ctx.resources)
         conflictPathErrors.length.should.equal(1)
         conflictPathErrors[0].path.should.equal('stuff')
       })
 
-      it('should not flag up when the file is a substring of a path', async function () {
-        this.resources.push({
+      it('should not flag up when the file is a substring of a path', async function (ctx) {
+        ctx.resources.push({
           path: 'stuf',
           content: 'other stuff',
         })
 
         const conflictPathErrors =
-          this.ClsiFormatChecker._checkForConflictingPaths(this.resources)
+          await ctx.ClsiFormatChecker._checkForConflictingPaths(ctx.resources)
         conflictPathErrors.length.should.equal(0)
       })
     })
 
     describe('_checkDocsAreUnderSizeLimit', function () {
-      it('should error when there is more than 5mb of data', async function () {
-        this.resources.push({
+      it('should error when there is more than 5mb of data', async function (ctx) {
+        ctx.resources.push({
           path: 'massive.tex',
           content: 'hello world'.repeat(833333), // over 5mb limit
         })
 
-        while (this.resources.length < 20) {
-          this.resources.push({
+        while (ctx.resources.length < 20) {
+          ctx.resources.push({
             path: 'chapters/chapter1.tex',
             url: 'http://somwhere.com',
           })
         }
 
-        const sizeError = this.ClsiFormatChecker._checkDocsAreUnderSizeLimit(
-          this.resources
-        )
+        const sizeError =
+          await ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit(ctx.resources)
         sizeError.totalSize.should.equal(16 + 833333 * 11) // 16 is for earlier resources
         sizeError.resources.length.should.equal(10)
         sizeError.resources[0].path.should.equal('massive.tex')
         sizeError.resources[0].size.should.equal(833333 * 11)
       })
 
-      it('should return nothing when project is correct size', async function () {
-        this.resources.push({
+      it('should return nothing when project is correct size', async function (ctx) {
+        ctx.resources.push({
           path: 'massive.tex',
           content: 'x'.repeat(2 * 1000 * 1000),
         })
 
-        while (this.resources.length < 20) {
-          this.resources.push({
+        while (ctx.resources.length < 20) {
+          ctx.resources.push({
             path: 'chapters/chapter1.tex',
             url: 'http://somwhere.com',
           })
         }
 
-        const sizeError = this.ClsiFormatChecker._checkDocsAreUnderSizeLimit(
-          this.resources
-        )
+        const sizeError =
+          await ctx.ClsiFormatChecker._checkDocsAreUnderSizeLimit(ctx.resources)
         expect(sizeError).to.not.exist
       })
     })

+ 84 - 95
services/web/test/unit/src/Compile/ClsiStateManager.test.mjs

@@ -1,42 +1,34 @@
-/* eslint-disable
-    n/handle-callback-err,
-    max-len,
-    no-return-assign,
-    no-unused-vars,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS101: Remove unnecessary use of Array.from
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
-const sinon = require('sinon')
-const { expect } = require('chai')
-const modulePath = '../../../../app/src/Features/Compile/ClsiStateManager.js'
-const SandboxedModule = require('sandboxed-module')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
+const modulePath = '../../../../app/src/Features/Compile/ClsiStateManager.mjs'
 
 describe('ClsiStateManager', function () {
-  beforeEach(function () {
-    this.ClsiStateManager = SandboxedModule.require(modulePath, {
-      requires: {
-        '@overleaf/settings': (this.settings = {}),
-        '../Project/ProjectEntityHandler': (this.ProjectEntityHandler = {}),
-      },
-    })
-    this.project = 'project'
-    this.options = { draft: true, isAutoCompile: false }
-    return (this.callback = sinon.stub())
+  beforeEach(async function (ctx) {
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.settings = {}),
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Project/ProjectEntityHandler',
+      () => ({
+        default: (ctx.ProjectEntityHandler = {}),
+      })
+    )
+
+    ctx.ClsiStateManager = (await import(modulePath)).default
+    ctx.project = 'project'
+    ctx.options = { draft: true, isAutoCompile: false }
+    ctx.callback = sinon.stub()
   })
 
   describe('computeHash', function () {
-    beforeEach(function () {
-      this.docs = [
+    beforeEach(function (ctx) {
+      ctx.docs = [
         { path: '/main.tex', doc: { _id: 'doc-id-1' } },
         { path: '/folder/sub.tex', doc: { _id: 'doc-id-2' } },
       ]
-      this.files = [
+      ctx.files = [
         {
           path: '/figure.pdf',
           file: { _id: 'file-id-1', rev: 123, created: 'aaaaaa' },
@@ -46,158 +38,155 @@ describe('ClsiStateManager', function () {
           file: { _id: 'file-id-2', rev: 456, created: 'bbbbbb' },
         },
       ]
-      this.ProjectEntityHandler.getAllEntitiesFromProject = sinon
+      ctx.ProjectEntityHandler.getAllEntitiesFromProject = sinon
         .stub()
-        .returns({ docs: this.docs, files: this.files })
-      this.hash0 = this.ClsiStateManager.computeHash(this.project, this.options)
+        .returns({ docs: ctx.docs, files: ctx.files })
+      ctx.hash0 = ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
     })
 
     describe('with a sample project', function () {
       beforeEach(function () {})
 
-      it('should return a hash value', function () {
+      it('should return a hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
         ).to.equal('21b1ab73aa3892bec452baf8ffa0956179e1880f')
       })
     })
 
     describe('when the files and docs are in a different order', function () {
-      beforeEach(function () {
-        ;[this.docs[0], this.docs[1]] = Array.from([this.docs[1], this.docs[0]])
-        ;[this.files[0], this.files[1]] = Array.from([
-          this.files[1],
-          this.files[0],
-        ])
+      beforeEach(function (ctx) {
+        ;[ctx.docs[0], ctx.docs[1]] = [ctx.docs[1], ctx.docs[0]]
+        ;[ctx.files[0], ctx.files[1]] = [ctx.files[1], ctx.files[0]]
       })
 
-      it('should return the same hash value', function () {
+      it('should return the same hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).to.equal(ctx.hash0)
       })
     })
 
     describe('when a doc is renamed', function () {
-      beforeEach(function () {
-        this.docs[0].path = '/new.tex'
+      beforeEach(function (ctx) {
+        ctx.docs[0].path = '/new.tex'
       })
 
-      it('should return a different hash value', function () {
+      it('should return a different hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).not.to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).not.to.equal(ctx.hash0)
       })
     })
 
     describe('when a file is renamed', function () {
-      beforeEach(function () {
-        this.files[0].path = '/newfigure.pdf'
+      beforeEach(function (ctx) {
+        ctx.files[0].path = '/newfigure.pdf'
       })
 
-      it('should return a different hash value', function () {
+      it('should return a different hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).not.to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).not.to.equal(ctx.hash0)
       })
     })
 
     describe('when a doc is added', function () {
-      beforeEach(function () {
-        this.docs.push({ path: '/newdoc.tex', doc: { _id: 'newdoc-id' } })
+      beforeEach(function (ctx) {
+        ctx.docs.push({ path: '/newdoc.tex', doc: { _id: 'newdoc-id' } })
       })
 
-      it('should return a different hash value', function () {
+      it('should return a different hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).not.to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).not.to.equal(ctx.hash0)
       })
     })
 
     describe('when a file is added', function () {
-      beforeEach(function () {
-        this.files.push({
+      beforeEach(function (ctx) {
+        ctx.files.push({
           path: '/newfile.tex',
           file: { _id: 'newfile-id', rev: 123 },
         })
       })
 
-      it('should return a different hash value', function () {
+      it('should return a different hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).not.to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).not.to.equal(ctx.hash0)
       })
     })
 
     describe('when a doc is removed', function () {
-      beforeEach(function () {
-        this.docs.pop()
+      beforeEach(function (ctx) {
+        ctx.docs.pop()
       })
 
-      it('should return a different hash value', function () {
+      it('should return a different hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).not.to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).not.to.equal(ctx.hash0)
       })
     })
 
     describe('when a file is removed', function () {
-      beforeEach(function () {
-        this.files.pop()
+      beforeEach(function (ctx) {
+        ctx.files.pop()
       })
 
-      it('should return a different hash value', function () {
+      it('should return a different hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).not.to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).not.to.equal(ctx.hash0)
       })
     })
 
     describe("when a file's revision is updated", function () {
-      beforeEach(function () {
-        this.files[0].file.rev++
+      beforeEach(function (ctx) {
+        ctx.files[0].file.rev++
       })
 
-      it('should return a different hash value', function () {
+      it('should return a different hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).not.to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).not.to.equal(ctx.hash0)
       })
     })
 
     describe("when a file's date is updated", function () {
-      beforeEach(function () {
-        this.files[0].file.created = 'zzzzzz'
+      beforeEach(function (ctx) {
+        ctx.files[0].file.created = 'zzzzzz'
       })
 
-      it('should return a different hash value', function () {
+      it('should return a different hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).not.to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).not.to.equal(ctx.hash0)
       })
     })
 
     describe('when the compile options are changed', function () {
-      beforeEach(function () {
-        this.options.draft = !this.options.draft
+      beforeEach(function (ctx) {
+        ctx.options.draft = !ctx.options.draft
       })
 
-      it('should return a different hash value', function () {
+      it('should return a different hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).not.to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).not.to.equal(ctx.hash0)
       })
     })
 
     describe('when the isAutoCompile option is changed', function () {
-      beforeEach(function () {
-        this.options.isAutoCompile = !this.options.isAutoCompile
+      beforeEach(function (ctx) {
+        ctx.options.isAutoCompile = !ctx.options.isAutoCompile
       })
 
-      it('should return the same hash value', function () {
+      it('should return the same hash value', function (ctx) {
         expect(
-          this.ClsiStateManager.computeHash(this.project, this.options)
-        ).to.equal(this.hash0)
+          ctx.ClsiStateManager.computeHash(ctx.project, ctx.options)
+        ).to.equal(ctx.hash0)
       })
     })
   })

+ 72 - 64
services/web/test/unit/src/Institutions/InstitutionsGetter.test.mjs

@@ -1,31 +1,39 @@
-const SandboxedModule = require('sandboxed-module')
-const { expect } = require('chai')
-const sinon = require('sinon')
-const modulePath = require('path').join(
-  __dirname,
-  '../../../../app/src/Features/Institutions/InstitutionsGetter.js'
-)
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+const modulePath =
+  '../../../../app/src/Features/Institutions/InstitutionsGetter.mjs'
 
 describe('InstitutionsGetter', function () {
-  beforeEach(function () {
-    this.UserGetter = {
+  beforeEach(async function (ctx) {
+    ctx.UserGetter = {
       getUserFullEmails: sinon.stub(),
       promises: {
         getUserFullEmails: sinon.stub(),
       },
     }
-    this.InstitutionsGetter = SandboxedModule.require(modulePath, {
-      requires: {
-        '../User/UserGetter': this.UserGetter,
-        '../UserMembership/UserMembershipsHandler':
-          (this.UserMembershipsHandler = {}),
-        '../UserMembership/UserMembershipEntityConfigs':
-          (this.UserMembershipEntityConfigs = {}),
-      },
-    })
 
-    this.userId = '12345abcde'
-    this.confirmedAffiliation = {
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/UserMembership/UserMembershipsHandler',
+      () => ({
+        default: (ctx.UserMembershipsHandler = {}),
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs',
+      () => ({
+        default: (ctx.UserMembershipEntityConfigs = {}),
+      })
+    )
+
+    ctx.InstitutionsGetter = (await import(modulePath)).default
+
+    ctx.userId = '12345abcde'
+    ctx.confirmedAffiliation = {
       confirmedAt: new Date(),
       affiliation: {
         institution: { id: 456, confirmed: true },
@@ -33,7 +41,7 @@ describe('InstitutionsGetter', function () {
         pastReconfirmDate: false,
       },
     }
-    this.confirmedAffiliationPastReconfirmation = {
+    ctx.confirmedAffiliationPastReconfirmation = {
       confirmedAt: new Date('2000-01-01'),
       affiliation: {
         institution: { id: 135, confirmed: true },
@@ -41,7 +49,7 @@ describe('InstitutionsGetter', function () {
         pastReconfirmDate: true,
       },
     }
-    this.licencedAffiliation = {
+    ctx.licencedAffiliation = {
       confirmedAt: new Date(),
       affiliation: {
         licence: 'pro_plus',
@@ -50,7 +58,7 @@ describe('InstitutionsGetter', function () {
         pastReconfirmDate: false,
       },
     }
-    this.licencedAffiliationPastReconfirmation = {
+    ctx.licencedAffiliationPastReconfirmation = {
       confirmedAt: new Date('2000-01-01'),
       affiliation: {
         licence: 'pro_plus',
@@ -59,7 +67,7 @@ describe('InstitutionsGetter', function () {
         pastReconfirmDate: true,
       },
     }
-    this.unconfirmedEmailLicensedAffiliation = {
+    ctx.unconfirmedEmailLicensedAffiliation = {
       confirmedAt: null,
       affiliation: {
         licence: 'pro_plus',
@@ -71,7 +79,7 @@ describe('InstitutionsGetter', function () {
         },
       },
     }
-    this.unconfirmedDomainLicensedAffiliation = {
+    ctx.unconfirmedDomainLicensedAffiliation = {
       confirmedAt: new Date(),
       affiliation: {
         licence: 'pro_plus',
@@ -83,7 +91,7 @@ describe('InstitutionsGetter', function () {
         },
       },
     }
-    this.userEmails = [
+    ctx.userEmails = [
       {
         confirmedAt: null,
         affiliation: {
@@ -95,9 +103,9 @@ describe('InstitutionsGetter', function () {
           },
         },
       },
-      this.confirmedAffiliation,
-      this.confirmedAffiliation,
-      this.confirmedAffiliationPastReconfirmation,
+      ctx.confirmedAffiliation,
+      ctx.confirmedAffiliation,
+      ctx.confirmedAffiliationPastReconfirmation,
       {
         confirmedAt: new Date(),
         affiliation: null,
@@ -124,41 +132,41 @@ describe('InstitutionsGetter', function () {
         },
       },
     ]
-    this.fullEmailCollection = [
-      this.licencedAffiliation,
-      this.licencedAffiliation,
-      this.licencedAffiliationPastReconfirmation,
-      this.confirmedAffiliation,
-      this.confirmedAffiliationPastReconfirmation,
-      this.unconfirmedDomainLicensedAffiliation,
-      this.unconfirmedEmailLicensedAffiliation,
+    ctx.fullEmailCollection = [
+      ctx.licencedAffiliation,
+      ctx.licencedAffiliation,
+      ctx.licencedAffiliationPastReconfirmation,
+      ctx.confirmedAffiliation,
+      ctx.confirmedAffiliationPastReconfirmation,
+      ctx.unconfirmedDomainLicensedAffiliation,
+      ctx.unconfirmedEmailLicensedAffiliation,
     ]
   })
 
   describe('getCurrentInstitutionIds', function () {
-    it('filters unconfirmed affiliations, those past reconfirmation, and returns only 1 result per institution', async function () {
-      this.UserGetter.promises.getUserFullEmails.resolves(this.userEmails)
+    it('filters unconfirmed affiliations, those past reconfirmation, and returns only 1 result per institution', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.resolves(ctx.userEmails)
       const institutions =
-        await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
-          this.userId
+        await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
+          ctx.userId
         )
       expect(institutions.length).to.equal(1)
       expect(institutions[0]).to.equal(456)
     })
-    it('handles empty response', async function () {
-      this.UserGetter.promises.getUserFullEmails.resolves([])
+    it('handles empty response', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.resolves([])
       const institutions =
-        await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
-          this.userId
+        await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
+          ctx.userId
         )
       expect(institutions).to.deep.equal([])
     })
-    it('handles errors', async function () {
-      this.UserGetter.promises.getUserFullEmails.rejects(new Error('oops'))
+    it('handles errors', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('oops'))
       let e
       try {
-        await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
-          this.userId
+        await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
+          ctx.userId
         )
       } catch (error) {
         e = error
@@ -168,37 +176,37 @@ describe('InstitutionsGetter', function () {
   })
 
   describe('getCurrentAndPastAffiliationIds', function () {
-    it('filters unconfirmed affiliations, preserves those past reconfirmation, and returns only 1 result per institution', async function () {
-      this.UserGetter.promises.getUserFullEmails.resolves(
-        this.fullEmailCollection
+    it('filters unconfirmed affiliations, preserves those past reconfirmation, and returns only 1 result per institution', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.resolves(
+        ctx.fullEmailCollection
       )
       const institutions =
-        await this.InstitutionsGetter.promises.getCurrentAndPastAffiliationIds(
-          this.userId
+        await ctx.InstitutionsGetter.promises.getCurrentAndPastAffiliationIds(
+          ctx.userId
         )
       expect(institutions).to.deep.equal([777, 888, 456, 135])
     })
-    it('handles empty response', async function () {
-      this.UserGetter.promises.getUserFullEmails.resolves([])
+    it('handles empty response', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.resolves([])
       const institutions =
-        await this.InstitutionsGetter.promises.getCurrentInstitutionIds(
-          this.userId
+        await ctx.InstitutionsGetter.promises.getCurrentInstitutionIds(
+          ctx.userId
         )
       expect(institutions).to.deep.equal([])
     })
   })
 
   describe('getCurrentInstitutionsWithLicence', function () {
-    it('returns one result per institution and filters out affiliations without license', async function () {
-      this.UserGetter.promises.getUserFullEmails.resolves(
-        this.fullEmailCollection
+    it('returns one result per institution and filters out affiliations without license', async function (ctx) {
+      ctx.UserGetter.promises.getUserFullEmails.resolves(
+        ctx.fullEmailCollection
       )
       const institutions =
-        await this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(
-          this.userId
+        await ctx.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(
+          ctx.userId
         )
       expect(institutions.map(institution => institution.id)).to.deep.equal([
-        this.licencedAffiliation.affiliation.institution.id,
+        ctx.licencedAffiliation.affiliation.institution.id,
       ])
     })
   })

+ 222 - 182
services/web/test/unit/src/Institutions/InstitutionsManager.test.mjs

@@ -1,270 +1,311 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const { expect } = require('chai')
-const { ObjectId } = require('mongodb-legacy')
+import { vi, expect } from 'vitest'
+import path from 'path'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+import Features from '../../../../app/src/infrastructure/Features.js'
 const modulePath = path.join(
-  __dirname,
+  import.meta.dirname,
   '../../../../app/src/Features/Institutions/InstitutionsManager'
 )
-const Features = require('../../../../app/src/infrastructure/Features')
+
+const { ObjectId } = mongodb
 
 describe('InstitutionsManager', function () {
-  beforeEach(function () {
-    this.institutionId = 123
-    this.user = {}
+  beforeEach(async function (ctx) {
+    ctx.institutionId = 123
+    ctx.user = {}
     const lapsedUser = {
       _id: '657300a08a14461b3d1aac3e',
       features: {},
     }
-    this.users = [
+    ctx.users = [
       lapsedUser,
       { _id: '657300a08a14461b3d1aac3f', features: {} },
       { _id: '657300a08a14461b3d1aac40', features: {} },
       { _id: '657300a08a14461b3d1aac41', features: {} },
     ]
-    this.ssoUsers = [
+    ctx.ssoUsers = [
       {
         _id: '657300a08a14461b3d1aac3f',
-        samlIdentifiers: [{ providerId: this.institutionId.toString() }],
+        samlIdentifiers: [{ providerId: ctx.institutionId.toString() }],
       },
       {
         _id: '657300a08a14461b3d1aac40',
         samlIdentifiers: [
           {
-            providerId: this.institutionId.toString(),
+            providerId: ctx.institutionId.toString(),
             hasEntitlement: true,
           },
         ],
       },
       {
         _id: '657300a08a14461b3d1aac3e',
-        samlIdentifiers: [{ providerId: this.institutionId.toString() }],
+        samlIdentifiers: [{ providerId: ctx.institutionId.toString() }],
         hasEntitlement: true,
       },
     ]
 
-    this.UserGetter = {
+    ctx.UserGetter = {
       promises: {
-        getUser: sinon.stub().resolves(this.user),
-        getUsers: sinon.stub().resolves(this.users),
+        getUser: sinon.stub().resolves(ctx.user),
+        getUsers: sinon.stub().resolves(ctx.users),
         getUsersByAnyConfirmedEmail: sinon.stub().resolves(),
-        getSsoUsersAtInstitution: (this.getSsoUsersAtInstitution = sinon
+        getSsoUsersAtInstitution: (ctx.getSsoUsersAtInstitution = sinon
           .stub()
-          .resolves(this.ssoUsers)),
+          .resolves(ctx.ssoUsers)),
       },
     }
-    this.creator = { create: sinon.stub().resolves() }
-    this.NotificationsBuilder = {
+    ctx.creator = { create: sinon.stub().resolves() }
+    ctx.NotificationsBuilder = {
       promises: {
-        featuresUpgradedByAffiliation: sinon.stub().returns(this.creator),
-        redundantPersonalSubscription: sinon.stub().returns(this.creator),
+        featuresUpgradedByAffiliation: sinon.stub().returns(ctx.creator),
+        redundantPersonalSubscription: sinon.stub().returns(ctx.creator),
       },
     }
-    this.SubscriptionLocator = {
+    ctx.SubscriptionLocator = {
       promises: {
         getUsersSubscription: sinon.stub().resolves(),
       },
     }
-    this.institutionWithV1Data = { name: 'Wombat University' }
-    this.institution = {
-      fetchV1DataPromise: sinon.stub().resolves(this.institutionWithV1Data),
+    ctx.institutionWithV1Data = { name: 'Wombat University' }
+    ctx.institution = {
+      fetchV1DataPromise: sinon.stub().resolves(ctx.institutionWithV1Data),
     }
-    this.InstitutionModel = {
+    ctx.InstitutionModel = {
       Institution: {
         findOne: sinon.stub().returns({
-          exec: sinon.stub().resolves(this.institution),
+          exec: sinon.stub().resolves(ctx.institution),
         }),
       },
     }
-    this.subscriptionExec = sinon.stub().resolves()
+    ctx.subscriptionExec = sinon.stub().resolves()
     const SubscriptionModel = {
       Subscription: {
         find: () => ({
           populate: () => ({
-            exec: this.subscriptionExec,
+            exec: ctx.subscriptionExec,
           }),
         }),
       },
     }
 
-    this.Mongo = {
+    ctx.Mongo = {
       ObjectId,
     }
 
-    this.v1Counts = {
-      user_ids: this.users.map(user => user._id),
+    ctx.v1Counts = {
+      user_ids: ctx.users.map(user => user._id),
       current_users_count: 3,
       lapsed_user_ids: [lapsedUser._id],
       entitled_via_sso: 1, // 2 entitled, but 1 lapsed
       with_confirmed_email: 2, // 1 non entitled SSO + 1 email user
     }
 
-    this.InstitutionsManager = SandboxedModule.require(modulePath, {
-      requires: {
-        './InstitutionsAPI': {
+    vi.doMock(
+      '../../../../app/src/Features/Institutions/InstitutionsAPI',
+      () => ({
+        default: {
           promises: {
-            addAffiliation: (this.addAffiliationPromise = sinon
+            addAffiliation: (ctx.addAffiliationPromise = sinon
               .stub()
               .resolves()),
-            getInstitutionAffiliations:
-              (this.getInstitutionAffiliationsPromise = sinon
-                .stub()
-                .resolves(this.affiliations)),
+            getInstitutionAffiliations: (ctx.getInstitutionAffiliationsPromise =
+              sinon.stub().resolves(ctx.affiliations)),
             getConfirmedInstitutionAffiliations:
-              (this.getConfirmedInstitutionAffiliationsPromise = sinon
+              (ctx.getConfirmedInstitutionAffiliationsPromise = sinon
                 .stub()
-                .resolves(this.affiliations)),
+                .resolves(ctx.affiliations)),
             getInstitutionAffiliationsCounts:
-              (this.getInstitutionAffiliationsCounts = sinon
+              (ctx.getInstitutionAffiliationsCounts = sinon
                 .stub()
-                .resolves(this.v1Counts)),
+                .resolves(ctx.v1Counts)),
           },
         },
-        '../Subscription/FeaturesUpdater': {
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/FeaturesUpdater',
+      () => ({
+        default: {
           promises: {
-            refreshFeatures: (this.refreshFeaturesPromise = sinon
+            refreshFeatures: (ctx.refreshFeaturesPromise = sinon
               .stub()
               .resolves()),
           },
         },
-        '../Subscription/FeaturesHelper': {
-          isFeatureSetBetter: (this.isFeatureSetBetter = sinon.stub()),
-        },
-        '../User/UserGetter': this.UserGetter,
-        '../Notifications/NotificationsBuilder': this.NotificationsBuilder,
-        '../Subscription/SubscriptionLocator': this.SubscriptionLocator,
-        '../../models/Institution': this.InstitutionModel,
-        '../../models/Subscription': SubscriptionModel,
-        'mongodb-legacy': this.Mongo,
-        '@overleaf/settings': {
-          features: { professional: { 'test-feature': true } },
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/FeaturesHelper',
+      () => ({
+        default: {
+          isFeatureSetBetter: (ctx.isFeatureSetBetter = sinon.stub()),
         },
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Notifications/NotificationsBuilder',
+      () => ({
+        default: ctx.NotificationsBuilder,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionLocator',
+      () => ({
+        default: ctx.SubscriptionLocator,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/models/Institution',
+      () => ctx.InstitutionModel
+    )
+
+    vi.doMock(
+      '../../../../app/src/models/Subscription',
+      () => SubscriptionModel
+    )
+
+    vi.doMock('mongodb-legacy', () => ({
+      default: ctx.Mongo,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
+        features: { professional: { 'test-feature': true } },
       },
-    })
+    }))
+
+    ctx.InstitutionsManager = (await import(modulePath)).default
   })
 
   describe('refreshInstitutionUsers', function () {
-    beforeEach(function () {
-      this.user1Id = '123abc123abc123abc123abc'
-      this.user2Id = '456def456def456def456def'
-      this.user3Id = '789abd789abd789abd789abd'
-      this.user4Id = '321cba321cba321cba321cba'
-      this.affiliations = [
-        { user_id: this.user1Id },
-        { user_id: this.user2Id },
-        { user_id: this.user3Id },
-        { user_id: this.user4Id },
+    beforeEach(function (ctx) {
+      ctx.user1Id = '123abc123abc123abc123abc'
+      ctx.user2Id = '456def456def456def456def'
+      ctx.user3Id = '789abd789abd789abd789abd'
+      ctx.user4Id = '321cba321cba321cba321cba'
+      ctx.affiliations = [
+        { user_id: ctx.user1Id },
+        { user_id: ctx.user2Id },
+        { user_id: ctx.user3Id },
+        { user_id: ctx.user4Id },
       ]
-      this.user1 = { _id: this.user1Id }
-      this.user2 = { _id: this.user2Id }
-      this.user3 = { _id: this.user3Id }
-      this.user4 = { _id: this.user4Id }
-
-      this.UserGetter.promises.getUser
-        .withArgs(new ObjectId(this.user1Id))
-        .resolves(this.user1)
-      this.UserGetter.promises.getUser
-        .withArgs(new ObjectId(this.user2Id))
-        .resolves(this.user2)
-      this.UserGetter.promises.getUser
-        .withArgs(new ObjectId(this.user3Id))
-        .resolves(this.user3)
-      this.UserGetter.promises.getUser
-        .withArgs(new ObjectId(this.user4Id))
-        .resolves(this.user4)
-
-      this.SubscriptionLocator.promises.getUsersSubscription
-        .withArgs(this.user2)
+      ctx.user1 = { _id: ctx.user1Id }
+      ctx.user2 = { _id: ctx.user2Id }
+      ctx.user3 = { _id: ctx.user3Id }
+      ctx.user4 = { _id: ctx.user4Id }
+
+      ctx.UserGetter.promises.getUser
+        .withArgs(new ObjectId(ctx.user1Id))
+        .resolves(ctx.user1)
+      ctx.UserGetter.promises.getUser
+        .withArgs(new ObjectId(ctx.user2Id))
+        .resolves(ctx.user2)
+      ctx.UserGetter.promises.getUser
+        .withArgs(new ObjectId(ctx.user3Id))
+        .resolves(ctx.user3)
+      ctx.UserGetter.promises.getUser
+        .withArgs(new ObjectId(ctx.user4Id))
+        .resolves(ctx.user4)
+
+      ctx.SubscriptionLocator.promises.getUsersSubscription
+        .withArgs(ctx.user2)
         .resolves({
           planCode: 'pro',
           groupPlan: false,
         })
-      this.SubscriptionLocator.promises.getUsersSubscription
-        .withArgs(this.user3)
+      ctx.SubscriptionLocator.promises.getUsersSubscription
+        .withArgs(ctx.user3)
         .resolves({
           planCode: 'collaborator_free_trial_7_days',
           groupPlan: false,
         })
-      this.SubscriptionLocator.promises.getUsersSubscription
-        .withArgs(this.user4)
+      ctx.SubscriptionLocator.promises.getUsersSubscription
+        .withArgs(ctx.user4)
         .resolves({
           planCode: 'collaborator-annual',
           groupPlan: true,
         })
 
-      this.refreshFeaturesPromise.resolves({
+      ctx.refreshFeaturesPromise.resolves({
         newFeatures: {},
         featuresChanged: false,
       })
-      this.refreshFeaturesPromise
-        .withArgs(new ObjectId(this.user1Id))
+      ctx.refreshFeaturesPromise
+        .withArgs(new ObjectId(ctx.user1Id))
         .resolves({ newFeatures: {}, featuresChanged: true })
-      this.getInstitutionAffiliationsPromise.resolves(this.affiliations)
-      this.getConfirmedInstitutionAffiliationsPromise.resolves(
-        this.affiliations
-      )
+      ctx.getInstitutionAffiliationsPromise.resolves(ctx.affiliations)
+      ctx.getConfirmedInstitutionAffiliationsPromise.resolves(ctx.affiliations)
     })
 
-    it('refresh all users Features', async function () {
-      await this.InstitutionsManager.promises.refreshInstitutionUsers(
-        this.institutionId,
+    it('refresh all users Features', async function (ctx) {
+      await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
+        ctx.institutionId,
         false
       )
-      sinon.assert.callCount(this.refreshFeaturesPromise, 4)
+      sinon.assert.callCount(ctx.refreshFeaturesPromise, 4)
       // expect no notifications
       sinon.assert.notCalled(
-        this.NotificationsBuilder.promises.featuresUpgradedByAffiliation
+        ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation
       )
       sinon.assert.notCalled(
-        this.NotificationsBuilder.promises.redundantPersonalSubscription
+        ctx.NotificationsBuilder.promises.redundantPersonalSubscription
       )
     })
 
-    it('notifies users if their features have been upgraded', async function () {
-      await this.InstitutionsManager.promises.refreshInstitutionUsers(
-        this.institutionId,
+    it('notifies users if their features have been upgraded', async function (ctx) {
+      await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
+        ctx.institutionId,
         true
       )
       sinon.assert.calledOnce(
-        this.NotificationsBuilder.promises.featuresUpgradedByAffiliation
+        ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation
       )
       sinon.assert.calledWith(
-        this.NotificationsBuilder.promises.featuresUpgradedByAffiliation,
-        this.affiliations[0],
-        this.user1
+        ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation,
+        ctx.affiliations[0],
+        ctx.user1
       )
     })
 
-    it('notifies users if they have a subscription, or a trial subscription, that should be cancelled', async function () {
-      await this.InstitutionsManager.promises.refreshInstitutionUsers(
-        this.institutionId,
+    it('notifies users if they have a subscription, or a trial subscription, that should be cancelled', async function (ctx) {
+      await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
+        ctx.institutionId,
         true
       )
 
       sinon.assert.calledTwice(
-        this.NotificationsBuilder.promises.redundantPersonalSubscription
+        ctx.NotificationsBuilder.promises.redundantPersonalSubscription
       )
       sinon.assert.calledWith(
-        this.NotificationsBuilder.promises.redundantPersonalSubscription,
-        this.affiliations[1],
-        this.user2
+        ctx.NotificationsBuilder.promises.redundantPersonalSubscription,
+        ctx.affiliations[1],
+        ctx.user2
       )
       sinon.assert.calledWith(
-        this.NotificationsBuilder.promises.redundantPersonalSubscription,
-        this.affiliations[2],
-        this.user3
+        ctx.NotificationsBuilder.promises.redundantPersonalSubscription,
+        ctx.affiliations[2],
+        ctx.user3
       )
     })
   })
 
   describe('checkInstitutionUsers', function () {
-    it('returns entitled/not, sso/not, lapsed/current, and pro counts', async function () {
+    it('returns entitled/not, sso/not, lapsed/current, and pro counts', async function (ctx) {
       if (Features.hasFeature('saas')) {
-        this.isFeatureSetBetter.returns(true)
+        ctx.isFeatureSetBetter.returns(true)
         const usersSummary =
-          await this.InstitutionsManager.promises.checkInstitutionUsers(
-            this.institutionId
+          await ctx.InstitutionsManager.promises.checkInstitutionUsers(
+            ctx.institutionId
           )
         expect(usersSummary).to.deep.equal({
           emailUsers: {
@@ -300,13 +341,13 @@ describe('InstitutionsManager', function () {
       }
     })
 
-    it('includes withConfirmedEmailMismatch when v1 and v2 counts do not add up', async function () {
+    it('includes withConfirmedEmailMismatch when v1 and v2 counts do not add up', async function (ctx) {
       if (Features.hasFeature('saas')) {
-        this.isFeatureSetBetter.returns(true)
-        this.v1Counts.with_confirmed_email = 100
+        ctx.isFeatureSetBetter.returns(true)
+        ctx.v1Counts.with_confirmed_email = 100
         const usersSummary =
-          await this.InstitutionsManager.promises.checkInstitutionUsers(
-            this.institutionId
+          await ctx.InstitutionsManager.promises.checkInstitutionUsers(
+            ctx.institutionId
           )
         expect(usersSummary).to.deep.equal({
           emailUsers: {
@@ -350,116 +391,115 @@ describe('InstitutionsManager', function () {
   })
 
   describe('getInstitutionUsersSubscriptions', function () {
-    it('returns all institution users subscriptions', async function () {
+    it('returns all institution users subscriptions', async function (ctx) {
       const stubbedUsers = [
         { user_id: '123abc123abc123abc123abc' },
         { user_id: '456def456def456def456def' },
         { user_id: '789def789def789def789def' },
       ]
-      this.getInstitutionAffiliationsPromise.resolves(stubbedUsers)
-      await this.InstitutionsManager.promises.getInstitutionUsersSubscriptions(
-        this.institutionId
+      ctx.getInstitutionAffiliationsPromise.resolves(stubbedUsers)
+      await ctx.InstitutionsManager.promises.getInstitutionUsersSubscriptions(
+        ctx.institutionId
       )
-      sinon.assert.calledOnce(this.subscriptionExec)
+      sinon.assert.calledOnce(ctx.subscriptionExec)
     })
   })
 
   describe('addAffiliations', function () {
-    beforeEach(function () {
-      this.host = 'mit.edu'.split('').reverse().join('')
-      this.stubbedUser1 = {
+    beforeEach(function (ctx) {
+      ctx.host = 'mit.edu'.split('').reverse().join('')
+      ctx.stubbedUser1 = {
         _id: '6573014d8a14461b3d1aac3f',
         name: 'bob',
         email: 'hello@world.com',
         emails: [
-          { email: 'stubb1@mit.edu', reversedHostname: this.host },
+          { email: 'stubb1@mit.edu', reversedHostname: ctx.host },
           { email: 'test@test.com', reversedHostname: 'test.com' },
-          { email: 'another@mit.edu', reversedHostname: this.host },
+          { email: 'another@mit.edu', reversedHostname: ctx.host },
         ],
       }
-      this.stubbedUser1DecoratedEmails = [
+      ctx.stubbedUser1DecoratedEmails = [
         {
           email: 'stubb1@mit.edu',
-          reversedHostname: this.host,
+          reversedHostname: ctx.host,
           samlIdentifier: { hasEntitlement: false },
         },
         { email: 'test@test.com', reversedHostname: 'test.com' },
         {
           email: 'another@mit.edu',
-          reversedHostname: this.host,
+          reversedHostname: ctx.host,
           samlIdentifier: { hasEntitlement: true },
         },
       ]
-      this.stubbedUser2 = {
+      ctx.stubbedUser2 = {
         _id: '6573014d8a14461b3d1aac40',
         name: 'test',
         email: 'hello2@world.com',
-        emails: [{ email: 'subb2@mit.edu', reversedHostname: this.host }],
+        emails: [{ email: 'subb2@mit.edu', reversedHostname: ctx.host }],
       }
-      this.stubbedUser2DecoratedEmails = [
+      ctx.stubbedUser2DecoratedEmails = [
         {
           email: 'subb2@mit.edu',
-          reversedHostname: this.host,
+          reversedHostname: ctx.host,
         },
       ]
 
-      this.getInstitutionUsersByHostname = sinon.stub().resolves([
+      ctx.getInstitutionUsersByHostname = sinon.stub().resolves([
         {
-          _id: this.stubbedUser1._id,
-          emails: this.stubbedUser1DecoratedEmails,
+          _id: ctx.stubbedUser1._id,
+          emails: ctx.stubbedUser1DecoratedEmails,
         },
         {
-          _id: this.stubbedUser2._id,
-          emails: this.stubbedUser2DecoratedEmails,
+          _id: ctx.stubbedUser2._id,
+          emails: ctx.stubbedUser2DecoratedEmails,
         },
       ])
-      this.UserGetter.promises.getInstitutionUsersByHostname =
-        this.getInstitutionUsersByHostname
+      ctx.UserGetter.promises.getInstitutionUsersByHostname =
+        ctx.getInstitutionUsersByHostname
     })
 
     describe('affiliateUsers', function () {
-      it('should add affiliations for matching users', async function () {
-        await this.InstitutionsManager.promises.affiliateUsers('mit.edu')
+      it('should add affiliations for matching users', async function (ctx) {
+        await ctx.InstitutionsManager.promises.affiliateUsers('mit.edu')
 
-        this.getInstitutionUsersByHostname.calledOnce.should.equal(true)
-        this.addAffiliationPromise.calledThrice.should.equal(true)
-        this.addAffiliationPromise
+        ctx.getInstitutionUsersByHostname.calledOnce.should.equal(true)
+        ctx.addAffiliationPromise.calledThrice.should.equal(true)
+        ctx.addAffiliationPromise
           .calledWithMatch(
-            this.stubbedUser1._id,
-            this.stubbedUser1.emails[0].email,
+            ctx.stubbedUser1._id,
+            ctx.stubbedUser1.emails[0].email,
             { entitlement: false }
           )
           .should.equal(true)
-        this.addAffiliationPromise
+        ctx.addAffiliationPromise
           .calledWithMatch(
-            this.stubbedUser1._id,
-            this.stubbedUser1.emails[2].email,
+            ctx.stubbedUser1._id,
+            ctx.stubbedUser1.emails[2].email,
             { entitlement: true }
           )
           .should.equal(true)
-        this.addAffiliationPromise
+        ctx.addAffiliationPromise
           .calledWithMatch(
-            this.stubbedUser2._id,
-            this.stubbedUser2.emails[0].email,
+            ctx.stubbedUser2._id,
+            ctx.stubbedUser2.emails[0].email,
             { entitlement: undefined }
           )
           .should.equal(true)
-        this.refreshFeaturesPromise
-          .calledWith(this.stubbedUser1._id)
+        ctx.refreshFeaturesPromise
+          .calledWith(ctx.stubbedUser1._id)
           .should.equal(true)
-        this.refreshFeaturesPromise
-          .calledWith(this.stubbedUser2._id)
+        ctx.refreshFeaturesPromise
+          .calledWith(ctx.stubbedUser2._id)
           .should.equal(true)
-        this.refreshFeaturesPromise.should.have.been.calledTwice
+        ctx.refreshFeaturesPromise.should.have.been.calledTwice
       })
 
-      it('should return errors if last affiliation cannot be added', async function () {
-        this.addAffiliationPromise.onCall(2).rejects()
-        await expect(
-          this.InstitutionsManager.promises.affiliateUsers('mit.edu')
-        ).to.be.rejected
+      it('should return errors if last affiliation cannot be added', async function (ctx) {
+        ctx.addAffiliationPromise.onCall(2).rejects()
+        await expect(ctx.InstitutionsManager.promises.affiliateUsers('mit.edu'))
+          .to.be.rejected
 
-        this.getInstitutionUsersByHostname.calledOnce.should.equal(true)
+        ctx.getInstitutionUsersByHostname.calledOnce.should.equal(true)
       })
     })
   })

+ 51 - 43
services/web/test/unit/src/Subscription/SubscriptionEmailHandler.test.mjs

@@ -1,69 +1,77 @@
-const SandboxedModule = require('sandboxed-module')
-const sinon = require('sinon')
-const { expect } = require('chai')
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
 const modulePath =
   '../../../../app/src/Features/Subscription/SubscriptionEmailHandler'
 
 describe('SubscriptionEmailHandler', function () {
-  beforeEach(function () {
-    this.userId = '123456789abcde'
-    this.email = 'test@test.com'
+  beforeEach(async function (ctx) {
+    ctx.userId = '123456789abcde'
+    ctx.email = 'test@test.com'
 
-    this.SubscriptionEmailHandler = SandboxedModule.require(modulePath, {
-      requires: {
-        '../Email/EmailHandler': (this.EmailHandler = {
-          promises: {
-            sendEmail: sinon.stub().resolves({}),
-          },
-        }),
-        '../User/UserGetter': (this.UserGetter = {
-          promises: {
-            getUser: sinon
-              .stub()
-              .resolves({ _id: this.userId, email: 'test@test.com' }),
-          },
-        }),
-        './PlansLocator': (this.PlansLocator = {
-          findLocalPlanInSettings: sinon.stub().returns({
-            name: 'foo',
-            features: { collaborators: 42 },
-          }),
-        }),
-        '@overleaf/settings': (this.Settings = {
-          enableOnboardingEmails: true,
+    vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
+      default: (ctx.EmailHandler = {
+        promises: {
+          sendEmail: sinon.stub().resolves({}),
+        },
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: (ctx.UserGetter = {
+        promises: {
+          getUser: sinon
+            .stub()
+            .resolves({ _id: ctx.userId, email: 'test@test.com' }),
+        },
+      }),
+    }))
+
+    vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
+      default: (ctx.PlansLocator = {
+        findLocalPlanInSettings: sinon.stub().returns({
+          name: 'foo',
+          features: { collaborators: 42 },
         }),
-      },
-    })
+      }),
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.Settings = {
+        enableOnboardingEmails: true,
+      }),
+    }))
+
+    ctx.SubscriptionEmailHandler = (await import(modulePath)).default
   })
 
   describe('when onboarding emails are disabled', function () {
-    beforeEach(function () {
-      this.Settings.enableOnboardingEmails = false
+    beforeEach(function (ctx) {
+      ctx.Settings.enableOnboardingEmails = false
     })
-    it('does not send a trial onboarding email', async function () {
-      await this.SubscriptionEmailHandler.sendTrialOnboardingEmail(
-        this.userId,
+    it('does not send a trial onboarding email', async function (ctx) {
+      await ctx.SubscriptionEmailHandler.sendTrialOnboardingEmail(
+        ctx.userId,
         'foo-plan-code'
       )
-      expect(this.EmailHandler.promises.sendEmail).to.not.have.been.called
+      expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
     })
   })
 
   describe('when onboarding emails are enabled', function () {
-    it('sends trial onboarding email', async function () {
-      await this.SubscriptionEmailHandler.sendTrialOnboardingEmail(
-        this.userId,
+    it('sends trial onboarding email', async function (ctx) {
+      await ctx.SubscriptionEmailHandler.sendTrialOnboardingEmail(
+        ctx.userId,
         'foo-plan-code'
       )
 
-      expect(this.PlansLocator.findLocalPlanInSettings).to.have.been.calledWith(
+      expect(ctx.PlansLocator.findLocalPlanInSettings).to.have.been.calledWith(
         'foo-plan-code'
       )
-      expect(this.EmailHandler.promises.sendEmail.lastCall.args).to.deep.equal([
+      expect(ctx.EmailHandler.promises.sendEmail.lastCall.args).to.deep.equal([
         'trialOnboarding',
         {
-          to: this.email,
-          sendingUser_id: this.userId,
+          to: ctx.email,
+          sendingUser_id: ctx.userId,
           planName: 'foo',
           features: { collaborators: 42 },
         },

+ 2 - 4
services/web/test/unit/src/Subscription/SubscriptionFormatters.test.mjs

@@ -1,7 +1,5 @@
-const chai = require('chai')
-const SubscriptionFormatters = require('../../../../app/src/Features/Subscription/SubscriptionFormatters')
-
-const { expect } = chai
+import { expect } from 'vitest'
+import SubscriptionFormatters from '../../../../app/src/Features/Subscription/SubscriptionFormatters.mjs'
 
 describe('SubscriptionFormatters', function () {
   describe('formatDateTime', function () {