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.
This commit is contained in:
giancarlo
2024-04-14 17:15:04 +08:00
parent 1321b31ae4
commit d078e0021c
20 changed files with 330 additions and 140 deletions

View File

@@ -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"]');
}

View File

@@ -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();
}
}

View File

@@ -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();
});
});

View File

@@ -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');
}
}

View File

@@ -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();
});
});

View File

@@ -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"]');
}
}

View File

@@ -10,12 +10,8 @@ export function SitePageHeader(props: {
<div className={'container flex flex-col space-y-4'}>
<h1 className={'font-base text-3xl xl:text-5xl'}>{props.title}</h1>
<h2
className={
'text-base text-secondary-foreground xl:text-lg 2xl:text-xl'
}
>
<span className={'font-normal'}>{props.subtitle}</span>
<h2 className={'text-muted-foreground xl:text-lg 2xl:text-xl'}>
{props.subtitle}
</h2>
</div>
</div>

View File

@@ -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 (
<div className={'flex flex-col'}>
<div className={'flex flex-1 flex-col'}>
<div className={cn('border-b py-8')}>
<div className={'container flex flex-col space-y-4'}>
<div className={'mx-auto flex max-w-3xl flex-col space-y-4'}>
<h1 className={'text-3xl font-semibold xl:text-5xl'}>{title}</h1>
<h2 className={'text-base text-secondary-foreground xl:text-lg'}>
<span
className={'font-normal'}
dangerouslySetInnerHTML={{ __html: description ?? '' }}
/>
</h2>
<div>
<span className={'text-muted-foreground'}>
<DateFormatter dateString={publishedAt.toISOString()} />
</span>
</div>
<DateFormatter dateString={publishedAt.toISOString()} />
<h2
className={'text-base text-muted-foreground xl:text-lg'}
dangerouslySetInnerHTML={{ __html: description ?? '' }}
></h2>
</div>
</div>
<If condition={image}>
{(imageUrl) => (
<div className="relative mx-auto mt-8 flex h-[378px] w-full max-w-2xl justify-center">
<div className="relative mx-auto mt-8 flex h-[378px] w-full max-w-3xl justify-center">
<CoverImage
preloadImage
className="rounded-md"

View File

@@ -12,7 +12,7 @@ export const Post: React.FC<{
<div>
<PostHeader post={post} />
<div className={'mx-auto flex max-w-2xl flex-col space-y-6 py-8'}>
<div className={'mx-auto flex max-w-3xl flex-col space-y-6 py-8'}>
<article className={styles.HTML}>
<ContentRenderer content={content} />
</article>

View File

@@ -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`,
);

View File

@@ -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',