diff --git a/packages/123done/static/index.html b/packages/123done/static/index.html index 2654702d2d5..d936bba1cce 100644 --- a/packages/123done/static/index.html +++ b/packages/123done/static/index.html @@ -86,6 +86,9 @@ SP3 - Sub to Pro 1m (EN GB) + + SP3 - Sub to Pro Plus 1m + diff --git a/packages/123done/static/js/123done.js b/packages/123done/static/js/123done.js index 72015c87992..0c8b44c91fe 100644 --- a/packages/123done/static/js/123done.js +++ b/packages/123done/static/js/123done.js @@ -41,6 +41,7 @@ $(document).ready(function () { 'sp3-6m': '123donepro/halfyearly/landing', 'sp3-12m': '123donepro/yearly/landing', 'sp3-1m-gb': 'en-GB/123donepro/monthly/landing', + 'sp3-plus-1m': '123doneproplus/monthly/landing', }, }; diff --git a/packages/functional-tests/lib/fixtures/standard.ts b/packages/functional-tests/lib/fixtures/standard.ts index f6fd3bd4544..e283184725c 100644 --- a/packages/functional-tests/lib/fixtures/standard.ts +++ b/packages/functional-tests/lib/fixtures/standard.ts @@ -35,6 +35,8 @@ export type TestOptions = { }; export type WorkerOptions = { targetName: TargetName; target: ServerTarget }; +const CI_WAF_TOKEN = process.env.CI_WAF_TOKEN; + export const test = base.extend({ targetName: ['local', { scope: 'worker', option: true }], @@ -47,6 +49,33 @@ export const test = base.extend({ { scope: 'worker', auto: true }, ], + page: async ({ page, target }, use) => { + // Add WAF bypass header only to FXA domains, not external + // services like Stripe/hCaptcha which reject unknown headers + if (CI_WAF_TOKEN) { + const fxaDomains = [ + new URL(target.contentServerUrl).host, + new URL(target.authServerUrl).host, + new URL(target.paymentsNextUrl).host, + new URL(target.relierUrl).host, + ]; + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (fxaDomains.some((domain) => url.host === domain)) { + await route.continue({ + headers: { + ...route.request().headers(), + 'fxa-ci': CI_WAF_TOKEN, + }, + }); + } else { + await route.continue(); + } + }); + } + await use(page); + }, + pages: async ({ target, page }, use) => { const pages = createPages(page, target); await use(pages); diff --git a/packages/functional-tests/lib/stripe-test-cards.ts b/packages/functional-tests/lib/stripe-test-cards.ts index 611c52e85b0..2424f31e7c4 100644 --- a/packages/functional-tests/lib/stripe-test-cards.ts +++ b/packages/functional-tests/lib/stripe-test-cards.ts @@ -9,6 +9,7 @@ export const StripeTestCards = { SUCCESS: '4242424242424242', + SUCCESS_MASTERCARD: '5555555555554444', THREE_DS_AUTHENTICATE: '4000000000003220', DECLINED: '4000000000000002', INSUFFICIENT_FUNDS: '4000000000009995', diff --git a/packages/functional-tests/pages/payments/cancel.ts b/packages/functional-tests/pages/payments/cancel.ts new file mode 100644 index 00000000000..ed1317122ed --- /dev/null +++ b/packages/functional-tests/pages/payments/cancel.ts @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Page, expect } from '@playwright/test'; +import { BaseTarget } from '../../lib/targets/base'; + +export class CancelPage { + constructor( + public page: Page, + readonly target: BaseTarget + ) {} + + // Cancel confirmation page + + get cancelHeading() { + return this.page.locator('#cancel-subscription-heading'); + } + + get confirmCheckbox() { + return this.page.locator('input#cancelAccess[type="checkbox"]'); + } + + get submitCancelButton() { + return this.page.getByRole('button', { + name: /Cancel your subscription to/i, + }); + } + + get keepSubscriptionButton() { + return this.page.getByRole('link', { name: /Keep subscription/i }); + } + + // Already-cancelled state + + get expirationDate() { + return this.page.getByText(/You will still have access .* until/); + } + + get productName() { + return this.cancelHeading; + } + + // Error state + + get errorMessage() { + return this.page.locator('[role="alert"]'); + } + + // Actions + + /** + * Check the acknowledge checkbox and click the cancel button. + */ + async confirmAndCancel() { + await expect(this.confirmCheckbox).toBeVisible({ timeout: 10_000 }); + await this.confirmCheckbox.click(); + await expect(this.confirmCheckbox).toBeChecked(); + await this.submitCancelButton.click(); + } + + /** + * Wait for the already-cancelled confirmation state. + */ + async waitForCancelConfirmation(timeout = 30_000) { + await expect(this.page.getByText(/sorry to see you go/i)).toBeVisible({ + timeout, + }); + } +} diff --git a/packages/functional-tests/pages/payments/index.ts b/packages/functional-tests/pages/payments/index.ts index 012f6a98318..a6c973c308a 100644 --- a/packages/functional-tests/pages/payments/index.ts +++ b/packages/functional-tests/pages/payments/index.ts @@ -4,10 +4,16 @@ import { Page } from '@playwright/test'; import { BaseTarget } from '../../lib/targets/base'; +import { CancelPage } from './cancel'; import { CheckoutPage } from './checkout'; +import { ManagePage } from './manage'; +import { StaySubscribedPage } from './stay-subscribed'; export function create(page: Page, target: BaseTarget) { return { + cancel: new CancelPage(page, target), checkout: new CheckoutPage(page, target), + manage: new ManagePage(page, target), + staySubscribed: new StaySubscribedPage(page, target), }; } diff --git a/packages/functional-tests/pages/payments/manage.ts b/packages/functional-tests/pages/payments/manage.ts new file mode 100644 index 00000000000..f84218e483a --- /dev/null +++ b/packages/functional-tests/pages/payments/manage.ts @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Page, expect } from '@playwright/test'; +import { BaseTarget } from '../../lib/targets/base'; + +export class ManagePage { + constructor( + public page: Page, + readonly target: BaseTarget + ) {} + + // Page heading + + get subscriptionHeading() { + return this.page.locator('#subscription-management'); + } + + // Active subscriptions section + + get activeSubscriptionsList() { + return this.page.locator('ul[aria-label="Your active subscriptions"]'); + } + + getSubscriptionCard(productName: string) { + return this.page.locator( + `li[aria-labelledby="${productName}-information"]` + ); + } + + getProductName(productName: string) { + return this.page.locator(`#${productName}-information`); + } + + getPlanInterval(productName: string) { + return this.getSubscriptionCard(productName).locator('.text-grey-500'); + } + + // Payment details section + + get paymentDetailsSection() { + return this.page.locator('#payment-details'); + } + + get paymentMethodLastFour() { + return this.paymentDetailsSection.getByText(/Card ending in \d{4}/); + } + + get paymentMethodIcon() { + return this.paymentDetailsSection.locator( + '.flex.items-center.gap-2 img' + ); + } + + get managePaymentButton() { + return this.page.locator( + 'a[aria-label="Manage payment method"]' + ); + } + + // Empty state + + get emptyStateMessage() { + return this.page.getByText('You have no active subscriptions'); + } + + // Subscription actions + + cancelButton() { + return this.page.getByRole('link', { + name: /Cancel your subscription to/i, + }); + } + + staySubscribedButton() { + return this.page.getByRole('link', { + name: /Stay subscribed to/i, + }); + } + + // Cancelled status + + get cancelledStatus() { + return this.page.locator('.text-yellow-800').getByText(/Expires on/); + } + + // Actions + + /** + * Navigate to the subscriptions landing page, which triggers + * FXA auth and redirects to the manage page. + */ + async goto(locale = 'en') { + await this.page.goto( + `${this.target.paymentsNextUrl}/${locale}/subscriptions/landing` + ); + } + + /** + * Wait for the manage page to fully load. + */ + async waitForManagePage(timeout = 60_000) { + await expect(this.subscriptionHeading).toBeVisible({ timeout }); + } +} diff --git a/packages/functional-tests/pages/payments/stay-subscribed.ts b/packages/functional-tests/pages/payments/stay-subscribed.ts new file mode 100644 index 00000000000..af26f4a7eb2 --- /dev/null +++ b/packages/functional-tests/pages/payments/stay-subscribed.ts @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Page, expect } from '@playwright/test'; +import { BaseTarget } from '../../lib/targets/base'; + +export class StaySubscribedPage { + constructor( + public page: Page, + readonly target: BaseTarget + ) {} + + // Resubscribe page + + get staySubscribedHeading() { + return this.page.locator('#stay-subscribed-heading'); + } + + get productName() { + return this.staySubscribedHeading; + } + + get nextChargeAmount() { + return this.page.getByText(/Your next charge will be/); + } + + get resubscribeButton() { + return this.page.getByRole('button', { name: /Resubscribe/i }); + } + + // Success state + + get successHeading() { + return this.page.getByText(/Thanks! You.re all set/i); + } + + get backToSubscriptionsLink() { + return this.page.getByRole('link', { name: /Back to subscriptions/i }); + } + + // Error state + + get errorMessage() { + return this.page.locator('[role="alert"]'); + } + + // Actions + + /** + * Click the Resubscribe button and wait for success. + */ + async resubscribe() { + await expect(this.resubscribeButton).toBeVisible({ timeout: 10_000 }); + await this.resubscribeButton.click(); + } + + /** + * Wait for the resubscribe success confirmation. + */ + async waitForSuccess(timeout = 30_000) { + await expect(this.successHeading).toBeVisible({ timeout }); + } +} diff --git a/packages/functional-tests/pages/relier.ts b/packages/functional-tests/pages/relier.ts index 5485b260721..85452ea9558 100644 --- a/packages/functional-tests/pages/relier.ts +++ b/packages/functional-tests/pages/relier.ts @@ -111,6 +111,17 @@ export class RelierPage extends BaseLayout { ); } + // Uses 123doneproplus offering (no free trial) to test that subscription + // appears under "Active subscriptions" on the manage page + async clickSubscribePlusMonthly() { + await this.page + .getByRole('link', { name: 'SP3 - Sub to Pro Plus 1m', exact: true }) + .click(); + await this.page.waitForURL( + (url) => !url.href.includes(this.target.relierUrl) + ); + } + async clickRequire2FA() { await this.page.getByText('Sign In (Require 2FA)').click(); return this.page.waitForURL(`${this.target.contentServerUrl}/**`); diff --git a/packages/functional-tests/playwright.config.ts b/packages/functional-tests/playwright.config.ts index 1e9888df106..4a849502458 100644 --- a/packages/functional-tests/playwright.config.ts +++ b/packages/functional-tests/playwright.config.ts @@ -10,16 +10,6 @@ import { TargetNames } from './lib/targets'; import { getFirefoxUserPrefs } from './lib/targets/firefoxUserPrefs'; const CI = !!process.env.CI; -const CI_WAF_TOKEN = process.env.CI_WAF_TOKEN; - -/** - * Returns a header used for WAF bypass. - * - * Requires `CI_WAF_TOKEN` set in CircleCI, or local environment, and corresponding WAF condition set for target rule - */ -function getCIHeader(): Record { - return CI_WAF_TOKEN ? { 'fxa-ci': CI_WAF_TOKEN } : {}; -} // If using the CircleCI parallelism feature, assure that the JUNIT XML report // has a unique name @@ -74,7 +64,6 @@ export default defineConfig>({ use: { viewport: { width: 1280, height: 720 }, - extraHTTPHeaders: getCIHeader(), }, projects: [ ...TargetNames.map( diff --git a/packages/functional-tests/tests-payments-next/subscription.spec.ts b/packages/functional-tests/tests-payments-next/subscription.spec.ts new file mode 100644 index 00000000000..49529ac6513 --- /dev/null +++ b/packages/functional-tests/tests-payments-next/subscription.spec.ts @@ -0,0 +1,306 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { expect, test } from '../lib/fixtures/payments'; +import { StripeTestCards, TestCardDefaults } from '../lib/stripe-test-cards'; + +// Subscription management tests require an existing subscription (created +// via UI checkout). Run serially to avoid Stripe rate limits. +test.describe.configure({ mode: 'serial' }); + +test.describe('severity-1 #smoke', () => { + test.setTimeout(240_000); + test.use({ viewport: { width: 1280, height: 1080 } }); + + test.beforeEach(async ({}, { project }) => { + test.skip( + project.name.includes('production'), + 'Subscription management smoke tests are not run in production' + ); + }); + + test('Manage page loads with active subscription', async ({ + target, + page, + pages: { relier, signin }, + paymentPages: { checkout, manage }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + // Create subscription via UI checkout + await relier.goto(); + await relier.clickSubscribePlusMonthly(); + + await checkout.handleLocationIfNeeded(); + + await checkout.emailInput.fill(credentials.email); + await checkout.signInContinueButton.click(); + + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutPasswordForm(credentials.password); + + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + await checkout.waitForStripeReady(); + await checkout.checkConsent(); + await checkout.fillCard(StripeTestCards.SUCCESS); + await checkout.submit(); + + await checkout.waitForSuccess(); + + // Navigate to manage page + await manage.goto(); + await manage.waitForManagePage(); + + // Verify subscription card details + await expect(manage.activeSubscriptionsList).toBeVisible(); + await expect(manage.paymentMethodLastFour).toBeVisible(); + await expect(manage.paymentMethodIcon).toBeVisible(); + }); + + test('Change payment method (Stripe)', async ({ + target, + page, + pages: { relier, signin }, + paymentPages: { checkout, manage }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + // Create subscription via UI checkout + await relier.goto(); + await relier.clickSubscribePlusMonthly(); + + await checkout.handleLocationIfNeeded(); + + await checkout.emailInput.fill(credentials.email); + await checkout.signInContinueButton.click(); + + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutPasswordForm(credentials.password); + + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + await checkout.waitForStripeReady(); + await checkout.checkConsent(); + await checkout.fillCard(StripeTestCards.SUCCESS); + await checkout.submit(); + + await checkout.waitForSuccess(); + + // Navigate to Stripe payment management page + await page.goto( + `${target.paymentsNextUrl}/en/subscriptions/payments/stripe` + ); + await expect(page).toHaveURL(/payments\/stripe/, { timeout: 30_000 }); + + // Wait for the Stripe PaymentElement accordion to load + const stripeIframe = page.locator( + 'iframe[title*="Secure payment input frame"]' + ); + await expect(stripeIframe).toBeVisible({ timeout: 30_000 }); + + // Click "Card" button in the accordion to expand the new card form + const stripeFrame = page.frameLocator( + 'iframe[title*="Secure payment input frame"]' + ); + await stripeFrame.getByRole('button', { name: 'Card' }).click(); + + // Fill card fields directly — checkout.fillCard() unchecks the + // Stripe Link checkbox which breaks the payment management form + const cardNumber = stripeFrame.locator('[autocomplete="cc-number"]'); + await expect(cardNumber).toBeAttached({ timeout: 10_000 }); + await cardNumber.click(); + await cardNumber.pressSequentially(StripeTestCards.SUCCESS_MASTERCARD, { + delay: 50, + }); + const cardExpiry = stripeFrame.locator('[autocomplete="cc-exp"]'); + await cardExpiry.click(); + await cardExpiry.pressSequentially( + `${TestCardDefaults.EXP_MONTH}/${String(TestCardDefaults.EXP_YEAR).slice(-2)}`, + { delay: 50 } + ); + const cardCvc = stripeFrame.locator('[autocomplete="cc-csc"]'); + await cardCvc.click(); + await cardCvc.pressSequentially('123', { delay: 50 }); + const postalCode = stripeFrame.locator( + '[autocomplete="postal-code"], [name="postalCode"], [name="zip"]' + ); + await postalCode.click(); + await postalCode.pressSequentially('10001', { delay: 50 }); + + // Wait for the save button to become enabled — Stripe's onChange + // event needs to fire with complete:true after the last field is filled + const saveButton = page.getByRole('button', { + name: /Save payment method/i, + }); + await expect(saveButton).toBeVisible({ timeout: 10_000 }); + await expect(saveButton).not.toHaveAttribute('disabled', '', { + timeout: 10_000, + }); + await saveButton.click(); + + // Wait for the save to process — the button shows a spinner, + // then the page redirects to manage via router.push + await expect(page).toHaveURL(/subscriptions\/manage/, { timeout: 90_000 }); + await manage.waitForManagePage(); + await expect(manage.paymentMethodLastFour).toContainText('4444'); + }); + + test('Cancel subscription', async ({ + target, + page, + pages: { relier, signin }, + paymentPages: { checkout, manage, cancel }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + // Create subscription via UI checkout + await relier.goto(); + await relier.clickSubscribePlusMonthly(); + + await checkout.handleLocationIfNeeded(); + + await checkout.emailInput.fill(credentials.email); + await checkout.signInContinueButton.click(); + + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutPasswordForm(credentials.password); + + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + await checkout.waitForStripeReady(); + await checkout.checkConsent(); + await checkout.fillCard(StripeTestCards.SUCCESS); + await checkout.submit(); + + await checkout.waitForSuccess(); + + // Navigate to manage page and cancel + await manage.goto(); + await manage.waitForManagePage(); + + await manage.cancelButton().click(); + + // Confirm cancellation + await expect(cancel.cancelHeading).toBeVisible({ timeout: 30_000 }); + await cancel.confirmAndCancel(); + await cancel.waitForCancelConfirmation(); + + // Navigate back to manage and verify cancelled status + await manage.goto(); + await manage.waitForManagePage(); + await expect(manage.cancelledStatus).toBeVisible(); + }); + + test('Resubscribe cancelled subscription', async ({ + target, + page, + pages: { relier, signin }, + paymentPages: { checkout, manage, cancel, staySubscribed }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + // Create subscription via UI checkout + await relier.goto(); + await relier.clickSubscribePlusMonthly(); + + await checkout.handleLocationIfNeeded(); + + await checkout.emailInput.fill(credentials.email); + await checkout.signInContinueButton.click(); + + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutPasswordForm(credentials.password); + + await expect(checkout.paymentHeading).toBeVisible({ timeout: 30_000 }); + + await checkout.waitForStripeReady(); + await checkout.checkConsent(); + await checkout.fillCard(StripeTestCards.SUCCESS); + await checkout.submit(); + + await checkout.waitForSuccess(); + + // Cancel the subscription first + await manage.goto(); + await manage.waitForManagePage(); + + await manage.cancelButton().click(); + + await expect(cancel.cancelHeading).toBeVisible({ timeout: 30_000 }); + await cancel.confirmAndCancel(); + await cancel.waitForCancelConfirmation(); + + // Navigate back to manage and resubscribe + await manage.goto(); + await manage.waitForManagePage(); + + await manage.staySubscribedButton().click(); + + // Confirm resubscribe + await expect(staySubscribed.staySubscribedHeading).toBeVisible({ + timeout: 30_000, + }); + await staySubscribed.resubscribe(); + await staySubscribed.waitForSuccess(); + + // Navigate back to manage and verify active again + await staySubscribed.backToSubscriptionsLink.click(); + await manage.waitForManagePage(); + + // Cancelled status should no longer be visible — subscription is active + await expect(manage.cancelledStatus).toBeHidden(); + await expect(manage.activeSubscriptionsList).toBeVisible(); + }); +}); + +test.describe('severity-2', () => { + test.setTimeout(180_000); + test.use({ viewport: { width: 1280, height: 1080 } }); + + test.beforeEach(async ({}, { project }) => { + test.skip( + project.name.includes('production'), + 'Subscription management tests are not run in production' + ); + }); + + test('Manage page empty state', async ({ + target, + page, + pages: { signin }, + paymentPages: { manage }, + testAccountTracker, + }) => { + const credentials = await testAccountTracker.signUp(); + + // Navigate to manage page (triggers FXA auth) + await manage.goto(); + + // Sign in when redirected to FXA + await expect(page).toHaveURL(new RegExp(target.contentServerUrl), { + timeout: 30_000, + }); + await signin.fillOutEmailFirstForm(credentials.email); + await signin.fillOutPasswordForm(credentials.password); + + // Wait for redirect back to manage page + await manage.waitForManagePage(); + + // Verify empty state + await expect(manage.emptyStateMessage).toBeVisible(); + }); +});