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

UserFeaturesContext with cross-tab syncing via BroadcastChannel

GitOrigin-RevId: 4262719f5018f5717211851ce28b3255af65461a
This commit is contained in:
Jimmy Domagala-Tang
2025-05-13 08:07:57 +00:00
committed by Copybot
parent 2f3166aa54
commit 82e5b2c5d7
16 changed files with 336 additions and 105 deletions
@@ -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 () {
@@ -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()
}
@@ -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
)
})
})
})
@@ -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)
})
})
})
})