Files
overleaf-cep/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
Liangjun Song eb5417fad5 Merge pull request #23462 from overleaf/ls-update-pricing-logic-for-small-educational-plans
Update pricing logic for small educational plans

GitOrigin-RevId: 0051f238ce50b2067b7dc75d08f55dc1c7ac3502
2025-02-10 09:05:11 +00:00

730 lines
23 KiB
JavaScript

const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const MockRequest = require('../helpers/MockRequest')
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionGroupHandler'
describe('SubscriptionGroupHandler', function () {
beforeEach(function () {
this.adminUser_id = '12321'
this.newEmail = 'bob@smith.com'
this.user_id = '3121321'
this.email = 'jim@example.com'
this.user = { _id: this.user_id, email: this.newEmail }
this.subscription_id = '31DSd1123D'
this.adding = 1
this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
this.RecurlyEntities = {
MEMBERS_LIMIT_ADD_ON_CODE: 'additional-license',
}
this.localPlanInSettings = {
membersLimit: 5,
membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
}
this.subscription = {
admin_id: this.adminUser_id,
manager_ids: [this.adminUser_id],
_id: this.subscription_id,
}
this.changeRequest = {
timeframe: 'now',
subscription: {
id: 'test_id',
},
}
this.recurlySubscription = {
id: 123,
addOns: [
{
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: 1,
},
],
getRequestForAddOnUpdate: sinon.stub().returns(this.changeRequest),
getRequestForGroupPlanUpgrade: sinon.stub().returns(this.changeRequest),
getRequestForAddOnPurchase: sinon.stub().returns(this.changeRequest),
getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
.stub()
.returns(this.changeRequest),
currency: 'USD',
hasAddOn(code) {
return this.addOns.some(addOn => addOn.code === code)
},
}
this.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub().resolves({
groupPlan: true,
recurlyStatus: {
state: 'active',
},
}),
getSubscriptionByMemberIdAndId: sinon.stub(),
getSubscription: sinon.stub().resolves(this.subscription),
},
}
this.changePreview = {
currency: 'USD',
}
this.SubscriptionController = {
makeChangePreview: sinon.stub().resolves(this.changePreview),
getPlanNameForDisplay: sinon.stub().resolves(),
}
this.SubscriptionUpdater = {
promises: {
removeUserFromGroup: sinon.stub().resolves(),
getSubscription: sinon.stub().resolves(),
},
}
this.Subscription = {
updateOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
updateMany: sinon.stub().returns({ exec: sinon.stub().resolves }),
findOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
}
this.SessionManager = {
getLoggedInUserId: sinon.stub().returns(this.user._id),
}
this.previewSubscriptionChange = {
nextAddOns: [
{
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: this.recurlySubscription.addOns[0].quantity + this.adding,
},
],
subscription: {
planName: 'test plan',
},
}
this.applySubscriptionChange = {}
this.RecurlyClient = {
promises: {
getSubscription: sinon.stub().resolves(this.recurlySubscription),
getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
previewSubscriptionChange: sinon
.stub()
.resolves(this.previewSubscriptionChange),
applySubscriptionChangeRequest: sinon
.stub()
.resolves(this.applySubscriptionChange),
},
}
this.PlansLocator = {
findLocalPlanInSettings: sinon.stub().returns(this.localPlanInSettings),
}
this.SubscriptionHandler = {
promises: {
syncSubscription: sinon.stub().resolves(),
},
}
this.GroupPlansData = {
enterprise: {
collaborator: {
USD: {
5: {
price_in_cents: 10000,
additional_license_legacy_price_in_cents: 5000,
},
},
},
},
educational: {
collaborator: {
USD: {
5: {
price_in_cents: 10000,
additional_license_legacy_price_in_cents: 5000,
},
},
},
},
}
this.Handler = SandboxedModule.require(modulePath, {
requires: {
'./SubscriptionUpdater': this.SubscriptionUpdater,
'./SubscriptionLocator': this.SubscriptionLocator,
'./SubscriptionController': this.SubscriptionController,
'./SubscriptionHandler': this.SubscriptionHandler,
'../../models/Subscription': {
Subscription: this.Subscription,
},
'./RecurlyClient': this.RecurlyClient,
'./PlansLocator': this.PlansLocator,
'./RecurlyEntities': this.RecurlyEntities,
'../Authentication/SessionManager': this.SessionManager,
'./GroupPlansData': this.GroupPlansData,
},
})
})
describe('removeUserFromGroup', function () {
it('should call the subscription updater to remove the user', async function () {
await this.Handler.promises.removeUserFromGroup(
this.adminUser_id,
this.user._id
)
this.SubscriptionUpdater.promises.removeUserFromGroup
.calledWith(this.adminUser_id, this.user._id)
.should.equal(true)
})
})
describe('replaceUserReferencesInGroups', function () {
beforeEach(async function () {
this.oldId = 'ba5eba11'
this.newId = '5ca1ab1e'
await this.Handler.promises.replaceUserReferencesInGroups(
this.oldId,
this.newId
)
})
it('replaces the admin_id', function () {
this.Subscription.updateOne
.calledWith({ admin_id: this.oldId }, { admin_id: this.newId })
.should.equal(true)
})
it('replaces the manager_ids', function () {
this.Subscription.updateMany
.calledWith(
{ manager_ids: 'ba5eba11' },
{ $addToSet: { manager_ids: '5ca1ab1e' } }
)
.should.equal(true)
this.Subscription.updateMany
.calledWith(
{ manager_ids: 'ba5eba11' },
{ $pull: { manager_ids: 'ba5eba11' } }
)
.should.equal(true)
})
it('replaces the member ids', function () {
this.Subscription.updateMany
.calledWith(
{ member_ids: this.oldId },
{ $addToSet: { member_ids: this.newId } }
)
.should.equal(true)
this.Subscription.updateMany
.calledWith(
{ member_ids: this.oldId },
{ $pull: { member_ids: this.oldId } }
)
.should.equal(true)
})
})
describe('isUserPartOfGroup', function () {
beforeEach(function () {
this.subscription_id = '123ed13123'
})
it('should return true when user is part of subscription', async function () {
this.SubscriptionLocator.promises.getSubscriptionByMemberIdAndId.resolves(
{
_id: this.subscription_id,
}
)
const partOfGroup = await this.Handler.promises.isUserPartOfGroup(
this.user_id,
this.subscription_id
)
partOfGroup.should.equal(true)
})
it('should return false when no subscription is found', async function () {
this.SubscriptionLocator.promises.getSubscriptionByMemberIdAndId.resolves(
null
)
const partOfGroup = await this.Handler.promises.isUserPartOfGroup(
this.user_id,
this.subscription_id
)
partOfGroup.should.equal(false)
})
})
describe('getTotalConfirmedUsersInGroup', function () {
describe('for existing subscriptions', function () {
beforeEach(function () {
this.subscription.member_ids = ['12321', '3121321']
})
it('should call the subscription locator and return 2 users', async function () {
const count = await this.Handler.promises.getTotalConfirmedUsersInGroup(
this.subscription_id
)
this.SubscriptionLocator.promises.getSubscription
.calledWith(this.subscription_id)
.should.equal(true)
count.should.equal(2)
})
})
describe('for nonexistent subscriptions', function () {
it('should return undefined', async function () {
const count =
await this.Handler.promises.getTotalConfirmedUsersInGroup('fake-id')
expect(count).not.to.exist
})
})
})
describe('getUsersGroupSubscriptionDetails', function () {
beforeEach(function () {
this.req = new MockRequest()
this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...this.localPlanInSettings,
canUseFlexibleLicensing: true,
})
})
it('should throw if the subscription is not a group plan', async function () {
this.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
.resolves({ groupPlan: false })
await expect(
this.Handler.promises.getUsersGroupSubscriptionDetails(
this.adminUser_id
)
).to.be.rejectedWith('User subscription is not a group plan')
})
it('should return users group subscription details', async function () {
const data = await this.Handler.promises.getUsersGroupSubscriptionDetails(
this.adminUser_id
)
expect(data).to.deep.equal({
userId: this.adminUser_id,
subscription: {
groupPlan: true,
recurlyStatus: {
state: 'active',
},
},
plan: {
membersLimit: 5,
membersLimitAddOn: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
canUseFlexibleLicensing: true,
},
recurlySubscription: this.recurlySubscription,
})
})
})
describe('add seats subscription change', function () {
beforeEach(function () {
this.req = new MockRequest()
Object.assign(this.req.body, { adding: this.adding })
this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...this.localPlanInSettings,
canUseFlexibleLicensing: true,
})
})
describe('has "additional-license" add-on', function () {
beforeEach(function () {
this.recurlySubscription.addOns = [
{
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: 6,
},
]
this.prevQuantity = this.recurlySubscription.addOns[0].quantity
this.previewSubscriptionChange.nextAddOns = [
{
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: this.prevQuantity + this.adding,
},
]
})
afterEach(function () {
sinon.assert.notCalled(
this.recurlySubscription.getRequestForAddOnPurchase
)
this.recurlySubscription.getRequestForAddOnUpdate
.calledWith(
this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.recurlySubscription.addOns[0].quantity + this.adding
)
.should.equal(true)
})
describe('previewAddSeatsSubscriptionChange', function () {
it('should return the subscription change preview', async function () {
const preview =
await this.Handler.promises.previewAddSeatsSubscriptionChange(
this.adminUser_id,
this.adding
)
this.RecurlyClient.promises.getPaymentMethod
.calledWith(this.adminUser_id)
.should.equal(true)
this.RecurlyClient.promises.previewSubscriptionChange
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionController.makeChangePreview
.calledWith(
{
type: 'add-on-update',
addOn: {
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity:
this.previewSubscriptionChange.nextAddOns[0].quantity,
prevQuantity: this.prevQuantity,
},
},
this.previewSubscriptionChange,
this.paymentMethod
)
.should.equal(true)
preview.should.equal(this.changePreview)
})
})
describe('createAddSeatsSubscriptionChange', function () {
it('should change the subscription', async function () {
const result =
await this.Handler.promises.createAddSeatsSubscriptionChange(
this.adminUser_id,
this.adding
)
this.RecurlyClient.promises.applySubscriptionChangeRequest
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionHandler.promises.syncSubscription
.calledWith(
{ uuid: this.recurlySubscription.id },
this.adminUser_id
)
.should.equal(true)
expect(result).to.deep.equal({
adding: this.req.body.adding,
})
})
})
})
describe('has no "additional-license" add-on', function () {
beforeEach(function () {
this.recurlySubscription.addOns = []
this.prevQuantity = this.recurlySubscription.addOns[0]?.quantity ?? 0
this.previewSubscriptionChange.nextAddOns = [
{
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: this.prevQuantity + this.adding,
},
]
this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...this.localPlanInSettings,
planCode: 'group_collaborator_5_enterprise',
canUseFlexibleLicensing: true,
})
})
afterEach(function () {
sinon.assert.notCalled(
this.recurlySubscription.getRequestForAddOnUpdate
)
})
describe('previewAddSeatsSubscriptionChange', function () {
let preview
afterEach(function () {
this.RecurlyClient.promises.getPaymentMethod
.calledWith(this.adminUser_id)
.should.equal(true)
this.RecurlyClient.promises.previewSubscriptionChange
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionController.makeChangePreview
.calledWith(
{
type: 'add-on-update',
addOn: {
code: this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity:
this.previewSubscriptionChange.nextAddOns[0].quantity,
prevQuantity: this.prevQuantity,
},
},
this.previewSubscriptionChange,
this.paymentMethod
)
.should.equal(true)
preview.should.equal(this.changePreview)
})
it('should return the subscription change preview with legacy add-on price', async function () {
this.recurlySubscription.planPrice =
this.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
100 -
1
preview =
await this.Handler.promises.previewAddSeatsSubscriptionChange(
this.adminUser_id,
this.adding
)
this.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.adding,
this.GroupPlansData.enterprise.collaborator.USD[5]
.additional_license_legacy_price_in_cents / 100
)
.should.equal(true)
})
it('should return the subscription change preview with non-legacy add-on price', async function () {
this.recurlySubscription.planPrice =
this.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
100
preview =
await this.Handler.promises.previewAddSeatsSubscriptionChange(
this.adminUser_id,
this.adding
)
this.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.adding,
undefined
)
.should.equal(true)
})
it('should return the subscription change preview with legacy add-on price for small educational group', async function () {
this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...this.localPlanInSettings,
planCode: 'group_collaborator_5_educational',
canUseFlexibleLicensing: true,
})
this.recurlySubscription.planPrice =
this.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
100 +
1
preview =
await this.Handler.promises.previewAddSeatsSubscriptionChange(
this.adminUser_id,
this.adding
)
this.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.adding,
this.GroupPlansData.enterprise.collaborator.USD[5]
.additional_license_legacy_price_in_cents / 100
)
.should.equal(true)
})
it('should return the subscription change preview with non-legacy add-on price for small educational group', async function () {
this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...this.localPlanInSettings,
planCode: 'group_collaborator_5_educational',
canUseFlexibleLicensing: true,
})
this.recurlySubscription.planPrice =
this.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
100
preview =
await this.Handler.promises.previewAddSeatsSubscriptionChange(
this.adminUser_id,
this.adding
)
this.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
this.RecurlyEntities.MEMBERS_LIMIT_ADD_ON_CODE,
this.adding,
undefined
)
.should.equal(true)
})
})
})
})
describe('ensureFlexibleLicensingEnabled', function () {
it('should throw if the subscription can not use flexible licensing', async function () {
await expect(
this.Handler.promises.ensureFlexibleLicensingEnabled({
canUseFlexibleLicensing: false,
})
).to.be.rejectedWith('The group plan does not support flexible licensing')
})
it('should not throw if the subscription can use flexible licensing', async function () {
await expect(
this.Handler.promises.ensureFlexibleLicensingEnabled({
canUseFlexibleLicensing: true,
})
).to.not.be.rejected
})
})
describe('ensureSubscriptionIsActive', function () {
it('should throw if the subscription is not active', async function () {
await expect(
this.Handler.promises.ensureSubscriptionIsActive({})
).to.be.rejectedWith('The subscription is not active')
})
it('should not throw if the subscription is active', async function () {
await expect(
this.Handler.promises.ensureSubscriptionIsActive({
recurlyStatus: { state: 'active' },
})
).to.not.be.rejected
})
})
describe('ensureSubscriptionCollectionMethodIsNotManual', function () {
it('should throw if the subscription is manually collected', async function () {
await expect(
this.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({
get isCollectionMethodManual() {
return true
},
})
).to.be.rejectedWith('This subscription is being collected manually')
})
it('should not throw if the subscription is automatically collected', async function () {
await expect(
this.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({
get isCollectionMethodManual() {
return false
},
})
).to.not.be.rejected
})
})
describe('upgradeGroupPlan', function () {
it('should upgrade the subscription for flexible licensing group plans', async function () {
this.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
.resolves({
groupPlan: true,
recurlyStatus: {
state: 'active',
},
planCode: 'group_collaborator',
})
await this.Handler.promises.upgradeGroupPlan(this.user_id)
this.recurlySubscription.getRequestForGroupPlanUpgrade
.calledWith('group_professional')
.should.equal(true)
this.RecurlyClient.promises.applySubscriptionChangeRequest
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionHandler.promises.syncSubscription
.calledWith({ uuid: this.changeRequest.subscription.id }, this.user_id)
.should.equal(true)
})
it('should upgrade the subscription for legacy group plans', async function () {
this.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
.resolves({
groupPlan: true,
recurlyStatus: {
state: 'active',
},
planCode: 'group_collaborator_10_educational',
})
await this.Handler.promises.upgradeGroupPlan(this.user_id)
this.recurlySubscription.getRequestForGroupPlanUpgrade
.calledWith('group_professional_10_educational')
.should.equal(true)
this.RecurlyClient.promises.applySubscriptionChangeRequest
.calledWith(this.changeRequest)
.should.equal(true)
this.SubscriptionHandler.promises.syncSubscription
.calledWith({ uuid: this.changeRequest.subscription.id }, this.user_id)
.should.equal(true)
})
it('should fail the upgrade if is professional already', async function () {
this.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
.resolves({
groupPlan: true,
recurlyStatus: {
state: 'active',
},
planCode: 'group_professional',
})
await expect(
this.Handler.promises.upgradeGroupPlan(this.user_id)
).to.be.rejectedWith('Not eligible for group plan upgrade')
})
it('should fail the upgrade if not group plan', async function () {
this.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
.resolves({
groupPlan: false,
recurlyStatus: {
state: 'active',
},
planCode: 'test_plan_code',
})
await expect(
this.Handler.promises.upgradeGroupPlan(this.user_id)
).to.be.rejectedWith('Not eligible for group plan upgrade')
})
})
describe('getGroupPlanUpgradePreview', function () {
it('should generate preview for subscription upgrade', async function () {
this.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
.resolves({
groupPlan: true,
recurlyStatus: {
state: 'active',
},
planCode: 'group_collaborator',
})
const result = await this.Handler.promises.getGroupPlanUpgradePreview(
this.user_id
)
this.RecurlyClient.promises.previewSubscriptionChange
.calledWith(this.changeRequest)
.should.equal(true)
result.should.equal(this.changePreview)
})
})
})