Просмотр исходного кода

Merge pull request #22448 from overleaf/ii-flexible-group-licensing-add-seats-tests

[web] Add seats tests

GitOrigin-RevId: 76fb5edc6eba5579fac2d3e05cd1f64fba16046c
ilkin-overleaf 1 год назад
Родитель
Сommit
8e7f1d5e89

+ 2 - 2
services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs

@@ -171,12 +171,12 @@ async function previewAddSeatsSubscriptionChange(req, res) {
  */
 async function createAddSeatsSubscriptionChange(req, res) {
   try {
-    const preview =
+    const create =
       await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange(
         req
       )
 
-    res.json(preview)
+    res.json(create)
   } catch (error) {
     logger.err(
       { error },

+ 2 - 1
services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx

@@ -29,7 +29,7 @@ import {
 } from '../../../../../../types/subscription/subscription-change-preview'
 import { MergeAndOverride } from '../../../../../../types/utils'
 
-const MAX_NUMBER_OF_USERS = 50
+export const MAX_NUMBER_OF_USERS = 50
 
 function AddSeats() {
   const { t } = useTranslation()
@@ -244,6 +244,7 @@ function AddSeats() {
                 className="d-grid gap-4"
                 onSubmit={handleSubmit}
                 ref={formRef}
+                data-testid="add-more-users-group-form"
               >
                 <div className="d-grid gap-1">
                   <h4 className="fw-bold m-0 card-description-secondary">

+ 19 - 7
services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx

@@ -20,7 +20,10 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
   const { t } = useTranslation()
 
   return (
-    <Card className="card-gray card-description-secondary">
+    <Card
+      className="card-gray card-description-secondary"
+      data-testid="cost-summary"
+    >
       <Card.Body className="d-grid gap-2 p-3">
         <div>
           <div className="fw-bold">{t('cost_summary')}</div>
@@ -53,35 +56,44 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) {
           <>
             <div>
               <ListGroup>
-                <ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
+                <ListGroup.Item
+                  className="bg-transparent border-0 px-0 gap-3 card-description-secondary"
+                  data-testid="plan"
+                >
                   <span className="me-auto">
                     {subscriptionChange.nextInvoice.plan.name} x{' '}
                     {subscriptionChange.change.addOn.quantity -
                       subscriptionChange.change.addOn.prevQuantity}{' '}
                     {t('seats')}
                   </span>
-                  <span>
+                  <span data-testid="price">
                     {formatCurrencyLocalized(
                       subscriptionChange.immediateCharge.subtotal,
                       subscriptionChange.currency
                     )}
                   </span>
                 </ListGroup.Item>
-                <ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
+                <ListGroup.Item
+                  className="bg-transparent border-0 px-0 gap-3 card-description-secondary"
+                  data-testid="tax"
+                >
                   <span className="me-auto">
                     {t('sales_tax')} &middot;{' '}
                     {subscriptionChange.nextInvoice.tax.rate * 100}%
                   </span>
-                  <span>
+                  <span data-testid="price">
                     {formatCurrencyLocalized(
                       subscriptionChange.immediateCharge.tax,
                       subscriptionChange.currency
                     )}
                   </span>
                 </ListGroup.Item>
-                <ListGroup.Item className="bg-transparent border-0 px-0 gap-3 card-description-secondary">
+                <ListGroup.Item
+                  className="bg-transparent border-0 px-0 gap-3 card-description-secondary"
+                  data-testid="total"
+                >
                   <strong className="me-auto">{t('total_due_today')}</strong>
-                  <strong>
+                  <strong data-testid="price">
                     {formatCurrencyLocalized(
                       subscriptionChange.immediateCharge.total,
                       subscriptionChange.currency

+ 351 - 0
services/web/test/frontend/features/group-management/components/add-seats.spec.tsx

@@ -0,0 +1,351 @@
+import '../../../helpers/bootstrap-5'
+import AddSeats, {
+  MAX_NUMBER_OF_USERS,
+} from '@/features/group-management/components/add-seats/add-seats'
+import { SplitTestProvider } from '@/shared/context/split-test-context'
+
+describe('<AddSeats />', function () {
+  beforeEach(function () {
+    this.totalLicenses = 5
+
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
+      win.metaAttributesCache.set('ol-subscriptionId', '123')
+      win.metaAttributesCache.set(
+        'ol-subscriptionEndsAt',
+        '2025-01-01T12:00:00.000Z'
+      )
+      win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses)
+    })
+
+    cy.mount(
+      <SplitTestProvider>
+        <AddSeats />
+      </SplitTestProvider>
+    )
+
+    cy.findByRole('button', { name: /add users/i })
+    cy.findByTestId('add-more-users-group-form')
+  })
+
+  it('renders the back button', function () {
+    cy.findByTestId('group-heading').within(() => {
+      cy.findByRole('button', { name: /back to subscription/i }).should(
+        'have.attr',
+        'href',
+        '/user/subscription'
+      )
+    })
+  })
+
+  it('shows the group name', function () {
+    cy.findByTestId('group-heading').within(() => {
+      cy.findByRole('heading', { name: 'My Awesome Team' })
+    })
+  })
+
+  it('shows the "Add more users" label', function () {
+    cy.findByText(/add more users/i)
+  })
+
+  it('shows the maximum supported users', function () {
+    cy.findByText(
+      new RegExp(
+        `your current plan supports up to ${this.totalLicenses} users`,
+        'i'
+      )
+    )
+  })
+
+  it('shows instructions on how to reduce users on a plan', function () {
+    cy.contains(
+      /if you want to reduce the number of users on your plan, please contact customer support/i
+    ).within(() => {
+      cy.findByRole('link', { name: /contact customer support/i }).should(
+        'have.attr',
+        'href',
+        '/contact'
+      )
+    })
+  })
+
+  it('shows the "Upgrade my plan" link', function () {
+    cy.findByRole('link', { name: /upgrade my plan/i }).should(
+      'have.attr',
+      'href',
+      '/user/subscription/group/upgrade-subscription'
+    )
+  })
+
+  it('renders the cancel button', function () {
+    cy.findByRole('button', { name: /cancel/i }).should(
+      'have.attr',
+      'href',
+      '/user/subscription'
+    )
+  })
+
+  describe('cost summary', function () {
+    beforeEach(function () {
+      cy.findByLabelText(/how many users do you want to add/i).as('input')
+    })
+
+    it('shows the title', function () {
+      cy.findByTestId('cost-summary').within(() => {
+        cy.findByText(/cost summary/i)
+      })
+    })
+
+    describe('shows default content when', function () {
+      afterEach(function () {
+        cy.findByTestId('cost-summary').within(() => {
+          cy.findByText(
+            /enter the number of users you’d like to add to see the cost breakdown/i
+          )
+        })
+      })
+
+      it('leaves input empty', function () {
+        cy.get('@input').should('have.value', '')
+      })
+
+      it('fills in a non-numeric value', function () {
+        cy.get('@input').type('ab')
+        cy.findByText(/value must be a number/i)
+      })
+
+      it('fills in a decimal value', function () {
+        cy.get('@input').type('1.5')
+        cy.findByText(/value must be a whole number/i)
+      })
+
+      it('fills in a "0" value', function () {
+        cy.get('@input').type('0')
+        cy.findByText(/value must be at least 1/i)
+      })
+
+      it('fills in a value and clears the input', function () {
+        cy.get('@input').type('a{backspace}')
+        cy.get('@input').should('have.text', '')
+        cy.findByText(/this field is required/i)
+      })
+    })
+
+    describe('entered more than the maximum allowed number of users', function () {
+      beforeEach(function () {
+        this.numberOfUsersExceedingMaxLimit = MAX_NUMBER_OF_USERS + 1
+
+        cy.get('@input').type(this.numberOfUsersExceedingMaxLimit.toString())
+        cy.findByRole('button', { name: /add users/i }).should('not.exist')
+        cy.findByRole('button', { name: /send request/i }).as('sendRequestBtn')
+      })
+
+      it('renders a notification', function () {
+        cy.findByTestId('cost-summary').should('not.exist')
+        cy.findByRole('alert').should(
+          'contain.text',
+          `If you want more than ${MAX_NUMBER_OF_USERS} users on your plan, we need to add them for you. Just click Send request below and we’ll be happy to help.`
+        )
+      })
+
+      describe('request', function () {
+        afterEach(function () {
+          cy.findByRole('button', { name: /go to subscriptions/i }).should(
+            'have.attr',
+            'href',
+            '/user/subscription'
+          )
+        })
+
+        function makeRequest(statusCode: number, adding: string) {
+          cy.intercept(
+            'POST',
+            '/user/subscription/group/add-users/sales-contact-form',
+            {
+              statusCode,
+            }
+          ).as('addUsersRequest')
+          cy.get('@sendRequestBtn').click()
+          cy.get('@addUsersRequest').its('request.body').should('deep.equal', {
+            adding,
+          })
+          cy.findByTestId('add-more-users-group-form').should('not.exist')
+        }
+
+        it('sends a request that succeeds', function () {
+          makeRequest(204, this.numberOfUsersExceedingMaxLimit.toString())
+          cy.findByTestId('title').should(
+            'contain.text',
+            'We’ve got your request'
+          )
+          cy.findByText(/our team will get back to you shortly/i)
+        })
+
+        it('sends a request that fails', function () {
+          makeRequest(400, this.numberOfUsersExceedingMaxLimit.toString())
+          cy.findByTestId('title').should(
+            'contain.text',
+            'Something went wrong'
+          )
+          cy.contains(
+            /it looks like that didn’t work. You can try again or get in touch with our Support team for more help/i
+          ).within(() => {
+            cy.findByRole('link', { name: /get in touch/i }).should(
+              'have.attr',
+              'href',
+              '/contact'
+            )
+          })
+        })
+      })
+    })
+
+    describe('entered less than the maximum allowed number of users', function () {
+      beforeEach(function () {
+        this.adding = 1
+
+        cy.findByRole('button', { name: /add users/i }).as('addUsersBtn')
+        cy.findByRole('button', { name: /send request/i }).should('not.exist')
+      })
+
+      it('renders the preview data', function () {
+        this.body = {
+          change: {
+            type: 'add-on-update',
+            addOn: {
+              code: 'additional-license',
+              quantity: this.totalLicenses + this.adding,
+              prevQuantity: this.totalLicenses,
+            },
+          },
+          currency: 'USD',
+          immediateCharge: {
+            subtotal: 100,
+            tax: 20,
+            total: 120,
+          },
+          nextInvoice: {
+            date: '2025-12-01T00:00:00.000Z',
+            plan: {
+              name: 'Overleaf Standard Group',
+              amount: 0,
+            },
+            subtotal: 895,
+            tax: {
+              rate: 0.2,
+            },
+            total: 1000,
+          },
+        }
+
+        cy.intercept('POST', '/user/subscription/group/add-users/preview', {
+          statusCode: 200,
+          body: this.body,
+        }).as('addUsersRequest')
+        cy.get('@input').type(this.adding.toString())
+
+        cy.findByTestId('cost-summary').within(() => {
+          cy.contains(
+            new RegExp(
+              `you’re adding ${this.adding} users to your plan giving you a total of ${this.body.change.addOn.quantity} users`,
+              'i'
+            )
+          )
+
+          cy.findByTestId('plan').within(() => {
+            cy.findByText(
+              `${this.body.nextInvoice.plan.name} x ${this.adding} Seats`
+            )
+            cy.findByTestId('price').should(
+              'have.text',
+              `$${this.body.immediateCharge.subtotal}.00`
+            )
+          })
+
+          cy.findByTestId('tax').within(() => {
+            cy.findByText(
+              new RegExp(
+                `sales tax · ${this.body.nextInvoice.tax.rate * 100}%`,
+                'i'
+              )
+            )
+            cy.findByTestId('price').should(
+              'have.text',
+              `$${this.body.immediateCharge.tax}.00`
+            )
+          })
+
+          cy.findByTestId('total').within(() => {
+            cy.findByText(/total due today/i)
+            cy.findByTestId('price').should(
+              'have.text',
+              `$${this.body.immediateCharge.total}.00`
+            )
+          })
+
+          cy.findByText(
+            /we’ll charge you now for the cost of your additional users based on the remaining months of your current subscription/i
+          )
+          cy.findByText(
+            /after that, we’ll bill you \$1,000.00 \+ applicable taxes annually on December 1, unless you cancel/i
+          )
+        })
+      })
+
+      describe('request', function () {
+        afterEach(function () {
+          cy.findByRole('button', { name: /go to subscriptions/i }).should(
+            'have.attr',
+            'href',
+            '/user/subscription'
+          )
+        })
+
+        function makeRequest(statusCode: number, adding: string) {
+          cy.intercept('POST', '/user/subscription/group/add-users/create', {
+            statusCode,
+          }).as('addUsersRequest')
+          cy.get('@input').type(adding)
+          cy.get('@addUsersBtn').click()
+          cy.get('@addUsersRequest')
+            .its('request.body')
+            .should('deep.equal', {
+              adding: Number(adding),
+            })
+          cy.findByTestId('add-more-users-group-form').should('not.exist')
+        }
+
+        it('sends a request that succeeds', function () {
+          makeRequest(204, this.adding.toString())
+          cy.findByTestId('title').should(
+            'contain.text',
+            'You’ve added more users'
+          )
+          cy.findByText(/you’ve added more users to your subscription/i)
+          cy.findByRole('link', { name: /invite people/i }).should(
+            'have.attr',
+            'href',
+            '/manage/groups/123/members'
+          )
+        })
+
+        it('sends a request that fails', function () {
+          makeRequest(400, this.adding.toString())
+          cy.findByTestId('title').should(
+            'contain.text',
+            'Something went wrong'
+          )
+          cy.contains(
+            /it looks like that didn’t work. You can try again or get in touch with our Support team for more help/i
+          ).within(() => {
+            cy.findByRole('link', { name: /get in touch/i }).should(
+              'have.attr',
+              'href',
+              '/contact'
+            )
+          })
+        })
+      })
+    })
+  })
+})

+ 1 - 1
services/web/test/frontend/features/group-management/components/request-status.spec.tsx

@@ -1,7 +1,7 @@
 import '../../../helpers/bootstrap-5'
 import RequestStatus from '@/features/group-management/components/request-status'
 
-describe('request confirmation page', function () {
+describe('<RequestStatus />', function () {
   beforeEach(function () {
     cy.window().then(win => {
       win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')

+ 212 - 3
services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs

@@ -27,11 +27,35 @@ describe('SubscriptionGroupController', function () {
       _id: this.subscriptionId,
       teamName: 'Cool group',
       groupPlan: true,
+      membersLimit: 5,
     }
 
+    this.plan = {
+      canUseFlexibleLicensing: true,
+    }
+
+    this.previewSubscriptionChangeData = {
+      change: {},
+      currency: 'USD',
+    }
+
+    this.createSubscriptionChangeData = { adding: 1 }
+
     this.SubscriptionGroupHandler = {
       promises: {
         removeUserFromGroup: sinon.stub().resolves(),
+        getUsersGroupSubscriptionDetails: sinon.stub().resolves({
+          subscription: this.subscription,
+          plan: this.plan,
+          recurlySubscription: {},
+        }),
+        previewAddSeatsSubscriptionChange: sinon
+          .stub()
+          .resolves(this.previewSubscriptionChangeData),
+        createAddSeatsSubscriptionChange: sinon
+          .stub()
+          .resolves(this.createSubscriptionChangeData),
+        ensureFlexibleLicensingEnabled: sinon.stub().resolves(),
       },
     }
 
@@ -66,14 +90,13 @@ describe('SubscriptionGroupController', function () {
 
     this.SplitTestHandler = {
       promises: {
-        getAssignment: sinon.stub().resolves({ variant: 'default' }),
+        getAssignment: sinon.stub().resolves(),
       },
-      getAssignment: sinon.stub().yields(null, { variant: 'default' }),
     }
 
     this.UserGetter = {
       promises: {
-        getUserEmail: sinon.stub().resolves(this.user),
+        getUserEmail: sinon.stub().resolves(this.user.email),
       },
     }
 
@@ -105,6 +128,13 @@ describe('SubscriptionGroupController', function () {
       '../../../../app/src/Features/Subscription/RecurlyClient':
         this.RecurlyClient,
       '../../../../app/src/models/Subscription': this.SubscriptionModel,
+      '@overleaf/logger': {
+        err: sinon.stub(),
+        error: sinon.stub(),
+        warn: sinon.stub(),
+        log: sinon.stub(),
+        debug: sinon.stub(),
+      },
     })
   })
 
@@ -287,4 +317,183 @@ describe('SubscriptionGroupController', function () {
       this.Controller.removeSelfFromGroup(this.req, res, done)
     })
   })
+
+  describe('addSeatsToGroupSubscription', function () {
+    it('should render the "add seats" page', function (done) {
+      const res = {
+        render: (page, props) => {
+          this.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails
+            .calledWith(this.req)
+            .should.equal(true)
+          this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled
+            .calledWith(this.plan)
+            .should.equal(true)
+          page.should.equal('subscriptions/add-seats')
+          props.subscriptionId.should.equal(this.subscriptionId)
+          props.groupName.should.equal(this.subscription.teamName)
+          props.totalLicenses.should.equal(this.subscription.membersLimit)
+          done()
+        },
+      }
+
+      this.Controller.addSeatsToGroupSubscription(this.req, res)
+    })
+
+    it('should redirect to subscription page when getting subscription details fails', function (done) {
+      this.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails =
+        sinon.stub().rejects()
+
+      const res = {
+        redirect: url => {
+          url.should.equal('/user/subscription')
+          done()
+        },
+      }
+
+      this.Controller.addSeatsToGroupSubscription(this.req, res)
+    })
+
+    it('should redirect to subscription page when flexible licensing is not enabled', function (done) {
+      this.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled =
+        sinon.stub().rejects()
+
+      const res = {
+        redirect: url => {
+          url.should.equal('/user/subscription')
+          done()
+        },
+      }
+
+      this.Controller.addSeatsToGroupSubscription(this.req, res)
+    })
+  })
+
+  describe('previewAddSeatsSubscriptionChange', function () {
+    it('should preview "add seats" change', function (done) {
+      const res = {
+        json: data => {
+          this.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange
+            .calledWith(this.req)
+            .should.equal(true)
+          data.should.deep.equal(this.previewSubscriptionChangeData)
+          done()
+        },
+      }
+
+      this.Controller.previewAddSeatsSubscriptionChange(this.req, res)
+    })
+
+    it('should fail previewing "add seats" change', function (done) {
+      this.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange =
+        sinon.stub().rejects()
+
+      const res = {
+        status: statusCode => {
+          statusCode.should.equal(400)
+
+          return {
+            end: () => {
+              done()
+            },
+          }
+        },
+      }
+
+      this.Controller.previewAddSeatsSubscriptionChange(this.req, res)
+    })
+  })
+
+  describe('createAddSeatsSubscriptionChange', function () {
+    it('should apply "add seats" change', function (done) {
+      const res = {
+        json: data => {
+          this.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange
+            .calledWith(this.req)
+            .should.equal(true)
+          data.should.deep.equal(this.createSubscriptionChangeData)
+          done()
+        },
+      }
+
+      this.Controller.createAddSeatsSubscriptionChange(this.req, res)
+    })
+
+    it('should fail applying "add seats" change', function (done) {
+      this.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
+        sinon.stub().rejects()
+
+      const res = {
+        status: statusCode => {
+          statusCode.should.equal(400)
+
+          return {
+            end: () => {
+              done()
+            },
+          }
+        },
+      }
+
+      this.Controller.createAddSeatsSubscriptionChange(this.req, res)
+    })
+  })
+
+  describe('submitForm', function () {
+    it('should build and pass the request body to the sales submit handler', function (done) {
+      const adding = 100
+      this.req.body = { adding }
+
+      const res = {
+        sendStatus: code => {
+          this.Modules.promises.hooks.fire
+            .calledWith('sendSupportRequest', {
+              email: this.user.email,
+              subject: 'Sales Contact Form',
+              message:
+                '\n' +
+                '**Overleaf Sales Contact Form:**\n' +
+                '\n' +
+                '**Subject:** Self-Serve Group User Increase Request\n' +
+                '\n' +
+                `**Estimated Number of Users:** ${adding}\n` +
+                '\n' +
+                `**Message:** This email has been generated on behalf of user with email **${this.user.email}** to request an increase in the total number of users for their subscription.`,
+              inbox: 'sales',
+            })
+            .should.equal(true)
+          sinon.assert.calledOnce(this.Modules.promises.hooks.fire)
+          code.should.equal(204)
+          done()
+        },
+      }
+      this.Controller.submitForm(this.req, res, done)
+    })
+  })
+
+  describe('flexibleLicensingSplitTest', function () {
+    it('passes when the variant is "enabled"', function (done) {
+      const res = sinon.stub()
+      const next = () => {
+        this.ErrorController.notFound.notCalled.should.equal(true)
+        done()
+      }
+      this.SplitTestHandler.promises.getAssignment.resolves({
+        variant: 'enabled',
+      })
+      this.Controller.flexibleLicensingSplitTest(this.req, res, next, done)
+    })
+
+    it('returns error page when the variant is "default"', function (done) {
+      const res = sinon.stub()
+      const next = sinon.stub()
+      this.ErrorController.notFound = sinon.stub().callsFake(() => {
+        next.notCalled.should.equal(true)
+        done()
+      })
+      this.SplitTestHandler.promises.getAssignment.resolves({
+        variant: 'default',
+      })
+      this.Controller.flexibleLicensingSplitTest(this.req, res, next, done)
+    })
+  })
 })

+ 182 - 3
services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js

@@ -1,6 +1,7 @@
 const SandboxedModule = require('sandboxed-module')
 const sinon = require('sinon')
 const { expect } = require('chai')
+const MockRequest = require('../helpers/MockRequest')
 const modulePath =
   '../../../../app/src/Features/Subscription/SubscriptionGroupHandler'
 
@@ -12,6 +13,12 @@ describe('SubscriptionGroupHandler', function () {
     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.localPlanInSettings = {
+      membersLimit: 2,
+      membersLimitAddOn: 'additional-license',
+    }
 
     this.subscription = {
       admin_id: this.adminUser_id,
@@ -19,16 +26,35 @@ describe('SubscriptionGroupHandler', function () {
       _id: this.subscription_id,
     }
 
+    this.changeRequest = {
+      timeframe: 'now',
+    }
+
+    this.recurlySubscription = {
+      id: 123,
+      addOns: [
+        {
+          code: 'additional-license',
+          quantity: 1,
+        },
+      ],
+      getRequestForAddOnUpdate: sinon.stub().returns(this.changeRequest),
+    }
+
     this.SubscriptionLocator = {
       promises: {
-        getUsersSubscription: sinon.stub(),
+        getUsersSubscription: sinon.stub().resolves({ groupPlan: true }),
         getSubscriptionByMemberIdAndId: sinon.stub(),
         getSubscription: sinon.stub().resolves(this.subscription),
       },
     }
 
+    this.changePreview = {
+      currency: 'USD',
+    }
+
     this.SubscriptionController = {
-      makeChangePreview: sinon.stub().resolves(),
+      makeChangePreview: sinon.stub().resolves(this.changePreview),
     }
 
     this.SubscriptionUpdater = {
@@ -44,8 +70,36 @@ describe('SubscriptionGroupHandler', function () {
       findOne: sinon.stub().returns({ exec: sinon.stub().resolves }),
     }
 
+    this.SessionManager = {
+      getLoggedInUserId: sinon.stub().returns(this.user._id),
+    }
+
+    this.previewSubscriptionChange = {
+      nextAddOns: [
+        {
+          code: 'additional-license',
+          quantity: this.recurlySubscription.addOns[0].quantity + this.adding,
+        },
+      ],
+    }
+
+    this.applySubscriptionChange = {}
+
     this.RecurlyClient = {
-      promises: {},
+      promises: {
+        getSubscription: sinon.stub().resolves(this.recurlySubscription),
+        getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
+        previewSubscriptionChange: sinon
+          .stub()
+          .resolves(this.previewSubscriptionChange),
+        applySubscriptionChangeRequest: sinon
+          .stub()
+          .resolves(this.applySubscriptionChange),
+      },
+    }
+
+    this.PlansLocator = {
+      findLocalPlanInSettings: sinon.stub(this.localPlanInSettings),
     }
 
     this.SubscriptionHandler = {
@@ -64,6 +118,8 @@ describe('SubscriptionGroupHandler', function () {
           Subscription: this.Subscription,
         },
         './RecurlyClient': this.RecurlyClient,
+        './PlansLocator': this.PlansLocator,
+        '../Authentication/SessionManager': this.SessionManager,
       },
     })
   })
@@ -183,4 +239,127 @@ describe('SubscriptionGroupHandler', function () {
       })
     })
   })
+
+  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.req)
+      ).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.req
+      )
+
+      expect(data).to.deep.equal({
+        subscription: { groupPlan: true },
+        plan: {
+          membersLimit: 2,
+          membersLimitAddOn: 'additional-license',
+          canUseFlexibleLicensing: true,
+        },
+        recurlySubscription: this.recurlySubscription,
+      })
+    })
+  })
+
+  describe('add seats subscription change', function () {
+    beforeEach(function () {
+      this.req = new MockRequest()
+      Object.assign(this.req.body, { adding: this.adding })
+      this.PlansLocator.findLocalPlanInSettings = sinon.stub().returns({
+        ...this.localPlanInSettings,
+        canUseFlexibleLicensing: true,
+      })
+    })
+
+    afterEach(function () {
+      this.recurlySubscription.getRequestForAddOnUpdate
+        .calledWith(
+          'additional-license',
+          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.req
+          )
+
+        this.RecurlyClient.promises.getPaymentMethod
+          .calledWith(this.user_id)
+          .should.equal(true)
+        this.RecurlyClient.promises.previewSubscriptionChange
+          .calledWith(this.changeRequest)
+          .should.equal(true)
+        this.SubscriptionController.makeChangePreview
+          .calledWith(
+            {
+              type: 'add-on-update',
+              addOn: {
+                code: 'additional-license',
+                quantity:
+                  this.recurlySubscription.addOns[0].quantity + this.adding,
+                prevQuantity: this.adding,
+              },
+            },
+            this.previewSubscriptionChange,
+            this.paymentMethod
+          )
+          .should.equal(true)
+        preview.should.equal(this.changePreview)
+      })
+    })
+
+    describe('createAddSeatsSubscriptionChange', function () {
+      it('should change the subscription', async function () {
+        const result =
+          await this.Handler.promises.createAddSeatsSubscriptionChange(this.req)
+
+        this.RecurlyClient.promises.applySubscriptionChangeRequest
+          .calledWith(this.changeRequest)
+          .should.equal(true)
+        this.SubscriptionHandler.promises.syncSubscription
+          .calledWith({ uuid: this.recurlySubscription.id }, this.user_id)
+          .should.equal(true)
+        expect(result).to.deep.equal({
+          adding: this.req.body.adding,
+        })
+      })
+    })
+  })
+
+  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 licencing')
+    })
+  })
+
+  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
+  })
 })