From d078e0021c34975152894407c8e17875409ad01f Mon Sep 17 00:00:00 2001 From: giancarlo Date: Sun, 14 Apr 2024 17:15:04 +0800 Subject: [PATCH] Add end-to-end tests for user and team billing features This commit introduces end-to-end tests for the user and team billing features. It also enhances existing billing configurations, logging, and error handling mechanisms. Refactoring has been done to simplify the code and make it more readable. Adjustments have also been made in the visual aspects of some components. The addition of these tests will help ensure the reliability of the billing features. --- .github/workflows/workflow.yml | 5 ++ .../tests/team-accounts/team-accounts.po.ts | 6 ++ .../e2e/tests/team-billing/team-billing.po.ts | 18 ++++ .../tests/team-billing/team-billing.spec.ts | 24 +++++ .../e2e/tests/user-billing/user-billing.po.ts | 17 ++++ .../tests/user-billing/user-billing.spec.ts | 24 +++++ apps/e2e/tests/utils/stripe.po.ts | 87 ++++++++++++++++++ .../_components/site-page-header.tsx | 8 +- .../blog/_components/post-header.tsx | 28 +++--- .../app/(marketing)/blog/_components/post.tsx | 2 +- apps/web/app/api/billing/webhook/route.ts | 6 +- apps/web/config/billing.sample.config.ts | 12 +-- .../billing/core/src/create-billing-schema.ts | 2 +- .../gateway/src/components/plan-picker.tsx | 6 +- .../billing-event-handler.service.ts | 89 ++++++++++--------- .../src/services/create-stripe-checkout.ts | 25 +++--- .../stripe-webhook-handler.service.ts | 5 +- .../invitations/update-invitation-dialog.tsx | 44 ++++----- .../members/update-member-role-dialog.tsx | 58 ++++++------ packages/ui/src/makerkit/global-loader.tsx | 4 +- 20 files changed, 330 insertions(+), 140 deletions(-) create mode 100644 apps/e2e/tests/team-billing/team-billing.po.ts create mode 100644 apps/e2e/tests/team-billing/team-billing.spec.ts create mode 100644 apps/e2e/tests/user-billing/user-billing.po.ts create mode 100644 apps/e2e/tests/user-billing/user-billing.spec.ts create mode 100644 apps/e2e/tests/utils/stripe.po.ts diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 297e7fd3d..8904b795d 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -32,11 +32,16 @@ jobs: - name: Install dependencies run: pnpm install + - name: Typecheck run: pnpm run typecheck + - name: Lint run: pnpm run lint + - name: Production Build (test env) + run: pnpm --filter web build:test + test: name: ⚫️ Test timeout-minutes: 8 diff --git a/apps/e2e/tests/team-accounts/team-accounts.po.ts b/apps/e2e/tests/team-accounts/team-accounts.po.ts index 4e51d33bc..eeefe25de 100644 --- a/apps/e2e/tests/team-accounts/team-accounts.po.ts +++ b/apps/e2e/tests/team-accounts/team-accounts.po.ts @@ -37,6 +37,12 @@ export class TeamAccountsPageObject { }).click(); } + async goToBilling() { + await this.page.locator('a', { + hasText: 'Billing', + }).click(); + } + async openAccountsSelector() { await this.page.click('[data-test="account-selector-trigger"]'); } diff --git a/apps/e2e/tests/team-billing/team-billing.po.ts b/apps/e2e/tests/team-billing/team-billing.po.ts new file mode 100644 index 000000000..f27cc3aae --- /dev/null +++ b/apps/e2e/tests/team-billing/team-billing.po.ts @@ -0,0 +1,18 @@ +import { Page } from '@playwright/test'; +import { StripePageObject } from '../utils/stripe.po'; +import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po'; + +export class TeamBillingPageObject { + private readonly teamAccounts: TeamAccountsPageObject; + public readonly stripe: StripePageObject; + + constructor(page: Page) { + this.teamAccounts = new TeamAccountsPageObject(page); + this.stripe = new StripePageObject(page); + } + + async setup() { + await this.teamAccounts.setup(); + await this.teamAccounts.goToBilling(); + } +} \ No newline at end of file diff --git a/apps/e2e/tests/team-billing/team-billing.spec.ts b/apps/e2e/tests/team-billing/team-billing.spec.ts new file mode 100644 index 000000000..dd46465be --- /dev/null +++ b/apps/e2e/tests/team-billing/team-billing.spec.ts @@ -0,0 +1,24 @@ +import { expect, Page, test } from '@playwright/test'; +import { TeamBillingPageObject } from './team-billing.po'; + +test.describe('Team Billing', () => { + let page: Page; + let billing: TeamBillingPageObject; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + billing = new TeamBillingPageObject(page); + + await billing.setup(); + }); + + test('a team can subscribe to a plan', async () => { + await billing.stripe.selectPlan(0); + await billing.stripe.proceedToCheckout(); + + await billing.stripe.fillForm(); + await billing.stripe.submitForm(); + + await expect(billing.stripe.successStatus()).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/user-billing/user-billing.po.ts b/apps/e2e/tests/user-billing/user-billing.po.ts new file mode 100644 index 000000000..0468cb2c2 --- /dev/null +++ b/apps/e2e/tests/user-billing/user-billing.po.ts @@ -0,0 +1,17 @@ +import { Page } from '@playwright/test'; +import { AuthPageObject } from '../authentication/auth.po'; +import { StripePageObject } from '../utils/stripe.po'; + +export class UserBillingPageObject { + private readonly auth: AuthPageObject; + public readonly stripe: StripePageObject; + + constructor(page: Page) { + this.auth = new AuthPageObject(page); + this.stripe = new StripePageObject(page); + } + + async setup() { + await this.auth.signUpFlow('/home/billing'); + } +} \ No newline at end of file diff --git a/apps/e2e/tests/user-billing/user-billing.spec.ts b/apps/e2e/tests/user-billing/user-billing.spec.ts new file mode 100644 index 000000000..9ef729356 --- /dev/null +++ b/apps/e2e/tests/user-billing/user-billing.spec.ts @@ -0,0 +1,24 @@ +import { expect, Page, test } from '@playwright/test'; +import { UserBillingPageObject } from './user-billing.po'; + +test.describe('User Billing', () => { + let page: Page; + let billing: UserBillingPageObject; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + billing = new UserBillingPageObject(page); + + await billing.setup(); + }); + + test('user can subscribe to a plan', async () => { + await billing.stripe.selectPlan(0); + await billing.stripe.proceedToCheckout(); + + await billing.stripe.fillForm(); + await billing.stripe.submitForm(); + + await expect(billing.stripe.successStatus()).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/utils/stripe.po.ts b/apps/e2e/tests/utils/stripe.po.ts new file mode 100644 index 000000000..ecbee69ee --- /dev/null +++ b/apps/e2e/tests/utils/stripe.po.ts @@ -0,0 +1,87 @@ +import { Page, expect } from '@playwright/test'; + +export class StripePageObject { + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + plans() { + return this.page.locator('[data-test-plan]'); + } + + getStripeCheckoutIframe() { + return this.page.frameLocator('[name="embedded-checkout"]'); + } + + async fillForm(params: { + billingName?: string; + cardNumber?: string; + expiry?: string; + cvc?: string; + billingCountry?: string; + } = {}) { + expect(() => { + return this.getStripeCheckoutIframe().locator('form').isVisible(); + }); + + const billingName = this.billingName(); + const cardNumber = this.cardNumber(); + const expiry = this.expiry(); + const cvc = this.cvc(); + const billingCountry = this.billingCountry(); + + await billingName.fill(params.billingName ?? 'Mr Makerkit'); + await cardNumber.fill(params.cardNumber ?? '4242424242424242'); + await expiry.fill(params.expiry ?? '1228'); + await cvc.fill(params.cvc ?? '123'); + await billingCountry.selectOption(params.billingCountry ?? 'IT'); + } + + submitForm() { + return this.getStripeCheckoutIframe().locator('form button').click(); + } + + cardNumber() { + return this.getStripeCheckoutIframe().locator('#cardNumber'); + } + + cvc() { + return this.getStripeCheckoutIframe().locator('#cardCvc'); + } + + expiry() { + return this.getStripeCheckoutIframe().locator('#cardExpiry'); + } + + billingName() { + return this.getStripeCheckoutIframe().locator('#billingName'); + } + + cardForm() { + return this.getStripeCheckoutIframe().locator('form'); + } + + billingCountry() { + return this.getStripeCheckoutIframe().locator('#billingCountry'); + } + + selectPlan(index: number = 0) { + const plans = this.plans(); + + return plans.nth(index).click(); + } + + manageBillingButton() { + return this.page.locator('manage-billing-redirect-button'); + } + + successStatus() { + return this.page.locator('[data-test="payment-return-success"]'); + } + + proceedToCheckout() { + return this.page.click('[data-test="checkout-submit-button"]'); + } +} \ No newline at end of file diff --git a/apps/web/app/(marketing)/_components/site-page-header.tsx b/apps/web/app/(marketing)/_components/site-page-header.tsx index 5fded2637..b767f9434 100644 --- a/apps/web/app/(marketing)/_components/site-page-header.tsx +++ b/apps/web/app/(marketing)/_components/site-page-header.tsx @@ -10,12 +10,8 @@ export function SitePageHeader(props: {

