diff --git a/.circleci/config.yml b/.circleci/config.yml index 234ffc1d2..d50f5dab8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -214,7 +214,7 @@ jobs: - run: name: Update ENV for E2E test command: | - echo 'export VITE_RC_API_KEY=${E2E_RC_API_KEY}; export VITE_RC_TAX_E2E_API_KEY=${E2E_RC_TAX_E2E_API_KEY}; export VITE_RC_STRIPE_CHECKOUT_E2E_API_KEY=${E2E_RC_STRIPE_CHECKOUT_E2E_API_KEY}; export VITE_SKIP_TAX_REAL_TESTS_UNTIL=${VITE_SKIP_TAX_REAL_TESTS_UNTIL}; export VITE_SKIP_STRIPE_TESTS=${VITE_SKIP_STRIPE_TESTS:-false}; export VITE_ALLOW_PAYWALLS_TESTS=true' >> "$BASH_ENV" + echo 'export VITE_RC_API_KEY=${E2E_RC_API_KEY}; export VITE_RC_TAX_E2E_API_KEY=${E2E_RC_TAX_E2E_API_KEY}; export VITE_RC_STRIPE_CHECKOUT_E2E_API_KEY=${E2E_RC_STRIPE_CHECKOUT_E2E_API_KEY}; export VITE_RC_PADDLE_E2E_API_KEY=${E2E_RC_PADDLE_E2E_API_KEY}; export VITE_SKIP_TAX_REAL_TESTS_UNTIL=${VITE_SKIP_TAX_REAL_TESTS_UNTIL}; export VITE_SKIP_STRIPE_TESTS=${VITE_SKIP_STRIPE_TESTS:-false}; export VITE_SKIP_PADDLE_TESTS=${VITE_SKIP_PADDLE_TESTS:-false}; export VITE_ALLOW_PAYWALLS_TESTS=true' >> "$BASH_ENV" source "$BASH_ENV" - install-dependencies - run: diff --git a/examples/webbilling-demo/.env.example b/examples/webbilling-demo/.env.example index b0e27f47c..f4e133775 100644 --- a/examples/webbilling-demo/.env.example +++ b/examples/webbilling-demo/.env.example @@ -2,6 +2,7 @@ VITE_RC_API_KEY="rcb_abcdef1234567890abcdef" VITE_RC_NON_TAX_E2E_API_KEY="rcb_abcdef1234567890abcdef" VITE_RC_TAX_E2E_API_KEY="rcb_abcdef1234567890abcdef" VITE_RC_STRIPE_CHECKOUT_E2E_API_KEY="strp_abcdef1234567890abcdef" +VITE_RC_PADDLE_E2E_API_KEY="pdl_abcdef1234567890abcdef" VITE_SKIP_STRIPE_TESTS=false VITE_SKIP_PADDLE_TESTS=false VITE_SKIP_TAX_REAL_TESTS_UNTIL=2199-10-18 # Do not run in local environment by default diff --git a/examples/webbilling-demo/README.md b/examples/webbilling-demo/README.md index 1fa0de0a4..3f88dbd9d 100644 --- a/examples/webbilling-demo/README.md +++ b/examples/webbilling-demo/README.md @@ -57,6 +57,7 @@ The SDK automatically detects Paddle API keys and routes to the Paddle flow. The export VITE_RC_NON_TAX_E2E_API_KEY = 'your e2e tests public api key' export VITE_RC_TAX_E2E_API_KEY = 'your e2e tests public api key' export VITE_RC_STRIPE_CHECKOUT_E2E_API_KEY = 'your stripe checkout e2e tests public api key' +export VITE_RC_PADDLE_E2E_API_KEY = 'your paddle e2e tests public api key' ``` Optional flags: @@ -64,6 +65,9 @@ Optional flags: ```bash # Useful if Stripe rate limiting is causing flaky CI runs. export VITE_SKIP_STRIPE_TESTS=true + +# Useful to temporarily disable Paddle tests. +export VITE_SKIP_PADDLE_TESTS=true ``` Install playwright diff --git a/examples/webbilling-demo/src/tests/helpers/fixtures.ts b/examples/webbilling-demo/src/tests/helpers/fixtures.ts index fc7ea02ee..d7caa85e5 100644 --- a/examples/webbilling-demo/src/tests/helpers/fixtures.ts +++ b/examples/webbilling-demo/src/tests/helpers/fixtures.ts @@ -40,6 +40,7 @@ export const NON_TAX_TEST_API_KEY = process.env.VITE_RC_NON_TAX_E2E_API_KEY; export const TAX_TEST_API_KEY = process.env.VITE_RC_TAX_E2E_API_KEY; export const STRIPE_CHECKOUT_TEST_API_KEY = process.env.VITE_RC_STRIPE_CHECKOUT_E2E_API_KEY; +export const PADDLE_TEST_API_KEY = process.env.VITE_RC_PADDLE_E2E_API_KEY; export const TAX_TEST_OFFERING_ID = "rcb_e2e_taxes"; export const TAX_TEST_OFFERING_ID_WITH_DISCOUNT = "rcb_e2e_taxes_discounted"; export const TAX_TEST_DISCOUNT_CODE = "FOREVER10"; diff --git a/examples/webbilling-demo/src/tests/helpers/integration-test.ts b/examples/webbilling-demo/src/tests/helpers/integration-test.ts index 8e93edecc..7a615b112 100644 --- a/examples/webbilling-demo/src/tests/helpers/integration-test.ts +++ b/examples/webbilling-demo/src/tests/helpers/integration-test.ts @@ -9,6 +9,10 @@ export const SKIP_STRIPE_TESTS = process.env.VITE_SKIP_STRIPE_TESTS === "true" || process.env.VITE_SKIP_STRIPE_TESTS === "1"; +export const SKIP_PADDLE_TESTS = + process.env.VITE_SKIP_PADDLE_TESTS === "true" || + process.env.VITE_SKIP_PADDLE_TESTS === "1"; + export const SKIP_TAX_REAL_TESTS = (() => { const skipUntilDate = process.env.VITE_SKIP_TAX_REAL_TESTS_UNTIL; if (!skipUntilDate) return false; @@ -55,14 +59,20 @@ interface TestFixtures { } export const integrationTest = test.extend({ - userId: async ({ browserName }, use) => { - const userId = getUserId(browserName); - await use(userId); - }, - email: async ({ userId }, use) => { - const email = getEmailFromUserId(userId); - await use(email); - }, + userId: [ + async ({ browserName }, use) => { + const userId = getUserId(browserName); + await use(userId); + }, + { scope: "test" }, + ], + email: [ + async ({ userId }, use) => { + const email = getEmailFromUserId(userId); + await use(email); + }, + { scope: "test" }, + ], page: async ({ browser }, use) => { const page = await browser.newPage(); await use(page); @@ -72,6 +82,12 @@ export const integrationTest = test.extend({ integrationTest.beforeEach(async ({ page }) => { await page.route("**/v1/events", async (route) => { + const url = route.request().url(); + if (!url.includes("revenuecat.com/v1/events")) { + await route.continue(); + return; + } + await route.fulfill({ status: 200, body: JSON.stringify({}), diff --git a/examples/webbilling-demo/src/tests/helpers/test-helpers.ts b/examples/webbilling-demo/src/tests/helpers/test-helpers.ts index 12225eafa..f85e0a770 100644 --- a/examples/webbilling-demo/src/tests/helpers/test-helpers.ts +++ b/examples/webbilling-demo/src/tests/helpers/test-helpers.ts @@ -3,7 +3,11 @@ import { type Page, type Locator, expect } from "@playwright/test"; import type { StoreLoadTime } from "@revenuecat/purchases-js"; import { BASE_URL, NON_TAX_TEST_API_KEY } from "./fixtures"; import type { integrationTest } from "./integration-test"; -import { ALLOW_PAYWALLS_TESTS, SKIP_STRIPE_TESTS } from "./integration-test"; +import { + ALLOW_PAYWALLS_TESTS, + SKIP_PADDLE_TESTS, + SKIP_STRIPE_TESTS, +} from "./integration-test"; export type RouteFulfillOptions = { body?: string | Buffer | undefined; @@ -37,6 +41,13 @@ export const skipStripeTestsIfDisabled = (test: typeof integrationTest) => { ); }; +export const skipPaddleTestsIfDisabled = (test: typeof integrationTest) => { + test.skip( + SKIP_PADDLE_TESTS, + "Paddle tests are disabled. To enable them, unset VITE_SKIP_PADDLE_TESTS or set it to false.", + ); +}; + function getRandomHash(length: number = 6): string { const characters = "abcdefghijklmnopqrstuvwxyz0123456789"; return Array.from({ length }, () => diff --git a/examples/webbilling-demo/src/tests/paddle/purchase-flow.test.ts b/examples/webbilling-demo/src/tests/paddle/purchase-flow.test.ts new file mode 100644 index 000000000..16d2759f2 --- /dev/null +++ b/examples/webbilling-demo/src/tests/paddle/purchase-flow.test.ts @@ -0,0 +1,152 @@ +import { expect } from "@playwright/test"; +import { PADDLE_TEST_API_KEY } from "../helpers/fixtures"; +import { integrationTest } from "../helpers/integration-test"; +import { + confirmPaymentError, + getPackageCards, + getPaywallPackageCards, + getPaywallPurchaseButtons, + skipPaddleTestsIfDisabled, + skipPaywallsTestIfDisabled, + startPurchaseFlow, +} from "../helpers/test-helpers"; +import { + completePaddleCheckoutForm, + confirmPaddleProcessingPayment, + navigateToPaddleLandingUrl, + PADDLE_TEST_TIMEOUT_MS, + PADDLE_UI_STEP_TIMEOUT_MS, +} from "./test-helpers"; + +integrationTest.describe("Paddle flow", () => { + const paddleCardholderName = "RevenueCat E2E"; + + integrationTest.describe.configure({ + timeout: PADDLE_TEST_TIMEOUT_MS, + }); + + integrationTest.skip( + ({ browserName }) => !!process.env.CI && browserName !== "chromium", + "Paddle tests run only in Chromium on CI", + ); + + skipPaddleTestsIfDisabled(integrationTest); + + integrationTest.skip( + !PADDLE_TEST_API_KEY, + "Paddle E2E tests require VITE_RC_PADDLE_E2E_API_KEY.", + ); + + integrationTest( + "Purchases a product with Paddle", + async ({ page, userId, email }) => { + page = await navigateToPaddleLandingUrl(page, userId); + + await expect(page.getByText("Paddle demo")).toBeVisible({ + timeout: PADDLE_UI_STEP_TIMEOUT_MS, + }); + + const packageCards = await getPackageCards(page); + expect(packageCards.length).toBeGreaterThan(0); + + await startPurchaseFlow(packageCards[0]); + await completePaddleCheckoutForm(page, email, paddleCardholderName); + await confirmPaddleProcessingPayment(page); + }, + ); + + integrationTest( + "Purchases a product with Paddle passing the email as query parameter", + async ({ page, userId, email }) => { + page = await navigateToPaddleLandingUrl(page, userId, { + email, + }); + + await expect(page.getByText("Paddle demo")).toBeVisible({ + timeout: PADDLE_UI_STEP_TIMEOUT_MS, + }); + + const packageCards = await getPackageCards(page); + expect(packageCards.length).toBeGreaterThan(0); + + await startPurchaseFlow(packageCards[0]); + await completePaddleCheckoutForm( + page, + email, + paddleCardholderName, + false, + ); + await confirmPaddleProcessingPayment(page); + }, + ); + + integrationTest( + "Shows an error screen when checkout/start returns missing paddle checkout params", + async ({ page, userId, email }) => { + page = await navigateToPaddleLandingUrl(page, userId, { + email, + }); + + await expect(page.getByText("Paddle demo")).toBeVisible({ + timeout: PADDLE_UI_STEP_TIMEOUT_MS, + }); + + await page.route("*/**/checkout/start", async (route) => { + const response = await route.fetch(); + const json = (await response.json()) as Record; + + await route.fulfill({ + response, + json: { + ...json, + paddle_billing_params: null, + }, + }); + }); + + const packageCards = await getPackageCards(page); + expect(packageCards.length).toBeGreaterThan(0); + + await startPurchaseFlow(packageCards[0]); + await confirmPaymentError( + page, + "Something went wrong", + PADDLE_UI_STEP_TIMEOUT_MS, + ); + await confirmPaymentError( + page, + /An unknown error occurred/i, + PADDLE_UI_STEP_TIMEOUT_MS, + ); + }, + ); + + integrationTest( + "Purchases monthly product from RC Paywall with Paddle", + async ({ page, userId, email }) => { + skipPaywallsTestIfDisabled(integrationTest); + + page = await navigateToPaddleLandingUrl(page, userId, { + useRcPaywall: true, + lang: "en", + email, + }); + + const paywallPackages = await getPaywallPackageCards(page); + expect(paywallPackages.length).toBeGreaterThan(0); + await paywallPackages[0].click(); + + const purchaseButtons = await getPaywallPurchaseButtons(page); + expect(purchaseButtons.length).toBeGreaterThan(0); + await purchaseButtons[0].click(); + + await completePaddleCheckoutForm( + page, + email, + paddleCardholderName, + false, + ); + await confirmPaddleProcessingPayment(page); + }, + ); +}); diff --git a/examples/webbilling-demo/src/tests/paddle/test-helpers.ts b/examples/webbilling-demo/src/tests/paddle/test-helpers.ts new file mode 100644 index 000000000..720688e25 --- /dev/null +++ b/examples/webbilling-demo/src/tests/paddle/test-helpers.ts @@ -0,0 +1,181 @@ +import { expect, type Page } from "@playwright/test"; +import { PADDLE_TEST_API_KEY } from "../helpers/fixtures"; +import { navigateToLandingUrl } from "../helpers/test-helpers"; + +export const PADDLE_UI_STEP_TIMEOUT_MS = 30_000; +export const PADDLE_TEST_TIMEOUT_MS = 120_000; +export const PADDLE_TEST_OFFERING_ID = "Paddle E2E Test Offering"; +export const PADDLE_TEST_CARD_NUMBER = "4242 4242 4242 4242"; +export const PADDLE_TEST_CARD_CVV = "100"; +export const PADDLE_TEST_CARD_EXPIRY = "12 / 34"; +export const PADDLE_TEST_POSTCODE = "12345"; + +type LandingQuery = { + offeringId?: string; + useRcPaywall?: boolean; + lang?: string; + utm_source?: string; + utm_medium?: string; + utm_campaign?: string; + utm_term?: string; + utm_content?: string; + optOutOfAutoUTM?: boolean; + email?: string; + $displayName?: string; + nickname?: string; + hideBackButtons?: boolean; + discountCode?: string; +}; + +export async function navigateToPaddleLandingUrl( + page: Page, + userId: string, + queryString?: LandingQuery, +) { + const queryWithOfferingId: LandingQuery = { + ...queryString, + offeringId: PADDLE_TEST_OFFERING_ID, + }; + + return await navigateToLandingUrl( + page, + userId, + queryWithOfferingId, + PADDLE_TEST_API_KEY, + ); +} + +export const getPaddleCheckoutIframe = (page: Page) => page.locator("iframe"); + +export const getPaddleCheckoutFrame = (page: Page) => + page.frameLocator("iframe"); + +export async function confirmPaddleCheckoutVisible(page: Page) { + const iframe = getPaddleCheckoutIframe(page); + await expect(iframe).toBeVisible({ timeout: PADDLE_UI_STEP_TIMEOUT_MS }); + + const checkoutFrame = getPaddleCheckoutFrame(page); + await expect( + checkoutFrame.getByRole("textbox", { name: /card number/i }), + ).toBeVisible({ + timeout: PADDLE_UI_STEP_TIMEOUT_MS, + }); +} + +export async function completePaddleCheckoutForm( + page: Page, + email: string, + fullName: string, + fillEmail: boolean = true, +) { + await confirmPaddleCheckoutVisible(page); + const checkoutFrame = getPaddleCheckoutFrame(page); + + const emailInput = checkoutFrame.getByRole("textbox", { + name: /email address/i, + }); + + if (fillEmail) { + await emailInput.waitFor({ + state: "visible", + timeout: PADDLE_UI_STEP_TIMEOUT_MS, + }); + + await emailInput.fill(email); + await emailInput.blur(); + } else { + const emailSummary = checkoutFrame.getByText(email, { + exact: true, + }); + + await expect(emailInput.or(emailSummary).first()).toBeVisible({ + timeout: PADDLE_UI_STEP_TIMEOUT_MS, + }); + + if (await emailInput.isVisible()) { + await expect(emailInput).toHaveValue(email); + } + } + + const cardNumberInput = checkoutFrame.getByRole("textbox", { + name: /card number/i, + }); + await cardNumberInput.click(); + await cardNumberInput.clear(); + await cardNumberInput.pressSequentially(PADDLE_TEST_CARD_NUMBER, { + delay: 15, + }); + await cardNumberInput.blur(); + + const expirationInput = checkoutFrame.getByRole("textbox", { + name: /expiry/i, + }); + await expirationInput.click(); + await expirationInput.clear(); + await expirationInput.pressSequentially(PADDLE_TEST_CARD_EXPIRY, { + delay: 15, + }); + await expirationInput.blur(); + + const securityCodeInput = checkoutFrame.getByRole("textbox", { + name: /cvv|security code/i, + }); + await securityCodeInput.click(); + await securityCodeInput.clear(); + await securityCodeInput.pressSequentially(PADDLE_TEST_CARD_CVV, { + delay: 15, + }); + await securityCodeInput.blur(); + + const cardHolderInput = checkoutFrame.getByRole("textbox", { + name: /card holder|name on card/i, + }); + await cardHolderInput.click(); + await cardHolderInput.clear(); + await cardHolderInput.pressSequentially(fullName, { delay: 10 }); + await cardHolderInput.blur(); + + const postcodeInput = checkoutFrame.getByRole("textbox", { + name: /zip\/postcode|postcode/i, + }); + await postcodeInput.click(); + await postcodeInput.clear(); + await postcodeInput.pressSequentially(PADDLE_TEST_POSTCODE, { delay: 15 }); + await postcodeInput.blur(); + + const payButton = checkoutFrame.getByRole("button", { + name: /^pay /i, + }); + await payButton.waitFor({ + state: "visible", + timeout: PADDLE_UI_STEP_TIMEOUT_MS, + }); + await expect(payButton).toBeEnabled({ timeout: PADDLE_UI_STEP_TIMEOUT_MS }); + + const paddleCheckoutSubmission = page.waitForRequest( + (request) => { + if (request.method() !== "POST") { + return false; + } + + return /checkout-service\.paddle\.com\/transaction-checkout\//.test( + request.url(), + ); + }, + { + timeout: PADDLE_UI_STEP_TIMEOUT_MS, + }, + ); + + await payButton.click(); + await paddleCheckoutSubmission; +} + +export async function confirmPaddleProcessingPayment(page: Page) { + const processingPayment = page.getByText("Processing payment"); + const paymentComplete = page.getByText("Payment complete"); + + await expect(processingPayment.or(paymentComplete).first()).toBeVisible({ + timeout: PADDLE_UI_STEP_TIMEOUT_MS, + }); +}