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

[web] Migrate some User and UserMembership files to ESM (#29181)

* Rename files to mjs

* Migrate files to ESM

* Fix imports

* Misc. fixes: Fixup InsititutionsAPI import, ObjectId import, ...

* Rename test files to mjs

* Convert test files to ESM

* Fix tests

* Update UserMembershipErrors imports

* Convert some tests: sinon -> vitest

* Fixup UserMembershipHandler.test.mjs

* Convert UserMembershipErrors to ESM

GitOrigin-RevId: 05d34c7e112a567f9c59398740ae0830ef93d32f
Antoine Clausse 11 месяцев назад
Родитель
Сommit
3a1f3af6a4
20 измененных файлов с 1055 добавлено и 872 удалено
  1. 2 2
      services/web/app/src/Features/User/UserCreator.mjs
  2. 6 6
      services/web/app/src/Features/User/UserOnboardingEmailManager.mjs
  3. 6 8
      services/web/app/src/Features/User/UserPostRegistrationAnalyticsManager.mjs
  4. 5 5
      services/web/app/src/Features/UserMembership/UserMembershipAuthorization.mjs
  5. 9 10
      services/web/app/src/Features/UserMembership/UserMembershipController.mjs
  6. 4 2
      services/web/app/src/Features/UserMembership/UserMembershipErrors.mjs
  7. 17 18
      services/web/app/src/Features/UserMembership/UserMembershipHandler.mjs
  8. 13 12
      services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs
  9. 1 1
      services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs
  10. 3 3
      services/web/app/src/Features/UserMembership/UserMembershipViewModel.mjs
  11. 2 2
      services/web/app/src/infrastructure/QueueWorkers.mjs
  12. 114 0
      services/web/test/unit/src/User/UserOnboardingEmailManager.test.mjs
  13. 0 112
      services/web/test/unit/src/User/UserOnboardingEmailManagerTests.js
  14. 125 0
      services/web/test/unit/src/User/UserPostRegistrationAnalyticsManager.test.mjs
  15. 0 114
      services/web/test/unit/src/User/UserPostRegistrationAnalyticsManagerTests.js
  16. 333 207
      services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs
  17. 287 0
      services/web/test/unit/src/UserMembership/UserMembershipHandler.test.mjs
  18. 0 251
      services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js
  19. 128 0
      services/web/test/unit/src/UserMembership/UserMembershipViewModel.test.mjs
  20. 0 119
      services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js

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

@@ -7,8 +7,8 @@ import UserDeleter from './UserDeleter.js'
 import UserGetter from './UserGetter.js'
 import UserUpdater from './UserUpdater.js'
 import Analytics from '../Analytics/AnalyticsManager.js'
-import UserOnboardingEmailManager from './UserOnboardingEmailManager.js'
-import UserPostRegistrationAnalyticsManager from './UserPostRegistrationAnalyticsManager.js'
+import UserOnboardingEmailManager from './UserOnboardingEmailManager.mjs'
+import UserPostRegistrationAnalyticsManager from './UserPostRegistrationAnalyticsManager.mjs'
 import OError from '@overleaf/o-error'
 
 async function _addAffiliation(user, affiliationOptions) {

+ 6 - 6
services/web/app/src/Features/User/UserOnboardingEmailManager.js → services/web/app/src/Features/User/UserOnboardingEmailManager.mjs

@@ -1,8 +1,8 @@
-const Queues = require('../../infrastructure/Queues')
-const EmailHandler = require('../Email/EmailHandler')
-const UserUpdater = require('./UserUpdater')
-const UserGetter = require('./UserGetter')
-const Settings = require('@overleaf/settings')
+import Queues from '../../infrastructure/Queues.js'
+import EmailHandler from '../Email/EmailHandler.js'
+import UserUpdater from './UserUpdater.js'
+import UserGetter from './UserGetter.js'
+import Settings from '@overleaf/settings'
 
 const ONE_DAY_MS = 24 * 60 * 60 * 1000
 
@@ -26,4 +26,4 @@ async function sendOnboardingEmail(userId) {
   }
 }
 
-module.exports = { scheduleOnboardingEmail, sendOnboardingEmail }
+export default { scheduleOnboardingEmail, sendOnboardingEmail }

+ 6 - 8
services/web/app/src/Features/User/UserPostRegistrationAnalyticsManager.js → services/web/app/src/Features/User/UserPostRegistrationAnalyticsManager.mjs

@@ -1,9 +1,7 @@
-const Queues = require('../../infrastructure/Queues')
-const UserGetter = require('./UserGetter')
-const {
-  promises: InstitutionsAPIPromises,
-} = require('../Institutions/InstitutionsAPI')
-const AnalyticsManager = require('../Analytics/AnalyticsManager')
+import Queues from '../../infrastructure/Queues.js'
+import UserGetter from './UserGetter.js'
+import InstitutionsAPI from '../Institutions/InstitutionsAPI.js'
+import AnalyticsManager from '../Analytics/AnalyticsManager.js'
 
 const ONE_DAY_MS = 24 * 60 * 60 * 1000
 
@@ -25,7 +23,7 @@ async function postRegistrationAnalytics(userId) {
 
 async function checkAffiliations(userId) {
   const affiliationsData =
-    await InstitutionsAPIPromises.getUserAffiliations(userId)
+    await InstitutionsAPI.promises.getUserAffiliations(userId)
   const hasCommonsAccountAffiliation = affiliationsData.some(
     affiliationData =>
       affiliationData.institution && affiliationData.institution.commonsAccount
@@ -40,7 +38,7 @@ async function checkAffiliations(userId) {
   }
 }
 
-module.exports = {
+export default {
   schedulePostRegistrationAnalytics,
   postRegistrationAnalytics,
 }

+ 5 - 5
services/web/app/src/Features/UserMembership/UserMembershipAuthorization.js → services/web/app/src/Features/UserMembership/UserMembershipAuthorization.mjs

@@ -1,9 +1,9 @@
-const {
+import {
   hasAdminCapability,
   hasAdminAccess,
-} = require('../Helpers/AdminAuthorizationHelper')
-const SessionManager = require('../Authentication/SessionManager')
-const Settings = require('@overleaf/settings')
+} from '../Helpers/AdminAuthorizationHelper.js'
+import SessionManager from '../Authentication/SessionManager.js'
+import Settings from '@overleaf/settings'
 
 const UserMembershipAuthorization = {
   hasStaffAccess(requiredStaffAccess) {
@@ -52,4 +52,4 @@ const UserMembershipAuthorization = {
     }
   },
 }
-module.exports = UserMembershipAuthorization
+export default UserMembershipAuthorization

+ 9 - 10
services/web/app/src/Features/UserMembership/UserMembershipController.mjs

@@ -1,20 +1,16 @@
 import SessionManager from '../Authentication/SessionManager.js'
-import UserMembershipHandler from './UserMembershipHandler.js'
+import UserMembershipHandler from './UserMembershipHandler.mjs'
 import Errors from '../Errors/Errors.js'
 import EmailHelper from '../Helpers/EmailHelper.js'
 import { csvAttachment } from '../../infrastructure/Response.js'
-import {
-  UserIsManagerError,
-  UserAlreadyAddedError,
-  UserNotFoundError,
-} from './UserMembershipErrors.js'
+import UserMembershipErrors from './UserMembershipErrors.mjs'
 import { SSOConfig } from '../../models/SSOConfig.js'
 import { Parser as CSVParser } from 'json2csv'
 import { expressify } from '@overleaf/promise-utils'
 import PlansLocator from '../Subscription/PlansLocator.js'
 import RecurlyClient from '../Subscription/RecurlyClient.js'
 import Modules from '../../infrastructure/Modules.js'
-import UserMembershipAuthorization from './UserMembershipAuthorization.js'
+import UserMembershipAuthorization from './UserMembershipAuthorization.mjs'
 
 async function manageGroupMembers(req, res, next) {
   const { entity: subscription, entityConfig } = req
@@ -201,7 +197,10 @@ export default {
       entityConfig,
       email,
       function (error, user) {
-        if (error && error instanceof UserAlreadyAddedError) {
+        if (
+          error &&
+          error instanceof UserMembershipErrors.UserAlreadyAddedError
+        ) {
           return res.status(400).json({
             error: {
               code: 'user_already_added',
@@ -209,7 +208,7 @@ export default {
             },
           })
         }
-        if (error && error instanceof UserNotFoundError) {
+        if (error && error instanceof UserMembershipErrors.UserNotFoundError) {
           return res.status(404).json({
             error: {
               code: 'user_not_found',
@@ -247,7 +246,7 @@ export default {
       entityConfig,
       userId,
       function (error, user) {
-        if (error && error instanceof UserIsManagerError) {
+        if (error && error instanceof UserMembershipErrors.UserIsManagerError) {
           return res.status(400).json({
             error: {
               code: 'managers_cannot_remove_admin',

+ 4 - 2
services/web/app/src/Features/UserMembership/UserMembershipErrors.js → services/web/app/src/Features/UserMembership/UserMembershipErrors.mjs

@@ -1,11 +1,13 @@
-const OError = require('@overleaf/o-error')
+import OError from '@overleaf/o-error'
 
 class UserIsManagerError extends OError {}
 class UserNotFoundError extends OError {}
 class UserAlreadyAddedError extends OError {}
 
-module.exports = {
+const UserMembershipErrors = {
   UserIsManagerError,
   UserNotFoundError,
   UserAlreadyAddedError,
 }
+
+export default UserMembershipErrors

+ 17 - 18
services/web/app/src/Features/UserMembership/UserMembershipHandler.js → services/web/app/src/Features/UserMembership/UserMembershipHandler.mjs

@@ -1,17 +1,15 @@
-const { ObjectId } = require('mongodb-legacy')
-const { promisifyAll, callbackify } = require('@overleaf/promise-utils')
-const EntityModels = {
-  Institution: require('../../models/Institution').Institution,
-  Subscription: require('../../models/Subscription').Subscription,
-  Publisher: require('../../models/Publisher').Publisher,
-}
-const UserMembershipViewModel = require('./UserMembershipViewModel')
-const UserGetter = require('../User/UserGetter')
-const {
-  UserIsManagerError,
-  UserNotFoundError,
-  UserAlreadyAddedError,
-} = require('./UserMembershipErrors')
+import mongodb from 'mongodb-legacy'
+import { promisifyAll, callbackify } from '@overleaf/promise-utils'
+import { Institution } from '../../models/Institution.js'
+import { Subscription } from '../../models/Subscription.js'
+import { Publisher } from '../../models/Publisher.js'
+import UserMembershipViewModel from './UserMembershipViewModel.mjs'
+import UserGetter from '../User/UserGetter.js'
+import UserMembershipErrors from './UserMembershipErrors.mjs'
+
+const { ObjectId } = mongodb
+
+const EntityModels = { Institution, Subscription, Publisher }
 
 const UserMembershipHandler = {
   async getEntityWithoutAuthorizationCheck(entityId, entityConfig) {
@@ -34,11 +32,11 @@ const UserMembershipHandler = {
     const user = await UserGetter.promises.getUserByAnyEmail(email)
 
     if (!user) {
-      throw new UserNotFoundError()
+      throw new UserMembershipErrors.UserNotFoundError()
     }
 
     if (entity[attribute].some(managerId => managerId.equals(user._id))) {
-      throw new UserAlreadyAddedError()
+      throw new UserMembershipErrors.UserAlreadyAddedError()
     }
 
     await addUserToEntity(entity, attribute, user)
@@ -48,14 +46,15 @@ const UserMembershipHandler = {
   async removeUser(entity, entityConfig, userId) {
     const attribute = entityConfig.fields.write
     if (entity.admin_id ? entity.admin_id.equals(userId) : undefined) {
-      throw new UserIsManagerError()
+      throw new UserMembershipErrors.UserIsManagerError()
     }
     return await removeUserFromEntity(entity, attribute, userId)
   },
 }
 
 UserMembershipHandler.promises = promisifyAll(UserMembershipHandler)
-module.exports = {
+
+export default {
   getEntityWithoutAuthorizationCheck: callbackify(
     UserMembershipHandler.getEntityWithoutAuthorizationCheck
   ),

+ 13 - 12
services/web/app/src/Features/UserMembership/UserMembershipMiddleware.js → services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs

@@ -1,16 +1,17 @@
 // @ts-check
 
-const { expressify } = require('@overleaf/promise-utils')
-const async = require('async')
-const UserMembershipAuthorization = require('./UserMembershipAuthorization')
-const AuthenticationController = require('../Authentication/AuthenticationController')
-const UserMembershipHandler = require('./UserMembershipHandler')
-const EntityConfigs = require('./UserMembershipEntityConfigs')
-const Errors = require('../Errors/Errors')
-const HttpErrorHandler = require('../Errors/HttpErrorHandler')
-const TemplatesManager = require('../Templates/TemplatesManager')
-const { z, zz, validateReq } = require('../../infrastructure/Validation')
-const { useAdminCapabilities } = require('../Helpers/AdminAuthorizationHelper')
+import { expressify } from '@overleaf/promise-utils'
+
+import async from 'async'
+import UserMembershipAuthorization from './UserMembershipAuthorization.mjs'
+import AuthenticationController from '../Authentication/AuthenticationController.js'
+import UserMembershipHandler from './UserMembershipHandler.mjs'
+import EntityConfigs from './UserMembershipEntityConfigs.js'
+import Errors from '../Errors/Errors.js'
+import HttpErrorHandler from '../Errors/HttpErrorHandler.js'
+import TemplatesManager from '../Templates/TemplatesManager.js'
+import { z, zz, validateReq } from '../../infrastructure/Validation.js'
+import { useAdminCapabilities } from '../Helpers/AdminAuthorizationHelper.js'
 
 // set of middleware arrays or functions that checks user access to an entity
 // (publisher, institution, group, template, etc.)
@@ -234,7 +235,7 @@ const UserMembershipMiddleware = {
   },
 }
 
-module.exports = UserMembershipMiddleware
+export default UserMembershipMiddleware
 
 // fetch entity config and set it in the request
 function fetchEntityConfig(entityName) {

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

@@ -1,4 +1,4 @@
-import UserMembershipMiddleware from './UserMembershipMiddleware.js'
+import UserMembershipMiddleware from './UserMembershipMiddleware.mjs'
 import UserMembershipController from './UserMembershipController.mjs'
 import SubscriptionGroupController from '../Subscription/SubscriptionGroupController.mjs'
 import TeamInvitesController from '../Subscription/TeamInvitesController.mjs'

+ 3 - 3
services/web/app/src/Features/UserMembership/UserMembershipViewModel.js → services/web/app/src/Features/UserMembership/UserMembershipViewModel.mjs

@@ -1,5 +1,5 @@
-const UserGetter = require('../User/UserGetter')
-const { isObjectIdInstance } = require('../Helpers/Mongo')
+import UserGetter from '../User/UserGetter.js'
+import { isObjectIdInstance } from '../Helpers/Mongo.js'
 
 const UserMembershipViewModel = {
   build(userOrEmail) {
@@ -81,4 +81,4 @@ UserMembershipViewModel.promises = {
   buildAsync: UserMembershipViewModel.buildAsync,
 }
 
-module.exports = UserMembershipViewModel
+export default UserMembershipViewModel

+ 2 - 2
services/web/app/src/infrastructure/QueueWorkers.mjs

@@ -1,7 +1,7 @@
 import Features from './Features.js'
 import Queues from './Queues.js'
-import UserOnboardingEmailManager from '../Features/User/UserOnboardingEmailManager.js'
-import UserPostRegistrationAnalyticsManager from '../Features/User/UserPostRegistrationAnalyticsManager.js'
+import UserOnboardingEmailManager from '../Features/User/UserOnboardingEmailManager.mjs'
+import UserPostRegistrationAnalyticsManager from '../Features/User/UserPostRegistrationAnalyticsManager.mjs'
 import FeaturesUpdater from '../Features/Subscription/FeaturesUpdater.js'
 
 import {

+ 114 - 0
services/web/test/unit/src/User/UserOnboardingEmailManager.test.mjs

@@ -0,0 +1,114 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
+const MODULE_PATH =
+  '../../../../app/src/Features/User/UserOnboardingEmailManager'
+
+describe('UserOnboardingEmailManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.fakeUserId = '123abc'
+    ctx.fakeUserEmail = 'frog@overleaf.com'
+    ctx.onboardingEmailsQueue = {
+      add: sinon.stub().resolves(),
+      process: callback => {
+        ctx.queueProcessFunction = callback
+      },
+    }
+    ctx.Queues = {
+      createScheduledJob: sinon.stub().resolves(),
+    }
+    ctx.UserGetter = {
+      promises: {
+        getUser: sinon.stub().resolves(null),
+      },
+    }
+    ctx.UserGetter.promises.getUser.withArgs({ _id: ctx.fakeUserId }).resolves({
+      _id: ctx.fakeUserId,
+      email: ctx.fakeUserEmail,
+    })
+    ctx.EmailHandler = {
+      promises: {
+        sendEmail: sinon.stub().resolves(),
+      },
+    }
+    ctx.UserUpdater = {
+      promises: {
+        updateUser: sinon.stub().resolves(),
+      },
+    }
+
+    vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
+      default: ctx.Queues,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
+      default: ctx.EmailHandler,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
+      default: ctx.UserUpdater,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: (ctx.Settings = {
+        enableOnboardingEmails: true,
+      }),
+    }))
+
+    ctx.UserOnboardingEmailManager = (await import(MODULE_PATH)).default
+  })
+
+  describe('scheduleOnboardingEmail', function () {
+    it('should schedule delayed job on queue', async function (ctx) {
+      await ctx.UserOnboardingEmailManager.scheduleOnboardingEmail({
+        _id: ctx.fakeUserId,
+      })
+      sinon.assert.calledWith(
+        ctx.Queues.createScheduledJob,
+        'emails-onboarding',
+        { data: { userId: ctx.fakeUserId } },
+        24 * 60 * 60 * 1000
+      )
+    })
+  })
+
+  describe('sendOnboardingEmail', function () {
+    describe('when onboarding emails are disabled', function () {
+      beforeEach(function (ctx) {
+        ctx.Settings.enableOnboardingEmails = false
+      })
+      it('should not send onboarding email', async function (ctx) {
+        await ctx.UserOnboardingEmailManager.sendOnboardingEmail(ctx.fakeUserId)
+        expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
+        expect(ctx.UserUpdater.promises.updateUser).not.to.have.been.called
+      })
+    })
+    describe('when onboarding emails are enabled', function () {
+      it('should send onboarding email and update user', async function (ctx) {
+        await ctx.UserOnboardingEmailManager.sendOnboardingEmail(ctx.fakeUserId)
+        expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
+          'userOnboardingEmail',
+          {
+            to: ctx.fakeUserEmail,
+          }
+        )
+        expect(ctx.UserUpdater.promises.updateUser).to.have.been.calledWith(
+          ctx.fakeUserId,
+          { $set: { onboardingEmailSentAt: sinon.match.date } }
+        )
+      })
+
+      it('should stop if user is not found', async function (ctx) {
+        await ctx.UserOnboardingEmailManager.sendOnboardingEmail({
+          data: { userId: 'deleted-user' },
+        })
+        expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
+        expect(ctx.UserUpdater.promises.updateUser).not.to.have.been.called
+      })
+    })
+  })
+})

+ 0 - 112
services/web/test/unit/src/User/UserOnboardingEmailManagerTests.js

@@ -1,112 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const { expect } = require('chai')
-
-const MODULE_PATH = path.join(
-  __dirname,
-  '../../../../app/src/Features/User/UserOnboardingEmailManager'
-)
-
-describe('UserOnboardingEmailManager', function () {
-  beforeEach(function () {
-    this.fakeUserId = '123abc'
-    this.fakeUserEmail = 'frog@overleaf.com'
-    this.onboardingEmailsQueue = {
-      add: sinon.stub().resolves(),
-      process: callback => {
-        this.queueProcessFunction = callback
-      },
-    }
-    this.Queues = {
-      createScheduledJob: sinon.stub().resolves(),
-    }
-    this.UserGetter = {
-      promises: {
-        getUser: sinon.stub().resolves(null),
-      },
-    }
-    this.UserGetter.promises.getUser
-      .withArgs({ _id: this.fakeUserId })
-      .resolves({
-        _id: this.fakeUserId,
-        email: this.fakeUserEmail,
-      })
-    this.EmailHandler = {
-      promises: {
-        sendEmail: sinon.stub().resolves(),
-      },
-    }
-    this.UserUpdater = {
-      promises: {
-        updateUser: sinon.stub().resolves(),
-      },
-    }
-
-    this.UserOnboardingEmailManager = SandboxedModule.require(MODULE_PATH, {
-      requires: {
-        '../../infrastructure/Queues': this.Queues,
-        '../Email/EmailHandler': this.EmailHandler,
-        './UserGetter': this.UserGetter,
-        './UserUpdater': this.UserUpdater,
-        '@overleaf/settings': (this.Settings = {
-          enableOnboardingEmails: true,
-        }),
-      },
-    })
-  })
-
-  describe('scheduleOnboardingEmail', function () {
-    it('should schedule delayed job on queue', async function () {
-      await this.UserOnboardingEmailManager.scheduleOnboardingEmail({
-        _id: this.fakeUserId,
-      })
-      sinon.assert.calledWith(
-        this.Queues.createScheduledJob,
-        'emails-onboarding',
-        { data: { userId: this.fakeUserId } },
-        24 * 60 * 60 * 1000
-      )
-    })
-  })
-
-  describe('sendOnboardingEmail', function () {
-    describe('when onboarding emails are disabled', function () {
-      beforeEach(function () {
-        this.Settings.enableOnboardingEmails = false
-      })
-      it('should not send onboarding email', async function () {
-        await this.UserOnboardingEmailManager.sendOnboardingEmail(
-          this.fakeUserId
-        )
-        expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called
-        expect(this.UserUpdater.promises.updateUser).not.to.have.been.called
-      })
-    })
-    describe('when onboarding emails are enabled', function () {
-      it('should send onboarding email and update user', async function () {
-        await this.UserOnboardingEmailManager.sendOnboardingEmail(
-          this.fakeUserId
-        )
-        expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
-          'userOnboardingEmail',
-          {
-            to: this.fakeUserEmail,
-          }
-        )
-        expect(this.UserUpdater.promises.updateUser).to.have.been.calledWith(
-          this.fakeUserId,
-          { $set: { onboardingEmailSentAt: sinon.match.date } }
-        )
-      })
-
-      it('should stop if user is not found', async function () {
-        await this.UserOnboardingEmailManager.sendOnboardingEmail({
-          data: { userId: 'deleted-user' },
-        })
-        expect(this.EmailHandler.promises.sendEmail).not.to.have.been.called
-        expect(this.UserUpdater.promises.updateUser).not.to.have.been.called
-      })
-    })
-  })
-})

+ 125 - 0
services/web/test/unit/src/User/UserPostRegistrationAnalyticsManager.test.mjs

@@ -0,0 +1,125 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
+const MODULE_PATH =
+  '../../../../app/src/Features/User/UserPostRegistrationAnalyticsManager'
+
+describe('UserPostRegistrationAnalyticsManager', function () {
+  beforeEach(async function (ctx) {
+    ctx.fakeUserId = '123abc'
+    ctx.Queues = {
+      createScheduledJob: sinon.stub().resolves(),
+    }
+    ctx.UserGetter = {
+      promises: {
+        getUser: sinon.stub().resolves(),
+      },
+    }
+    ctx.UserGetter.promises.getUser
+      .withArgs({ _id: ctx.fakeUserId })
+      .resolves({ _id: ctx.fakeUserId })
+    ctx.InstitutionsAPI = {
+      promises: {
+        getUserAffiliations: sinon.stub().resolves([]),
+      },
+    }
+    ctx.AnalyticsManager = {
+      setUserPropertyForUser: sinon.stub().resolves(),
+    }
+
+    vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
+      default: ctx.Queues,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/Institutions/InstitutionsAPI',
+      () => ({
+        default: ctx.InstitutionsAPI,
+      })
+    )
+
+    vi.doMock(
+      '../../../../app/src/Features/Analytics/AnalyticsManager',
+      () => ({
+        default: ctx.AnalyticsManager,
+      })
+    )
+
+    ctx.UserPostRegistrationAnalyticsManager = (
+      await import(MODULE_PATH)
+    ).default
+  })
+
+  describe('schedulePostRegistrationAnalytics', function () {
+    it('should schedule delayed job on queue', async function (ctx) {
+      await ctx.UserPostRegistrationAnalyticsManager.schedulePostRegistrationAnalytics(
+        {
+          _id: ctx.fakeUserId,
+        }
+      )
+      sinon.assert.calledWith(
+        ctx.Queues.createScheduledJob,
+        'post-registration-analytics',
+        { data: { userId: ctx.fakeUserId } },
+        24 * 60 * 60 * 1000
+      )
+    })
+  })
+
+  describe('postRegistrationAnalytics', function () {
+    it('stops without errors if user is not found', async function (ctx) {
+      await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
+        'not-a-user'
+      )
+      expect(ctx.InstitutionsAPI.promises.getUserAffiliations).not.to.have.been
+        .called
+      expect(ctx.AnalyticsManager.setUserPropertyForUser).not.to.have.been
+        .called
+    })
+
+    it('sets user property if user has commons account affiliationd', async function (ctx) {
+      ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([
+        {},
+        {
+          institution: {
+            commonsAccount: true,
+          },
+        },
+        {
+          institution: {
+            commonsAccount: false,
+          },
+        },
+      ])
+      await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
+        ctx.fakeUserId
+      )
+      expect(
+        ctx.AnalyticsManager.setUserPropertyForUser
+      ).to.have.been.calledWith(
+        ctx.fakeUserId,
+        'registered-from-commons-account',
+        true
+      )
+    })
+
+    it('does not set user property if user has no commons account affiliation', async function (ctx) {
+      ctx.InstitutionsAPI.promises.getUserAffiliations.resolves([
+        {
+          institution: {
+            commonsAccount: false,
+          },
+        },
+      ])
+      await ctx.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
+        ctx.fakeUserId
+      )
+      expect(ctx.AnalyticsManager.setUserPropertyForUser).not.to.have.been
+        .called
+    })
+  })
+})

+ 0 - 114
services/web/test/unit/src/User/UserPostRegistrationAnalyticsManagerTests.js

@@ -1,114 +0,0 @@
-const SandboxedModule = require('sandboxed-module')
-const path = require('path')
-const sinon = require('sinon')
-const { expect } = require('chai')
-
-const MODULE_PATH = path.join(
-  __dirname,
-  '../../../../app/src/Features/User/UserPostRegistrationAnalyticsManager'
-)
-
-describe('UserPostRegistrationAnalyticsManager', function () {
-  beforeEach(function () {
-    this.fakeUserId = '123abc'
-    this.Queues = {
-      createScheduledJob: sinon.stub().resolves(),
-    }
-    this.UserGetter = {
-      promises: {
-        getUser: sinon.stub().resolves(),
-      },
-    }
-    this.UserGetter.promises.getUser
-      .withArgs({ _id: this.fakeUserId })
-      .resolves({ _id: this.fakeUserId })
-    this.InstitutionsAPI = {
-      promises: {
-        getUserAffiliations: sinon.stub().resolves([]),
-      },
-    }
-    this.AnalyticsManager = {
-      setUserPropertyForUser: sinon.stub().resolves(),
-    }
-    this.UserPostRegistrationAnalyticsManager = SandboxedModule.require(
-      MODULE_PATH,
-      {
-        requires: {
-          '../../infrastructure/Queues': this.Queues,
-          './UserGetter': this.UserGetter,
-          '../Institutions/InstitutionsAPI': this.InstitutionsAPI,
-          '../Analytics/AnalyticsManager': this.AnalyticsManager,
-        },
-      }
-    )
-  })
-
-  describe('schedulePostRegistrationAnalytics', function () {
-    it('should schedule delayed job on queue', async function () {
-      await this.UserPostRegistrationAnalyticsManager.schedulePostRegistrationAnalytics(
-        {
-          _id: this.fakeUserId,
-        }
-      )
-      sinon.assert.calledWith(
-        this.Queues.createScheduledJob,
-        'post-registration-analytics',
-        { data: { userId: this.fakeUserId } },
-        24 * 60 * 60 * 1000
-      )
-    })
-  })
-
-  describe('postRegistrationAnalytics', function () {
-    it('stops without errors if user is not found', async function () {
-      await this.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
-        'not-a-user'
-      )
-      expect(this.InstitutionsAPI.promises.getUserAffiliations).not.to.have.been
-        .called
-      expect(this.AnalyticsManager.setUserPropertyForUser).not.to.have.been
-        .called
-    })
-
-    it('sets user property if user has commons account affiliationd', async function () {
-      this.InstitutionsAPI.promises.getUserAffiliations.resolves([
-        {},
-        {
-          institution: {
-            commonsAccount: true,
-          },
-        },
-        {
-          institution: {
-            commonsAccount: false,
-          },
-        },
-      ])
-      await this.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
-        this.fakeUserId
-      )
-      expect(
-        this.AnalyticsManager.setUserPropertyForUser
-      ).to.have.been.calledWith(
-        this.fakeUserId,
-        'registered-from-commons-account',
-        true
-      )
-    })
-
-    it('does not set user property if user has no commons account affiliation', async function () {
-      this.InstitutionsAPI.promises.getUserAffiliations.resolves([
-        {
-          institution: {
-            commonsAccount: false,
-          },
-        },
-      ])
-      await this.UserPostRegistrationAnalyticsManager.postRegistrationAnalytics(
-        this.fakeUserId
-      )
-      expect(this.AnalyticsManager.setUserPropertyForUser).not.to.have.been
-        .called
-    })
-  })
-})

+ 333 - 207
services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs

@@ -1,25 +1,18 @@
-import { expect, vi } from 'vitest'
-import sinon from 'sinon'
-import MockRequest from '../helpers/MockRequest.js'
-import MockResponse from '../helpers/MockResponse.js'
+import { expect, vi, describe, it, beforeEach } from 'vitest'
+import MockRequest from '../helpers/MockRequestVitest.mjs'
+import MockResponse from '../helpers/MockResponseVitest.mjs'
 import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js'
 import Errors from '../../../../app/src/Features/Errors/Errors.js'
-import {
-  UserIsManagerError,
-  UserNotFoundError,
-  UserAlreadyAddedError,
-} from '../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
-
-const assertCalledWith = sinon.assert.calledWith
+import UserMembershipErrors from '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
 
 const modulePath =
   '../../../../app/src/Features/UserMembership/UserMembershipController.mjs'
 
 vi.mock(
-  '../../../../app/src/Features/UserMembership/UserMembershipErrors.js',
+  '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs',
   () =>
     vi.importActual(
-      '../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
+      '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
     )
 )
 
@@ -27,9 +20,9 @@ vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
   vi.importActual('../../../../app/src/Features/Errors/Errors.js')
 )
 
-describe('UserMembershipController', function () {
-  beforeEach(async function (ctx) {
-    ctx.req = new MockRequest()
+describe('UserMembershipController', () => {
+  beforeEach(async ctx => {
+    ctx.req = new MockRequest(vi)
     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' }
@@ -37,16 +30,16 @@ describe('UserMembershipController', function () {
       _id: 'mock-subscription-id',
       admin_id: 'mock-admin-id',
       manager_ids: ['mock-admin-id'],
-      fetchV1Data: callback => callback(null, ctx.subscription),
+      fetchV1Data: vi.fn(callback => callback(null, ctx.subscription)),
     }
     ctx.institution = {
       _id: 'mock-institution-id',
       v1Id: 123,
-      fetchV1Data: callback => {
-        const institution = Object.assign({}, ctx.institution)
+      fetchV1Data: vi.fn(callback => {
+        const institution = { ...ctx.institution }
         institution.name = 'Test Institution Name'
         callback(null, institution)
-      },
+      }),
       managerIds: ['mock-member-id-1'],
     }
     ctx.users = [
@@ -113,45 +106,48 @@ describe('UserMembershipController', function () {
     }
 
     ctx.SessionManager = {
-      getSessionUser: sinon.stub().returns(ctx.user),
-      getLoggedInUserId: sinon.stub().returns(ctx.user._id),
+      getSessionUser: vi.fn().mockReturnValue(ctx.user),
+      getLoggedInUserId: vi.fn().mockReturnValue(ctx.user._id),
     }
     ctx.SSOConfig = {
-      findById: sinon
-        .stub()
-        .returns({ exec: sinon.stub().resolves({ enabled: true }) }),
+      findById: vi.fn().mockReturnValue({
+        exec: vi.fn().mockResolvedValue({ enabled: true }),
+      }),
     }
     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),
+      getEntity: vi.fn((_entity, _options, callback) =>
+        callback(null, ctx.subscription)
+      ),
+      createEntity: vi.fn((_entity, _options, callback) =>
+        callback(null, ctx.institution)
+      ),
+      getUsers: vi.fn((_entity, _options, callback) =>
+        callback(null, ctx.users)
+      ),
+      addUser: vi.fn((_entity, _options, _email, callback) =>
+        callback(null, ctx.newUser)
+      ),
+      removeUser: vi.fn((_entity, _options, _userId, callback) =>
+        callback(null)
+      ),
       promises: {
-        getUsers: sinon.stub().resolves(ctx.users),
+        getUsers: vi.fn().mockResolvedValue(ctx.users),
       },
     }
     ctx.SplitTestHandler = {
       promises: {
-        getAssignment: sinon.stub().resolves({ variant: 'default' }),
+        getAssignment: vi.fn().mockResolvedValue({ variant: 'default' }),
       },
-      getAssignment: sinon.stub().yields(null, { variant: 'default' }),
+      getAssignment: vi.fn((_testName, _userId, callback) =>
+        callback(null, { variant: 'default' })
+      ),
     }
     ctx.RecurlyClient = {
       promises: {
-        getSubscription: sinon.stub().resolves({}),
+        getSubscription: vi.fn().mockResolvedValue({}),
       },
     }
 
-    vi.doMock(
-      '../../../../app/src/Features/UserMembership/UserMembershipErrors',
-      () => ({
-        UserIsManagerError,
-        UserNotFoundError,
-        UserAlreadyAddedError,
-      })
-    )
-
     vi.doMock(
       '../../../../app/src/Features/Authentication/SessionManager',
       () => ({
@@ -191,7 +187,7 @@ describe('UserMembershipController', function () {
     ctx.Modules = {
       promises: {
         hooks: {
-          fire: sinon.stub(),
+          fire: vi.fn(),
         },
       },
     }
@@ -202,55 +198,90 @@ describe('UserMembershipController', function () {
     ctx.UserMembershipController = (await import(modulePath)).default
   })
 
-  describe('index', function () {
-    beforeEach(function (ctx) {
+  describe('index', () => {
+    beforeEach(ctx => {
       ctx.req.user = ctx.user
       ctx.req.entity = ctx.subscription
       ctx.req.entityConfig = EntityConfigs.group
-      ctx.Modules.promises.hooks.fire.resolves([])
+      ctx.Modules.promises.hooks.fire.mockResolvedValue([])
     })
 
-    it('get users', async function (ctx) {
-      await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
+    it('get users', async ({
+      UserMembershipController,
+      req,
+      UserMembershipHandler,
+      subscription,
+    }) => {
+      expect.assertions(1)
+      await UserMembershipController.manageGroupMembers(req, {
         render: () => {
-          sinon.assert.calledWithMatch(
-            ctx.UserMembershipHandler.promises.getUsers,
-            ctx.subscription,
-            { modelName: 'Subscription' }
+          expect(UserMembershipHandler.promises.getUsers).toHaveBeenCalledWith(
+            subscription,
+            {
+              modelName: 'Subscription',
+              baseQuery: { groupPlan: true },
+              fields: {
+                access: 'manager_ids',
+                membership: 'member_ids',
+                name: 'teamName',
+                primaryKey: '_id',
+                read: ['invited_emails', 'teamInvites', 'member_ids'],
+                write: null,
+              },
+              hasMembersLimit: true,
+              readOnly: true,
+            }
           )
         },
       })
     })
 
-    it('render group view', async function (ctx) {
-      ctx.subscription.managedUsersEnabled = false
-      await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
+    it('render group view', async ({
+      UserMembershipController,
+      req,
+      subscription,
+      users,
+    }) => {
+      expect.assertions(4)
+      subscription.managedUsersEnabled = false
+      await UserMembershipController.manageGroupMembers(req, {
         render: (viewPath, viewParams) => {
           expect(viewPath).to.equal('user_membership/group-members-react')
-          expect(viewParams.users).to.deep.equal(ctx.users)
-          expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
+          expect(viewParams.users).to.deep.equal(users)
+          expect(viewParams.groupSize).to.equal(subscription.membersLimit)
           expect(viewParams.managedUsersActive).to.equal(false)
         },
       })
     })
 
-    it('render group view with managed users', async function (ctx) {
-      ctx.subscription.managedUsersEnabled = true
-      await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
+    it('render group view with managed users', async ({
+      UserMembershipController,
+      req,
+      subscription,
+      users,
+    }) => {
+      expect.assertions(5)
+      subscription.managedUsersEnabled = true
+      await UserMembershipController.manageGroupMembers(req, {
         render: (viewPath, viewParams) => {
           expect(viewPath).to.equal('user_membership/group-members-react')
-          expect(viewParams.users).to.deep.equal(ctx.users)
-          expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
+          expect(viewParams.users).to.deep.equal(users)
+          expect(viewParams.groupSize).to.equal(subscription.membersLimit)
           expect(viewParams.managedUsersActive).to.equal(true)
           expect(viewParams.isUserGroupManager).to.equal(false)
         },
       })
     })
 
-    it('render group managers view', async function (ctx) {
-      ctx.req.user = ctx.user
-      ctx.req.entityConfig = EntityConfigs.groupManagers
-      await ctx.UserMembershipController.manageGroupManagers(ctx.req, {
+    it('render group managers view', async ({
+      UserMembershipController,
+      req,
+      user,
+    }) => {
+      expect.assertions(2)
+      req.user = user
+      req.entityConfig = EntityConfigs.groupManagers
+      await UserMembershipController.manageGroupManagers(req, {
         render: (viewPath, viewParams) => {
           expect(viewPath).to.equal('user_membership/group-managers-react')
           expect(viewParams.groupSize).to.equal(undefined)
@@ -258,11 +289,17 @@ describe('UserMembershipController', function () {
       })
     })
 
-    it('render institution view', async function (ctx) {
-      ctx.req.user = ctx.user
-      ctx.req.entity = ctx.institution
-      ctx.req.entityConfig = EntityConfigs.institution
-      await ctx.UserMembershipController.manageInstitutionManagers(ctx.req, {
+    it('render institution view', async ({
+      UserMembershipController,
+      req,
+      user,
+      institution,
+    }) => {
+      expect.assertions(3)
+      req.user = user
+      req.entity = institution
+      req.entityConfig = EntityConfigs.institution
+      await UserMembershipController.manageInstitutionManagers(req, {
         render: (viewPath, viewParams) => {
           expect(viewPath).to.equal(
             'user_membership/institution-managers-react'
@@ -274,22 +311,40 @@ describe('UserMembershipController', function () {
     })
   })
 
-  describe('add', function () {
-    beforeEach(function (ctx) {
+  describe('add', () => {
+    beforeEach(ctx => {
       ctx.req.body.email = ctx.newUser.email
       ctx.req.entity = ctx.subscription
       ctx.req.entityConfig = EntityConfigs.groupManagers
     })
 
-    it('add user', async function (ctx) {
+    it('add user', async ({
+      UserMembershipController,
+      req,
+      UserMembershipHandler,
+      subscription,
+      newUser,
+    }) => {
+      expect.assertions(1)
       await new Promise(resolve => {
-        ctx.UserMembershipController.add(ctx.req, {
+        UserMembershipController.add(req, {
           json: () => {
-            sinon.assert.calledWithMatch(
-              ctx.UserMembershipHandler.addUser,
-              ctx.subscription,
-              { modelName: 'Subscription' },
-              ctx.newUser.email
+            expect(UserMembershipHandler.addUser).toHaveBeenCalledWith(
+              subscription,
+              {
+                modelName: 'Subscription',
+                baseQuery: { groupPlan: true },
+                fields: {
+                  access: 'manager_ids',
+                  membership: 'member_ids',
+                  name: 'teamName',
+                  primaryKey: '_id',
+                  read: ['manager_ids'],
+                  write: 'manager_ids',
+                },
+              },
+              newUser.email,
+              expect.any(Function)
             )
             resolve()
           },
@@ -297,21 +352,27 @@ describe('UserMembershipController', function () {
       })
     })
 
-    it('return user object', async function (ctx) {
+    it('return user object', async ({
+      UserMembershipController,
+      req,
+      newUser,
+    }) => {
+      expect.assertions(1)
       await new Promise(resolve => {
-        ctx.UserMembershipController.add(ctx.req, {
+        UserMembershipController.add(req, {
           json: payload => {
-            payload.user.should.equal(ctx.newUser)
+            expect(payload.user).to.equal(newUser)
             resolve()
           },
         })
       })
     })
 
-    it('handle readOnly entity', async function (ctx) {
+    it('handle readOnly entity', async ({ UserMembershipController, req }) => {
+      expect.assertions(2)
+      req.entityConfig = EntityConfigs.group
       await new Promise(resolve => {
-        ctx.req.entityConfig = EntityConfigs.group
-        ctx.UserMembershipController.add(ctx.req, null, error => {
+        UserMembershipController.add(req, null, error => {
           expect(error).to.exist
           expect(error).to.be.an.instanceof(Errors.NotFoundError)
           resolve()
@@ -319,24 +380,47 @@ describe('UserMembershipController', function () {
       })
     })
 
-    it('handle user already added', async function (ctx) {
+    it('handle user already added', async ({
+      UserMembershipController,
+      req,
+      UserMembershipHandler,
+    }) => {
+      expect.assertions(1)
+      UserMembershipHandler.addUser.mockImplementation(
+        (_entity, _options, _email, callback) => {
+          callback(new UserMembershipErrors.UserAlreadyAddedError())
+        }
+      )
       await 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()
-            },
-          }),
-        })
+        UserMembershipController.add(
+          req,
+          {
+            status: () => ({
+              json: payload => {
+                expect(payload.error.code).to.equal('user_already_added')
+                resolve()
+              },
+            }),
+          },
+
+          () => {}
+        )
       })
     })
 
-    it('handle user not found', async function (ctx) {
+    it('handle user not found', async ({
+      UserMembershipController,
+      req,
+      UserMembershipHandler,
+    }) => {
+      expect.assertions(1)
+      UserMembershipHandler.addUser.mockImplementation(
+        (_entity, _options, _email, callback) => {
+          callback(new UserMembershipErrors.UserNotFoundError())
+        }
+      )
       await new Promise(resolve => {
-        ctx.UserMembershipHandler.addUser.yields(new UserNotFoundError())
-        ctx.UserMembershipController.add(ctx.req, {
+        UserMembershipController.add(req, {
           status: () => ({
             json: payload => {
               expect(payload.error.code).to.equal('user_not_found')
@@ -347,10 +431,11 @@ describe('UserMembershipController', function () {
       })
     })
 
-    it('handle invalid email', async function (ctx) {
+    it('handle invalid email', async ({ UserMembershipController, req }) => {
+      expect.assertions(1)
+      req.body.email = 'not_valid_email'
       await new Promise(resolve => {
-        ctx.req.body.email = 'not_valid_email'
-        ctx.UserMembershipController.add(ctx.req, {
+        UserMembershipController.add(req, {
           status: () => ({
             json: payload => {
               expect(payload.error.code).to.equal('invalid_email')
@@ -362,33 +447,52 @@ describe('UserMembershipController', function () {
     })
   })
 
-  describe('remove', function () {
-    beforeEach(function (ctx) {
+  describe('remove', () => {
+    beforeEach(ctx => {
       ctx.req.params.userId = ctx.newUser._id
       ctx.req.entity = ctx.subscription
       ctx.req.entityConfig = EntityConfigs.groupManagers
     })
 
-    it('remove user', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.UserMembershipController.remove(ctx.req, {
-          sendStatus: () => {
-            sinon.assert.calledWithMatch(
-              ctx.UserMembershipHandler.removeUser,
-              ctx.subscription,
-              { modelName: 'Subscription' },
-              ctx.newUser._id
-            )
-            resolve()
-          },
-        })
+    it('remove user', async ({
+      UserMembershipController,
+      req,
+      UserMembershipHandler,
+      subscription,
+      newUser,
+    }) => {
+      expect.assertions(1)
+      await UserMembershipController.remove(req, {
+        sendStatus: () => {
+          expect(UserMembershipHandler.removeUser).toHaveBeenCalledWith(
+            subscription,
+            {
+              modelName: 'Subscription',
+
+              baseQuery: {
+                groupPlan: true,
+              },
+              fields: {
+                access: 'manager_ids',
+                membership: 'member_ids',
+                name: 'teamName',
+                primaryKey: '_id',
+                read: ['manager_ids'],
+                write: 'manager_ids',
+              },
+            },
+            newUser._id,
+            expect.any(Function)
+          )
+        },
       })
     })
 
-    it('handle readOnly entity', async function (ctx) {
+    it('handle readOnly entity', async ({ UserMembershipController, req }) => {
+      expect.assertions(2)
+      req.entityConfig = EntityConfigs.group
       await new Promise(resolve => {
-        ctx.req.entityConfig = EntityConfigs.group
-        ctx.UserMembershipController.remove(ctx.req, null, error => {
+        UserMembershipController.remove(req, null, error => {
           expect(error).to.exist
           expect(error).to.be.an.instanceof(Errors.NotFoundError)
           resolve()
@@ -396,172 +500,194 @@ describe('UserMembershipController', function () {
       })
     })
 
-    it('prevent self removal', async function (ctx) {
-      await 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 self removal', async ({
+      UserMembershipController,
+      req,
+      user,
+    }) => {
+      expect.assertions(1)
+      req.params.userId = user._id
+      await UserMembershipController.remove(req, {
+        status: () => ({
+          json: payload => {
+            expect(payload.error.code).to.equal('managers_cannot_remove_self')
+          },
+        }),
       })
     })
 
-    it('prevent admin removal', async function (ctx) {
-      await 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()
-            },
-          }),
-        })
+    it('prevent admin removal', async ({
+      UserMembershipController,
+      req,
+      UserMembershipHandler,
+    }) => {
+      expect.assertions(1)
+      UserMembershipHandler.removeUser.mockImplementation(
+        (_entity, _options, _userId, callback) => {
+          callback(new UserMembershipErrors.UserIsManagerError())
+        }
+      )
+      await UserMembershipController.remove(req, {
+        status: () => ({
+          json: payload => {
+            expect(payload.error.code).to.equal('managers_cannot_remove_admin')
+          },
+        }),
       })
     })
   })
 
-  describe('exportCsv', function () {
-    beforeEach(function (ctx) {
+  describe('exportCsv', () => {
+    beforeEach(ctx => {
       ctx.req.entity = ctx.subscription
       ctx.req.entityConfig = EntityConfigs.groupManagers
-      ctx.res = new MockResponse()
+      ctx.res = new MockResponse(vi)
       ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
     })
 
-    it('get users', function (ctx) {
-      sinon.assert.calledWithMatch(
-        ctx.UserMembershipHandler.promises.getUsers,
-        ctx.subscription,
-        { modelName: 'Subscription' }
+    it('get users', ({ UserMembershipHandler, subscription }) => {
+      expect(UserMembershipHandler.promises.getUsers).toHaveBeenCalledWith(
+        subscription,
+        {
+          modelName: 'Subscription',
+          baseQuery: { groupPlan: true },
+          fields: {
+            access: 'manager_ids',
+            membership: 'member_ids',
+            name: 'teamName',
+            primaryKey: '_id',
+            read: ['manager_ids'],
+            write: 'manager_ids',
+          },
+        }
       )
     })
 
-    it('should set the correct content type on the request', function (ctx) {
-      assertCalledWith(ctx.res.contentType, 'text/csv; charset=utf-8')
+    it('should set the correct content type on the request', ({ res }) => {
+      expect(res.contentType).toHaveBeenCalledWith('text/csv; charset=utf-8')
     })
 
-    it('should name the exported csv file', function (ctx) {
-      assertCalledWith(
-        ctx.res.header,
+    it('should name the exported csv file', ({ res }) => {
+      expect(res.header).toHaveBeenCalledWith(
         'Content-Disposition',
         'attachment; filename="Group.csv"'
       )
     })
 
-    it('should export the correct csv', function (ctx) {
-      assertCalledWith(
-        ctx.res.send,
+    it('should export the correct csv', ({ res }) => {
+      expect(res.send).toHaveBeenCalledWith(
         '"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"\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z"\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z"\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z"\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z"'
       )
     })
   })
 
-  describe('exportCsv when group is managed', function () {
-    beforeEach(function (ctx) {
+  describe('exportCsv when group is managed', () => {
+    beforeEach(ctx => {
       ctx.req.entity = Object.assign(
         { managedUsersEnabled: true },
         ctx.subscription
       )
       ctx.req.entityConfig = EntityConfigs.groupManagers
-      ctx.res = new MockResponse()
+      ctx.res = new MockResponse(vi)
       ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
     })
 
-    it('should export the correct csv', function (ctx) {
-      assertCalledWith(
-        ctx.res.send,
+    it('should export the correct csv', ({ res }) => {
+      expect(res.send).toHaveBeenCalledWith(
         '"email","last_logged_in_at","last_active_at","managed"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true'
       )
     })
   })
 
-  describe('exportCsv when group has SSO', function () {
-    beforeEach(function (ctx) {
+  describe('exportCsv when group has SSO', () => {
+    beforeEach(ctx => {
       ctx.req.entity = Object.assign(
         { ssoConfig: 'sso-config-id' },
         ctx.subscription
       )
       ctx.req.entityConfig = EntityConfigs.groupManagers
-      ctx.Modules.promises.hooks.fire.resolves([true])
-      ctx.res = new MockResponse()
+      ctx.Modules.promises.hooks.fire.mockResolvedValue([true])
+      ctx.res = new MockResponse(vi)
       ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
     })
 
-    it('should export the correct csv', function (ctx) {
-      assertCalledWith(
-        ctx.res.send,
+    it('should export the correct csv', ({ res }) => {
+      expect(res.send).toHaveBeenCalledWith(
         '"email","last_logged_in_at","last_active_at","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true'
       )
     })
   })
 
-  describe('exportCsv when group has SSO and managed users enabled', function () {
-    beforeEach(function (ctx) {
+  describe('exportCsv when group has SSO and managed users enabled', () => {
+    beforeEach(ctx => {
       ctx.req.entity = Object.assign(
         { managedUsersEnabled: true },
         { ssoConfig: 'sso-config-id' },
         ctx.subscription
       )
       ctx.req.entityConfig = EntityConfigs.groupManagers
-      ctx.Modules.promises.hooks.fire.resolves([true])
-      ctx.res = new MockResponse()
+      ctx.Modules.promises.hooks.fire.mockResolvedValue([true])
+      ctx.res = new MockResponse(vi)
       ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
     })
 
-    it('should export the correct csv', function (ctx) {
-      assertCalledWith(
-        ctx.res.send,
+    it('should export the correct csv', ({ res }) => {
+      expect(res.send).toHaveBeenCalledWith(
         '"email","last_logged_in_at","last_active_at","managed","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false,false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false,false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false,false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true,false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false,true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true,true'
       )
     })
   })
 
-  describe('new', function () {
-    beforeEach(function (ctx) {
+  describe('new', () => {
+    beforeEach(ctx => {
       ctx.req.params.name = 'publisher'
       ctx.req.params.id = 'abc'
     })
 
-    it('renders view', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.UserMembershipController.new(ctx.req, {
-          render: (viewPath, data) => {
-            expect(data.entityName).to.eq('publisher')
-            expect(data.entityId).to.eq('abc')
-            resolve()
-          },
-        })
+    it('renders view', async ({ UserMembershipController, req }) => {
+      expect.assertions(2)
+      await UserMembershipController.new(req, {
+        render: (viewPath, data) => {
+          expect(data.entityName).to.eq('publisher')
+          expect(data.entityId).to.eq('abc')
+        },
       })
     })
   })
 
-  describe('create', function () {
-    beforeEach(function (ctx) {
+  describe('create', () => {
+    beforeEach(ctx => {
       ctx.req.params.name = 'institution'
       ctx.req.entityConfig = EntityConfigs.institution
       ctx.req.params.id = 123
     })
 
-    it('creates institution', async function (ctx) {
-      await 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()
-          },
-        })
+    it('creates institution', async ({
+      UserMembershipController,
+      req,
+      UserMembershipHandler,
+    }) => {
+      expect.assertions(2)
+      await UserMembershipController.create(req, {
+        redirect: path => {
+          expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index)
+          expect(UserMembershipHandler.createEntity).toHaveBeenCalledWith(
+            123,
+            {
+              fields: {
+                access: 'managerIds',
+                membership: 'member_ids',
+                name: 'name',
+                primaryKey: 'v1Id',
+                read: ['managerIds'],
+                write: 'managerIds',
+              },
+              modelName: 'Institution',
+              pathsFor: EntityConfigs.institution.pathsFor,
+            },
+            expect.any(Function)
+          )
+        },
       })
     })
   })

+ 287 - 0
services/web/test/unit/src/UserMembership/UserMembershipHandler.test.mjs

@@ -0,0 +1,287 @@
+import { vi, expect } from 'vitest'
+import mongodb from 'mongodb-legacy'
+import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js'
+import UserMembershipErrors from '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
+
+const { ObjectId } = mongodb
+
+const modulePath =
+  '../../../../app/src/Features/UserMembership/UserMembershipHandler'
+
+const serializeIds = ids =>
+  ids.map(id => (id instanceof ObjectId ? `objectId-${id.toString()}` : id))
+
+vi.mock(
+  '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs',
+  () =>
+    vi.importActual(
+      '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
+    )
+)
+
+describe('UserMembershipHandler', function () {
+  beforeEach(async function (ctx) {
+    ctx.user = { _id: new ObjectId() }
+    ctx.newUser = { _id: new ObjectId(), email: 'new-user-email@foo.bar' }
+    ctx.fakeEntityId = new ObjectId()
+    ctx.subscription = {
+      _id: 'mock-subscription-id',
+      groupPlan: true,
+      membersLimit: 10,
+      member_ids: [new ObjectId(), new ObjectId()],
+      manager_ids: [new ObjectId()],
+      invited_emails: ['mock-email-1@foo.com'],
+      teamInvites: [{ email: 'mock-email-1@bar.com' }],
+      update: vi.fn().mockReturnValue({
+        exec: vi.fn().mockResolvedValue(),
+      }),
+    }
+    ctx.institution = {
+      _id: 'mock-institution-id',
+      v1Id: 123,
+      managerIds: [new ObjectId(), new ObjectId(), new ObjectId()],
+      updateOne: vi.fn().mockReturnValue({
+        exec: vi.fn().mockResolvedValue(),
+      }),
+    }
+    ctx.publisher = {
+      _id: 'mock-publisher-id',
+      slug: 'slug',
+      managerIds: [new ObjectId(), new ObjectId()],
+      updateOne: vi.fn().mockReturnValue({
+        exec: vi.fn().mockResolvedValue(),
+      }),
+    }
+
+    ctx.UserMembershipViewModel = {
+      promises: {
+        buildAsync: vi.fn().mockResolvedValue([{ _id: 'mock-member-id' }]),
+      },
+      build: vi.fn().mockReturnValue(ctx.newUser),
+    }
+    ctx.UserGetter = {
+      promises: {
+        getUserByAnyEmail: vi.fn().mockResolvedValue(ctx.newUser),
+      },
+    }
+    ctx.Institution = {
+      findOne: vi.fn().mockReturnValue({
+        exec: vi.fn().mockResolvedValue(ctx.institution),
+      }),
+    }
+    ctx.Subscription = {
+      findOne: vi.fn().mockReturnValue({
+        exec: vi.fn().mockResolvedValue(ctx.subscription),
+      }),
+    }
+    ctx.Publisher = {
+      findOne: vi.fn().mockReturnValue({
+        exec: vi.fn().mockResolvedValue(ctx.publisher),
+      }),
+      create: vi.fn().mockReturnValue({
+        exec: vi.fn().mockResolvedValue(ctx.publisher),
+      }),
+    }
+
+    vi.doMock('mongodb-legacy', () => ({
+      default: { ObjectId },
+    }))
+
+    vi.doMock(
+      '../../../../app/src/Features/UserMembership/UserMembershipViewModel',
+      () => ({
+        default: ctx.UserMembershipViewModel,
+      })
+    )
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    vi.doMock('../../../../app/src/models/Institution', () => ({
+      Institution: ctx.Institution,
+    }))
+
+    vi.doMock('../../../../app/src/models/Subscription', () => ({
+      Subscription: ctx.Subscription,
+    }))
+
+    vi.doMock('../../../../app/src/models/Publisher', () => ({
+      Publisher: ctx.Publisher,
+    }))
+
+    ctx.UserMembershipHandler = (await import(modulePath)).default
+  })
+
+  describe('getEntityWithoutAuthorizationCheck', function () {
+    it('get publisher', async function (ctx) {
+      const subscription =
+        await ctx.UserMembershipHandler.promises.getEntityWithoutAuthorizationCheck(
+          ctx.fakeEntityId,
+          EntityConfigs.publisher
+        )
+      const expectedQuery = { slug: ctx.fakeEntityId }
+      expect(ctx.Publisher.findOne).toHaveBeenCalledWith(expectedQuery)
+      expect(subscription).to.equal(ctx.publisher)
+    })
+  })
+
+  describe('getUsers', function () {
+    describe('group', function () {
+      it('build view model for all users', async function (ctx) {
+        await ctx.UserMembershipHandler.promises.getUsers(
+          ctx.subscription,
+          EntityConfigs.group
+        )
+        expect(
+          serializeIds(
+            ctx.UserMembershipViewModel.promises.buildAsync.mock.calls[0][0]
+          )
+        ).toEqual(
+          serializeIds(
+            ctx.subscription.invited_emails.concat(
+              ctx.subscription.teamInvites[0].email,
+              ctx.subscription.member_ids
+            )
+          )
+        )
+      })
+    })
+
+    describe('group managers', function () {
+      it('build view model for all managers', async function (ctx) {
+        await ctx.UserMembershipHandler.promises.getUsers(
+          ctx.subscription,
+          EntityConfigs.groupManagers
+        )
+        expect(
+          serializeIds(
+            ctx.UserMembershipViewModel.promises.buildAsync.mock.calls[0][0]
+          )
+        ).toEqual(serializeIds(ctx.subscription.manager_ids))
+      })
+    })
+
+    describe('institution', function () {
+      it('build view model for all managers', async function (ctx) {
+        await ctx.UserMembershipHandler.promises.getUsers(
+          ctx.institution,
+          EntityConfigs.institution
+        )
+        expect(
+          serializeIds(
+            ctx.UserMembershipViewModel.promises.buildAsync.mock.calls[0][0]
+          )
+        ).toEqual(serializeIds(ctx.institution.managerIds))
+      })
+    })
+  })
+
+  describe('createEntity', function () {
+    it('creates publisher', async function (ctx) {
+      await ctx.UserMembershipHandler.promises.createEntity(
+        ctx.fakeEntityId,
+        EntityConfigs.publisher
+      )
+      expect(ctx.Publisher.create).toHaveBeenCalledWith({
+        slug: ctx.fakeEntityId,
+      })
+    })
+  })
+
+  describe('addUser', function () {
+    beforeEach(function (ctx) {
+      ctx.email = ctx.newUser.email
+    })
+
+    describe('institution', function () {
+      it('get user', async function (ctx) {
+        await ctx.UserMembershipHandler.promises.addUser(
+          ctx.institution,
+          EntityConfigs.institution,
+          ctx.email
+        )
+        expect(ctx.UserGetter.promises.getUserByAnyEmail).toHaveBeenCalledWith(
+          ctx.email
+        )
+      })
+
+      it('handle user not found', async function (ctx) {
+        ctx.UserGetter.promises.getUserByAnyEmail.mockResolvedValue(null)
+        try {
+          await ctx.UserMembershipHandler.promises.addUser(
+            ctx.institution,
+            EntityConfigs.institution,
+            ctx.email
+          )
+          expect.fail('Expected addUser to throw')
+        } catch (err) {
+          expect(err).toBeInstanceOf(UserMembershipErrors.UserNotFoundError)
+        }
+      })
+
+      it('handle user already added', async function (ctx) {
+        ctx.institution.managerIds.push(ctx.newUser._id)
+        try {
+          await ctx.UserMembershipHandler.promises.addUser(
+            ctx.institution,
+            EntityConfigs.institution,
+            ctx.email
+          )
+          expect.fail('Expected addUser to throw')
+        } catch (err) {
+          expect(err).toBeInstanceOf(UserMembershipErrors.UserAlreadyAddedError)
+        }
+      })
+
+      it('add user to institution', async function (ctx) {
+        await ctx.UserMembershipHandler.promises.addUser(
+          ctx.institution,
+          EntityConfigs.institution,
+          ctx.email
+        )
+        expect(ctx.institution.updateOne).toHaveBeenCalledWith({
+          $addToSet: { managerIds: ctx.newUser._id },
+        })
+      })
+
+      it('return user view', async function (ctx) {
+        const user = await ctx.UserMembershipHandler.promises.addUser(
+          ctx.institution,
+          EntityConfigs.institution,
+          ctx.email
+        )
+        expect(user).to.equal(ctx.newUser)
+      })
+    })
+  })
+
+  describe('removeUser', function () {
+    describe('institution', function () {
+      it('remove user from institution', async function (ctx) {
+        await ctx.UserMembershipHandler.promises.removeUser(
+          ctx.institution,
+          EntityConfigs.institution,
+          ctx.newUser._id
+        )
+        expect(ctx.institution.updateOne).toHaveBeenCalledWith({
+          $pull: { managerIds: ctx.newUser._id },
+        })
+      })
+
+      it('handle admin', async function (ctx) {
+        ctx.subscription.admin_id = ctx.newUser._id
+        try {
+          await ctx.UserMembershipHandler.promises.removeUser(
+            ctx.subscription,
+            EntityConfigs.groupManagers,
+            ctx.newUser._id
+          )
+          expect.fail('Expected removeUser to throw')
+        } catch (err) {
+          expect(err).toBeInstanceOf(UserMembershipErrors.UserIsManagerError)
+        }
+      })
+    })
+  })
+})

+ 0 - 251
services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js

@@ -1,251 +0,0 @@
-const { expect } = require('chai')
-const sinon = require('sinon')
-const assertCalledWith = sinon.assert.calledWith
-const { ObjectId } = require('mongodb-legacy')
-const modulePath =
-  '../../../../app/src/Features/UserMembership/UserMembershipHandler'
-const SandboxedModule = require('sandboxed-module')
-const EntityConfigs = require('../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs')
-const {
-  UserIsManagerError,
-  UserNotFoundError,
-  UserAlreadyAddedError,
-} = require('../../../../app/src/Features/UserMembership/UserMembershipErrors')
-
-describe('UserMembershipHandler', function () {
-  beforeEach(function () {
-    this.user = { _id: new ObjectId() }
-    this.newUser = { _id: new ObjectId(), email: 'new-user-email@foo.bar' }
-    this.fakeEntityId = new ObjectId()
-    this.subscription = {
-      _id: 'mock-subscription-id',
-      groupPlan: true,
-      membersLimit: 10,
-      member_ids: [new ObjectId(), new ObjectId()],
-      manager_ids: [new ObjectId()],
-      invited_emails: ['mock-email-1@foo.com'],
-      teamInvites: [{ email: 'mock-email-1@bar.com' }],
-      update: sinon.stub().returns({
-        exec: sinon.stub().resolves(),
-      }),
-    }
-    this.institution = {
-      _id: 'mock-institution-id',
-      v1Id: 123,
-      managerIds: [new ObjectId(), new ObjectId(), new ObjectId()],
-      updateOne: sinon.stub().returns({
-        exec: sinon.stub().resolves(),
-      }),
-    }
-    this.publisher = {
-      _id: 'mock-publisher-id',
-      slug: 'slug',
-      managerIds: [new ObjectId(), new ObjectId()],
-      updateOne: sinon.stub().returns({
-        exec: sinon.stub().resolves(),
-      }),
-    }
-
-    this.UserMembershipViewModel = {
-      promises: {
-        buildAsync: sinon.stub().resolves([{ _id: 'mock-member-id' }]),
-      },
-      build: sinon.stub().returns(this.newUser),
-    }
-    this.UserGetter = {
-      promises: {
-        getUserByAnyEmail: sinon.stub().resolves(this.newUser),
-      },
-    }
-    this.Institution = {
-      findOne: sinon.stub().returns({
-        exec: sinon.stub().resolves(this.institution),
-      }),
-    }
-    this.Subscription = {
-      findOne: sinon.stub().returns({
-        exec: sinon.stub().resolves(this.subscription),
-      }),
-    }
-    this.Publisher = {
-      findOne: sinon.stub().returns({
-        exec: sinon.stub().resolves(this.publisher),
-      }),
-      create: sinon.stub().returns({
-        exec: sinon.stub().resolves(this.publisher),
-      }),
-    }
-    this.UserMembershipHandler = SandboxedModule.require(modulePath, {
-      requires: {
-        'mongodb-legacy': { ObjectId },
-        './UserMembershipErrors': {
-          UserIsManagerError,
-          UserNotFoundError,
-          UserAlreadyAddedError,
-        },
-        './UserMembershipViewModel': this.UserMembershipViewModel,
-        '../User/UserGetter': this.UserGetter,
-        '../../models/Institution': {
-          Institution: this.Institution,
-        },
-        '../../models/Subscription': {
-          Subscription: this.Subscription,
-        },
-        '../../models/Publisher': {
-          Publisher: this.Publisher,
-        },
-      },
-    })
-  })
-
-  describe('getEntityWithoutAuthorizationCheck', function () {
-    it('get publisher', async function () {
-      const subscription =
-        await this.UserMembershipHandler.promises.getEntityWithoutAuthorizationCheck(
-          this.fakeEntityId,
-          EntityConfigs.publisher
-        )
-      const expectedQuery = { slug: this.fakeEntityId }
-      assertCalledWith(this.Publisher.findOne, expectedQuery)
-      expect(subscription).to.equal(this.publisher)
-    })
-  })
-
-  describe('getUsers', function () {
-    describe('group', function () {
-      it('build view model for all users', async function () {
-        await this.UserMembershipHandler.promises.getUsers(
-          this.subscription,
-          EntityConfigs.group
-        )
-        expect(
-          this.UserMembershipViewModel.promises.buildAsync
-        ).to.be.calledOnceWith(
-          this.subscription.invited_emails.concat(
-            this.subscription.teamInvites[0].email,
-            this.subscription.member_ids
-          )
-        )
-      })
-    })
-
-    describe('group managers', function () {
-      it('build view model for all managers', async function () {
-        await this.UserMembershipHandler.promises.getUsers(
-          this.subscription,
-          EntityConfigs.groupManagers
-        )
-        expect(
-          this.UserMembershipViewModel.promises.buildAsync
-        ).to.be.calledOnceWith(this.subscription.manager_ids)
-      })
-    })
-
-    describe('institution', function () {
-      it('build view model for all managers', async function () {
-        await this.UserMembershipHandler.promises.getUsers(
-          this.institution,
-          EntityConfigs.institution
-        )
-        expect(
-          this.UserMembershipViewModel.promises.buildAsync
-        ).to.be.calledOnceWith(this.institution.managerIds)
-      })
-    })
-  })
-
-  describe('createEntity', function () {
-    it('creates publisher', async function () {
-      await this.UserMembershipHandler.promises.createEntity(
-        this.fakeEntityId,
-        EntityConfigs.publisher
-      )
-      assertCalledWith(this.Publisher.create, { slug: this.fakeEntityId })
-    })
-  })
-
-  describe('addUser', function () {
-    beforeEach(function () {
-      this.email = this.newUser.email
-    })
-
-    describe('institution', function () {
-      it('get user', async function () {
-        await this.UserMembershipHandler.promises.addUser(
-          this.institution,
-          EntityConfigs.institution,
-          this.email
-        )
-        assertCalledWith(this.UserGetter.promises.getUserByAnyEmail, this.email)
-      })
-
-      it('handle user not found', async function () {
-        this.UserGetter.promises.getUserByAnyEmail.resolves(null)
-        expect(
-          this.UserMembershipHandler.promises.addUser(
-            this.institution,
-            EntityConfigs.institution,
-            this.email
-          )
-        ).to.be.rejectedWith(UserNotFoundError)
-      })
-
-      it('handle user already added', async function () {
-        this.institution.managerIds.push(this.newUser._id)
-        expect(
-          this.UserMembershipHandler.promises.addUser(
-            this.institution,
-            EntityConfigs.institution,
-            this.email
-          )
-        ).to.be.rejectedWith(UserAlreadyAddedError)
-      })
-
-      it('add user to institution', async function () {
-        await this.UserMembershipHandler.promises.addUser(
-          this.institution,
-          EntityConfigs.institution,
-          this.email
-        )
-        assertCalledWith(this.institution.updateOne, {
-          $addToSet: { managerIds: this.newUser._id },
-        })
-      })
-
-      it('return user view', async function () {
-        const user = await this.UserMembershipHandler.promises.addUser(
-          this.institution,
-          EntityConfigs.institution,
-          this.email
-        )
-        user.should.equal(this.newUser)
-      })
-    })
-  })
-
-  describe('removeUser', function () {
-    describe('institution', function () {
-      it('remove user from institution', async function () {
-        await this.UserMembershipHandler.promises.removeUser(
-          this.institution,
-          EntityConfigs.institution,
-          this.newUser._id
-        )
-        assertCalledWith(this.institution.updateOne, {
-          $pull: { managerIds: this.newUser._id },
-        })
-      })
-
-      it('handle admin', async function () {
-        this.subscription.admin_id = this.newUser._id
-        expect(
-          this.UserMembershipHandler.promises.removeUser(
-            this.subscription,
-            EntityConfigs.groupManagers,
-            this.newUser._id
-          )
-        ).to.be.rejectedWith(UserIsManagerError)
-      })
-    })
-  })
-})

+ 128 - 0
services/web/test/unit/src/UserMembership/UserMembershipViewModel.test.mjs

@@ -0,0 +1,128 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+import mongodb from 'mongodb-legacy'
+import {
+  isObjectIdInstance,
+  normalizeQuery,
+} from '../../../../app/src/Features/Helpers/Mongo.js'
+
+const assertCalledWith = sinon.assert.calledWith
+const assertNotCalled = sinon.assert.notCalled
+
+const { ObjectId } = mongodb
+
+const modulePath =
+  '../../../../app/src/Features/UserMembership/UserMembershipViewModel'
+
+describe('UserMembershipViewModel', function () {
+  beforeEach(async function (ctx) {
+    ctx.UserGetter = { promises: { getUsers: sinon.stub() } }
+
+    vi.doMock('mongodb-legacy', () => ({
+      default: { ObjectId },
+    }))
+
+    vi.doMock('../../../../app/src/Features/Helpers/Mongo', () => ({
+      isObjectIdInstance,
+      normalizeQuery,
+    }))
+
+    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
+      default: ctx.UserGetter,
+    }))
+
+    ctx.UserMembershipViewModel = (await import(modulePath)).default
+    ctx.email = 'mock-email@bar.com'
+    ctx.user = {
+      _id: 'mock-user-id',
+      email: 'mock-email@baz.com',
+      first_name: 'Name',
+      lastLoggedIn: '2020-05-20T10:41:11.407Z',
+      enrollment: {
+        managedBy: 'mock-group-id',
+        enrolledAt: new Date(),
+        sso: {
+          groupId: 'abc123abc123',
+          linkedAt: new Date(),
+          primary: true,
+        },
+      },
+    }
+  })
+
+  describe('build', function () {
+    it('build email', function (ctx) {
+      const viewModel = ctx.UserMembershipViewModel.build(ctx.email)
+      expect(viewModel).to.deep.equal({
+        email: ctx.email,
+        invite: true,
+        last_active_at: null,
+        last_logged_in_at: null,
+        first_name: null,
+        last_name: null,
+        _id: null,
+        enrollment: undefined,
+      })
+    })
+
+    it('build user', function (ctx) {
+      const viewModel = ctx.UserMembershipViewModel.build(ctx.user)
+      expect(viewModel).to.deep.equal({
+        email: ctx.user.email,
+        invite: false,
+        last_active_at: ctx.user.lastLoggedIn,
+        last_logged_in_at: ctx.user.lastLoggedIn,
+        first_name: ctx.user.first_name,
+        last_name: null,
+        _id: ctx.user._id,
+        enrollment: ctx.user.enrollment,
+      })
+    })
+  })
+
+  describe('build async', function () {
+    beforeEach(function (ctx) {
+      ctx.UserMembershipViewModel.build = sinon.stub()
+    })
+
+    it('build email', async function (ctx) {
+      ctx.UserGetter.promises.getUsers.resolves([])
+      await ctx.UserMembershipViewModel.buildAsync([ctx.email])
+      assertCalledWith(ctx.UserMembershipViewModel.build, ctx.email)
+    })
+
+    it('build user', async function (ctx) {
+      ctx.UserGetter.promises.getUsers.resolves([])
+      await ctx.UserMembershipViewModel.buildAsync([ctx.user])
+      assertCalledWith(ctx.UserMembershipViewModel.build, ctx.user)
+    })
+
+    it('build user id', async function (ctx) {
+      const user = {
+        ...ctx.user,
+        _id: new ObjectId(),
+      }
+      ctx.UserGetter.promises.getUsers.resolves([user])
+      const [viewModel] = await ctx.UserMembershipViewModel.buildAsync([
+        user._id,
+      ])
+      assertNotCalled(ctx.UserMembershipViewModel.build)
+      expect(viewModel._id.toString()).to.equal(user._id.toString())
+      expect(viewModel.email).to.equal(user.email)
+      expect(viewModel.first_name).to.equal(user.first_name)
+      expect(viewModel.invite).to.equal(false)
+      expect(viewModel.email).to.exist
+      expect(viewModel.enrollment).to.exist
+      expect(viewModel.enrollment).to.deep.equal(user.enrollment)
+    })
+
+    it('build user id with error', async function (ctx) {
+      ctx.UserGetter.promises.getUsers.rejects(new Error('nope'))
+      const userId = new ObjectId()
+      const [viewModel] = await ctx.UserMembershipViewModel.buildAsync([userId])
+      assertNotCalled(ctx.UserMembershipViewModel.build)
+      expect(viewModel._id).to.equal(userId.toString())
+      expect(viewModel.email).not.to.exist
+    })
+  })
+})

+ 0 - 119
services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js

@@ -1,119 +0,0 @@
-const { expect } = require('chai')
-const sinon = require('sinon')
-const assertCalledWith = sinon.assert.calledWith
-const assertNotCalled = sinon.assert.notCalled
-const { ObjectId } = require('mongodb-legacy')
-const modulePath =
-  '../../../../app/src/Features/UserMembership/UserMembershipViewModel'
-const SandboxedModule = require('sandboxed-module')
-const {
-  isObjectIdInstance,
-  normalizeQuery,
-} = require('../../../../app/src/Features/Helpers/Mongo')
-
-describe('UserMembershipViewModel', function () {
-  beforeEach(function () {
-    this.UserGetter = { promises: { getUsers: sinon.stub() } }
-    this.UserMembershipViewModel = SandboxedModule.require(modulePath, {
-      requires: {
-        'mongodb-legacy': { ObjectId },
-        '../Helpers/Mongo': { isObjectIdInstance, normalizeQuery },
-        '../User/UserGetter': this.UserGetter,
-      },
-    })
-    this.email = 'mock-email@bar.com'
-    this.user = {
-      _id: 'mock-user-id',
-      email: 'mock-email@baz.com',
-      first_name: 'Name',
-      lastLoggedIn: '2020-05-20T10:41:11.407Z',
-      enrollment: {
-        managedBy: 'mock-group-id',
-        enrolledAt: new Date(),
-        sso: {
-          groupId: 'abc123abc123',
-          linkedAt: new Date(),
-          primary: true,
-        },
-      },
-    }
-  })
-
-  describe('build', function () {
-    it('build email', function () {
-      const viewModel = this.UserMembershipViewModel.build(this.email)
-      expect(viewModel).to.deep.equal({
-        email: this.email,
-        invite: true,
-        last_active_at: null,
-        last_logged_in_at: null,
-        first_name: null,
-        last_name: null,
-        _id: null,
-        enrollment: undefined,
-      })
-    })
-
-    it('build user', function () {
-      const viewModel = this.UserMembershipViewModel.build(this.user)
-      expect(viewModel).to.deep.equal({
-        email: this.user.email,
-        invite: false,
-        last_active_at: this.user.lastLoggedIn,
-        last_logged_in_at: this.user.lastLoggedIn,
-        first_name: this.user.first_name,
-        last_name: null,
-        _id: this.user._id,
-        enrollment: this.user.enrollment,
-      })
-    })
-  })
-
-  describe('build async', function () {
-    beforeEach(function () {
-      this.UserMembershipViewModel.build = sinon.stub()
-    })
-
-    it('build email', async function () {
-      this.UserGetter.promises.getUsers.resolves([])
-      await this.UserMembershipViewModel.buildAsync([this.email])
-      assertCalledWith(this.UserMembershipViewModel.build, this.email)
-    })
-
-    it('build user', async function () {
-      this.UserGetter.promises.getUsers.resolves([])
-      await this.UserMembershipViewModel.buildAsync([this.user])
-      assertCalledWith(this.UserMembershipViewModel.build, this.user)
-    })
-
-    it('build user id', async function () {
-      const user = {
-        ...this.user,
-        _id: new ObjectId(),
-      }
-      this.UserGetter.promises.getUsers.resolves([user])
-      const [viewModel] = await this.UserMembershipViewModel.buildAsync([
-        user._id,
-      ])
-      assertNotCalled(this.UserMembershipViewModel.build)
-      expect(viewModel._id.toString()).to.equal(user._id.toString())
-      expect(viewModel.email).to.equal(user.email)
-      expect(viewModel.first_name).to.equal(user.first_name)
-      expect(viewModel.invite).to.equal(false)
-      expect(viewModel.email).to.exist
-      expect(viewModel.enrollment).to.exist
-      expect(viewModel.enrollment).to.deep.equal(user.enrollment)
-    })
-
-    it('build user id with error', async function () {
-      this.UserGetter.promises.getUsers.rejects(new Error('nope'))
-      const userId = new ObjectId()
-      const [viewModel] = await this.UserMembershipViewModel.buildAsync([
-        userId,
-      ])
-      assertNotCalled(this.UserMembershipViewModel.build)
-      expect(viewModel._id).to.equal(userId.toString())
-      expect(viewModel.email).not.to.exist
-    })
-  })
-})