From 076bc9b39cc55b7646e436131d3dbb959679f159 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Tue, 28 Feb 2023 12:04:44 +0200 Subject: [PATCH] Merge pull request #11872 from overleaf/ii-payment-page-tests [web] Payment page tests GitOrigin-RevId: 0ab9a75c13f1833cbdf7aa71ffe3ab66174ca773 --- .../new/checkout/checkout-panel.tsx | 6 +- .../new/checkout/payment-method-toggle.tsx | 5 +- .../new/payment-preview/currency-dropdown.tsx | 2 +- .../new/payment-preview/features-list.tsx | 2 +- .../new/payment-preview/price-summary.tsx | 16 +- .../payment-preview/trial-coupon-summary.tsx | 4 +- .../subscription/context/payment-context.tsx | 2 +- .../components/new/checkout.spec.tsx | 465 ++++++++++++++++++ .../components/new/common.spec.tsx | 42 ++ .../components/new/payment-preview.spec.tsx | 310 ++++++++++++ .../fixtures/{plans.tsx => plans.ts} | 0 .../subscription/fixtures/recurly-mock.ts | 230 +++++++++ .../{subscriptions.tsx => subscriptions.ts} | 0 13 files changed, 1073 insertions(+), 11 deletions(-) create mode 100644 services/web/test/frontend/features/subscription/components/new/checkout.spec.tsx create mode 100644 services/web/test/frontend/features/subscription/components/new/common.spec.tsx create mode 100644 services/web/test/frontend/features/subscription/components/new/payment-preview.spec.tsx rename services/web/test/frontend/features/subscription/fixtures/{plans.tsx => plans.ts} (100%) create mode 100644 services/web/test/frontend/features/subscription/fixtures/recurly-mock.ts rename services/web/test/frontend/features/subscription/fixtures/{subscriptions.tsx => subscriptions.ts} (100%) diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx index 1c3431b7f4..10a1df6110 100644 --- a/services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx +++ b/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() 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 && ( diff --git a/services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx b/services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx index 7e2e7244e1..3da177d301 100644 --- a/services/web/frontend/js/features/subscription/components/new/checkout/payment-method-toggle.tsx +++ b/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 ( - +
diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx index 99b0e1cb9e..3ff8883e8c 100644 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/currency-dropdown.tsx @@ -26,7 +26,7 @@ function CurrencyDropdown(props: DropdownProps) { > {currency === currencyCode && ( - + )} {currency} ({symbol}) diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx index 3524c9bbda..393f8941ab 100644 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/features-list.tsx @@ -11,7 +11,7 @@ function FeaturesList({ features }: FeaturesListProps) { return ( <>
{t('all_premium_features_including')}
-
    +
      {features.compileTimeout > 1 && (
    • {t('increased_compile_timeout')}
    • )} diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx index 7962df736c..bad13d4ece 100644 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/price-summary.tsx @@ -24,10 +24,10 @@ function PriceSummary() { return ( <>
      -
      +

      {t('payment_summary')}

      -
      +
      {planName} {currencySymbol} @@ -35,7 +35,10 @@ function PriceSummary() {
      {coupon && ( -
      +
      {coupon.name} –{currencySymbol} @@ -49,7 +52,7 @@ function PriceSummary() {
      )} {rate > 0 && ( -
      +
      {t('vat')} {rate * 100}% @@ -59,7 +62,10 @@ function PriceSummary() {
      )} -
      +
      {monthlyBilling ? t('total_per_month') : t('total_per_year')} {currencySymbol} diff --git a/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-coupon-summary.tsx b/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-coupon-summary.tsx index 86c8615348..cb72e01745 100644 --- a/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-coupon-summary.tsx +++ b/services/web/frontend/js/features/subscription/components/new/payment-preview/trial-coupon-summary.tsx @@ -14,7 +14,9 @@ function TrialCouponSummary() { return ( <>
      -
      {children}
      +
      + {children} +
      ) } diff --git a/services/web/frontend/js/features/subscription/context/payment-context.tsx b/services/web/frontend/js/features/subscription/context/payment-context.tsx index 906ab16954..8929f8204a 100644 --- a/services/web/frontend/js/features/subscription/context/payment-context.tsx +++ b/services/web/frontend/js/features/subscription/context/payment-context.tsx @@ -339,7 +339,7 @@ export const PaymentContext = createContext( type PaymentProviderProps = { publicKey: string - children: React.ReactNode + children?: React.ReactNode } export function PaymentProvider({ publicKey, ...props }: PaymentProviderProps) { diff --git a/services/web/test/frontend/features/subscription/components/new/checkout.spec.tsx b/services/web/test/frontend/features/subscription/components/new/checkout.spec.tsx new file mode 100644 index 0000000000..057334db09 --- /dev/null +++ b/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 ( + + + + ) +} + +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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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' + ) + }) + }) + }) +}) diff --git a/services/web/test/frontend/features/subscription/components/new/common.spec.tsx b/services/web/test/frontend/features/subscription/components/new/common.spec.tsx new file mode 100644 index 0000000000..0461ca05ea --- /dev/null +++ b/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() + + cy.window().then(win => { + expect(win.recurly.configure).to.be.calledOnce + expect(win.recurly.Pricing.Subscription).to.be.calledOnce + }) + }) +}) diff --git a/services/web/test/frontend/features/subscription/components/new/payment-preview.spec.tsx b/services/web/test/frontend/features/subscription/components/new/payment-preview.spec.tsx new file mode 100644 index 0000000000..64a931a369 --- /dev/null +++ b/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 ( + + + + ) +} + +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() + + cy.contains(defaultSubscription.items.plan!.name) + }) + + it('renders collaborators per project', function () { + cy.mount() + + cy.get('@plan').then(plan => { + cy.contains(`${plan.features?.collaborators} collaborators per project`) + }) + }) + + it('renders features list', function () { + cy.mount() + + 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').then(plan => { + const { features: _, ...noFeaturesPlan } = plan + win.metaAttributesCache.set('ol-plan', noFeaturesPlan) + }) + }) + + cy.mount() + + cy.findByTestId('features-list').should('not.exist') + }) + + describe('price summary', function () { + beforeEach(function () { + cy.mount() + 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() + 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() + 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() + 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() + 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() + 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() + cy.findByTestId('trial-coupon-summary').contains( + `$${defaultSubscription.price.now.total} for your first month` + ) + }) + }) + + it('renders "X price for first year"', function () { + cy.mount() + 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() + cy.findByTestId('trial-coupon-summary').contains( + `Then $26.00 per month` + ) + }) + }) + + it('renders "then X price per year"', function () { + cy.mount() + 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() + 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() + cy.findByTestId('trial-coupon-summary').contains( + `Normally $26.00 per year` + ) + }) + }) + }) + + it('renders "cancel anytime" content', function () { + cy.mount() + + 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 + ) + }) +}) diff --git a/services/web/test/frontend/features/subscription/fixtures/plans.tsx b/services/web/test/frontend/features/subscription/fixtures/plans.ts similarity index 100% rename from services/web/test/frontend/features/subscription/fixtures/plans.tsx rename to services/web/test/frontend/features/subscription/fixtures/plans.ts diff --git a/services/web/test/frontend/features/subscription/fixtures/recurly-mock.ts b/services/web/test/frontend/features/subscription/fixtures/recurly-mock.ts new file mode 100644 index 0000000000..80f45e18b6 --- /dev/null +++ b/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) => 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 +} diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts similarity index 100% rename from services/web/test/frontend/features/subscription/fixtures/subscriptions.tsx rename to services/web/test/frontend/features/subscription/fixtures/subscriptions.ts