From 82e5b2c5d715504475acb4873b4569cfba0e202e Mon Sep 17 00:00:00 2001 From: Jimmy Domagala-Tang Date: Mon, 12 May 2025 08:15:33 -0400 Subject: [PATCH] Merge pull request #25151 from overleaf/dk-use-user-features UserFeaturesContext with cross-tab syncing via BroadcastChannel GitOrigin-RevId: 4262719f5018f5717211851ce28b3255af65461a --- .../src/Features/Project/ProjectController.js | 12 +-- .../Subscription/SubscriptionController.js | 28 ++++++- .../web/app/src/Features/User/UserGetter.js | 28 +++++-- .../src/Features/User/UserInfoController.js | 11 +++ services/web/app/src/router.mjs | 5 ++ .../fixtures/build/mock-writefull-api.js | 5 +- .../ide-react/context/react-context-root.tsx | 62 ++++++++------- .../successful-subscription/root.tsx | 5 +- .../successful-subscription.tsx | 2 + .../context/types/writefull-instance.ts | 1 + .../shared/context/user-features-context.tsx | 69 +++++++++++++++++ .../successful-subscription.test.tsx | 29 ++++--- .../src/Project/ProjectControllerTests.js | 38 ---------- .../SubscriptionControllerTests.js | 11 ++- .../web/test/unit/src/User/UserGetterTests.js | 59 +++++++++++++- .../unit/src/User/UserInfoControllerTests.js | 76 ++++++++++++++++++- 16 files changed, 336 insertions(+), 105 deletions(-) create mode 100644 services/web/frontend/js/shared/context/user-features-context.tsx diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index f34e2522ec..1e92a55519 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -15,7 +15,6 @@ const metrics = require('@overleaf/metrics') const { User } = require('../../models/User') const SubscriptionLocator = require('../Subscription/SubscriptionLocator') const LimitationsManager = require('../Subscription/LimitationsManager') -const FeaturesHelper = require('../Subscription/FeaturesHelper') const Settings = require('@overleaf/settings') const AuthorizationManager = require('../Authorization/AuthorizationManager') const InactiveProjectManager = require('../InactiveData/InactiveProjectManager') @@ -753,16 +752,7 @@ const _ProjectController = { let fullFeatureSet = user?.features if (!anonymous) { - // generate users feature set including features added, or overriden via modules - const moduleFeatures = - (await Modules.promises.hooks.fire( - 'getModuleProvidedFeatures', - userId - )) || [] - fullFeatureSet = FeaturesHelper.computeFeatureSet([ - user.features, - ...moduleFeatures, - ]) + fullFeatureSet = await UserGetter.promises.getUserFeatures(userId) } const isPaywallChangeCompileTimeoutEnabled = diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 72efe77980..fe23512901 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -25,6 +25,7 @@ const RecurlyClient = require('./RecurlyClient') const { AI_ADD_ON_CODE } = require('./PaymentProviderEntities') const PlansLocator = require('./PlansLocator') const PaymentProviderEntities = require('./PaymentProviderEntities') +const { User } = require('../../models/User') /** * @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview' @@ -186,7 +187,9 @@ async function userSubscriptionPage(req, res) { async function successfulSubscription(req, res) { const user = SessionManager.getSessionUser(req.session) - + if (!user) { + throw new Error('User is not logged in') + } const { personalSubscription } = await SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel( user, @@ -198,11 +201,23 @@ async function successfulSubscription(req, res) { if (!personalSubscription) { res.redirect('/user/subscription/plans') } else { + const userInDb = await User.findById(user._id, { + _id: 1, + features: 1, + }) + + if (!userInDb) { + throw new Error('User not found') + } + res.render('subscriptions/successful-subscription-react', { title: 'thank_you', personalSubscription, postCheckoutRedirect, - user, + user: { + _id: user._id, + features: userInDb.features, + }, }) } } @@ -387,7 +402,6 @@ async function purchaseAddon(req, res, next) { addOnCode, quantity ) - return res.sendStatus(200) } catch (err) { if (err instanceof DuplicateAddOnError) { HttpErrorHandler.badRequest( @@ -406,6 +420,14 @@ async function purchaseAddon(req, res, next) { return next(err) } } + + try { + await FeaturesUpdater.promises.refreshFeatures(user._id, 'add-on-purchase') + } catch (err) { + logger.error({ err }, 'Failed to refresh features after add-on purchase') + } + + return res.sendStatus(200) } async function removeAddon(req, res, next) { diff --git a/services/web/app/src/Features/User/UserGetter.js b/services/web/app/src/Features/User/UserGetter.js index 34d758add6..9be0c64b9a 100644 --- a/services/web/app/src/Features/User/UserGetter.js +++ b/services/web/app/src/Features/User/UserGetter.js @@ -11,6 +11,8 @@ const Errors = require('../Errors/Errors') const Features = require('../../infrastructure/Features') const { User } = require('../../models/User') const { normalizeQuery, normalizeMultiQuery } = require('../Helpers/Mongo') +const Modules = require('../../infrastructure/Modules') +const FeaturesHelper = require('../Subscription/FeaturesHelper') function _lastDayToReconfirm(emailData, institutionData) { const globalReconfirmPeriod = settings.reconfirmNotificationDays @@ -95,6 +97,21 @@ async function getUserFullEmails(userId) { ) } +async function getUserFeatures(userId) { + const user = await UserGetter.promises.getUser(userId, { + features: 1, + }) + if (!user) { + throw new Error('User not Found') + } + + const moduleFeatures = + (await Modules.promises.hooks.fire('getModuleProvidedFeatures', userId)) || + [] + + return FeaturesHelper.computeFeatureSet([user.features, ...moduleFeatures]) +} + async function getUserConfirmedEmails(userId) { const user = await UserGetter.promises.getUser(userId, { emails: 1, @@ -136,13 +153,7 @@ const UserGetter = { } }, - getUserFeatures(userId, callback) { - this.getUser(userId, { features: 1 }, (error, user) => { - if (error) return callback(error) - if (!user) return callback(new Errors.NotFoundError('user not found')) - callback(null, user.features) - }) - }, + getUserFeatures: callbackify(getUserFeatures), getUserEmail(userId, callback) { this.getUser(userId, { email: 1 }, (error, user) => @@ -335,9 +346,10 @@ const decorateFullEmails = ( } UserGetter.promises = promisifyAll(UserGetter, { - without: ['getSsoUsersAtInstitution', 'getUserFullEmails'], + without: ['getSsoUsersAtInstitution', 'getUserFullEmails', 'getUserFeatures'], }) UserGetter.promises.getUserFullEmails = getUserFullEmails UserGetter.promises.getSsoUsersAtInstitution = getSsoUsersAtInstitution +UserGetter.promises.getUserFeatures = getUserFeatures module.exports = UserGetter diff --git a/services/web/app/src/Features/User/UserInfoController.js b/services/web/app/src/Features/User/UserInfoController.js index 4eeea4f5e9..c95bc45af5 100644 --- a/services/web/app/src/Features/User/UserInfoController.js +++ b/services/web/app/src/Features/User/UserInfoController.js @@ -1,6 +1,7 @@ const UserGetter = require('./UserGetter') const SessionManager = require('../Authentication/SessionManager') const { ObjectId } = require('mongodb-legacy') +const { expressify } = require('@overleaf/promise-utils') function getLoggedInUsersPersonalInfo(req, res, next) { const userId = SessionManager.getLoggedInUserId(req.session) @@ -78,9 +79,19 @@ function formatPersonalInfo(user) { return formattedUser } +async function getUserFeatures(req, res, next) { + const userId = SessionManager.getLoggedInUserId(req.session) + if (!userId) { + throw new Error('User is not logged in') + } + const features = await UserGetter.promises.getUserFeatures(userId) + return res.json(features) +} + module.exports = { getLoggedInUsersPersonalInfo, getPersonalInfo, sendFormattedPersonalInfo, formatPersonalInfo, + getUserFeatures: expressify(getUserFeatures), } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 9201ad4c55..f87297c35c 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -484,6 +484,11 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { AuthenticationController.requirePrivateApiAuth(), UserInfoController.getPersonalInfo ) + webRouter.get( + '/user/features', + AuthenticationController.requireLogin(), + UserInfoController.getUserFeatures + ) webRouter.get( '/user/reconfirm', diff --git a/services/web/cypress/fixtures/build/mock-writefull-api.js b/services/web/cypress/fixtures/build/mock-writefull-api.js index 4ba52ba2c8..a70a28653e 100644 --- a/services/web/cypress/fixtures/build/mock-writefull-api.js +++ b/services/web/cypress/fixtures/build/mock-writefull-api.js @@ -1 +1,4 @@ -module.exports = {} +module.exports = { + addEventListener: () => {}, + removeEventListener: () => {}, +} diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx index f8b094f821..0eeb870a2b 100644 --- a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx +++ b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx @@ -23,6 +23,7 @@ import { ReferencesProvider } from '@/features/ide-react/context/references-cont import { SnapshotProvider } from '@/features/ide-react/context/snapshot-context' import { SplitTestProvider } from '@/shared/context/split-test-context' import { UserProvider } from '@/shared/context/user-context' +import { UserFeaturesProvider } from '@/shared/context/user-features-context' import { UserSettingsProvider } from '@/shared/context/user-settings-context' import { IdeRedesignSwitcherProvider } from './ide-redesign-switcher-context' import { CommandRegistryProvider } from './command-registry-context' @@ -60,6 +61,7 @@ export const ReactContextRoot: FC< UserSettingsProvider, IdeRedesignSwitcherProvider, CommandRegistryProvider, + UserFeaturesProvider, ...providers, } @@ -77,35 +79,37 @@ export const ReactContextRoot: FC< - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + diff --git a/services/web/frontend/js/features/subscription/components/successful-subscription/root.tsx b/services/web/frontend/js/features/subscription/components/successful-subscription/root.tsx index f7ef400ded..437ac2928e 100644 --- a/services/web/frontend/js/features/subscription/components/successful-subscription/root.tsx +++ b/services/web/frontend/js/features/subscription/components/successful-subscription/root.tsx @@ -1,3 +1,4 @@ +import { UserProvider } from '@/shared/context/user-context' import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n' import { SubscriptionDashboardProvider } from '../../context/subscription-dashboard-context' import SuccessfulSubscription from './successful-subscription' @@ -13,7 +14,9 @@ function Root() { return ( - + + + ) diff --git a/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx b/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx index f211c85f10..e48ce0053f 100644 --- a/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx +++ b/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx @@ -13,6 +13,7 @@ import { isStandaloneAiPlanCode, } from '../../data/add-on-codes' import { PaidSubscription } from '../../../../../../types/subscription/dashboard/subscription' +import { useBroadcastUser } from '@/shared/hooks/user-channel/use-broadcast-user' function SuccessfulSubscription() { const { t } = useTranslation() @@ -20,6 +21,7 @@ function SuccessfulSubscription() { useSubscriptionDashboardContext() const postCheckoutRedirect = getMeta('ol-postCheckoutRedirect') const { appName, adminEmail } = getMeta('ol-ExposedSettings') + useBroadcastUser() if (!subscription || !('payment' in subscription)) return null diff --git a/services/web/frontend/js/shared/context/types/writefull-instance.ts b/services/web/frontend/js/shared/context/types/writefull-instance.ts index 213ab67f18..120590e668 100644 --- a/services/web/frontend/js/shared/context/types/writefull-instance.ts +++ b/services/web/frontend/js/shared/context/types/writefull-instance.ts @@ -1,6 +1,7 @@ export interface WritefullEvents { 'writefull-login-complete': { method: 'email-password' | 'login-with-overleaf' + isPremium: boolean } 'writefull-received-suggestions': { numberOfSuggestions: number } 'writefull-register-as-auto-account': { email: string } diff --git a/services/web/frontend/js/shared/context/user-features-context.tsx b/services/web/frontend/js/shared/context/user-features-context.tsx new file mode 100644 index 0000000000..2cc9ba932d --- /dev/null +++ b/services/web/frontend/js/shared/context/user-features-context.tsx @@ -0,0 +1,69 @@ +import { + createContext, + FC, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { User } from '../../../../types/user' +import { useUserContext } from './user-context' +import { useReceiveUser } from '../hooks/user-channel/use-receive-user' +import { getJSON } from '@/infrastructure/fetch-json' +import { useEditorContext } from './editor-context' + +export const UserFeaturesContext = createContext(undefined) + +export const UserFeaturesProvider: FC = ({ + children, +}) => { + const user = useUserContext() + const { writefullInstance } = useEditorContext() + const [features, setFeatures] = useState(user.features) + + useReceiveUser( + useCallback(data => { + if (data?.features) { + setFeatures(data.features) + } + }, []) + ) + + useEffect(() => { + const listener = async ({ isPremium }: { isPremium: boolean }) => { + if (features?.aiErrorAssistant === isPremium) { + // the user is premium on writefull and has the AI assist, no need to refresh the features + return + } + const newFeatures = await getJSON('/user/features') + setFeatures(newFeatures) + } + + writefullInstance?.addEventListener('writefull-login-complete', listener) + + return () => { + writefullInstance?.removeEventListener( + 'writefull-login-complete', + listener + ) + } + }, [features?.aiErrorAssistant, writefullInstance]) + + return ( + + {children} + + ) +} + +export function useUserFeaturesContext() { + const context = useContext(UserFeaturesContext) + + if (!context) { + throw new Error( + 'useUserFeaturesContext is only available inside UserFeaturesContext, or `ol-user` meta is not defined' + ) + } + + return context +} diff --git a/services/web/test/frontend/features/subscription/components/successful-subscription/successful-subscription.test.tsx b/services/web/test/frontend/features/subscription/components/successful-subscription/successful-subscription.test.tsx index 33979e6b9e..90453c9349 100644 --- a/services/web/test/frontend/features/subscription/components/successful-subscription/successful-subscription.test.tsx +++ b/services/web/test/frontend/features/subscription/components/successful-subscription/successful-subscription.test.tsx @@ -4,21 +4,28 @@ import SuccessfulSubscription from '../../../../../../frontend/js/features/subsc import { renderWithSubscriptionDashContext } from '../../helpers/render-with-subscription-dash-context' import { annualActiveSubscription } from '../../fixtures/subscriptions' import { ExposedSettings } from '../../../../../../types/exposed-settings' +import { UserProvider } from '@/shared/context/user-context' describe('successful subscription page', function () { it('renders the invoices link', function () { const adminEmail = 'foo@example.com' - renderWithSubscriptionDashContext(, { - metaTags: [ - { - name: 'ol-ExposedSettings', - value: { - adminEmail, - } as ExposedSettings, - }, - { name: 'ol-subscription', value: annualActiveSubscription }, - ], - }) + renderWithSubscriptionDashContext( + + + , + + { + metaTags: [ + { + name: 'ol-ExposedSettings', + value: { + adminEmail, + } as ExposedSettings, + }, + { name: 'ol-subscription', value: annualActiveSubscription }, + ], + } + ) screen.getByRole('heading', { name: /thanks for subscribing/i }) const alert = screen.getByRole('alert') diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index 71de5023b1..46427171da 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -1121,44 +1121,6 @@ describe('ProjectController', function () { this.ProjectController.loadEditor(this.req, this.res) }) }) - - describe('when fetching the users featureSet', function () { - beforeEach(function () { - this.Modules.promises.hooks.fire = sinon.stub().resolves() - this.user.features = {} - }) - - it('should take into account features overrides from modules', function (done) { - // this case occurs when the user has bought the ai bundle on WF, which should include our error assistant - const bundleFeatures = { aiErrorAssistant: true } - this.user.features = { aiErrorAssistant: false } - this.Modules.promises.hooks.fire = sinon - .stub() - .resolves([bundleFeatures]) - this.res.render = (pageName, opts) => { - expect(opts.user.features).to.deep.equal(bundleFeatures) - this.Modules.promises.hooks.fire.should.have.been.calledWith( - 'getModuleProvidedFeatures', - this.user._id - ) - done() - } - this.ProjectController.loadEditor(this.req, this.res) - }) - - it('should handle modules not returning any features', function (done) { - this.Modules.promises.hooks.fire = sinon.stub().resolves([]) - this.res.render = (pageName, opts) => { - expect(opts.user.features).to.deep.equal({}) - this.Modules.promises.hooks.fire.should.have.been.calledWith( - 'getModuleProvidedFeatures', - this.user._id - ) - done() - } - this.ProjectController.loadEditor(this.req, this.res) - }) - }) }) describe('userProjectsJson', function () { diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 27ba5bf85b..5b6e86663e 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -158,6 +158,7 @@ describe('SubscriptionController', function () { './FeaturesUpdater': (this.FeaturesUpdater = { promises: { hasFeaturesViaWritefull: sinon.stub().resolves(false), + refreshFeatures: sinon.stub().resolves({ features: {} }), }, }), './GroupPlansData': (this.GroupPlansData = {}), @@ -186,6 +187,11 @@ describe('SubscriptionController', function () { '../../util/currency': (this.currency = { formatCurrency: sinon.stub(), }), + '../../models/User': { + User: { + findById: sinon.stub().resolves(this.user), + }, + }, }, }) @@ -221,7 +227,10 @@ describe('SubscriptionController', function () { title: 'thank_you', personalSubscription: 'foo', postCheckoutRedirect: undefined, - user: this.user, + user: { + _id: this.user._id, + features: this.user.features, + }, }) done() } diff --git a/services/web/test/unit/src/User/UserGetterTests.js b/services/web/test/unit/src/User/UserGetterTests.js index 91df5d8c6d..0e0c170fd6 100644 --- a/services/web/test/unit/src/User/UserGetterTests.js +++ b/services/web/test/unit/src/User/UserGetterTests.js @@ -19,7 +19,7 @@ describe('UserGetter', function () { beforeEach(function () { const confirmedAt = new Date() this.fakeUser = { - _id: '12390i', + _id: new ObjectId(), email: 'email2@foo.bar', emails: [ { @@ -45,6 +45,10 @@ describe('UserGetter', function () { } this.getUserAffiliations = sinon.stub().resolves([]) + this.Modules = { + promises: { hooks: { fire: sinon.stub().resolves() } }, + } + this.UserGetter = SandboxedModule.require(modulePath, { requires: { '../Helpers/Mongo': { normalizeQuery, normalizeMultiQuery }, @@ -63,6 +67,7 @@ describe('UserGetter', function () { '../../models/User': { User: (this.User = {}), }, + '../../infrastructure/Modules': this.Modules, }, }) }) @@ -1259,4 +1264,56 @@ describe('UserGetter', function () { }) }) }) + + describe('getUserFeatures', function () { + beforeEach(function () { + this.Modules.promises.hooks.fire = sinon.stub().resolves() + this.fakeUser.features = {} + }) + + it('should return user features', function (done) { + this.fakeUser.features = { feature1: true, feature2: false } + this.UserGetter.getUserFeatures(new ObjectId(), (error, features) => { + expect(error).to.not.exist + expect(features).to.deep.equal(this.fakeUser.features) + done() + }) + }) + + it('should return user features when using promises', async function () { + this.fakeUser.features = { feature1: true, feature2: false } + const features = await this.UserGetter.promises.getUserFeatures( + this.fakeUser._id + ) + expect(features).to.deep.equal(this.fakeUser.features) + }) + + it('should take into account features overrides from modules', async function () { + // this case occurs when the user has bought the ai bundle on WF, which should include our error assistant + const bundleFeatures = { aiErrorAssistant: true } + this.fakeUser.features = { aiErrorAssistant: false } + this.Modules.promises.hooks.fire = sinon.stub().resolves([bundleFeatures]) + const features = await this.UserGetter.promises.getUserFeatures( + this.fakeUser._id + ) + expect(features).to.deep.equal(bundleFeatures) + this.Modules.promises.hooks.fire.should.have.been.calledWith( + 'getModuleProvidedFeatures', + this.fakeUser._id + ) + }) + + it('should handle modules not returning any features', async function () { + this.Modules.promises.hooks.fire = sinon.stub().resolves([]) + this.fakeUser.features = { test: true } + const features = await this.UserGetter.promises.getUserFeatures( + this.fakeUser._id + ) + expect(features).to.deep.equal({ test: true }) + this.Modules.promises.hooks.fire.should.have.been.calledWith( + 'getModuleProvidedFeatures', + this.fakeUser._id + ) + }) + }) }) diff --git a/services/web/test/unit/src/User/UserInfoControllerTests.js b/services/web/test/unit/src/User/UserInfoControllerTests.js index 022dbd2d7f..dd90ac3b00 100644 --- a/services/web/test/unit/src/User/UserInfoControllerTests.js +++ b/services/web/test/unit/src/User/UserInfoControllerTests.js @@ -10,7 +10,11 @@ describe('UserInfoController', function () { beforeEach(function () { this.UserDeleter = { deleteUser: sinon.stub().callsArgWith(1) } this.UserUpdater = { updatePersonalInfo: sinon.stub() } - this.UserGetter = {} + this.UserGetter = { + promises: { + getUserFeatures: sinon.stub(), + }, + } this.UserInfoController = SandboxedModule.require(modulePath, { requires: { @@ -148,4 +152,74 @@ describe('UserInfoController', function () { }) }) }) + + describe('getUserFeatures', function () { + describe('when the user is logged in', function () { + beforeEach(async function () { + this.user_id = new ObjectId().toString() + this.features = { + collaborators: 10, + trackChanges: true, + references: true, + } + this.SessionManager.getLoggedInUserId.returns(this.user_id) + this.UserGetter.promises.getUserFeatures.resolves(this.features) + await this.UserInfoController.getUserFeatures( + this.req, + this.res, + this.next + ) + }) + + it('should fetch the user features', function () { + expect(this.UserGetter.promises.getUserFeatures.callCount).to.equal(1) + expect( + this.UserGetter.promises.getUserFeatures.calledWith(this.user_id) + ).to.equal(true) + }) + + it('should return the features as JSON', function () { + expect(this.res.json.callCount).to.equal(1) + expect(this.res.json.calledWith(this.features)).to.equal(true) + }) + }) + + describe('when the user is not logged in', function () { + beforeEach(async function () { + this.SessionManager.getLoggedInUserId.returns(null) + await this.UserInfoController.getUserFeatures( + this.req, + this.res, + this.next + ) + }) + + it('should call next with an error', function () { + expect(this.next.callCount).to.equal(1) + expect(this.next.firstCall.args[0]).to.be.an.instanceof(Error) + expect(this.next.firstCall.args[0].message).to.equal( + 'User is not logged in' + ) + }) + }) + + describe('when fetching features fails', function () { + beforeEach(async function () { + this.user_id = new ObjectId().toString() + this.error = new Error('something went wrong') + this.SessionManager.getLoggedInUserId.returns(this.user_id) + this.UserGetter.promises.getUserFeatures.rejects(this.error) + await this.UserInfoController.getUserFeatures( + this.req, + this.res, + this.next + ) + }) + + it('should call next with the error', function () { + expect(this.next.callCount).to.equal(1) + expect(this.next.firstCall.args[0]).to.equal(this.error) + }) + }) + }) })