Files
overleaf-cep/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js
ilkin-overleaf d898582b2f Merge pull request #26829 from overleaf/ii-flexible-licensing-manually-billed-users-add-seats
[web] FL manually billed subscriptions with no upsell

GitOrigin-RevId: b5f2083c7eabd0a1a5d024d5699d2c5e5556671a
2025-07-09 08:06:44 +00:00

1214 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb-legacy')
const sinon = require('sinon')
const { expect } = require('chai')
const MockRequest = require('../helpers/MockRequest')
const {
InvalidEmailError,
} = require('../../../../app/src/Features/Errors/Errors')
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.PaymentProviderEntities = {
MEMBERS_LIMIT_ADD_ON_CODE: 'additional-license',
}
this.localPlanInSettings = {
membersLimit: 5,
membersLimitAddOn: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
}
this.subscription = {
admin_id: this.adminUser_id,
manager_ids: [this.adminUser_id],
_id: this.subscription_id,
membersLimit: 100,
}
this.changeRequest = {
timeframe: 'now',
subscription: {
id: 'test_id',
},
}
this.termsAndConditionsUpdate = {
termsAndConditions: 'T&C copy',
}
this.poNumberAndTermsAndConditionsUpdate = {
poNumber: '4444',
...this.termsAndConditionsUpdate,
}
this.recurlySubscription = {
id: 123,
addOns: [
{
code: this.PaymentProviderEntities.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),
getRequestForPoNumberAndTermsAndConditionsUpdate: sinon
.stub()
.returns(this.poNumberAndTermsAndConditionsUpdate),
getRequestForTermsAndConditionsUpdate: sinon
.stub()
.returns(this.termsAndConditionsUpdate),
currency: 'USD',
hasAddOn(code) {
return this.addOns.some(addOn => addOn.code === code)
},
get isCollectionMethodManual() {
return false
},
}
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.User = {
find: sinon.stub().returns({ exec: sinon.stub().resolves }),
}
this.SessionManager = {
getLoggedInUserId: sinon.stub().returns(this.user._id),
}
this.previewSubscriptionChange = {
nextAddOns: [
{
code: this.PaymentProviderEntities.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),
updateSubscriptionDetails: sinon.stub().resolves(),
},
}
this.PlansLocator = {
findLocalPlanInSettings: sinon.stub().returns(this.localPlanInSettings),
}
this.SubscriptionHandler = {
promises: {
syncSubscription: sinon.stub().resolves(),
},
}
this.TeamInvitesHandler = {
promises: {
revokeInvite: sinon.stub().resolves(),
createInvite: 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.Modules = {
promises: {
hooks: {
fire: sinon.stub(),
},
},
}
this.Modules.promises.hooks.fire
.withArgs('generateTermsAndConditions')
.resolves(['T&Cs'])
.withArgs('getPaymentFromRecord')
.resolves([
{
subscription: this.recurlySubscription,
account: { hasPastDueInvoice: false },
},
])
.withArgs('previewSubscriptionChangeRequest')
.resolves([this.previewSubscriptionChange])
.withArgs('previewGroupPlanUpgrade')
.resolves([{ subscriptionChange: this.previewSubscriptionChange }])
this.Handler = SandboxedModule.require(modulePath, {
requires: {
'./SubscriptionUpdater': this.SubscriptionUpdater,
'./SubscriptionLocator': this.SubscriptionLocator,
'./SubscriptionController': this.SubscriptionController,
'./SubscriptionHandler': this.SubscriptionHandler,
'./TeamInvitesHandler': this.TeamInvitesHandler,
'../../models/Subscription': {
Subscription: this.Subscription,
},
'../../models/User': {
User: this.User,
},
'./RecurlyClient': this.RecurlyClient,
'./PlansLocator': this.PlansLocator,
'./PaymentProviderEntities': this.PaymentProviderEntities,
'../Authentication/SessionManager': this.SessionManager,
'./GroupPlansData': this.GroupPlansData,
'../../infrastructure/Modules': this.Modules,
},
})
})
describe('removeUserFromGroup', function () {
it('should call the subscription updater to remove the user', async function () {
const auditLog = { ipAddress: '0:0:0:0', initiatorId: this.user._id }
await this.Handler.promises.removeUserFromGroup(
this.adminUser_id,
this.user._id,
auditLog
)
this.SubscriptionUpdater.promises.removeUserFromGroup
.calledWith(this.adminUser_id, this.user._id, auditLog)
.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.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
canUseFlexibleLicensing: true,
},
paymentProviderSubscription: 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.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: 6,
},
]
this.prevQuantity = this.recurlySubscription.addOns[0].quantity
this.previewSubscriptionChange.nextAddOns = [
{
code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: this.prevQuantity + this.adding,
},
]
})
afterEach(function () {
sinon.assert.notCalled(
this.recurlySubscription.getRequestForAddOnPurchase
)
this.recurlySubscription.getRequestForAddOnUpdate
.calledWith(
this.PaymentProviderEntities.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.Modules.promises.hooks.fire
.calledWith('getPaymentFromRecord', {
groupPlan: true,
recurlyStatus: {
state: 'active',
},
})
.should.equal(true)
this.Modules.promises.hooks.fire
.calledWith('previewSubscriptionChangeRequest', this.changeRequest)
.should.equal(true)
this.SubscriptionController.makeChangePreview
.calledWith(
{
type: 'add-on-update',
addOn: {
code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity:
this.previewSubscriptionChange.nextAddOns[0].quantity,
prevQuantity: this.prevQuantity,
},
},
this.previewSubscriptionChange
)
.should.equal(true)
preview.should.equal(this.changePreview)
})
})
describe('createAddSeatsSubscriptionChange', function () {
it('should change the subscription', async function () {
this.recurlySubscription = {
...this.recurlySubscription,
get isCollectionMethodManual() {
return true
},
}
this.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord')
.resolves([
{
subscription: this.recurlySubscription,
account: { hasPastDueInvoice: false },
},
])
const result =
await this.Handler.promises.createAddSeatsSubscriptionChange(
this.adminUser_id,
this.adding,
'123'
)
this.RecurlyClient.promises.updateSubscriptionDetails
.calledWith(
sinon.match
.has('poNumber')
.and(sinon.match.has('termsAndConditions'))
)
.should.equal(true)
this.Modules.promises.hooks.fire
.calledWith(
'applySubscriptionChangeRequestAndSync',
this.changeRequest
)
.should.equal(true)
expect(result).to.deep.equal({
adding: this.req.body.adding,
})
})
})
})
describe('updateSubscriptionPaymentTerms', function () {
describe('accounts with PO number', function () {
it('should update the subscription PO number and T&C', async function () {
await this.Handler.promises.updateSubscriptionPaymentTerms(
this.recurlySubscription,
this.poNumberAndTermsAndConditionsUpdate.poNumber
)
this.recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate
.calledWithMatch(
this.poNumberAndTermsAndConditionsUpdate.poNumber,
'T&Cs'
)
.should.equal(true)
this.RecurlyClient.promises.updateSubscriptionDetails
.calledWith(this.poNumberAndTermsAndConditionsUpdate)
.should.equal(true)
})
it('should fail for stripe', async function () {
this.recurlySubscription.service = 'stripe'
await expect(
this.Handler.promises.updateSubscriptionPaymentTerms(
this.recurlySubscription,
this.poNumberAndTermsAndConditionsUpdate.poNumber
)
).to.be.rejectedWith(
'Updating payment terms is not supported for Stripe subscriptions'
)
})
})
describe('accounts with no PO number', function () {
it('should update the subscription T&C only', async function () {
await this.Handler.promises.updateSubscriptionPaymentTerms(
this.recurlySubscription
)
this.recurlySubscription.getRequestForTermsAndConditionsUpdate
.calledWithMatch('T&Cs')
.should.equal(true)
this.RecurlyClient.promises.updateSubscriptionDetails
.calledWith(this.termsAndConditionsUpdate)
.should.equal(true)
})
})
})
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.PaymentProviderEntities.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.Modules.promises.hooks.fire
.calledWith('getPaymentFromRecord', {
groupPlan: true,
recurlyStatus: {
state: 'active',
},
})
.should.equal(true)
this.Modules.promises.hooks.fire
.calledWith('previewSubscriptionChangeRequest', this.changeRequest)
.should.equal(true)
this.SubscriptionController.makeChangePreview
.calledWith(
{
type: 'add-on-update',
addOn: {
code: this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity:
this.previewSubscriptionChange.nextAddOns[0].quantity,
prevQuantity: this.prevQuantity,
},
},
this.previewSubscriptionChange
)
.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.PaymentProviderEntities.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.PaymentProviderEntities.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.PaymentProviderEntities.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.PaymentProviderEntities.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(this.subscription)
).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('ensureSubscriptionHasNoPendingChanges', function () {
it('should throw if the subscription has pending change', async function () {
await expect(
this.Handler.promises.ensureSubscriptionHasNoPendingChanges({
pendingChange: {},
})
).to.be.rejectedWith('This subscription has a pending change')
})
it('should not throw if the subscription has no pending change', async function () {
await expect(
this.Handler.promises.ensureSubscriptionHasNoPendingChanges({})
).to.not.be.rejected
})
})
describe('ensureSubscriptionHasNoPastDueInvoice', function () {
it('should throw if the subscription has past due invoice', async function () {
this.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord')
.resolves([{ account: { hasPastDueInvoice: true } }])
await expect(
this.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
this.subscription
)
).to.be.rejectedWith('This subscription has a past due invoice')
})
it('should not throw if the subscription has no past due invoice', async function () {
await expect(
this.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
this.subscription
)
).to.not.be.rejected
})
})
describe('ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual', function () {
it('should throw if the subscription is manually collected and has no additional license add-on', async function () {
await expect(
this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
{
isCollectionMethodManual: true,
hasAddOn: sinon
.stub()
.withArgs('additional-license')
.returns(false),
}
)
).to.be.rejectedWith(
'This subscription is being collected manually has no "additional-license" add-on'
)
})
it('should not throw if the subscription is not manually collected and has no additional license add-on and ', async function () {
await expect(
this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
{
isCollectionMethodManual: false,
hasAddOn: sinon
.stub()
.withArgs('additional-license')
.returns(false),
}
)
).to.not.be.rejected
})
it('should not throw if the subscription is not manually collected and has additional license add-on', async function () {
await expect(
this.Handler.promises.ensureSubscriptionHasAdditionalLicenseAddOnWhenCollectionMethodIsManual(
{
isCollectionMethodManual: true,
hasAddOn: sinon.stub().withArgs('additional-license').returns(true),
}
)
).to.not.be.rejected
})
})
describe('getGroupPlanUpgradePreview', function () {
it('should generate preview for subscription upgrade', async function () {
const result = await this.Handler.promises.getGroupPlanUpgradePreview(
this.user_id
)
result.should.equal(this.changePreview)
})
})
describe('checkBillingInfoExistence', function () {
it('should invoke the payment method function when collection method is "automatic"', async function () {
await this.Handler.promises.checkBillingInfoExistence(
this.recurlySubscription,
this.adminUser_id
)
this.Modules.promises.hooks.fire
.calledWith('getPaymentMethod', this.adminUser_id)
.should.equal(true)
})
it('shouldnt invoke the payment method function when collection method is "manual"', async function () {
const recurlySubscription = {
...this.recurlySubscription,
get isCollectionMethodManual() {
return true
},
}
await this.Handler.promises.checkBillingInfoExistence(
recurlySubscription,
this.adminUser_id
)
this.RecurlyClient.promises.getPaymentMethod.should.not.have.been.called
})
})
describe('updateGroupMembersBulk', function () {
const inviterId = new ObjectId()
let members
let emailList
let callUpdateGroupMembersBulk
beforeEach(function () {
members = [
{
_id: new ObjectId(),
email: 'user1@example.com',
emails: [{ email: 'user1@example.com' }],
},
{
_id: new ObjectId(),
email: 'user2-alias@example.com',
emails: [
{
email: 'user2-alias@example.com',
},
{
email: 'user2@example.com',
},
],
},
{
_id: new ObjectId(),
email: 'user3@example.com',
emails: [{ email: 'user3@example.com' }],
},
]
emailList = [
'user1@example.com',
'user2@example.com',
'new-user@example.com', // primary email of existing user
'new-user-2@example.com', // secondary email of existing user
]
callUpdateGroupMembersBulk = async (options = {}) => {
this.Subscription.findOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves(this.subscription) })
this.User.find = sinon
.stub()
.returns({ exec: sinon.stub().resolves(members) })
return await this.Handler.promises.updateGroupMembersBulk(
inviterId,
this.subscription._id,
emailList,
options
)
}
})
it('throws an error when any of the emails is invalid', async function () {
emailList.push('invalid@email')
await expect(
callUpdateGroupMembersBulk({ commit: true })
).to.be.rejectedWith(InvalidEmailError)
})
describe('with commit = false', function () {
describe('with removeMembersNotIncluded = false', function () {
it('should preview zero users to delete, and should not send invites', async function () {
const result = await callUpdateGroupMembersBulk()
expect(result).to.deep.equal({
emailsToSendInvite: [
'new-user@example.com',
'new-user-2@example.com',
],
emailsToRevokeInvite: [],
membersToRemove: [],
currentMemberCount: 3,
newTotalCount: 5,
membersLimit: this.subscription.membersLimit,
})
expect(this.TeamInvitesHandler.promises.createInvite).not.to.have.been
.called
expect(this.SubscriptionUpdater.promises.removeUserFromGroup).not.to
.have.been.called
})
})
describe('with removeMembersNotIncluded = true', function () {
it('should preview the users to be deleted, and should not send invites', async function () {
const result = await callUpdateGroupMembersBulk({
removeMembersNotIncluded: true,
})
expect(result).to.deep.equal({
emailsToSendInvite: [
'new-user@example.com',
'new-user-2@example.com',
],
emailsToRevokeInvite: [],
membersToRemove: [members[2]._id],
currentMemberCount: 3,
newTotalCount: 4,
membersLimit: this.subscription.membersLimit,
})
expect(this.TeamInvitesHandler.promises.createInvite).not.to.have.been
.called
expect(this.SubscriptionUpdater.promises.removeUserFromGroup).not.to
.have.been.called
})
it('should preview but not revoke invites to emails that are no longer invited', async function () {
this.subscription.teamInvites = [
{ email: 'new-user@example.com' },
{ email: 'no-longer-invited@example.com' },
]
const result = await callUpdateGroupMembersBulk({
removeMembersNotIncluded: true,
})
expect(result.emailsToRevokeInvite).to.deep.equal([
'no-longer-invited@example.com',
])
expect(this.TeamInvitesHandler.promises.revokeInvite).not.to.have.been
.called
})
})
it('does not throw an error when the member limit is reached', async function () {
this.subscription.membersLimit = 3
const result = await callUpdateGroupMembersBulk()
expect(result.membersLimit).to.equal(3)
expect(result.newTotalCount).to.equal(5)
})
})
describe('with commit = true', function () {
describe('with removeMembersNotIncluded = false', function () {
it('should preview zero users to delete, and should send invites', async function () {
const result = await callUpdateGroupMembersBulk({ commit: true })
expect(result).to.deep.equal({
emailsToSendInvite: [
'new-user@example.com',
'new-user-2@example.com',
],
emailsToRevokeInvite: [],
membersToRemove: [],
currentMemberCount: 3,
newTotalCount: 5,
membersLimit: this.subscription.membersLimit,
})
expect(this.SubscriptionUpdater.promises.removeUserFromGroup).not.to
.have.been.called
expect(
this.TeamInvitesHandler.promises.createInvite.callCount
).to.equal(2)
expect(
this.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
this.subscription,
'new-user@example.com'
)
expect(
this.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
this.subscription,
'new-user-2@example.com'
)
})
it('should not send invites to emails already invited', async function () {
this.subscription.teamInvites = [{ email: 'new-user@example.com' }]
const result = await callUpdateGroupMembersBulk({ commit: true })
expect(result.emailsToSendInvite).to.deep.equal([
'new-user-2@example.com',
])
expect(
this.TeamInvitesHandler.promises.createInvite.callCount
).to.equal(1)
expect(
this.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
this.subscription,
'new-user-2@example.com'
)
})
it('should preview and not revoke invites to emails that are no longer invited', async function () {
this.subscription.teamInvites = [
{ email: 'new-user@example.com' },
{ email: 'no-longer-invited@example.com' },
]
const result = await callUpdateGroupMembersBulk({
commit: true,
})
expect(result.emailsToRevokeInvite).to.deep.equal([])
expect(this.TeamInvitesHandler.promises.revokeInvite).not.to.have.been
.called
})
})
describe('with removeMembersNotIncluded = true', function () {
it('should remove users from group, and should send invites', async function () {
const result = await callUpdateGroupMembersBulk({
commit: true,
removeMembersNotIncluded: true,
})
expect(result).to.deep.equal({
emailsToSendInvite: [
'new-user@example.com',
'new-user-2@example.com',
],
emailsToRevokeInvite: [],
membersToRemove: [members[2]._id],
currentMemberCount: 3,
newTotalCount: 4,
membersLimit: this.subscription.membersLimit,
})
expect(
this.SubscriptionUpdater.promises.removeUserFromGroup.callCount
).to.equal(1)
expect(
this.SubscriptionUpdater.promises.removeUserFromGroup
).to.have.been.calledWith(this.subscription._id, members[2]._id, {
initiatorId: inviterId,
})
expect(
this.TeamInvitesHandler.promises.createInvite.callCount
).to.equal(2)
expect(
this.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
this.subscription,
'new-user@example.com'
)
expect(
this.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
this.subscription,
'new-user-2@example.com'
)
})
it('should send invites and revoke invites to emails no longer invited', async function () {
this.subscription.teamInvites = [
{ email: 'new-user@example.com' },
{ email: 'no-longer-invited@example.com' },
]
const result = await callUpdateGroupMembersBulk({
commit: true,
removeMembersNotIncluded: true,
})
expect(result.emailsToSendInvite).to.deep.equal([
'new-user-2@example.com',
])
expect(result.emailsToRevokeInvite).to.deep.equal([
'no-longer-invited@example.com',
])
expect(
this.TeamInvitesHandler.promises.createInvite.callCount
).to.equal(1)
expect(
this.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
this.subscription,
'new-user-2@example.com'
)
expect(
this.TeamInvitesHandler.promises.revokeInvite.callCount
).to.equal(1)
expect(
this.TeamInvitesHandler.promises.revokeInvite
).to.have.been.calledWith(
inviterId,
this.subscription,
'no-longer-invited@example.com'
)
})
})
it('throws an error when the member limit is reached', async function () {
this.subscription.membersLimit = 3
await expect(
callUpdateGroupMembersBulk({ commit: true })
).to.be.rejectedWith('limit reached')
})
})
})
})