Files
overleaf-cep/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs
T
roo hutton dece22ba92 Merge pull request #32871 from overleaf/rh-cio-comms-attributes
Expose remaining marketing properties to customer.io

GitOrigin-RevId: 6956e1faf90ecc650108404fe13b2f6de2eb4d0c
2026-04-23 08:06:04 +00:00

868 lines
27 KiB
JavaScript

import { vi, expect } from 'vitest'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.mjs'
const { ObjectId } = mongodb
const MODULE_PATH = '../../../../app/src/Features/Subscription/FeaturesUpdater'
describe('FeaturesUpdater', function () {
beforeEach(async function (ctx) {
ctx.renewalDate = new Date('2099-04-01T00:00:00Z')
ctx.v1UserId = 12345
ctx.user = {
_id: new ObjectId(),
features: {},
overleaf: { id: ctx.v1UserId },
}
ctx.aiAddOn = { addOnCode: AI_ADD_ON_CODE, quantity: 1 }
ctx.subscriptions = {
individual: {
planCode: 'individual-plan',
groupPlan: false,
recurlySubscription_id: 'sub-individual',
recurlyStatus: { state: 'active' },
},
group1: { planCode: 'group-plan-1', groupPlan: true },
group2: { planCode: 'group-plan-2', groupPlan: true },
noDropbox: { planCode: 'no-dropbox' },
individualPlusAiAddOn: {
planCode: 'individual-plan',
addOns: [ctx.aiAddOn],
},
groupPlusAiAddOn: {
planCode: 'group-plan-1',
groupPlan: true,
addOns: [ctx.aiAddOn],
},
}
ctx.UserFeaturesUpdater = {
promises: {
updateFeatures: sinon
.stub()
.resolves({ features: { some: 'features' }, featuresChanged: true }),
},
}
ctx.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub(),
getGroupSubscriptionsMemberOf: sinon.stub(),
},
}
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user._id)
.resolves(ctx.subscriptions.individual)
ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
.withArgs(ctx.user._id)
.resolves([ctx.subscriptions.group1, ctx.subscriptions.group2])
ctx.Settings = {
defaultFeatures: { default: 'features' },
plans: [
{ planCode: 'individual-plan', features: { individual: 'features' } },
{ planCode: 'group-plan-1', features: { group1: 'features' } },
{ planCode: 'group-plan-2', features: { group2: 'features' } },
{ planCode: 'no-dropbox', features: { dropbox: false } },
],
features: {
all: {
default: 'features',
individual: 'features',
group1: 'features',
group2: 'features',
institutions: 'features',
grandfathered: 'features',
bonus: 'features',
},
},
writefull: {
overleafApiUrl: 'https://www.writefull.com',
quotaTierGranted: 'unlimited',
},
aiFeatures: {
freeQuota: 'free',
standardQuota: 'standard',
basicQuota: 'basic',
unlimitedQuota: 'unlimited',
},
quotaGrants: {
ai: {
free: 5,
basic: 5,
standard: 10,
unlimited: 200,
},
},
}
ctx.ReferalFeatures = {
promises: {
getBonusFeatures: sinon.stub().resolves({ bonus: 'features' }),
},
}
ctx.V1SubscriptionManager = {
getGrandfatheredFeaturesForV1User: sinon.stub(),
}
ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
.withArgs(ctx.v1UserId)
.returns({ grandfathered: 'features' })
ctx.InstitutionsFeatures = {
promises: {
getInstitutionsFeatures: sinon
.stub()
.resolves({ institutions: 'features' }),
},
}
ctx.UserGetter = {
promises: {
getUser: sinon.stub().resolves(null),
getWritefullData: sinon.stub().resolves(null),
},
}
ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
ctx.UserGetter.promises.getUser
.withArgs({ 'overleaf.id': ctx.v1UserId })
.resolves(ctx.user)
ctx.AnalyticsManager = {
setUserPropertyForUserInBackground: sinon.stub(),
}
ctx.Modules = {
promises: { hooks: { fire: sinon.stub().resolves([]) } },
}
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecordPromise', ctx.subscriptions.individual)
.resolves([
{
subscription: {
state: 'active',
periodEnd: ctx.renewalDate,
},
},
])
ctx.SubscriptionViewModelBuilder = {
promises: {
getUsersSubscriptionDetails: sinon.stub().resolves({
bestSubscription: { type: 'individual' },
individualSubscription: ctx.subscriptions.individual,
memberGroupSubscriptions: [],
managedGroupSubscriptions: [],
}),
},
}
ctx.Queues = {
getQueue: sinon.stub().returns({
add: sinon.stub().resolves(),
}),
}
ctx.SplitTestHandler = {
promises: {
featureFlagEnabledForUser: sinon.stub().resolves(false),
},
}
vi.doMock(
'../../../../app/src/Features/Subscription/UserFeaturesUpdater',
() => ({
default: ctx.UserFeaturesUpdater,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionLocator',
() => ({
default: ctx.SubscriptionLocator,
})
)
vi.doMock('@overleaf/settings', () => ({
default: ctx.Settings,
}))
vi.doMock('../../../../app/src/Features/Referal/ReferalFeatures', () => ({
default: ctx.ReferalFeatures,
}))
vi.doMock(
'../../../../app/src/Features/Subscription/V1SubscriptionManager',
() => ({
default: ctx.V1SubscriptionManager,
})
)
vi.doMock(
'../../../../app/src/Features/Institutions/InstitutionsFeatures',
() => ({
default: ctx.InstitutionsFeatures,
})
)
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: ctx.UserGetter,
}))
vi.doMock(
'../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({
default: ctx.AnalyticsManager,
})
)
vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
default: ctx.Modules,
}))
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder',
() => ({
default: ctx.SubscriptionViewModelBuilder,
})
)
vi.doMock('../../../../app/src/infrastructure/Queues', () => ({
default: ctx.Queues,
}))
vi.doMock('../../../../app/src/models/Subscription', () => ({}))
vi.doMock('@overleaf/fetch-utils', () => ({
fetchNothing: sinon.stub().resolves(),
}))
vi.doMock(
'../../../../app/src/Features/SplitTests/SplitTestHandler',
() => ({
default: ctx.SplitTestHandler,
})
)
ctx.FeaturesUpdater = (await import(MODULE_PATH)).default
})
describe('computeFeatures', function () {
describe('when userFeaturesDisabled is true for individual plan', function () {
beforeEach(function (ctx) {
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user._id)
.resolves({
planCode: 'individual-plan',
userFeaturesDisabled: true,
groupPlan: false,
addOns: [ctx.aiAddOn],
})
})
it('removes all individual plan features', async function (ctx) {
const features = await ctx.FeaturesUpdater.promises.computeFeatures(
ctx.user._id
)
expect(features).to.deep.equal({ default: 'features' })
})
})
describe('when userFeaturesDisabled is true for group plan', function () {
beforeEach(function (ctx) {
const groupSubscription = {
planCode: 'group-plan-1',
userFeaturesDisabled: true,
groupPlan: true,
addOns: [ctx.aiAddOn],
}
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user._id)
.resolves(groupSubscription)
ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
.withArgs(ctx.user._id)
.resolves([groupSubscription])
})
it('removes all group plan features', async function (ctx) {
const features = await ctx.FeaturesUpdater.promises.computeFeatures(
ctx.user._id
)
expect(features).to.deep.equal({ default: 'features' })
})
})
beforeEach(function (ctx) {
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user._id)
.resolves(null)
ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
.withArgs(ctx.user._id)
.resolves([])
ctx.ReferalFeatures.promises.getBonusFeatures.resolves({})
ctx.V1SubscriptionManager.getGrandfatheredFeaturesForV1User
.withArgs(ctx.v1UserId)
.returns({})
ctx.InstitutionsFeatures.promises.getInstitutionsFeatures.resolves({})
})
describe('individual subscriber', function () {
beforeEach(function (ctx) {
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user._id)
.resolves(ctx.subscriptions.individual)
})
it('returns the individual features', async function (ctx) {
const features = await ctx.FeaturesUpdater.promises.computeFeatures(
ctx.user._id
)
expect(features).to.deep.equal({
default: 'features',
individual: 'features',
})
})
})
describe('group admin', function () {
beforeEach(function (ctx) {
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user._id)
.resolves(ctx.subscriptions.group1)
})
it("doesn't return the group features", async function (ctx) {
const features = await ctx.FeaturesUpdater.promises.computeFeatures(
ctx.user._id
)
expect(features).to.deep.equal({
default: 'features',
})
})
})
describe('group member', function () {
beforeEach(function (ctx) {
ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
.withArgs(ctx.user._id)
.resolves([ctx.subscriptions.group1])
})
it('returns the group features', async function (ctx) {
const features = await ctx.FeaturesUpdater.promises.computeFeatures(
ctx.user._id
)
expect(features).to.deep.equal({
default: 'features',
group1: 'features',
})
})
})
describe('individual subscription + AI add-on', function () {
beforeEach(function (ctx) {
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user._id)
.resolves(ctx.subscriptions.individualPlusAiAddOn)
})
it('returns the individual features and the AI error assistant', async function (ctx) {
const features = await ctx.FeaturesUpdater.promises.computeFeatures(
ctx.user._id
)
expect(features).to.deep.equal({
default: 'features',
individual: 'features',
aiErrorAssistant: true,
aiUsageQuota: 'unlimited',
})
})
})
describe('group admin + AI add-on', function () {
beforeEach(function (ctx) {
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user._id)
.resolves(ctx.subscriptions.groupPlusAiAddOn)
})
it('returns the AI error assistant only', async function (ctx) {
const features = await ctx.FeaturesUpdater.promises.computeFeatures(
ctx.user._id
)
expect(features).to.deep.equal({
default: 'features',
aiErrorAssistant: true,
aiUsageQuota: 'unlimited',
})
})
})
describe('group member + AI add-on', function () {
beforeEach(function (ctx) {
ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
.withArgs(ctx.user._id)
.resolves([ctx.subscriptions.groupPlusAiAddOn])
})
it('returns the group features without the AI features', async function (ctx) {
const features = await ctx.FeaturesUpdater.promises.computeFeatures(
ctx.user._id
)
expect(features).to.deep.equal({
default: 'features',
group1: 'features',
})
})
})
})
describe('refreshFeatures', function () {
it('should return features and featuresChanged', async function (ctx) {
const { features, featuresChanged } =
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
expect(features).to.exist
expect(featuresChanged).to.exist
})
describe('normally', function () {
beforeEach(async function (ctx) {
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should update the user with the merged features', function (ctx) {
expect(
ctx.UserFeaturesUpdater.promises.updateFeatures
).to.have.been.calledWith(ctx.user._id, ctx.Settings.features.all)
})
it('should send the corresponding feature set user property', function (ctx) {
expect(
ctx.AnalyticsManager.setUserPropertyForUserInBackground
).to.have.been.calledWith(ctx.user._id, 'feature-set', 'all')
})
it('should sync subscription properties to customer.io', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({
plan_type: 'individual',
display_plan_type: 'individual',
ai_plan: 'none',
next_renewal_date: Math.floor(ctx.renewalDate.getTime() / 1000),
expiry_date: '',
group: false,
commons: false,
individual_subscription: true,
payment_provider: 'recurly',
features: sinon.match.object,
})
)
})
it('should not set trial_end_date when no trial is active', function (ctx) {
const call = ctx.Modules.promises.hooks.fire
.getCalls()
.find(c => c.args[0] === 'setUserProperties')
expect(call).to.exist
expect(call.args[2]).to.not.have.property('trial_end_date')
})
})
describe('when the individual subscription is on a trial', function () {
beforeEach(async function (ctx) {
ctx.trialEndsAt = new Date('2099-05-01T00:00:00Z')
const trialingSubscription = {
...ctx.subscriptions.individual,
recurlyStatus: {
state: 'active',
trialEndsAt: ctx.trialEndsAt,
},
}
ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
{
bestSubscription: { type: 'individual' },
individualSubscription: trialingSubscription,
memberGroupSubscriptions: [],
managedGroupSubscriptions: [],
}
)
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecordPromise', trialingSubscription)
.resolves([
{
subscription: {
state: 'active',
periodEnd: ctx.renewalDate,
},
},
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should sync trial_end_date to customer.io', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({
trial_end_date: Math.floor(ctx.trialEndsAt.getTime() / 1000),
})
)
})
})
describe('when the individual subscription uses stripe', function () {
beforeEach(async function (ctx) {
const stripeSubscription = {
...ctx.subscriptions.individual,
paymentProvider: { service: 'stripe-us', state: 'active' },
}
ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
{
bestSubscription: { type: 'individual' },
individualSubscription: stripeSubscription,
memberGroupSubscriptions: [],
managedGroupSubscriptions: [],
}
)
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecordPromise', stripeSubscription)
.resolves([
{
subscription: {
state: 'active',
periodEnd: ctx.renewalDate,
},
},
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should report stripe as the payment_provider', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({
payment_provider: 'stripe',
individual_subscription: true,
})
)
})
})
describe('when the user has a commons institution licence', function () {
beforeEach(async function (ctx) {
ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
{
bestSubscription: { type: 'commons' },
individualSubscription: null,
memberGroupSubscriptions: [],
managedGroupSubscriptions: [],
currentInstitutionsWithLicence: [{ id: 1, name: 'Uni' }],
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should sync commons=true to customer.io', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({
commons: true,
group: false,
individual_subscription: false,
})
)
})
})
describe('when the user has commons and an individual AI add-on', function () {
beforeEach(async function (ctx) {
ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
{
bestSubscription: {
type: 'individual',
plan: { planCode: 'individual-plan' },
},
individualSubscription: ctx.subscriptions.individual,
memberGroupSubscriptions: [],
managedGroupSubscriptions: [],
currentInstitutionsWithLicence: [{ id: 1, name: 'Uni' }],
}
)
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecordPromise', ctx.subscriptions.individual)
.resolves([
{
subscription: {
state: 'active',
periodEnd: ctx.renewalDate,
addOns: [{ code: AI_ADD_ON_CODE }],
},
},
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should set commons, individual_subscription, and ai-assist-add-on together', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({
commons: true,
individual_subscription: true,
group: false,
ai_plan: 'ai-assist-add-on',
})
)
})
})
describe('when the user has no subscription', function () {
beforeEach(async function (ctx) {
ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
{
bestSubscription: null,
individualSubscription: null,
memberGroupSubscriptions: [],
managedGroupSubscriptions: [],
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should sync false subscription flags and no payment_provider', function (ctx) {
const call = ctx.Modules.promises.hooks.fire
.getCalls()
.find(c => c.args[0] === 'setUserProperties')
expect(call).to.exist
expect(call.args[2]).to.include({
group: false,
commons: false,
individual_subscription: false,
})
expect(call.args[2]).to.not.have.property('payment_provider')
expect(call.args[2]).to.not.have.property('trial_end_date')
})
})
describe('when the individual subscription has a pending cancellation', function () {
beforeEach(async function (ctx) {
const pendingCancellationSubscription = {
...ctx.subscriptions.individual,
recurlyStatus: { state: 'canceled' },
}
ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
{
bestSubscription: { type: 'individual' },
individualSubscription: pendingCancellationSubscription,
memberGroupSubscriptions: [],
managedGroupSubscriptions: [],
}
)
ctx.Modules.promises.hooks.fire
.withArgs(
'getPaymentFromRecordPromise',
pendingCancellationSubscription
)
.resolves([
{
subscription: {
state: 'canceled',
periodEnd: ctx.renewalDate,
},
},
])
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should sync expiry_date and blank next_renewal_date in customer.io', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({
plan_type: 'individual',
display_plan_type: 'individual',
ai_plan: 'none',
next_renewal_date: '',
expiry_date: Math.floor(ctx.renewalDate.getTime() / 1000),
features: sinon.match.object,
})
)
})
})
describe('when the user is in a group subscription', function () {
beforeEach(async function (ctx) {
ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
{
bestSubscription: {
type: 'group',
plan: {
planCode: 'group-plan-1',
groupPlan: true,
membersLimit: 5,
},
subscription: {
teamName: 'Team Alpha',
},
},
memberGroupSubscriptions: [
{
planCode: 'group-plan-1',
teamName: 'Team Alpha',
membersLimit: 8,
},
],
managedGroupSubscriptions: [],
individualSubscription: null,
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should sync groupSize to customer.io', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({
plan_type: 'group-standard',
display_plan_type: 'Group Standard',
plan_term_label: 'monthly',
ai_plan: 'none',
group_ai_enabled: false,
group_size: 8,
next_renewal_date: '',
expiry_date: '',
group: true,
commons: false,
individual_subscription: false,
payment_provider: 'recurly',
features: sinon.match.object,
overleaf_id: ctx.user._id,
})
)
})
})
describe('when the user is in a stripe group subscription', function () {
beforeEach(async function (ctx) {
ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(
{
bestSubscription: {
type: 'group',
plan: {
planCode: 'group-plan-1',
groupPlan: true,
membersLimit: 5,
},
subscription: {
teamName: 'Team Alpha',
},
},
memberGroupSubscriptions: [
{
planCode: 'group-plan-1',
teamName: 'Team Alpha',
membersLimit: 8,
paymentProvider: { service: 'stripe-uk' },
},
],
managedGroupSubscriptions: [],
individualSubscription: null,
}
)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should derive payment_provider from the group subscription', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({
payment_provider: 'stripe',
group: true,
individual_subscription: false,
})
)
})
})
describe('with a non-standard feature set', async function () {
beforeEach(async function (ctx) {
ctx.SubscriptionLocator.promises.getGroupSubscriptionsMemberOf
.withArgs(ctx.user._id)
.resolves(null)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should send mixed feature set user property', function (ctx) {
sinon.assert.calledWith(
ctx.AnalyticsManager.setUserPropertyForUserInBackground,
ctx.user._id,
'feature-set',
'mixed'
)
})
})
describe('when losing dropbox feature', async function () {
beforeEach(async function (ctx) {
ctx.user.features = { dropbox: true }
ctx.SubscriptionLocator.promises.getUsersSubscription
.withArgs(ctx.user._id)
.resolves(ctx.subscriptions.noDropbox)
await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test')
})
it('should fire module hook to unlink dropbox', function (ctx) {
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'removeDropbox',
ctx.user._id,
'test'
)
})
})
})
describe('doSyncFromV1', function () {
describe('when all goes well', function () {
beforeEach(async function (ctx) {
await ctx.FeaturesUpdater.promises.doSyncFromV1(ctx.v1UserId)
})
it('should update the user with the merged features', function (ctx) {
expect(
ctx.UserFeaturesUpdater.promises.updateFeatures
).to.have.been.calledWith(ctx.user._id, ctx.Settings.features.all)
})
})
describe('when getUser produces an error', function () {
beforeEach(function (ctx) {
ctx.UserGetter.promises.getUser.rejects(new Error('woops'))
})
it('should propagate the error', async function (ctx) {
const someId = 9090
await expect(ctx.FeaturesUpdater.promises.doSyncFromV1(someId)).to.be
.rejected
expect(ctx.UserFeaturesUpdater.promises.updateFeatures).not.to.have.been
.called
})
})
describe('when getUser does not find a user', function () {
beforeEach(async function (ctx) {
const someOtherId = 987
await ctx.FeaturesUpdater.promises.doSyncFromV1(someOtherId)
})
it('should not update the user', function (ctx) {
expect(ctx.UserFeaturesUpdater.promises.updateFeatures).not.to.have.been
.called
})
})
})
})