Преглед изворни кода

Merge pull request #25151 from overleaf/dk-use-user-features

UserFeaturesContext with cross-tab syncing via BroadcastChannel

GitOrigin-RevId: 4262719f5018f5717211851ce28b3255af65461a
Jimmy Domagala-Tang пре 1 година
родитељ
комит
82e5b2c5d7

+ 1 - 11
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 =

+ 25 - 3
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) {

+ 20 - 8
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

+ 11 - 0
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),
 }

+ 5 - 0
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',

+ 4 - 1
services/web/cypress/fixtures/build/mock-writefull-api.js

@@ -1 +1,4 @@
-module.exports = {}
+module.exports = {
+  addEventListener: () => {},
+  removeEventListener: () => {},
+}

+ 33 - 29
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<
                         <Providers.ReferencesProvider>
                           <Providers.DetachProvider>
                             <Providers.EditorProvider>
-                              <Providers.PermissionsProvider>
-                                <Providers.RailProvider>
-                                  <Providers.LayoutProvider>
-                                    <Providers.ProjectSettingsProvider>
-                                      <Providers.EditorManagerProvider>
-                                        <Providers.LocalCompileProvider>
-                                          <Providers.DetachCompileProvider>
-                                            <Providers.ChatProvider>
-                                              <Providers.FileTreeOpenProvider>
-                                                <Providers.OnlineUsersProvider>
-                                                  <Providers.MetadataProvider>
-                                                    <Providers.OutlineProvider>
-                                                      <Providers.IdeRedesignSwitcherProvider>
-                                                        <Providers.CommandRegistryProvider>
-                                                          {children}
-                                                        </Providers.CommandRegistryProvider>
-                                                      </Providers.IdeRedesignSwitcherProvider>
-                                                    </Providers.OutlineProvider>
-                                                  </Providers.MetadataProvider>
-                                                </Providers.OnlineUsersProvider>
-                                              </Providers.FileTreeOpenProvider>
-                                            </Providers.ChatProvider>
-                                          </Providers.DetachCompileProvider>
-                                        </Providers.LocalCompileProvider>
-                                      </Providers.EditorManagerProvider>
-                                    </Providers.ProjectSettingsProvider>
-                                  </Providers.LayoutProvider>
-                                </Providers.RailProvider>
-                              </Providers.PermissionsProvider>
+                              <Providers.UserFeaturesProvider>
+                                <Providers.PermissionsProvider>
+                                  <Providers.RailProvider>
+                                    <Providers.LayoutProvider>
+                                      <Providers.ProjectSettingsProvider>
+                                        <Providers.EditorManagerProvider>
+                                          <Providers.LocalCompileProvider>
+                                            <Providers.DetachCompileProvider>
+                                              <Providers.ChatProvider>
+                                                <Providers.FileTreeOpenProvider>
+                                                  <Providers.OnlineUsersProvider>
+                                                    <Providers.MetadataProvider>
+                                                      <Providers.OutlineProvider>
+                                                        <Providers.IdeRedesignSwitcherProvider>
+                                                          <Providers.CommandRegistryProvider>
+                                                            {children}
+                                                          </Providers.CommandRegistryProvider>
+                                                        </Providers.IdeRedesignSwitcherProvider>
+                                                      </Providers.OutlineProvider>
+                                                    </Providers.MetadataProvider>
+                                                  </Providers.OnlineUsersProvider>
+                                                </Providers.FileTreeOpenProvider>
+                                              </Providers.ChatProvider>
+                                            </Providers.DetachCompileProvider>
+                                          </Providers.LocalCompileProvider>
+                                        </Providers.EditorManagerProvider>
+                                      </Providers.ProjectSettingsProvider>
+                                    </Providers.LayoutProvider>
+                                  </Providers.RailProvider>
+                                </Providers.PermissionsProvider>
+                              </Providers.UserFeaturesProvider>
                             </Providers.EditorProvider>
                           </Providers.DetachProvider>
                         </Providers.ReferencesProvider>

+ 4 - 1
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 (
     <SplitTestProvider>
       <SubscriptionDashboardProvider>
-        <SuccessfulSubscription />
+        <UserProvider>
+          <SuccessfulSubscription />
+        </UserProvider>
       </SubscriptionDashboardProvider>
     </SplitTestProvider>
   )

+ 2 - 0
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
 

+ 1 - 0
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 }

+ 69 - 0
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<User['features']>(undefined)
+
+export const UserFeaturesProvider: FC<React.PropsWithChildren> = ({
+  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 (
+    <UserFeaturesContext.Provider value={features}>
+      {children}
+    </UserFeaturesContext.Provider>
+  )
+}
+
+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
+}

+ 18 - 11
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(<SuccessfulSubscription />, {
-      metaTags: [
-        {
-          name: 'ol-ExposedSettings',
-          value: {
-            adminEmail,
-          } as ExposedSettings,
-        },
-        { name: 'ol-subscription', value: annualActiveSubscription },
-      ],
-    })
+    renderWithSubscriptionDashContext(
+      <UserProvider>
+        <SuccessfulSubscription />
+      </UserProvider>,
+
+      {
+        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')

+ 0 - 38
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 () {

+ 10 - 1
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()
       }

+ 58 - 1
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
+      )
+    })
+  })
 })

+ 75 - 1
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)
+      })
+    })
+  })
 })