Skip to content

Commit 254ec47

Browse files
authored
Moved portal offer redemption tests to e2e (TryGhost#26957)
ref https://linear.app/ghost/issue/PLA-9/ - migrates the remaining Portal offer redemption coverage into top-level e2e - adds the Portal offer page object and redemption flow support - deletes the legacy browser offers spec
1 parent 31e09b5 commit 254ec47

7 files changed

Lines changed: 284 additions & 322 deletions

File tree

e2e/helpers/pages/portal/account-page.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export class PortalAccountPage extends PortalPage {
1010
readonly resumeSubscriptionButton: Locator;
1111
readonly canceledBadge: Locator;
1212
readonly emailNewsletterHeading: Locator;
13+
readonly freeTrialLabel: Locator;
14+
readonly offerLabel: Locator;
1315

1416
constructor(page: Page) {
1517
super(page);
@@ -22,6 +24,8 @@ export class PortalAccountPage extends PortalPage {
2224
this.resumeSubscriptionButton = this.portalFrame.getByRole('button', {name: 'Resume subscription'});
2325
this.canceledBadge = this.portalFrame.getByText('Canceled', {exact: true});
2426
this.emailNewsletterHeading = this.portalFrame.getByRole('heading', {name: 'Email newsletter'});
27+
this.freeTrialLabel = this.portalFrame.getByText(/Free Trial Ends/i);
28+
this.offerLabel = this.portalFrame.getByTestId('offer-label');
2529
}
2630

2731
cardLast4(last4: string): Locator {

e2e/helpers/pages/portal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from './account-home-page';
22
export * from './account-page';
33
export * from './account-plan-page';
44
export * from './newsletter-management-page';
5+
export * from './offer-page';
56
export * from './portal-page';
67
export * from './sign-in-page';
78
export * from './sign-up-page';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {Locator, Page} from '@playwright/test';
2+
import {PortalPage} from './portal-page';
3+
4+
export class PortalOfferPage extends PortalPage {
5+
readonly nameInput: Locator;
6+
readonly emailInput: Locator;
7+
readonly submitButton: Locator;
8+
readonly continueButton: Locator;
9+
10+
constructor(page: Page) {
11+
super(page);
12+
13+
this.nameInput = this.portalFrame.getByRole('textbox', {name: 'Name'});
14+
this.emailInput = this.portalFrame.getByRole('textbox', {name: 'Email'});
15+
this.submitButton = this.portalFrame.getByRole('button', {name: /Continue|Start .* free trial|Retry/});
16+
this.continueButton = this.portalFrame.getByRole('button', {name: 'Continue'});
17+
}
18+
19+
headingWithText(text: string): Locator {
20+
return this.portalFrame.getByRole('heading', {name: text});
21+
}
22+
23+
text(text: string | RegExp): Locator {
24+
return this.portalFrame.getByText(text);
25+
}
26+
27+
async fillAndSubmit(email: string, name?: string): Promise<void> {
28+
if (name) {
29+
await this.nameInput.fill(name);
30+
}
31+
32+
await this.emailInput.fill(email);
33+
await this.submitButton.click();
34+
}
35+
36+
async continueIfVisible(): Promise<void> {
37+
if (await this.continueButton.isVisible()) {
38+
await this.continueButton.click();
39+
}
40+
}
41+
42+
async waitForOfferPage(title?: string): Promise<void> {
43+
await this.waitForPortalToOpen();
44+
await this.emailInput.waitFor({state: 'visible'});
45+
46+
if (title) {
47+
await this.headingWithText(title).waitFor({state: 'visible'});
48+
}
49+
}
50+
}

e2e/helpers/playwright/flows/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './donations';
2+
export * from './offers';
23
export * from './sign-in';
34
export * from './signup';
45
export * from './tiers';
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {FakeStripeCheckoutPage, HomePage, PortalAccountPage} from '@/helpers/pages';
2+
import {MembersService} from '@/helpers/services/members/members-service';
3+
import {PortalOfferPage} from '@/portal-pages';
4+
import {faker} from '@faker-js/faker';
5+
import type {MemberSubscription} from '@/helpers/services/members/members-service';
6+
import type {Page} from '@playwright/test';
7+
import type {StripeTestService} from '@/helpers/services/stripe';
8+
9+
export async function completeOfferSignupViaPortal(page: Page, stripe: StripeTestService, opts?: {emailAddress?: string; name?: string}): Promise<{emailAddress: string; name: string}> {
10+
const offerPage = new PortalOfferPage(page);
11+
const emailAddress = opts?.emailAddress ?? `test${faker.string.uuid()}@ghost.org`;
12+
const name = opts?.name ?? faker.person.fullName();
13+
14+
await offerPage.fillAndSubmit(emailAddress, name);
15+
await offerPage.continueIfVisible();
16+
17+
const fakeCheckoutPage = new FakeStripeCheckoutPage(page);
18+
await fakeCheckoutPage.waitUntilLoaded();
19+
await stripe.completeLatestSubscriptionCheckout({name});
20+
21+
const latestCheckoutSession = stripe.getCheckoutSessions().at(-1);
22+
const successUrl = latestCheckoutSession?.response.success_url;
23+
24+
if (!successUrl) {
25+
throw new Error('Latest Stripe checkout session does not include a success URL');
26+
}
27+
28+
await page.goto(successUrl);
29+
30+
return {emailAddress, name};
31+
}
32+
33+
export async function redeemOfferViaPortal(page: Page, stripe: StripeTestService, opts?: {
34+
emailAddress?: string;
35+
name?: string;
36+
}): Promise<{
37+
accountPage: PortalAccountPage;
38+
emailAddress: string;
39+
name: string;
40+
subscription: MemberSubscription;
41+
}> {
42+
const membersService = new MembersService(page.request);
43+
const {emailAddress, name} = await completeOfferSignupViaPortal(page, stripe, opts);
44+
45+
const homePage = new HomePage(page);
46+
await homePage.goto();
47+
await homePage.openAccountPortal();
48+
49+
const accountPage = new PortalAccountPage(page);
50+
await accountPage.waitForPortalToOpen();
51+
await accountPage.emailText(emailAddress).waitFor({state: 'visible'});
52+
53+
const member = await membersService.getByEmailWithSubscriptions(emailAddress);
54+
const subscription = member.subscriptions[0];
55+
56+
if (!subscription) {
57+
throw new Error(`Expected redeemed offer member ${emailAddress} to have a subscription`);
58+
}
59+
60+
return {
61+
accountPage,
62+
emailAddress,
63+
name,
64+
subscription
65+
};
66+
}

e2e/tests/public/portal-offers.test.ts

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,58 @@
11
import {OffersService} from '@/helpers/services/offers/offers-service';
2-
import {PublicPage} from '@/helpers/pages';
3-
import {createPaidPortalTier, expect, test} from '@/helpers/playwright';
2+
import {PortalOfferPage, PublicPage} from '@/helpers/pages';
3+
import {SettingsService} from '@/helpers/services/settings/settings-service';
4+
import {createPaidPortalTier, expect, redeemOfferViaPortal, test} from '@/helpers/playwright';
5+
import type {AdminOffer, OfferCreateInput} from '@/helpers/services/offers/offers-service';
6+
import type {HttpClient} from '@/data-factory';
7+
import type {StripeTestService} from '@/helpers/services/stripe';
8+
9+
const MEMBER_NAME = 'Testy McTesterson';
10+
11+
type SignupOfferInput = Pick<OfferCreateInput, 'amount' | 'duration' | 'duration_in_months' | 'type'> & {
12+
codePrefix: string;
13+
tierNamePrefix: string;
14+
};
15+
16+
// TODO: Move this setup into an OfferFactory-backed helper that owns tier creation,
17+
// portal settings, and Stripe sync instead of keeping it local to the test file.
18+
async function createSignupOffer(request: HttpClient, stripe: StripeTestService, input: SignupOfferInput): Promise<AdminOffer> {
19+
const offersService = new OffersService(request);
20+
const settingsService = new SettingsService(request);
21+
const suffix = Date.now();
22+
const tierName = `${input.tierNamePrefix} ${suffix}`;
23+
24+
await settingsService.updateSettings([{key: 'portal_button', value: true}]);
25+
26+
const tier = await createPaidPortalTier(request, {
27+
name: tierName,
28+
currency: 'usd',
29+
monthly_price: 600,
30+
yearly_price: 6000
31+
});
32+
await waitForTierStripeSync(stripe, tierName);
33+
34+
return await offersService.createOffer({
35+
name: 'Black Friday Special',
36+
code: `${input.codePrefix}-${suffix}`,
37+
cadence: 'month',
38+
amount: input.amount,
39+
duration: input.duration,
40+
duration_in_months: input.duration_in_months ?? null,
41+
tierId: tier.id,
42+
type: input.type
43+
});
44+
}
45+
46+
async function waitForTierStripeSync(stripe: StripeTestService, tierName: string): Promise<void> {
47+
await expect.poll(() => {
48+
const product = stripe.getProducts().find(item => item.name === tierName);
49+
if (!product) {
50+
return 0;
51+
}
52+
53+
return stripe.getPrices().filter(item => item.product === product.id).length;
54+
}, {timeout: 10000}).toBe(2);
55+
}
456

557
test.describe('Ghost Public - Portal Offers', () => {
658
test.use({stripeEnabled: true});
@@ -53,4 +105,112 @@ test.describe('Ghost Public - Portal Offers', () => {
53105
await expect(publicPage.portalPopupFrame).toHaveCount(0);
54106
await expect(page).not.toHaveURL(/#\/portal\/offers\//);
55107
});
108+
109+
test('free trial offer opens in portal - redemption shows free trial state', async ({page, stripe}) => {
110+
const offer = await createSignupOffer(page.request, stripe!, {
111+
amount: 14,
112+
codePrefix: 'trial-offer',
113+
duration: 'trial',
114+
tierNamePrefix: 'Trial Offer Tier',
115+
type: 'trial'
116+
});
117+
118+
const publicPage = new PublicPage(page);
119+
await publicPage.gotoOfferCode(offer.code);
120+
121+
const offerPage = new PortalOfferPage(page);
122+
await offerPage.waitForOfferPage(offer.name);
123+
await expect(offerPage.headingWithText(offer.name)).toBeVisible();
124+
await expect(offerPage.text('14 days free')).toBeVisible();
125+
await expect(offerPage.text('Try free for 14 days')).toBeVisible();
126+
127+
const {accountPage, subscription} = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
128+
await expect(accountPage.freeTrialLabel).toBeVisible();
129+
expect(subscription.offer?.id).toBe(offer.id);
130+
expect(subscription.offer_redemptions?.some(item => item.id === offer.id)).toBe(true);
131+
expect(subscription.status).toBe('trialing');
132+
});
133+
134+
test('one-time discount offer opens in portal - redemption shows discounted plan label', async ({page, stripe}) => {
135+
const offer = await createSignupOffer(page.request, stripe!, {
136+
amount: 10,
137+
codePrefix: 'once-offer',
138+
duration: 'once',
139+
tierNamePrefix: 'One-time Offer Tier',
140+
type: 'percent'
141+
});
142+
143+
const publicPage = new PublicPage(page);
144+
await publicPage.gotoOfferCode(offer.code);
145+
146+
const offerPage = new PortalOfferPage(page);
147+
await offerPage.waitForOfferPage(offer.name);
148+
await expect(offerPage.headingWithText(offer.name)).toBeVisible();
149+
await expect(offerPage.text(/^10% off$/)).toBeVisible();
150+
await expect(offerPage.text(/\$5\.40/)).toBeVisible();
151+
await expect(offerPage.text('10% off for first month')).toBeVisible();
152+
153+
const {accountPage, subscription} = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
154+
await expect(accountPage.offerLabel).toContainText('$5.40/month');
155+
await expect(accountPage.offerLabel).toContainText('Ends');
156+
expect(subscription.offer?.id).toBe(offer.id);
157+
expect(subscription.offer_redemptions?.some(item => item.id === offer.id)).toBe(true);
158+
expect(subscription.offer?.duration).toBe('once');
159+
});
160+
161+
test('repeating discount offer opens in portal - redemption shows discounted plan label', async ({page, stripe}) => {
162+
const offer = await createSignupOffer(page.request, stripe!, {
163+
amount: 10,
164+
codePrefix: 'repeating-offer',
165+
duration: 'repeating',
166+
duration_in_months: 3,
167+
tierNamePrefix: 'Repeating Offer Tier',
168+
type: 'percent'
169+
});
170+
171+
const publicPage = new PublicPage(page);
172+
await publicPage.gotoOfferCode(offer.code);
173+
174+
const offerPage = new PortalOfferPage(page);
175+
await offerPage.waitForOfferPage(offer.name);
176+
await expect(offerPage.headingWithText(offer.name)).toBeVisible();
177+
await expect(offerPage.text(/^10% off$/)).toBeVisible();
178+
await expect(offerPage.text(/\$5\.40/)).toBeVisible();
179+
await expect(offerPage.text('10% off for first 3 months')).toBeVisible();
180+
181+
const {accountPage, subscription} = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
182+
await expect(accountPage.offerLabel).toContainText('$5.40/month');
183+
await expect(accountPage.offerLabel).toContainText('Ends');
184+
expect(subscription.offer?.id).toBe(offer.id);
185+
expect(subscription.offer_redemptions?.some(item => item.id === offer.id)).toBe(true);
186+
expect(subscription.offer?.duration).toBe('repeating');
187+
expect(subscription.offer?.duration_in_months).toBe(3);
188+
});
189+
190+
test('forever discount offer opens in portal - redemption shows discounted plan label', async ({page, stripe}) => {
191+
const offer = await createSignupOffer(page.request, stripe!, {
192+
amount: 10,
193+
codePrefix: 'forever-offer',
194+
duration: 'forever',
195+
tierNamePrefix: 'Forever Offer Tier',
196+
type: 'percent'
197+
});
198+
199+
const publicPage = new PublicPage(page);
200+
await publicPage.gotoOfferCode(offer.code);
201+
202+
const offerPage = new PortalOfferPage(page);
203+
await offerPage.waitForOfferPage(offer.name);
204+
await expect(offerPage.headingWithText(offer.name)).toBeVisible();
205+
await expect(offerPage.text(/^10% off$/)).toBeVisible();
206+
await expect(offerPage.text(/\$5\.40/)).toBeVisible();
207+
await expect(offerPage.text('10% off forever')).toBeVisible();
208+
209+
const {accountPage, subscription} = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
210+
await expect(accountPage.offerLabel).toContainText('$5.40/month');
211+
await expect(accountPage.offerLabel).toContainText('Forever');
212+
expect(subscription.offer?.id).toBe(offer.id);
213+
expect(subscription.offer_redemptions?.some(item => item.id === offer.id)).toBe(true);
214+
expect(subscription.offer?.duration).toBe('forever');
215+
});
56216
});

0 commit comments

Comments
 (0)