diff --git a/.github/workflows/frontend-deploy-production.yml b/.github/workflows/frontend-deploy-production.yml index 2fdb43ae3d70..5f2e1b3f66e7 100644 --- a/.github/workflows/frontend-deploy-production.yml +++ b/.github/workflows/frontend-deploy-production.yml @@ -68,6 +68,7 @@ jobs: e2e_test_token: ${{ secrets.E2E_TEST_TOKEN }} slack_token: ${{ secrets.SLACK_TOKEN }} environment: prod + tests: --grep "@oss|@enterprise|@saas" deploy-production: name: Deploy to Vercel Production diff --git a/.github/workflows/frontend-test-staging.yml b/.github/workflows/frontend-test-staging.yml index da8f552a281a..00e516b235d5 100644 --- a/.github/workflows/frontend-test-staging.yml +++ b/.github/workflows/frontend-test-staging.yml @@ -21,3 +21,4 @@ jobs: e2e_test_token: ${{ secrets.E2E_TEST_TOKEN }} slack_token: ${{ secrets.SLACK_TOKEN }} environment: staging + tests: --grep "@oss|@enterprise|@saas" diff --git a/api/app/settings/common.py b/api/app/settings/common.py index a112501fd89d..634c27cf9bd5 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -601,6 +601,9 @@ f"e2e_non_admin_user_with_a_role@{E2E_TEST_EMAIL_DOMAIN}" ) E2E_SEPARATE_TEST_USER = f"e2e_separate_test_user@{E2E_TEST_EMAIL_DOMAIN}" +# User on a Free-plan organisation, used by the Billing tab E2E tests +# that need to exercise the "no active subscription" payment flow. +E2E_BILLING_USER = f"e2e_billing_user@{E2E_TEST_EMAIL_DOMAIN}" # Identity for E2E segment tests E2E_IDENTITY = "test-identity" diff --git a/api/e2etests/e2e_seed_data.py b/api/e2etests/e2e_seed_data.py index 3a08ea5fc1ef..f00717c897de 100644 --- a/api/e2etests/e2e_seed_data.py +++ b/api/e2etests/e2e_seed_data.py @@ -59,6 +59,7 @@ def teardown() -> None: user_email=settings.E2E_NON_ADMIN_USER_WITH_A_ROLE ) delete_user_and_its_organisations(user_email=settings.E2E_SEPARATE_TEST_USER) + delete_user_and_its_organisations(user_email=settings.E2E_BILLING_USER) def seed_data() -> None: @@ -108,6 +109,17 @@ def seed_data() -> None: ) separate_test_user.add_organisation(separate_org, OrganisationRole.ADMIN) + # Billing-tab tests need an organisation on a Free plan (no + # `subscription_id`) so PaymentButton renders the Chargebee DOM-scan + # branch. The default Subscription created by Organisation's AFTER_CREATE + # hook already matches — leave it untouched. + billing_org: Organisation = Organisation.objects.create(name="E2E Billing Org") + billing_user: FFAdminUser = FFAdminUser.objects.create_user( # type: ignore[no-untyped-call] + email=settings.E2E_BILLING_USER, + password=PASSWORD, + ) + billing_user.add_organisation(billing_org, OrganisationRole.ADMIN) + # We add different projects and environments to give each e2e test its own isolated context. project_test_data = [ { diff --git a/frontend/e2e/config.ts b/frontend/e2e/config.ts index d09b12a1b8e8..e7d3146d6fea 100644 --- a/frontend/e2e/config.ts +++ b/frontend/e2e/config.ts @@ -10,6 +10,7 @@ export const E2E_NON_ADMIN_USER_WITH_ENV_PERMISSIONS = `e2e_non_admin_user_with_ export const E2E_NON_ADMIN_USER_WITH_A_ROLE = `e2e_non_admin_user_with_a_role@${E2E_EMAIL_DOMAIN}` export const E2E_CHANGE_MAIL = `e2e_change_email@${E2E_EMAIL_DOMAIN}` export const E2E_SEPARATE_TEST_USER = `e2e_separate_test_user@${E2E_EMAIL_DOMAIN}` +export const E2E_BILLING_USER = `e2e_billing_user@${E2E_EMAIL_DOMAIN}` export const PASSWORD = 'Str0ngp4ssw0rd!' export const E2E_TEST_IDENTITY = 'test-identity' diff --git a/frontend/e2e/tests/billing-test.pw.ts b/frontend/e2e/tests/billing-test.pw.ts new file mode 100644 index 000000000000..6195e8de213e --- /dev/null +++ b/frontend/e2e/tests/billing-test.pw.ts @@ -0,0 +1,115 @@ +import { test, expect } from '../test-setup' +import { byId, log, createHelpers } from '../helpers' +import { E2E_BILLING_USER, PASSWORD } from '../config' + +declare global { + interface Window { + __cbCheckoutClicks: Array + } +} + +test.describe('Billing', () => { + test('Free trial buttons stay wired after toggling between yearly and monthly @saas', async ({ + page, + }) => { + const { click, login, waitForElementVisible } = createHelpers(page) + + // Serve an empty Chargebee bundle so useScript resolves without pulling + // the real library from Chargebee's CDN. + await page.route('**/js.chargebee.com/**/chargebee.js*', (route) => + route.fulfill({ + body: '', + contentType: 'application/javascript', + status: 200, + }), + ) + + // Stub window.Chargebee with a minimal implementation of the DOM-scan + // contract: registerAgain() binds a click handler to every + // [data-cb-type="checkout"] element that records the plan id. + // This is what the real library does — we record instead of opening a + // hosted checkout so the test can observe whether the button is wired. + await page.addInitScript(() => { + window.__cbCheckoutClicks = [] + const registerAgain = () => { + document + .querySelectorAll('[data-cb-type="checkout"]') + .forEach((element) => { + const marker = '__cbBound' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((element as any)[marker]) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(element as any)[marker] = true + element.addEventListener('click', (event) => { + event.preventDefault() + window.__cbCheckoutClicks.push( + element.getAttribute('data-cb-plan-id'), + ) + }) + }) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).Chargebee = { + getInstance: () => ({ + openCheckout: () => {}, + setCheckoutCallbacks: () => {}, + }), + init: () => {}, + registerAgain, + } + }) + + log('Login') + await login(E2E_BILLING_USER, PASSWORD) + + log('Navigate to Billing tab') + await waitForElementVisible(byId('organisation-link')) + await click(byId('organisation-link')) + await waitForElementVisible(byId('org-settings-link')) + await click(byId('org-settings-link')) + await click(byId('billing')) + + const getTrialButton = () => + page + .locator('button[data-cb-type="checkout"]') + .filter({ hasText: '14 Day Free Trial' }) + .first() + const toggle = page.getByRole('switch').first() + + log('Confirm free-trial button is wired in yearly mode') + const yearlyButton = getTrialButton() + await yearlyButton.waitFor({ state: 'visible' }) + const yearlyPlanId = await yearlyButton.getAttribute('data-cb-plan-id') + expect(yearlyPlanId).toBeTruthy() + await yearlyButton.click() + await expect + .poll(() => page.evaluate(() => window.__cbCheckoutClicks)) + .toEqual([yearlyPlanId]) + + log('Toggle to Monthly and click the free-trial button') + await toggle.click() + const monthlyButton = getTrialButton() + await monthlyButton.waitFor({ state: 'visible' }) + const monthlyPlanId = await monthlyButton.getAttribute('data-cb-plan-id') + expect(monthlyPlanId).toBeTruthy() + expect(monthlyPlanId).not.toBe(yearlyPlanId) + await monthlyButton.click() + // Before the fix, registerAgain is not called after the toggle, so the + // remounted button has no click handler and nothing is recorded. + await expect + .poll(() => page.evaluate(() => window.__cbCheckoutClicks)) + .toEqual([yearlyPlanId, monthlyPlanId]) + + log('Toggle back to Yearly and click the free-trial button') + await toggle.click() + const yearlyButtonAgain = getTrialButton() + await yearlyButtonAgain.waitFor({ state: 'visible' }) + expect(await yearlyButtonAgain.getAttribute('data-cb-plan-id')).toBe( + yearlyPlanId, + ) + await yearlyButtonAgain.click() + await expect + .poll(() => page.evaluate(() => window.__cbCheckoutClicks)) + .toEqual([yearlyPlanId, monthlyPlanId, yearlyPlanId]) + }) +}) diff --git a/frontend/web/components/modals/payment/Payment.tsx b/frontend/web/components/modals/payment/Payment.tsx index a099ec443cc3..67cc56d61b92 100644 --- a/frontend/web/components/modals/payment/Payment.tsx +++ b/frontend/web/components/modals/payment/Payment.tsx @@ -19,7 +19,7 @@ import { } from './constants' import { useScript } from 'common/hooks/useScript' import { usePaymentState } from './hooks' -import { initChargebee } from './chargebee' +import { bindChargebeeButtons } from './chargebee' export type PaymentProps = { isDisableAccountText?: string @@ -42,9 +42,9 @@ export const Payment: FC = ({ useEffect(() => { if (ready && !error) { - initChargebee({ isPaymentsEnabled }) + bindChargebeeButtons({ isPaymentsEnabled }) } - }, [ready, error, isPaymentsEnabled]) + }, [ready, error, isPaymentsEnabled, yearly]) if (isAWS) { return ( diff --git a/frontend/web/components/modals/payment/chargebee.ts b/frontend/web/components/modals/payment/chargebee.ts index c6be329bf2aa..1ee8d20f23f1 100644 --- a/frontend/web/components/modals/payment/chargebee.ts +++ b/frontend/web/components/modals/payment/chargebee.ts @@ -3,15 +3,14 @@ import firstpromoter from 'project/firstPromoter' let initialised = false -type InitChargebeeParams = { +type BindChargebeeButtonsParams = { isPaymentsEnabled: boolean } -export const initChargebee = ({ isPaymentsEnabled }: InitChargebeeParams) => { - if (initialised || !Project.chargebee?.site) return - +const initChargebee = ({ + isPaymentsEnabled, +}: BindChargebeeButtonsParams) => { Chargebee.init({ site: Project.chargebee.site }) - Chargebee.registerAgain() firstpromoter() Chargebee.getInstance().setCheckoutCallbacks?.(() => ({ success: (hostedPageId: string) => { @@ -19,7 +18,6 @@ export const initChargebee = ({ isPaymentsEnabled }: InitChargebeeParams) => { }, })) - // Handle plan cookie from signup flow const planId = API.getCookie('plan') if (planId && isPaymentsEnabled) { const link = document.createElement('a') @@ -32,6 +30,19 @@ export const initChargebee = ({ isPaymentsEnabled }: InitChargebeeParams) => { document.body.removeChild(link) API.setCookie('plan', null) } +} + +export const bindChargebeeButtons = ({ + isPaymentsEnabled, +}: BindChargebeeButtonsParams) => { + if (!Project.chargebee?.site) return - initialised = true + if (!initialised) { + initChargebee({ isPaymentsEnabled }) + initialised = true + } + + // Re-scan the DOM so buttons rendered after the initial mount + // (e.g. after toggling the yearly/monthly switch) get wired up. + Chargebee.registerAgain() }