Files
Verso/services/web/test/unit/src/Subscription/SubscriptionGroupHandler.test.mjs
T
KristinaandCopybot 4c5cdecffa [web] fix bug where pending downgrades are removed when subscriptions change (#30420)
* preserve pending changes when generating change requests
* re-apply pending term_end changes after immediate updates
* block changes when Stripe subscription has multiple phases
* handle MultiplePendingChangesError & rm PendingChangeError

GitOrigin-RevId: 0af11044766ff48e683d684ad6d62b732d17290c
2026-02-03 09:05:55 +00:00

1248 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.
import { vi, expect } from 'vitest'
import mongodb from 'mongodb-legacy'
import sinon from 'sinon'
import MockRequest from '../helpers/MockRequest.mjs'
import { InvalidEmailError } from '../../../../app/src/Features/Errors/Errors.js'
const { ObjectId } = mongodb
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionGroupHandler.mjs'
describe('SubscriptionGroupHandler', function () {
beforeEach(async function (ctx) {
ctx.adminUser_id = '12321'
ctx.newEmail = 'bob@smith.com'
ctx.user_id = '3121321'
ctx.email = 'jim@example.com'
ctx.user = { _id: ctx.user_id, email: ctx.newEmail }
ctx.subscription_id = '31DSd1123D'
ctx.adding = 1
ctx.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
ctx.PaymentProviderEntities = {
MEMBERS_LIMIT_ADD_ON_CODE: 'additional-license',
}
ctx.localPlanInSettings = {
membersLimit: 5,
membersLimitAddOn: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
}
ctx.subscription = {
admin_id: ctx.adminUser_id,
manager_ids: [ctx.adminUser_id],
_id: ctx.subscription_id,
membersLimit: 100,
}
ctx.changeRequest = {
timeframe: 'now',
subscription: {
id: 'test_id',
},
}
ctx.termsAndConditionsUpdate = {
termsAndConditions: 'T&C copy',
}
ctx.poNumberAndTermsAndConditionsUpdate = {
poNumber: '4444',
...ctx.termsAndConditionsUpdate,
}
ctx.recurlySubscription = {
id: 123,
addOns: [
{
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: 1,
},
],
getRequestForAddOnUpdate: sinon.stub().returns(ctx.changeRequest),
getRequestForGroupPlanUpgrade: sinon.stub().returns(ctx.changeRequest),
getRequestForAddOnPurchase: sinon.stub().returns(ctx.changeRequest),
getRequestForFlexibleLicensingGroupPlanUpgrade: sinon
.stub()
.returns(ctx.changeRequest),
getRequestForPoNumberAndTermsAndConditionsUpdate: sinon
.stub()
.returns(ctx.poNumberAndTermsAndConditionsUpdate),
getRequestForTermsAndConditionsUpdate: sinon
.stub()
.returns(ctx.termsAndConditionsUpdate),
currency: 'USD',
hasAddOn(code) {
return this.addOns.some(addOn => addOn.code === code)
},
get isCollectionMethodManual() {
return false
},
}
ctx.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub().resolves({
groupPlan: true,
recurlyStatus: {
state: 'active',
},
}),
getSubscriptionByMemberIdAndId: sinon.stub(),
getSubscription: sinon.stub().resolves(ctx.subscription),
},
}
ctx.changePreview = {
currency: 'USD',
}
ctx.SubscriptionController = {
makeChangePreview: sinon.stub().resolves(ctx.changePreview),
getPlanNameForDisplay: sinon.stub().resolves(),
}
ctx.SubscriptionUpdater = {
promises: {
removeUserFromGroup: sinon.stub().resolves(),
getSubscription: sinon.stub().resolves(),
},
}
ctx.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 }),
}
ctx.User = {
find: sinon.stub().returns({ exec: sinon.stub().resolves }),
}
ctx.SessionManager = {
getLoggedInUserId: sinon.stub().returns(ctx.user._id),
}
ctx.previewSubscriptionChange = {
nextAddOns: [
{
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: ctx.recurlySubscription.addOns[0].quantity + ctx.adding,
},
],
subscription: {
planName: 'test plan',
},
}
ctx.applySubscriptionChange = {}
ctx.RecurlyClient = {
promises: {
getSubscription: sinon.stub().resolves(ctx.recurlySubscription),
getPaymentMethod: sinon.stub().resolves(ctx.paymentMethod),
previewSubscriptionChange: sinon
.stub()
.resolves(ctx.previewSubscriptionChange),
applySubscriptionChangeRequest: sinon
.stub()
.resolves(ctx.applySubscriptionChange),
updateSubscriptionDetails: sinon.stub().resolves(),
},
}
ctx.PlansLocator = {
findLocalPlanInSettings: sinon.stub().returns(ctx.localPlanInSettings),
}
ctx.SubscriptionHandler = {
promises: {
syncSubscription: sinon.stub().resolves(),
},
}
ctx.TeamInvitesHandler = {
promises: {
revokeInvite: sinon.stub().resolves(),
createInvite: sinon.stub().resolves(),
},
}
ctx.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,
},
},
},
},
}
ctx.Modules = {
promises: {
hooks: {
fire: sinon.stub(),
},
},
}
ctx.Modules.promises.hooks.fire
.withArgs('generateTermsAndConditions')
.resolves(['T&Cs'])
.withArgs('getPaymentFromRecord')
.resolves([
{
subscription: ctx.recurlySubscription,
account: { hasPastDueInvoice: false },
},
])
.withArgs('previewSubscriptionChangeRequest')
.resolves([ctx.previewSubscriptionChange])
.withArgs('previewGroupPlanUpgrade')
.resolves([{ subscriptionChange: ctx.previewSubscriptionChange }])
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionUpdater.mjs',
() => ({
default: ctx.SubscriptionUpdater,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionLocator',
() => ({
default: ctx.SubscriptionLocator,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionController',
() => ({
default: ctx.SubscriptionController,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/SubscriptionHandler',
() => ({
default: ctx.SubscriptionHandler,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/TeamInvitesHandler',
() => ({
default: ctx.TeamInvitesHandler,
})
)
vi.doMock('../../../../app/src/models/Subscription', () => ({
Subscription: ctx.Subscription,
}))
vi.doMock('../../../../app/src/models/User', () => ({
User: ctx.User,
}))
vi.doMock(
'../../../../app/src/Features/Subscription/RecurlyClient',
() => ({
default: ctx.RecurlyClient,
})
)
vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
default: ctx.PlansLocator,
}))
vi.doMock(
'../../../../app/src/Features/Subscription/PaymentProviderEntities',
() => ({
default: ctx.PaymentProviderEntities,
})
)
vi.doMock(
'../../../../app/src/Features/Authentication/SessionManager',
() => ({
default: ctx.SessionManager,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/GroupPlansData',
() => ({
default: ctx.GroupPlansData,
})
)
vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
default: ctx.Modules,
}))
ctx.Handler = (await import(modulePath)).default
})
describe('removeUserFromGroup', function () {
it('should call the subscription updater to remove the user', async function (ctx) {
const auditLog = { ipAddress: '0:0:0:0', initiatorId: ctx.user._id }
await ctx.Handler.promises.removeUserFromGroup(
ctx.adminUser_id,
ctx.user._id,
auditLog
)
ctx.SubscriptionUpdater.promises.removeUserFromGroup
.calledWith(ctx.adminUser_id, ctx.user._id, auditLog)
.should.equal(true)
})
})
describe('replaceUserReferencesInGroups', function () {
beforeEach(async function (ctx) {
ctx.oldId = 'ba5eba11'
ctx.newId = '5ca1ab1e'
await ctx.Handler.promises.replaceUserReferencesInGroups(
ctx.oldId,
ctx.newId
)
})
it('replaces the admin_id', function (ctx) {
ctx.Subscription.updateOne
.calledWith({ admin_id: ctx.oldId }, { admin_id: ctx.newId })
.should.equal(true)
})
it('replaces the manager_ids', function (ctx) {
ctx.Subscription.updateMany
.calledWith(
{ manager_ids: 'ba5eba11' },
{ $addToSet: { manager_ids: '5ca1ab1e' } }
)
.should.equal(true)
ctx.Subscription.updateMany
.calledWith(
{ manager_ids: 'ba5eba11' },
{ $pull: { manager_ids: 'ba5eba11' } }
)
.should.equal(true)
})
it('replaces the member ids', function (ctx) {
ctx.Subscription.updateMany
.calledWith(
{ member_ids: ctx.oldId },
{ $addToSet: { member_ids: ctx.newId } }
)
.should.equal(true)
ctx.Subscription.updateMany
.calledWith(
{ member_ids: ctx.oldId },
{ $pull: { member_ids: ctx.oldId } }
)
.should.equal(true)
})
})
describe('isUserPartOfGroup', function () {
beforeEach(function (ctx) {
ctx.subscription_id = '123ed13123'
})
it('should return true when user is part of subscription', async function (ctx) {
ctx.SubscriptionLocator.promises.getSubscriptionByMemberIdAndId.resolves({
_id: ctx.subscription_id,
})
const partOfGroup = await ctx.Handler.promises.isUserPartOfGroup(
ctx.user_id,
ctx.subscription_id
)
partOfGroup.should.equal(true)
})
it('should return false when no subscription is found', async function (ctx) {
ctx.SubscriptionLocator.promises.getSubscriptionByMemberIdAndId.resolves(
null
)
const partOfGroup = await ctx.Handler.promises.isUserPartOfGroup(
ctx.user_id,
ctx.subscription_id
)
partOfGroup.should.equal(false)
})
})
describe('getTotalConfirmedUsersInGroup', function () {
describe('for existing subscriptions', function () {
beforeEach(function (ctx) {
ctx.subscription.member_ids = ['12321', '3121321']
})
it('should call the subscription locator and return 2 users', async function (ctx) {
const count = await ctx.Handler.promises.getTotalConfirmedUsersInGroup(
ctx.subscription_id
)
ctx.SubscriptionLocator.promises.getSubscription
.calledWith(ctx.subscription_id)
.should.equal(true)
count.should.equal(2)
})
})
describe('for nonexistent subscriptions', function () {
it('should return undefined', async function (ctx) {
const count =
await ctx.Handler.promises.getTotalConfirmedUsersInGroup('fake-id')
expect(count).not.to.exist
})
})
})
describe('getUsersGroupSubscriptionDetails', function () {
beforeEach(function (ctx) {
ctx.req = new MockRequest(vi)
ctx.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...ctx.localPlanInSettings,
canUseFlexibleLicensing: true,
})
})
it('should throw if the subscription is not a group plan', async function (ctx) {
ctx.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
.resolves({ groupPlan: false })
await expect(
ctx.Handler.promises.getUsersGroupSubscriptionDetails(ctx.adminUser_id)
).to.be.rejectedWith('User subscription is not a group plan')
})
it('should return users group subscription details', async function (ctx) {
const data = await ctx.Handler.promises.getUsersGroupSubscriptionDetails(
ctx.adminUser_id
)
expect(data).to.deep.equal({
userId: ctx.adminUser_id,
subscription: {
groupPlan: true,
recurlyStatus: {
state: 'active',
},
},
plan: {
membersLimit: 5,
membersLimitAddOn:
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
canUseFlexibleLicensing: true,
},
paymentProviderSubscription: ctx.recurlySubscription,
})
})
})
describe('add seats subscription change', function () {
beforeEach(function (ctx) {
ctx.req = new MockRequest(vi)
Object.assign(ctx.req.body, { adding: ctx.adding })
ctx.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...ctx.localPlanInSettings,
canUseFlexibleLicensing: true,
})
})
describe('has "additional-license" add-on', function () {
beforeEach(function (ctx) {
ctx.recurlySubscription.addOns = [
{
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: 6,
},
]
ctx.prevQuantity = ctx.recurlySubscription.addOns[0].quantity
ctx.previewSubscriptionChange.nextAddOns = [
{
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: ctx.prevQuantity + ctx.adding,
},
]
})
afterEach(function (ctx) {
sinon.assert.notCalled(
ctx.recurlySubscription.getRequestForAddOnPurchase
)
ctx.recurlySubscription.getRequestForAddOnUpdate
.calledWith(
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
ctx.recurlySubscription.addOns[0].quantity + ctx.adding
)
.should.equal(true)
})
describe('previewAddSeatsSubscriptionChange', function () {
it('should return the subscription change preview', async function (ctx) {
const preview =
await ctx.Handler.promises.previewAddSeatsSubscriptionChange(
ctx.adminUser_id,
ctx.adding
)
ctx.Modules.promises.hooks.fire
.calledWith('getPaymentFromRecord', {
groupPlan: true,
recurlyStatus: {
state: 'active',
},
})
.should.equal(true)
ctx.Modules.promises.hooks.fire
.calledWith('previewSubscriptionChangeRequest', ctx.changeRequest)
.should.equal(true)
ctx.SubscriptionController.makeChangePreview
.calledWith(
{
type: 'add-on-update',
addOn: {
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity:
ctx.previewSubscriptionChange.nextAddOns[0].quantity,
prevQuantity: ctx.prevQuantity,
},
},
ctx.previewSubscriptionChange
)
.should.equal(true)
preview.should.equal(ctx.changePreview)
})
})
describe('createAddSeatsSubscriptionChange', function () {
it('should change the subscription', async function (ctx) {
ctx.recurlySubscription = {
...ctx.recurlySubscription,
get isCollectionMethodManual() {
return true
},
}
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord')
.resolves([
{
subscription: ctx.recurlySubscription,
account: { hasPastDueInvoice: false },
},
])
const result =
await ctx.Handler.promises.createAddSeatsSubscriptionChange(
ctx.adminUser_id,
ctx.adding,
'123'
)
ctx.Modules.promises.hooks.fire
.calledWith(
'updateSubscriptionDetails',
sinon.match
.has('poNumber')
.and(sinon.match.has('termsAndConditions'))
)
.should.equal(true)
ctx.Modules.promises.hooks.fire
.calledWith(
'applySubscriptionChangeRequestAndSync',
ctx.changeRequest
)
.should.equal(true)
expect(result).to.deep.equal({
adding: ctx.req.body.adding,
})
})
})
})
describe('updateSubscriptionPaymentTerms', function () {
describe('accounts with PO number', function () {
it('should update the subscription PO number and T&C', async function (ctx) {
await ctx.Handler.promises.updateSubscriptionPaymentTerms(
ctx.recurlySubscription,
ctx.poNumberAndTermsAndConditionsUpdate.poNumber
)
ctx.recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate
.calledWithMatch(
ctx.poNumberAndTermsAndConditionsUpdate.poNumber,
'T&Cs'
)
.should.equal(true)
ctx.Modules.promises.hooks.fire
.calledWith(
'updateSubscriptionDetails',
ctx.poNumberAndTermsAndConditionsUpdate
)
.should.equal(true)
})
})
describe('accounts with no PO number', function () {
it('should update the subscription T&C only', async function (ctx) {
await ctx.Handler.promises.updateSubscriptionPaymentTerms(
ctx.recurlySubscription
)
ctx.recurlySubscription.getRequestForTermsAndConditionsUpdate
.calledWithMatch('T&Cs')
.should.equal(true)
ctx.Modules.promises.hooks.fire
.calledWith(
'updateSubscriptionDetails',
ctx.termsAndConditionsUpdate
)
.should.equal(true)
})
})
})
describe('has no "additional-license" add-on', function () {
beforeEach(function (ctx) {
ctx.recurlySubscription.addOns = []
ctx.prevQuantity = ctx.recurlySubscription.addOns[0]?.quantity ?? 0
ctx.previewSubscriptionChange.nextAddOns = [
{
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity: ctx.prevQuantity + ctx.adding,
},
]
ctx.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...ctx.localPlanInSettings,
planCode: 'group_collaborator_5_enterprise',
canUseFlexibleLicensing: true,
})
})
afterEach(function (ctx) {
sinon.assert.notCalled(ctx.recurlySubscription.getRequestForAddOnUpdate)
})
describe('previewAddSeatsSubscriptionChange', function () {
let preview
afterEach(function (ctx) {
ctx.Modules.promises.hooks.fire
.calledWith('getPaymentFromRecord', {
groupPlan: true,
recurlyStatus: {
state: 'active',
},
})
.should.equal(true)
ctx.Modules.promises.hooks.fire
.calledWith('previewSubscriptionChangeRequest', ctx.changeRequest)
.should.equal(true)
ctx.SubscriptionController.makeChangePreview
.calledWith(
{
type: 'add-on-update',
addOn: {
code: ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
quantity:
ctx.previewSubscriptionChange.nextAddOns[0].quantity,
prevQuantity: ctx.prevQuantity,
},
},
ctx.previewSubscriptionChange
)
.should.equal(true)
preview.should.equal(ctx.changePreview)
})
it('should return the subscription change preview with legacy add-on price', async function (ctx) {
ctx.recurlySubscription.planPrice =
ctx.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
100 -
1
preview =
await ctx.Handler.promises.previewAddSeatsSubscriptionChange(
ctx.adminUser_id,
ctx.adding
)
ctx.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
ctx.adding,
ctx.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 (ctx) {
ctx.recurlySubscription.planPrice =
ctx.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
100
preview =
await ctx.Handler.promises.previewAddSeatsSubscriptionChange(
ctx.adminUser_id,
ctx.adding
)
ctx.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
ctx.adding,
undefined
)
.should.equal(true)
})
it('should return the subscription change preview with legacy add-on price for small educational group', async function (ctx) {
ctx.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...ctx.localPlanInSettings,
planCode: 'group_collaborator_5_educational',
canUseFlexibleLicensing: true,
})
ctx.recurlySubscription.planPrice =
ctx.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
100 +
1
preview =
await ctx.Handler.promises.previewAddSeatsSubscriptionChange(
ctx.adminUser_id,
ctx.adding
)
ctx.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
ctx.adding,
ctx.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 (ctx) {
ctx.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
...ctx.localPlanInSettings,
planCode: 'group_collaborator_5_educational',
canUseFlexibleLicensing: true,
})
ctx.recurlySubscription.planPrice =
ctx.GroupPlansData.enterprise.collaborator.USD[5].price_in_cents /
100
preview =
await ctx.Handler.promises.previewAddSeatsSubscriptionChange(
ctx.adminUser_id,
ctx.adding
)
ctx.recurlySubscription.getRequestForAddOnPurchase
.calledWithExactly(
ctx.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
ctx.adding,
undefined
)
.should.equal(true)
})
})
})
})
describe('ensureFlexibleLicensingEnabled', function () {
it('should throw if the subscription can not use flexible licensing', async function (ctx) {
await expect(
ctx.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 (ctx) {
await expect(
ctx.Handler.promises.ensureFlexibleLicensingEnabled({
canUseFlexibleLicensing: true,
})
).to.not.be.rejected
})
})
describe('ensureSubscriptionIsActive', function () {
it('should throw if the subscription is not active', async function (ctx) {
await expect(
ctx.Handler.promises.ensureSubscriptionIsActive(ctx.subscription)
).to.be.rejectedWith('The subscription is not active')
})
it('should not throw if the subscription is active', async function (ctx) {
await expect(
ctx.Handler.promises.ensureSubscriptionIsActive({
recurlyStatus: { state: 'active' },
})
).to.not.be.rejected
})
})
describe('ensureSubscriptionCollectionMethodIsNotManual', function () {
it('should throw if the subscription is manually collected', async function (ctx) {
await expect(
ctx.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 (ctx) {
await expect(
ctx.Handler.promises.ensureSubscriptionCollectionMethodIsNotManual({
get isCollectionMethodManual() {
return false
},
})
).to.not.be.rejected
})
})
describe('ensureSubscriptionHasNoPastDueInvoice', function () {
it('should throw if the subscription has past due invoice', async function (ctx) {
ctx.Modules.promises.hooks.fire
.withArgs('getPaymentFromRecord')
.resolves([{ account: { hasPastDueInvoice: true } }])
await expect(
ctx.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
ctx.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 (ctx) {
await expect(
ctx.Handler.promises.ensureSubscriptionHasNoPastDueInvoice(
ctx.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 (ctx) {
await expect(
ctx.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 (ctx) {
await expect(
ctx.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 (ctx) {
await expect(
ctx.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 (ctx) {
const result = await ctx.Handler.promises.getGroupPlanUpgradePreview(
ctx.user_id
)
result.should.equal(ctx.changePreview)
})
})
describe('checkBillingInfoExistence', function () {
it('should invoke the payment method function when collection method is "automatic"', async function (ctx) {
await ctx.Handler.promises.checkBillingInfoExistence(
ctx.recurlySubscription,
ctx.adminUser_id
)
ctx.Modules.promises.hooks.fire
.calledWith('getPaymentMethod', ctx.adminUser_id)
.should.equal(true)
})
it('shouldnt invoke the payment method function when collection method is "manual"', async function (ctx) {
const recurlySubscription = {
...ctx.recurlySubscription,
get isCollectionMethodManual() {
return true
},
}
await ctx.Handler.promises.checkBillingInfoExistence(
recurlySubscription,
ctx.adminUser_id
)
ctx.RecurlyClient.promises.getPaymentMethod.should.not.have.been.called
})
})
describe('updateGroupMembersBulk', function () {
const inviterId = new ObjectId()
let members
let emailList
let callUpdateGroupMembersBulk
beforeEach(function (ctx) {
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 = {}) => {
ctx.Subscription.findOne = sinon
.stub()
.returns({ exec: sinon.stub().resolves(ctx.subscription) })
ctx.User.find = sinon
.stub()
.returns({ exec: sinon.stub().resolves(members) })
return await ctx.Handler.promises.updateGroupMembersBulk(
inviterId,
ctx.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 (ctx) {
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: ctx.subscription.membersLimit,
})
expect(ctx.TeamInvitesHandler.promises.createInvite).not.to.have.been
.called
expect(ctx.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 (ctx) {
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: ctx.subscription.membersLimit,
})
expect(ctx.TeamInvitesHandler.promises.createInvite).not.to.have.been
.called
expect(ctx.SubscriptionUpdater.promises.removeUserFromGroup).not.to
.have.been.called
})
it('should preview but not revoke invites to emails that are no longer invited', async function (ctx) {
ctx.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(ctx.TeamInvitesHandler.promises.revokeInvite).not.to.have.been
.called
})
})
it('does not throw an error when the member limit is reached', async function (ctx) {
ctx.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 (ctx) {
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: ctx.subscription.membersLimit,
})
expect(ctx.SubscriptionUpdater.promises.removeUserFromGroup).not.to
.have.been.called
expect(
ctx.TeamInvitesHandler.promises.createInvite.callCount
).to.equal(2)
expect(
ctx.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
ctx.subscription,
'new-user@example.com'
)
expect(
ctx.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
ctx.subscription,
'new-user-2@example.com'
)
})
it('should not send invites to emails already invited', async function (ctx) {
ctx.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(
ctx.TeamInvitesHandler.promises.createInvite.callCount
).to.equal(1)
expect(
ctx.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
ctx.subscription,
'new-user-2@example.com'
)
})
it('should preview and not revoke invites to emails that are no longer invited', async function (ctx) {
ctx.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(ctx.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 (ctx) {
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: ctx.subscription.membersLimit,
})
expect(
ctx.SubscriptionUpdater.promises.removeUserFromGroup.callCount
).to.equal(1)
expect(
ctx.SubscriptionUpdater.promises.removeUserFromGroup
).to.have.been.calledWith(ctx.subscription._id, members[2]._id, {
initiatorId: inviterId,
})
expect(
ctx.TeamInvitesHandler.promises.createInvite.callCount
).to.equal(2)
expect(
ctx.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
ctx.subscription,
'new-user@example.com'
)
expect(
ctx.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
ctx.subscription,
'new-user-2@example.com'
)
})
it('should send invites and revoke invites to emails no longer invited', async function (ctx) {
ctx.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(
ctx.TeamInvitesHandler.promises.createInvite.callCount
).to.equal(1)
expect(
ctx.TeamInvitesHandler.promises.createInvite
).to.have.been.calledWith(
inviterId,
ctx.subscription,
'new-user-2@example.com'
)
expect(
ctx.TeamInvitesHandler.promises.revokeInvite.callCount
).to.equal(1)
expect(
ctx.TeamInvitesHandler.promises.revokeInvite
).to.have.been.calledWith(
inviterId,
ctx.subscription,
'no-longer-invited@example.com'
)
})
})
it('throws an error when the member limit is reached', async function (ctx) {
ctx.subscription.membersLimit = 3
await expect(
callUpdateGroupMembersBulk({ commit: true })
).to.be.rejectedWith('limit reached')
})
})
})
})