| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263 |
- 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('ensureSubscriptionHasNoPendingChanges', function () {
- it('should throw if the subscription has pending change', async function (ctx) {
- await expect(
- ctx.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 (ctx) {
- await expect(
- ctx.Handler.promises.ensureSubscriptionHasNoPendingChanges({})
- ).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('shouldn’t 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')
- })
- })
- })
- })
|