{props.title}

-

- {props.subtitle} +

+ {props.subtitle}

diff --git a/apps/web/app/(marketing)/blog/_components/post-header.tsx b/apps/web/app/(marketing)/blog/_components/post-header.tsx index 7a0559063..88d57405f 100644 --- a/apps/web/app/(marketing)/blog/_components/post-header.tsx +++ b/apps/web/app/(marketing)/blog/_components/post-header.tsx @@ -1,11 +1,5 @@ -import Link from 'next/link'; - -import { ArrowLeft } from 'lucide-react'; - import { Cms } from '@kit/cms'; -import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; -import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; import { CoverImage } from '~/(marketing)/blog/_components/cover-image'; @@ -17,25 +11,27 @@ export const PostHeader: React.FC<{ const { title, publishedAt, description, image } = post; return ( -
+
-
+

{title}

-

- -

+
+ + + +
- +

{(imageUrl) => ( -
+
-
+
diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts index 0b6c97207..1a206c502 100644 --- a/apps/web/app/api/billing/webhook/route.ts +++ b/apps/web/app/api/billing/webhook/route.ts @@ -39,11 +39,13 @@ export async function POST(request: Request) { ); return new Response('OK', { status: 200 }); - } catch (e) { + } catch (error) { + console.error(error); + logger.error( { name: 'billing', - error: e, + error: JSON.stringify(error), }, `Failed to process billing webhook`, ); diff --git a/apps/web/config/billing.sample.config.ts b/apps/web/config/billing.sample.config.ts index 295406d34..0b613fcdc 100644 --- a/apps/web/config/billing.sample.config.ts +++ b/apps/web/config/billing.sample.config.ts @@ -31,7 +31,7 @@ export default createBillingSchema({ interval: 'month', lineItems: [ { - id: '324646', + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', name: 'Addon 2', cost: 9.99, type: 'flat', @@ -45,7 +45,7 @@ export default createBillingSchema({ interval: 'year', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1', + id: 'starter-yearly', name: 'Base', cost: 99.99, type: 'flat', @@ -70,7 +70,7 @@ export default createBillingSchema({ interval: 'month', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2', + id: 'price_pro', name: 'Base', cost: 19.99, type: 'flat', @@ -84,7 +84,7 @@ export default createBillingSchema({ interval: 'year', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3', + id: 'price_pro_yearly', name: 'Base', cost: 199.99, type: 'flat', @@ -113,7 +113,7 @@ export default createBillingSchema({ interval: 'month', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4', + id: 'price_enterprise-monthly', name: 'Base', cost: 29.99, type: 'flat', @@ -127,7 +127,7 @@ export default createBillingSchema({ interval: 'year', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5', + id: 'price_enterprise_yearly', name: 'Base', cost: 299.99, type: 'flat', diff --git a/packages/billing/core/src/create-billing-schema.ts b/packages/billing/core/src/create-billing-schema.ts index 496fc1fd1..c85d4305d 100644 --- a/packages/billing/core/src/create-billing-schema.ts +++ b/packages/billing/core/src/create-billing-schema.ts @@ -356,7 +356,7 @@ export function getLineItemTypeById( for (const product of config.products) { for (const plan of product.plans) { for (const lineItem of plan.lineItems) { - if (lineItem.type === id) { + if (lineItem.id === id) { return lineItem.type; } } diff --git a/packages/billing/gateway/src/components/plan-picker.tsx b/packages/billing/gateway/src/components/plan-picker.tsx index 58841c222..79a91c38b 100644 --- a/packages/billing/gateway/src/components/plan-picker.tsx +++ b/packages/billing/gateway/src/components/plan-picker.tsx @@ -236,6 +236,7 @@ export function PlanPicker( key={primaryLineItem.id} >
-