Forráskód Böngészése

Merge pull request #11872 from overleaf/ii-payment-page-tests

[web] Payment page tests

GitOrigin-RevId: 0ab9a75c13f1833cbdf7aa71ffe3ab66174ca773
ilkin-overleaf 3 éve
szülő
commit
076bc9b39c

+ 5 - 1
services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx

@@ -18,6 +18,7 @@ import SubmitButton from './submit-button'
 import ThreeDSecure from './three-d-secure'
 import getMeta from '../../../../../utils/meta'
 import { postJSON } from '../../../../../infrastructure/fetch-json'
+import { assign } from '../../../../../shared/components/location'
 import * as eventTracking from '../../../../../infrastructure/event-tracking'
 import classnames from 'classnames'
 import {
@@ -153,7 +154,7 @@ function CheckoutPanel() {
             'subscription-submission-success',
             planCode
           )
-          window.location.assign('/user/subscription/thank-you')
+          assign('/user/subscription/thank-you')
         } catch (error) {
           setIsProcessing(false)
 
@@ -184,6 +185,8 @@ function CheckoutPanel() {
   const payPal = useRef<PayPalInstance>()
 
   useEffect(() => {
+    if (!recurly) return
+
     payPal.current = recurly.PayPal({
       display: { displayName: planName },
     })
@@ -304,6 +307,7 @@ function CheckoutPanel() {
           onSubmit={handleSubmit}
           onChange={handleFormValidation}
           ref={formRef}
+          data-testid="checkout-form"
         >
           {genericError && (
             <Alert bsStyle="warning" className="small">

+ 4 - 1
services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx

@@ -11,7 +11,10 @@ function PaymentMethodToggle(props: PaymentMethodToggleProps) {
   const { t } = useTranslation()
 
   return (
-    <FormGroup className="payment-method-toggle">
+    <FormGroup
+      className="payment-method-toggle"
+      data-testid="payment-method-toggle"
+    >
       <hr className="thin" />
       <div className="radio">
         <Col xs={8}>

+ 1 - 1
services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx

@@ -26,7 +26,7 @@ function CurrencyDropdown(props: DropdownProps) {
           >
             {currency === currencyCode && (
               <span className="change-currency-dropdown-selected-icon">
-                <Icon type="check" />
+                <Icon type="check" accessibilityLabel={t('selected')} />
               </span>
             )}
             {currency} ({symbol})

+ 1 - 1
services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx

@@ -11,7 +11,7 @@ function FeaturesList({ features }: FeaturesListProps) {
   return (
     <>
       <div className="text-small">{t('all_premium_features_including')}</div>
-      <ul className="small">
+      <ul className="small" data-testid="features-list">
         {features.compileTimeout > 1 && (
           <li>{t('increased_compile_timeout')}</li>
         )}

+ 11 - 5
services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx

@@ -24,10 +24,10 @@ function PriceSummary() {
   return (
     <>
       <hr />
-      <div className="price-summary">
+      <div className="price-summary" data-testid="price-summary">
         <h4>{t('payment_summary')}</h4>
         <div className="small">
-          <div className="price-summary-line">
+          <div className="price-summary-line" data-testid="price-summary-plan">
             <span>{planName}</span>
             <span>
               {currencySymbol}
@@ -35,7 +35,10 @@ function PriceSummary() {
             </span>
           </div>
           {coupon && (
-            <div className="price-summary-line">
+            <div
+              className="price-summary-line"
+              data-testid="price-summary-coupon"
+            >
               <span>{coupon.name}</span>
               <span aria-hidden>
                 &ndash;{currencySymbol}
@@ -49,7 +52,7 @@ function PriceSummary() {
             </div>
           )}
           {rate > 0 && (
-            <div className="price-summary-line">
+            <div className="price-summary-line" data-testid="price-summary-vat">
               <span>
                 {t('vat')} {rate * 100}%
               </span>
@@ -59,7 +62,10 @@ function PriceSummary() {
               </span>
             </div>
           )}
-          <div className="price-summary-line price-summary-total-line">
+          <div
+            className="price-summary-line price-summary-total-line"
+            data-testid="price-summary-total"
+          >
             <b>{monthlyBilling ? t('total_per_month') : t('total_per_year')}</b>
             <b>
               {currencySymbol}

+ 3 - 1
services/web/frontend/js/features/subscription/components/new/payment-preview/trial-coupon-summary.tsx

@@ -14,7 +14,9 @@ function TrialCouponSummary() {
   return (
     <>
       <hr className="thin" />
-      <div className="trial-coupon-summary">{children}</div>
+      <div className="trial-coupon-summary" data-testid="trial-coupon-summary">
+        {children}
+      </div>
     </>
   )
 }

+ 1 - 1
services/web/frontend/js/features/subscription/context/payment-context.tsx

@@ -339,7 +339,7 @@ export const PaymentContext = createContext<PaymentContextValue | undefined>(
 
 type PaymentProviderProps = {
   publicKey: string
-  children: React.ReactNode
+  children?: React.ReactNode
 }
 
 export function PaymentProvider({ publicKey, ...props }: PaymentProviderProps) {

+ 465 - 0
services/web/test/frontend/features/subscription/components/new/checkout.spec.tsx

@@ -0,0 +1,465 @@
+import CheckoutPanel from '../../../../../../frontend/js/features/subscription/components/new/checkout/checkout-panel'
+import { PaymentProvider } from '../../../../../../frontend/js/features/subscription/context/payment-context'
+import { plans } from '../../fixtures/plans'
+import {
+  createFakeRecurly,
+  defaultSubscription,
+  ElementsBase,
+} from '../../fixtures/recurly-mock'
+import { cloneDeep } from 'lodash'
+import { TokenHandler, RecurlyError } from 'recurly__recurly-js'
+
+function CheckoutPanelWithPaymentProvider() {
+  return (
+    <PaymentProvider publicKey="0000">
+      <CheckoutPanel />
+    </PaymentProvider>
+  )
+}
+
+function fillForm() {
+  cy.findByTestId('test-card-element').within(() => {
+    cy.get('input').each(el => {
+      cy.wrap(el).type('123', { delay: 0 })
+    })
+  })
+  cy.findByLabelText(/first name/i).type('123', { delay: 0 })
+  cy.findByLabelText(/last name/i).type('123', { delay: 0 })
+  cy.findByLabelText('Address').type('123', { delay: 0 })
+  cy.findByLabelText(/postal code/i).type('123', { delay: 0 })
+  cy.findByLabelText(/country/i).select('Bulgaria')
+}
+
+describe('checkout panel', function () {
+  const itmCampaign = 'fake_itm_campaign'
+  const itmContent = 'fake_itm_content'
+  const itmReferrer = 'fake_itm_referrer'
+
+  beforeEach(function () {
+    const plan = plans.find(({ planCode }) => planCode === 'student-annual')
+
+    if (!plan) {
+      throw new Error('No plan was found while running the test!')
+    }
+
+    cy.window().then(win => {
+      win.metaAttributesCache = new Map()
+      win.metaAttributesCache.set('ol-countryCode', '')
+      win.metaAttributesCache.set('ol-recurlyApiKey', '0000')
+      win.metaAttributesCache.set('ol-recommendedCurrency', 'USD')
+      win.metaAttributesCache.set('ol-plan', plan)
+      win.metaAttributesCache.set('ol-planCode', plan.planCode)
+      win.metaAttributesCache.set('ol-showCouponField', true)
+      win.metaAttributesCache.set('ol-itm_campaign', itmCampaign)
+      win.metaAttributesCache.set('ol-itm_content', itmContent)
+      win.metaAttributesCache.set('ol-itm_referrer', itmReferrer)
+
+      cy.wrap(plan).as('plan')
+
+      // init default recurly
+      win.recurly = createFakeRecurly(defaultSubscription)
+
+      cy.interceptEvents()
+    })
+
+    cy.mount(<CheckoutPanelWithPaymentProvider />)
+    cy.findByTestId('checkout-form').as('form')
+  })
+
+  it('renders heading', function () {
+    cy.contains(/select a payment method/i)
+  })
+
+  it('renders student disclaimer', function () {
+    cy.contains(
+      'The educational discount applies to all students at secondary and postsecondary institutions ' +
+        '(schools and universities). We may contact you to confirm that you’re eligible for the discount.'
+    )
+  })
+
+  it('renders payment method toggle', function () {
+    cy.findByTestId('payment-method-toggle').within(() => {
+      cy.findByLabelText(/card payment/i)
+      cy.findByLabelText(/paypal/i)
+    })
+  })
+
+  it('renders address first line input', function () {
+    cy.get('@form').within(() => {
+      cy.findByLabelText('Address')
+      cy.findByLabelText(/this address will be shown on the invoice/i)
+    })
+  })
+
+  it('renders address second line input', function () {
+    cy.get('@form').within(() => {
+      cy.findByLabelText(/address second line/i).should('not.exist')
+      cy.findByRole('button', { name: /add another address line/i }).click()
+      cy.findByLabelText(/address second line/i)
+    })
+  })
+
+  it('renders postal code input', function () {
+    cy.get('@form').within(() => {
+      cy.findByLabelText(/postal code/i)
+    })
+  })
+
+  it('renders country dropdown', function () {
+    cy.get('@form').within(() => {
+      cy.findByLabelText(/country/i)
+    })
+  })
+
+  it('renders company details', function () {
+    cy.get('@form').within(() => {
+      cy.findByLabelText(/add company details/i).as('checkbox')
+      cy.get('@checkbox').should('not.be.checked')
+      cy.findByLabelText(/company name/i).should('not.exist')
+      cy.findByLabelText(/vat number/i).should('not.exist')
+      cy.get('@checkbox').click()
+      cy.findByLabelText(/company name/i)
+      cy.findByLabelText(/vat number/i)
+    })
+  })
+
+  it('renders coupon field', function () {
+    cy.get('@form').within(() => {
+      cy.findByLabelText(/coupon code/i)
+    })
+  })
+
+  it('renders tos agreement notice', function () {
+    cy.contains(/by subscribing, you agree to our terms of service/i)
+  })
+
+  it('renders recurly error', function () {
+    cy.window().then(win => {
+      win.recurly = undefined!
+    })
+    cy.mount(<CheckoutPanelWithPaymentProvider />)
+    cy.contains(
+      /sorry, there was an error talking to our payment provider. Please try again in a few moments/i
+    )
+    cy.contains(
+      /if you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them/i
+    )
+  })
+
+  it('calls recurly.token on submit', function () {
+    cy.window().then(win => {
+      cy.stub(win.recurly, 'token')
+    })
+    cy.mount(<CheckoutPanelWithPaymentProvider />)
+    cy.get('@form').within(() => fillForm())
+    cy.findByRole('button', { name: /upgrade now/i }).click()
+
+    cy.window().then(win => {
+      expect(win.recurly.token).to.be.calledOnceWith(
+        Cypress.sinon.match.instanceOf(ElementsBase),
+        {
+          first_name: '123',
+          last_name: '123',
+          postal_code: '123',
+          address1: '123',
+          address2: '',
+          state: '',
+          city: '',
+          country: 'BG',
+          coupon: '',
+        },
+        Cypress.sinon.match.func
+      )
+    })
+  })
+
+  it('renders generic error', function () {
+    const errorMessage = 'generic error'
+    cy.window().then(win => {
+      win.recurly = createFakeRecurly(defaultSubscription, {
+        token: (_1: unknown, _2: unknown, handler: TokenHandler) => {
+          const err = new Error(errorMessage) as RecurlyError
+          setTimeout(() => handler(err, { id: '1', type: 'abc' }), 100)
+        },
+      })
+    })
+    cy.mount(<CheckoutPanelWithPaymentProvider />)
+    cy.get('@form').within(() => fillForm())
+
+    cy.findByRole('button', { name: /upgrade now/i }).as('button')
+    cy.get('@button').click()
+    cy.get('@button').within(() => {
+      cy.contains(/processing/i)
+    })
+    cy.findByRole('alert').should('have.text', errorMessage)
+    cy.get('@button').within(() => {
+      cy.findByText(/processing/i).should('not.exist')
+    })
+  })
+
+  it('renders prefilled coupon input', function () {
+    const couponCode = 'promo_code'
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-couponCode', couponCode)
+    })
+    cy.mount(<CheckoutPanelWithPaymentProvider />)
+    cy.findByLabelText(/coupon code/i).should('have.value', couponCode)
+  })
+
+  it('calls coupon method when entering coupon code', function () {
+    const couponCode = 'promo_code'
+    cy.window().then(win => {
+      const couponStub = cy.stub().as('coupon')
+      couponStub.returnsThis()
+      win.recurly = createFakeRecurly({
+        ...defaultSubscription,
+        coupon: couponStub,
+      })
+    })
+    cy.mount(<CheckoutPanelWithPaymentProvider />)
+    cy.get('@coupon').should('be.calledOnce')
+    cy.findByTestId('checkout-form').within(() => {
+      cy.findByLabelText(/coupon code/i)
+        .type(couponCode, { delay: 0 })
+        .blur()
+    })
+    cy.get('@coupon').should('be.calledTwice').and('be.calledWith', couponCode)
+  })
+
+  it('enters invalid coupon code', function () {
+    cy.window().then(win => {
+      const catchStub = cy.stub().as('catch')
+      catchStub.onFirstCall().returnsThis()
+      catchStub
+        .onSecondCall()
+        .callsFake(function (this: unknown, cb: (err: RecurlyError) => void) {
+          const err = {
+            name: 'api-error',
+            code: 'not-found',
+          } as RecurlyError
+
+          cb(err)
+          return this
+        })
+
+      win.recurly = createFakeRecurly({
+        ...defaultSubscription,
+        catch: catchStub,
+      })
+    })
+    cy.mount(<CheckoutPanelWithPaymentProvider />)
+    cy.findByTestId('checkout-form').within(() => {
+      cy.findByLabelText(/coupon code/i)
+        .type('promo_code', { delay: 0 })
+        .blur()
+    })
+    cy.findByRole('alert').within(() => {
+      cy.contains(/coupon code is not valid for selected plan/i)
+    })
+  })
+
+  it('fails coupon verification', function () {
+    cy.window().then(win => {
+      const catchStub = cy.stub().as('catch')
+      // call original method on change event
+      catchStub.onFirstCall().returnsThis()
+      catchStub
+        .onSecondCall()
+        .callsFake(function (this: unknown, cb: (err: RecurlyError) => void) {
+          const err = {} as RecurlyError
+
+          try {
+            cb(err)
+          } catch (e) {}
+
+          return this
+        })
+
+      win.recurly = createFakeRecurly({
+        ...defaultSubscription,
+        catch: catchStub,
+      })
+    })
+    cy.mount(<CheckoutPanelWithPaymentProvider />)
+    cy.get('@catch').should('be.calledOnce')
+    cy.findByTestId('checkout-form').within(() => {
+      cy.findByLabelText(/coupon code/i)
+        .type('promo_code', { delay: 0 })
+        .blur()
+    })
+    cy.get('@catch').should('be.calledTwice')
+    cy.findByRole('alert').within(() => {
+      cy.contains(/an error occurred when verifying the coupon code/i)
+    })
+  })
+
+  /* The test is disabled due to https://github.com/overleaf/internal/issues/12004
+  it.skip('creates a new subscription', function () {
+    cy.stub(locationModule, 'assign').as('assign')
+    cy.intercept('POST', 'user/subscription/create', {
+      statusCode: 201,
+    }).as('create')
+
+    cy.mount(<CheckoutPanelWithPaymentProvider />)
+    cy.findByTestId('checkout-form').within(() => fillForm())
+    cy.findByRole('button', { name: /upgrade now/i }).click()
+    // verify itm params are also passed
+    cy.get('@create')
+      .its('request.body.subscriptionDetails')
+      .should('contain', {
+        ITMCampaign: itmCampaign,
+        ITMContent: itmContent,
+        ITMReferrer: itmReferrer,
+      })
+    cy.get('@assign')
+      .should('be.calledOnce')
+      .and('be.calledWith', '/user/subscription/thank-you')
+  })
+  */
+
+  it('fails to create a new subscription', function () {
+    cy.intercept('POST', 'user/subscription/create', {
+      statusCode: 404,
+    })
+
+    cy.mount(<CheckoutPanelWithPaymentProvider />)
+    cy.findByTestId('checkout-form').within(() => fillForm())
+    cy.findByRole('button', { name: /upgrade now/i }).click()
+    cy.findByRole('alert').within(() => {
+      cy.contains(/something went wrong processing the request/i)
+    })
+  })
+
+  describe('3D challenge', function () {
+    it('shows three d secure challenge', function () {
+      cy.intercept('POST', 'user/subscription/create', {
+        statusCode: 404,
+        body: {
+          threeDSecureActionTokenId: '123',
+        },
+      })
+
+      cy.mount(<CheckoutPanelWithPaymentProvider />)
+      cy.findByTestId('checkout-form').within(() => fillForm())
+      cy.findByRole('button', { name: /upgrade now/i }).click()
+      cy.findByRole('alert').within(() => {
+        cy.contains(
+          /your card must be authenticated with 3D Secure before continuing/i
+        )
+      })
+      cy.contains('3D challenge content')
+    })
+  })
+
+  describe('card payments', function () {
+    beforeEach(function () {
+      cy.findByLabelText(/card payment/i).click()
+    })
+
+    it('renders card element', function () {
+      cy.get('@form').within(() => {
+        cy.findByText(/card details/i, { selector: 'label' })
+        cy.findByTestId('test-card-element')
+      })
+    })
+
+    it('verifies the card element does not disappear when switching between payment methods', function () {
+      cy.get('@form').within(() => {
+        cy.findByText(/card details/i, { selector: 'label' })
+        cy.findByTestId('test-card-element')
+        cy.findByLabelText(/paypal/i).click()
+        cy.findByLabelText(/card payment/i).click()
+        cy.findByText(/card details/i, { selector: 'label' })
+        cy.findByTestId('test-card-element')
+      })
+    })
+
+    it('renders first name input', function () {
+      cy.get('@form').within(() => {
+        cy.findByLabelText(/first name/i)
+      })
+    })
+
+    it('renders last name input', function () {
+      cy.get('@form').within(() => {
+        cy.findByLabelText(/last name/i)
+      })
+    })
+
+    describe('submit button', function () {
+      it('renders trial button', function () {
+        cy.get('@form').within(() => {
+          cy.findByRole('button', { name: /upgrade now, pay after \d+ days/i })
+        })
+      })
+
+      it('renders non-trial button', function () {
+        cy.window().then(win => {
+          const clone = cloneDeep(defaultSubscription)
+          clone.items.plan!.trial = undefined
+          win.recurly = createFakeRecurly(clone)
+        })
+        cy.mount(<CheckoutPanelWithPaymentProvider />)
+        cy.findByTestId('checkout-form').within(() => {
+          cy.findByRole('button', { name: 'Upgrade Now' })
+        })
+      })
+
+      it('handles the disabled state of submit button', function () {
+        cy.get('@form').within(() => {
+          cy.findByRole('button', { name: /upgrade now/i }).should(
+            'be.disabled'
+          )
+          fillForm()
+          cy.findByRole('button', { name: /upgrade now/i }).should(
+            'not.be.disabled'
+          )
+        })
+      })
+    })
+  })
+
+  describe('paypal payments', function () {
+    beforeEach(function () {
+      cy.findByLabelText(/paypal/i).click()
+    })
+
+    it('should not render card element', function () {
+      cy.get('@form').within(() => {
+        cy.findByLabelText(/card details/i).should('not.exist')
+      })
+    })
+
+    it('should not render first name input', function () {
+      cy.get('@form').within(() => {
+        cy.findByLabelText(/first name/i).should('not.exist')
+      })
+    })
+
+    it('should not render last name input', function () {
+      cy.get('@form').within(() => {
+        cy.findByLabelText(/last name/i).should('not.exist')
+      })
+    })
+
+    it('renders proceeding to PayPal notice', function () {
+      cy.get('@form').within(() => {
+        cy.contains(
+          /proceeding to PayPal will take you to the PayPal site to pay for your subscription/i
+        )
+      })
+    })
+
+    it('handles the disabled state of submit button', function () {
+      cy.get('@form').within(() => {
+        cy.findByRole('button', { name: /proceed to paypal/i }).should(
+          'be.disabled'
+        )
+        cy.findByLabelText(/country/i).select('Bulgaria')
+        cy.findByRole('button', { name: /proceed to paypal/i }).should(
+          'not.be.disabled'
+        )
+      })
+    })
+  })
+})

+ 42 - 0
services/web/test/frontend/features/subscription/components/new/common.spec.tsx

@@ -0,0 +1,42 @@
+import {
+  createFakeRecurly,
+  defaultSubscription,
+} from '../../fixtures/recurly-mock'
+import { PaymentProvider } from '../../../../../../frontend/js/features/subscription/context/payment-context'
+import { plans } from '../../fixtures/plans'
+
+describe('common recurly validations', function () {
+  beforeEach(function () {
+    const plan = plans.find(({ planCode }) => planCode === 'collaborator')
+
+    if (!plan) {
+      throw new Error('No plan was found while running the test!')
+    }
+
+    cy.window().then(win => {
+      win.metaAttributesCache = new Map()
+      win.metaAttributesCache.set('ol-countryCode', '')
+      win.metaAttributesCache.set('ol-recurlyApiKey', '1234')
+      win.metaAttributesCache.set('ol-recommendedCurrency', 'USD')
+      win.metaAttributesCache.set('ol-plan', plan)
+      win.metaAttributesCache.set('ol-planCode', plan.planCode)
+      win.metaAttributesCache.set('ol-showCouponField', true)
+      win.recurly = createFakeRecurly(defaultSubscription)
+      cy.interceptEvents()
+    })
+  })
+
+  it('initializes recurly', function () {
+    cy.window().then(win => {
+      cy.spy(win.recurly, 'configure')
+      cy.spy(win.recurly.Pricing, 'Subscription')
+    })
+
+    cy.mount(<PaymentProvider publicKey="0000" />)
+
+    cy.window().then(win => {
+      expect(win.recurly.configure).to.be.calledOnce
+      expect(win.recurly.Pricing.Subscription).to.be.calledOnce
+    })
+  })
+})

+ 310 - 0
services/web/test/frontend/features/subscription/components/new/payment-preview.spec.tsx

@@ -0,0 +1,310 @@
+import PaymentPreviewPanel from '../../../../../../frontend/js/features/subscription/components/new/payment-preview/payment-preview-panel'
+import { PaymentProvider } from '../../../../../../frontend/js/features/subscription/context/payment-context'
+import { plans } from '../../fixtures/plans'
+import {
+  createFakeRecurly,
+  defaultSubscription,
+} from '../../fixtures/recurly-mock'
+import { cloneDeep } from 'lodash'
+import { Plan } from '../../../../../../types/subscription/plan'
+
+function PaymentPreviewPanelWithPaymentProvider() {
+  return (
+    <PaymentProvider publicKey="0000">
+      <PaymentPreviewPanel />
+    </PaymentProvider>
+  )
+}
+
+describe('payment preview panel', function () {
+  beforeEach(function () {
+    const plan = plans.find(({ planCode }) => planCode === 'collaborator')
+
+    if (!plan) {
+      throw new Error('No plan was found while running the test!')
+    }
+
+    cy.window().then(win => {
+      win.metaAttributesCache = new Map()
+      win.metaAttributesCache.set('ol-countryCode', '')
+      win.metaAttributesCache.set('ol-recurlyApiKey', '0000')
+      win.metaAttributesCache.set('ol-recommendedCurrency', 'USD')
+      win.metaAttributesCache.set('ol-plan', plan)
+      win.metaAttributesCache.set('ol-planCode', plan.planCode)
+      cy.wrap(plan).as('plan')
+
+      // init default recurly
+      win.recurly = createFakeRecurly(defaultSubscription)
+      cy.interceptEvents()
+    })
+  })
+
+  it('renders plan name', function () {
+    cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+
+    cy.contains(defaultSubscription.items.plan!.name)
+  })
+
+  it('renders collaborators per project', function () {
+    cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+
+    cy.get<Plan>('@plan').then(plan => {
+      cy.contains(`${plan.features?.collaborators} collaborators per project`)
+    })
+  })
+
+  it('renders features list', function () {
+    cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+
+    cy.contains(/all premium features/i)
+    cy.findByTestId('features-list').within(() => {
+      cy.get(':nth-child(1)').contains(/increased compile timeout/i)
+      cy.get(':nth-child(2)').contains(/sync with dropbox and github/i)
+      cy.get(':nth-child(3)').contains(/full document history/i)
+      cy.get(':nth-child(4)').contains(/track changes/i)
+      cy.get(':nth-child(5)').contains(/advanced reference search/i)
+      cy.get(':nth-child(6)').contains(/reference manager sync/i)
+      cy.get(':nth-child(7)').contains(/symbol palette/i)
+    })
+  })
+
+  it('renders no features list', function () {
+    cy.window().then(win => {
+      cy.get<Plan>('@plan').then(plan => {
+        const { features: _, ...noFeaturesPlan } = plan
+        win.metaAttributesCache.set('ol-plan', noFeaturesPlan)
+      })
+    })
+
+    cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+
+    cy.findByTestId('features-list').should('not.exist')
+  })
+
+  describe('price summary', function () {
+    beforeEach(function () {
+      cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+      cy.findByTestId('price-summary').as('priceSummary')
+      cy.findByTestId('price-summary-plan').as('priceSummaryPlan')
+      cy.findByTestId('price-summary-coupon').as('priceSummaryCoupon')
+      cy.findByTestId('price-summary-vat').as('priceSummaryVat')
+      cy.findByTestId('price-summary-total').as('priceSummaryTotal')
+    })
+
+    it('renders title', function () {
+      cy.get('@priceSummary').contains(/payment summary/i)
+    })
+
+    it('renders plan info', function () {
+      cy.get('@priceSummaryPlan').contains(defaultSubscription.items.plan!.name)
+      cy.get('@priceSummaryPlan').contains(
+        `$${defaultSubscription.price.base.plan.unit}`
+      )
+    })
+
+    it('renders coupon info', function () {
+      cy.get('@priceSummaryCoupon').contains(
+        defaultSubscription.items.coupon!.name
+      )
+      cy.get('@priceSummaryCoupon').contains(
+        `Discount of $${defaultSubscription.price.now.discount}`
+      )
+    })
+
+    it('does not render coupon info when there is no coupon', function () {
+      cy.window().then(win => {
+        const { coupon: _, ...items } = defaultSubscription.items
+        win.recurly = createFakeRecurly({ ...defaultSubscription, items })
+      })
+      cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+      cy.findByTestId('price-summary-coupon').should('not.exist')
+    })
+
+    it('renders VAT', function () {
+      cy.get('@priceSummaryVat').contains(
+        `VAT ${parseFloat(defaultSubscription.price.taxes[0].rate) * 100}%`
+      )
+      cy.get('@priceSummaryVat').contains(
+        `$${defaultSubscription.price.now.tax}`
+      )
+    })
+
+    describe('total amount', function () {
+      it('renders "total per month" text', function () {
+        cy.window().then(win => {
+          const clone = cloneDeep(defaultSubscription)
+          clone.items.plan!.period.length = 1
+          win.recurly = createFakeRecurly(clone)
+        })
+        cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+        cy.findByTestId('price-summary-total').contains(/total per month/i)
+      })
+
+      it('renders "total per year" text', function () {
+        cy.window().then(win => {
+          const clone = cloneDeep(defaultSubscription)
+          clone.items.plan!.period.length = 2
+          win.recurly = createFakeRecurly(clone)
+        })
+        cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+        cy.findByTestId('price-summary-total').contains(/total per year/i)
+      })
+
+      it('renders total amount', function () {
+        cy.get('@priceSummaryTotal').contains(
+          `$${defaultSubscription.price.now.total}`
+        )
+      })
+    })
+
+    it('renders "change currency" dropdown and changes currency', function () {
+      cy.get('@priceSummary').within(() => {
+        cy.get('@priceSummary')
+          .findByRole('button', { name: /change currency/i })
+          .as('button')
+        cy.findByRole('menu').should('not.exist')
+        cy.get('@button').click()
+        cy.findByRole('menu').within(() => {
+          cy.findByRole('menuitem', { name: /usd \(\$\)/i }).contains(
+            /selected/i
+          )
+          cy.findByRole('menuitem', { name: /eur \(€\)/i })
+          cy.findByRole('menuitem', { name: /gbp \(£\)/i }).click()
+
+          cy.get('@priceSummaryPlan').contains(
+            `£${defaultSubscription.price.base.plan.unit}`
+          )
+          cy.get('@priceSummaryCoupon').contains(
+            `Discount of £${defaultSubscription.price.now.discount}`
+          )
+          cy.get('@priceSummaryVat').contains(
+            `£${defaultSubscription.price.now.tax}`
+          )
+          cy.get('@priceSummaryTotal').contains(
+            `£${defaultSubscription.price.now.total}`
+          )
+        })
+      })
+      cy.findByTestId('trial-coupon-summary')
+        .should('not.contain.text', '$')
+        .should('contain.text', '£')
+    })
+  })
+
+  describe('trial coupon summary', function () {
+    it('renders trial price', function () {
+      cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+      cy.findByTestId('trial-coupon-summary').contains(
+        `First ${defaultSubscription.items.plan!.trial!.length} days free, ` +
+          `after that $${defaultSubscription.price.now.total} per month`
+      )
+    })
+
+    it('renders "X price for Y months"', function () {
+      cy.window()
+        .then(win => {
+          const clone = cloneDeep(defaultSubscription)
+          clone.items.coupon!.applies_for_months = 6
+          clone.items.coupon!.single_use = false
+          clone.items.plan!.period.length = 1
+          win.recurly = createFakeRecurly(clone)
+          return clone
+        })
+        .then((clone: typeof defaultSubscription) => {
+          cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+          cy.findByTestId('trial-coupon-summary').contains(
+            `$${clone.price.now.total} for your first ${
+              clone.items.coupon!.applies_for_months
+            } months`
+          )
+        })
+    })
+
+    it('renders "X price for first month"', function () {
+      cy.window()
+        .then(win => {
+          const clone = cloneDeep(defaultSubscription)
+          clone.items.plan!.period.length = 1
+          win.recurly = createFakeRecurly(clone)
+        })
+        .then(() => {
+          cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+          cy.findByTestId('trial-coupon-summary').contains(
+            `$${defaultSubscription.price.now.total} for your first month`
+          )
+        })
+    })
+
+    it('renders "X price for first year"', function () {
+      cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+      cy.findByTestId('trial-coupon-summary').contains(
+        `$${defaultSubscription.price.now.total} for your first year`
+      )
+    })
+
+    it('renders "then X price per month"', function () {
+      cy.window()
+        .then(win => {
+          const clone = cloneDeep(defaultSubscription)
+          clone.items.coupon!.applies_for_months = 6
+          clone.items.coupon!.single_use = false
+          clone.items.plan!.period.length = 1
+          win.recurly = createFakeRecurly(clone)
+        })
+        .then(() => {
+          cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+          cy.findByTestId('trial-coupon-summary').contains(
+            `Then $26.00 per month`
+          )
+        })
+    })
+
+    it('renders "then X price per year"', function () {
+      cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+      cy.findByTestId('trial-coupon-summary').contains(`Then $26.00 per year`)
+    })
+
+    it('renders "normally X price per month"', function () {
+      cy.window()
+        .then(win => {
+          const clone = cloneDeep(defaultSubscription)
+          clone.items.coupon!.applies_for_months = 0
+          clone.items.coupon!.single_use = false
+          clone.items.plan!.period.length = 1
+          win.recurly = createFakeRecurly(clone)
+        })
+        .then(() => {
+          cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+          cy.findByTestId('trial-coupon-summary').contains(
+            `Normally $26.00 per month`
+          )
+        })
+    })
+
+    it('renders "normally X price per year"', function () {
+      cy.window()
+        .then(win => {
+          const clone = cloneDeep(defaultSubscription)
+          clone.items.coupon!.single_use = false
+          clone.items.plan!.period.length = 2
+          win.recurly = createFakeRecurly(clone)
+        })
+        .then(() => {
+          cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+          cy.findByTestId('trial-coupon-summary').contains(
+            `Normally $26.00 per year`
+          )
+        })
+    })
+  })
+
+  it('renders "cancel anytime" content', function () {
+    cy.mount(<PaymentPreviewPanelWithPaymentProvider />)
+
+    cy.contains(
+      /we’re confident that you’ll love Overleaf, but if not you can cancel anytime/i
+    ).contains(
+      /we’ll give you your money back, no questions asked, if you let us know within 30 days/i
+    )
+  })
+})

+ 0 - 0
services/web/test/frontend/features/subscription/fixtures/plans.tsx → services/web/test/frontend/features/subscription/fixtures/plans.ts


+ 230 - 0
services/web/test/frontend/features/subscription/fixtures/recurly-mock.ts

@@ -0,0 +1,230 @@
+import {
+  Address,
+  CardElementOptions,
+  ElementsInstance,
+  PayPalConfig,
+  PlanOptions,
+  Recurly,
+  RecurlyError,
+  RecurlyOptions,
+  RiskOptions,
+  Tax,
+  TokenPayload,
+} from 'recurly__recurly-js'
+import { SubscriptionPricingInstanceCustom } from '../../../../../types/recurly/pricing/subscription'
+
+export const defaultSubscription = {
+  id: '123',
+  price: {
+    base: {
+      plan: {
+        unit: '20.00',
+        setup_fee: '0.00',
+      },
+    },
+    next: {
+      addons: '0.00',
+      discount: '1.00',
+      plan: '10.00',
+      setup_fee: '0.00',
+      subtotal: '9.00',
+      tax: '1.20',
+      total: '12.00',
+    },
+    now: {
+      addons: '0.00',
+      discount: '1.00',
+      plan: '10.00',
+      setup_fee: '0.00',
+      subtotal: '9.00',
+      tax: '1.20',
+      total: '12.00',
+    },
+    taxes: [
+      {
+        tax_type: 'tax_type_1',
+        region: 'EU',
+        rate: '0.3',
+      },
+    ],
+  },
+  items: {
+    coupon: {
+      applies_for_months: 2,
+      code: 'react',
+      discount: {
+        type: 'percent',
+        rate: 0.2,
+      },
+      name: 'fake coupon',
+      single_use: true,
+    },
+    currency: 'USD',
+    plan: {
+      code: 'asd',
+      name: 'Standard (Collaborator)',
+      period: {
+        interval: '2',
+        length: 5,
+      },
+      price: {
+        '15': {
+          unit_amount: 5,
+          symbol: '$',
+          setup_fee: 2,
+        },
+      },
+      quantity: 1,
+      tax_code: 'digital',
+      tax_exempt: false,
+      trial: {
+        interval: 'weekly',
+        length: 7,
+      },
+    },
+  },
+} as unknown as SubscriptionPricingInstanceCustom
+
+class PayPalBase {
+  protected constructor(config?: PayPalConfig) {
+    Object.assign(this, config)
+  }
+
+  static PayPal = () => new this()
+
+  destroy() {}
+
+  on(_eventName: string, _callback: () => void) {}
+}
+
+class Card {
+  protected fakeCardEl
+
+  constructor() {
+    this.fakeCardEl = document.createElement('div')
+  }
+
+  attach(el: HTMLElement) {
+    this.fakeCardEl.dataset.testid = 'test-card-element'
+    const input = document.createElement('input')
+    input.style.border = '1px solid black'
+    input.style.width = '50px'
+    const cardNumberInput = input.cloneNode(true) as HTMLInputElement
+    cardNumberInput.style.width = '200px'
+    cardNumberInput.placeholder = 'XXXX-XXXX-XXXX-XXXX'
+
+    this.fakeCardEl.appendChild(cardNumberInput)
+    this.fakeCardEl.appendChild(input.cloneNode(true))
+    this.fakeCardEl.appendChild(input.cloneNode(true))
+    this.fakeCardEl.appendChild(input.cloneNode(true))
+    el.appendChild(this.fakeCardEl)
+  }
+
+  on(eventName = 'change', callback: (state: Record<string, unknown>) => void) {
+    this.fakeCardEl.querySelectorAll('input').forEach(node => {
+      node.addEventListener(eventName, () => {
+        const state = {
+          valid: true,
+        }
+        callback(state)
+      })
+    })
+  }
+}
+
+export class ElementsBase {
+  protected constructor(config?: unknown) {
+    Object.assign(this, config)
+  }
+
+  static Elements = () => new this()
+
+  CardElement(_cardElementOptions?: CardElementOptions) {
+    return new Card()
+  }
+}
+
+export class ThreeDSecureBase {
+  protected constructor(riskOptions?: RiskOptions) {
+    Object.assign(this, riskOptions)
+  }
+
+  static ThreeDSecure = (_riskOptions: RiskOptions) => new this()
+
+  on(_eventName = 'change', _callback: () => void) {}
+
+  attach(el: HTMLElement) {
+    el.textContent = '3D challenge content'
+  }
+}
+
+abstract class PricingBase {
+  plan(_planCode: string, _options: PlanOptions) {
+    return this
+  }
+
+  address(_address: Address) {
+    return this
+  }
+
+  tax(_tax: Tax) {
+    return this
+  }
+
+  currency(_currency: string) {
+    return this
+  }
+
+  coupon(_coupon: string) {
+    return this
+  }
+
+  catch(_callback?: (reason?: RecurlyError) => void) {
+    return this
+  }
+
+  done(callback?: () => unknown) {
+    callback?.()
+    return this
+  }
+
+  on(_eventName: string, callback: () => void) {
+    callback()
+  }
+}
+
+const createSubscriptionClass = (classProps: unknown) => {
+  return class extends PricingBase {
+    protected constructor() {
+      super()
+      Object.assign(this, classProps)
+    }
+
+    static Subscription = () => new this()
+  }
+}
+
+// Using `overrides` as currently can't stub/spy external files with cypress
+export const createFakeRecurly = (classProps: unknown, overrides = {}) => {
+  return {
+    configure: (_options: RecurlyOptions) => {},
+    token: (
+      _elements: ElementsInstance,
+      _second: unknown,
+      handler: (
+        err?: RecurlyError | null,
+        recurlyBillingToken?: TokenPayload,
+        threeDResultToken?: TokenPayload
+      ) => void
+    ) => {
+      handler(undefined, undefined, { id: '123', type: '456' })
+    },
+    Elements: ElementsBase.Elements,
+    PayPal: PayPalBase.PayPal,
+    Pricing: createSubscriptionClass(classProps),
+    Risk: () => ({
+      ThreeDSecure: ThreeDSecureBase.ThreeDSecure,
+    }),
+    ...overrides,
+  } as unknown as Recurly
+}

+ 0 - 0
services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx → services/web/test/frontend/features/subscription/fixtures/subscriptions.ts