From 3a1f3af6a474143f0bf0858ec91990906ae2360f Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Mon, 20 Oct 2025 16:14:31 +0200 Subject: [PATCH] [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 --- .../web/app/src/Features/User/UserCreator.mjs | 4 +- ...ager.js => UserOnboardingEmailManager.mjs} | 12 +- ... UserPostRegistrationAnalyticsManager.mjs} | 14 +- ...ion.js => UserMembershipAuthorization.mjs} | 10 +- .../UserMembershipController.mjs | 19 +- ...shipErrors.js => UserMembershipErrors.mjs} | 6 +- ...ipHandler.js => UserMembershipHandler.mjs} | 35 +- ...leware.js => UserMembershipMiddleware.mjs} | 25 +- .../UserMembership/UserMembershipRouter.mjs | 2 +- ...ewModel.js => UserMembershipViewModel.mjs} | 6 +- .../app/src/infrastructure/QueueWorkers.mjs | 4 +- .../User/UserOnboardingEmailManager.test.mjs | 114 ++++ .../User/UserOnboardingEmailManagerTests.js | 112 ---- ...rPostRegistrationAnalyticsManager.test.mjs | 125 ++++ ...erPostRegistrationAnalyticsManagerTests.js | 114 ---- .../UserMembershipController.test.mjs | 540 +++++++++++------- .../UserMembershipHandler.test.mjs | 287 ++++++++++ .../UserMembershipHandlerTests.js | 251 -------- .../UserMembershipViewModel.test.mjs | 128 +++++ .../UserMembershipViewModelTests.js | 119 ---- 20 files changed, 1055 insertions(+), 872 deletions(-) rename services/web/app/src/Features/User/{UserOnboardingEmailManager.js => UserOnboardingEmailManager.mjs} (65%) rename services/web/app/src/Features/User/{UserPostRegistrationAnalyticsManager.js => UserPostRegistrationAnalyticsManager.mjs} (72%) rename services/web/app/src/Features/UserMembership/{UserMembershipAuthorization.js => UserMembershipAuthorization.mjs} (84%) rename services/web/app/src/Features/UserMembership/{UserMembershipErrors.js => UserMembershipErrors.mjs} (65%) rename services/web/app/src/Features/UserMembership/{UserMembershipHandler.js => UserMembershipHandler.mjs} (80%) rename services/web/app/src/Features/UserMembership/{UserMembershipMiddleware.js => UserMembershipMiddleware.mjs} (93%) rename services/web/app/src/Features/UserMembership/{UserMembershipViewModel.js => UserMembershipViewModel.mjs} (94%) create mode 100644 services/web/test/unit/src/User/UserOnboardingEmailManager.test.mjs delete mode 100644 services/web/test/unit/src/User/UserOnboardingEmailManagerTests.js create mode 100644 services/web/test/unit/src/User/UserPostRegistrationAnalyticsManager.test.mjs delete mode 100644 services/web/test/unit/src/User/UserPostRegistrationAnalyticsManagerTests.js create mode 100644 services/web/test/unit/src/UserMembership/UserMembershipHandler.test.mjs delete mode 100644 services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js create mode 100644 services/web/test/unit/src/UserMembership/UserMembershipViewModel.test.mjs delete mode 100644 services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js diff --git a/services/web/app/src/Features/User/UserCreator.mjs b/services/web/app/src/Features/User/UserCreator.mjs index 558d8db56f..bcb0885d9e 100644 --- a/services/web/app/src/Features/User/UserCreator.mjs +++ b/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) { diff --git a/services/web/app/src/Features/User/UserOnboardingEmailManager.js b/services/web/app/src/Features/User/UserOnboardingEmailManager.mjs similarity index 65% rename from services/web/app/src/Features/User/UserOnboardingEmailManager.js rename to services/web/app/src/Features/User/UserOnboardingEmailManager.mjs index dd984c4bd2..3b260f4994 100644 --- a/services/web/app/src/Features/User/UserOnboardingEmailManager.js +++ b/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 } diff --git a/services/web/app/src/Features/User/UserPostRegistrationAnalyticsManager.js b/services/web/app/src/Features/User/UserPostRegistrationAnalyticsManager.mjs similarity index 72% rename from services/web/app/src/Features/User/UserPostRegistrationAnalyticsManager.js rename to services/web/app/src/Features/User/UserPostRegistrationAnalyticsManager.mjs index 26dbf86e26..67c08e33e3 100644 --- a/services/web/app/src/Features/User/UserPostRegistrationAnalyticsManager.js +++ b/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, } diff --git a/services/web/app/src/Features/UserMembership/UserMembershipAuthorization.js b/services/web/app/src/Features/UserMembership/UserMembershipAuthorization.mjs similarity index 84% rename from services/web/app/src/Features/UserMembership/UserMembershipAuthorization.js rename to services/web/app/src/Features/UserMembership/UserMembershipAuthorization.mjs index 2cda54a2e7..abb37e37da 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipAuthorization.js +++ b/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 diff --git a/services/web/app/src/Features/UserMembership/UserMembershipController.mjs b/services/web/app/src/Features/UserMembership/UserMembershipController.mjs index b4d457ca7e..31a85e0c55 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipController.mjs +++ b/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', diff --git a/services/web/app/src/Features/UserMembership/UserMembershipErrors.js b/services/web/app/src/Features/UserMembership/UserMembershipErrors.mjs similarity index 65% rename from services/web/app/src/Features/UserMembership/UserMembershipErrors.js rename to services/web/app/src/Features/UserMembership/UserMembershipErrors.mjs index 6667ced79f..48e6bf101b 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipErrors.js +++ b/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 diff --git a/services/web/app/src/Features/UserMembership/UserMembershipHandler.js b/services/web/app/src/Features/UserMembership/UserMembershipHandler.mjs similarity index 80% rename from services/web/app/src/Features/UserMembership/UserMembershipHandler.js rename to services/web/app/src/Features/UserMembership/UserMembershipHandler.mjs index 56d92e9c96..7cce97ad0a 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipHandler.js +++ b/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 ), diff --git a/services/web/app/src/Features/UserMembership/UserMembershipMiddleware.js b/services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs similarity index 93% rename from services/web/app/src/Features/UserMembership/UserMembershipMiddleware.js rename to services/web/app/src/Features/UserMembership/UserMembershipMiddleware.mjs index 55f68a8118..785695accc 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipMiddleware.js +++ b/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) { diff --git a/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs b/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs index e70bc391c5..ecdd7571e5 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipRouter.mjs +++ b/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' diff --git a/services/web/app/src/Features/UserMembership/UserMembershipViewModel.js b/services/web/app/src/Features/UserMembership/UserMembershipViewModel.mjs similarity index 94% rename from services/web/app/src/Features/UserMembership/UserMembershipViewModel.js rename to services/web/app/src/Features/UserMembership/UserMembershipViewModel.mjs index 0b8bf511ea..e14d6ff705 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipViewModel.js +++ b/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 diff --git a/services/web/app/src/infrastructure/QueueWorkers.mjs b/services/web/app/src/infrastructure/QueueWorkers.mjs index ff6eabee5f..5f6292f698 100644 --- a/services/web/app/src/infrastructure/QueueWorkers.mjs +++ b/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 { diff --git a/services/web/test/unit/src/User/UserOnboardingEmailManager.test.mjs b/services/web/test/unit/src/User/UserOnboardingEmailManager.test.mjs new file mode 100644 index 0000000000..e888645bd9 --- /dev/null +++ b/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 + }) + }) + }) +}) diff --git a/services/web/test/unit/src/User/UserOnboardingEmailManagerTests.js b/services/web/test/unit/src/User/UserOnboardingEmailManagerTests.js deleted file mode 100644 index 3243695dde..0000000000 --- a/services/web/test/unit/src/User/UserOnboardingEmailManagerTests.js +++ /dev/null @@ -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 - }) - }) - }) -}) diff --git a/services/web/test/unit/src/User/UserPostRegistrationAnalyticsManager.test.mjs b/services/web/test/unit/src/User/UserPostRegistrationAnalyticsManager.test.mjs new file mode 100644 index 0000000000..3d1cf859b1 --- /dev/null +++ b/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 + }) + }) +}) diff --git a/services/web/test/unit/src/User/UserPostRegistrationAnalyticsManagerTests.js b/services/web/test/unit/src/User/UserPostRegistrationAnalyticsManagerTests.js deleted file mode 100644 index 67b76ba3d8..0000000000 --- a/services/web/test/unit/src/User/UserPostRegistrationAnalyticsManagerTests.js +++ /dev/null @@ -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 - }) - }) -}) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs index 32f8cadf05..75fa498e8c 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs +++ b/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) + ) + }, }) }) }) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipHandler.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipHandler.test.mjs new file mode 100644 index 0000000000..404a0ffeff --- /dev/null +++ b/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) + } + }) + }) + }) +}) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js b/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js deleted file mode 100644 index 3bf74c0ab7..0000000000 --- a/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js +++ /dev/null @@ -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) - }) - }) - }) -}) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipViewModel.test.mjs b/services/web/test/unit/src/UserMembership/UserMembershipViewModel.test.mjs new file mode 100644 index 0000000000..590008d454 --- /dev/null +++ b/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 + }) + }) +}) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js b/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js deleted file mode 100644 index afdccef68c..0000000000 --- a/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js +++ /dev/null @@ -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 - }) - }) -})