first commit
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled

This commit is contained in:
2026-04-22 19:51:20 +07:00
commit 93d1b7c3d3
579 changed files with 99797 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import {AnalyticsOverviewPage, LoginPage, PasswordResetPage, SettingsPage} from '@/admin-pages';
import {EmailClient, MailPit} from '@/helpers/services/email/mail-pit';
import {Page} from '@playwright/test';
import {expect, test} from '@/helpers/playwright';
import {extractPasswordResetLink} from '@/helpers/services/email/utils';
test.describe('Ghost Admin - Reset Password', () => {
const emailClient: EmailClient = new MailPit();
async function logout(page: Page) {
const loginPage = new LoginPage(page);
await loginPage.logout();
}
test('resets account owner password', async ({page, ghostAccountOwner}) => {
await logout(page);
const {email} = ghostAccountOwner;
const newPassword = 'test@lginSecure@123';
const loginPage = new LoginPage(page);
await loginPage.requestPasswordReset(ghostAccountOwner.email);
await expect.soft(loginPage.body).toContainText('An email with password reset instructions has been sent.');
const messages = await emailClient.search({subject: 'Reset Password', to: email});
const latestMessage = await emailClient.getMessageDetailed(messages[0]);
const passwordResetUrl = extractPasswordResetLink(latestMessage);
await loginPage.goto(passwordResetUrl);
const passwordResetPage = new PasswordResetPage(page);
await passwordResetPage.resetPassword(newPassword, newPassword);
const analyticsPage = new AnalyticsOverviewPage(page);
await expect(analyticsPage.header).toBeVisible();
});
test('resets account owner password when 2FA enabled', async ({page, ghostAccountOwner}) => {
const newPassword = 'test@lginSecure@123';
const settingsPage = new SettingsPage(page);
await settingsPage.staffSection.goto();
await settingsPage.staffSection.enableRequireTwoFa();
await logout(page);
const loginPage = new LoginPage(page);
await loginPage.requestPasswordReset(ghostAccountOwner.email);
await expect.soft(loginPage.body).toContainText('An email with password reset instructions has been sent.');
const messages = await emailClient.search({subject: 'Reset Password', to: ghostAccountOwner.email});
const latestMessage = await emailClient.getMessageDetailed(messages[0]);
const passwordResetUrl = extractPasswordResetLink(latestMessage);
await loginPage.goto(passwordResetUrl);
const passwordResetPage = new PasswordResetPage(page);
await passwordResetPage.resetPassword(newPassword, newPassword);
const analyticsPage = new AnalyticsOverviewPage(page);
await expect(analyticsPage.header).toBeVisible();
});
});
+38
View File
@@ -0,0 +1,38 @@
import {LoginPage, PostsPage, TagsPage} from '@/admin-pages';
import {Page} from '@playwright/test';
import {expect, test} from '@/helpers/playwright';
test.describe('Ghost Admin - Signin Redirect', () => {
async function logout(page: Page) {
const loginPage = new LoginPage(page);
await loginPage.logout();
}
test('deep-linking to a React route while logged out redirects back after signin', async ({page, ghostAccountOwner}) => {
await logout(page);
const tagsPage = new TagsPage(page);
await tagsPage.goto();
const loginPage = new LoginPage(page);
await expect(loginPage.signInButton).toBeVisible();
await loginPage.signIn(ghostAccountOwner.email, ghostAccountOwner.password);
await tagsPage.waitForPageToFullyLoad();
});
test('deep-linking to an Ember route while logged out redirects back after signin', async ({page, ghostAccountOwner}) => {
await logout(page);
const postsPage = new PostsPage(page);
await postsPage.goto();
const loginPage = new LoginPage(page);
await expect(loginPage.signInButton).toBeVisible();
await loginPage.signIn(ghostAccountOwner.email, ghostAccountOwner.password);
await postsPage.waitForPageToFullyLoad();
});
});
+77
View File
@@ -0,0 +1,77 @@
import {AnalyticsOverviewPage, LoginPage, LoginVerifyPage} from '@/admin-pages';
import {EmailClient, EmailMessage, MailPit} from '@/helpers/services/email/mail-pit';
import {expect, test, withIsolatedPage} from '@/helpers/playwright';
test.describe('Two-Factor authentication', () => {
const emailClient: EmailClient = new MailPit();
function parseCodeFromMessageSubject(message: EmailMessage) {
const subject = message.Subject;
const match = subject.match(/\d+/);
if (!match) {
throw new Error(`No verification code found in subject: ${subject}`);
}
return match[0];
}
test.beforeEach(async ({page}) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
});
test('authenticates with 2FA token', async ({browser, baseURL, ghostAccountOwner}) => {
await withIsolatedPage(browser, {baseURL}, async ({page: page}) => {
const {email, password} = ghostAccountOwner;
const adminLoginPage = new LoginPage(page);
await adminLoginPage.goto();
await adminLoginPage.signIn(email, password);
const messages = await emailClient.search({
subject: 'verification code',
to: ghostAccountOwner.email
});
const code = parseCodeFromMessageSubject(messages[0]);
const verifyPage = new LoginVerifyPage(page);
await verifyPage.twoFactorTokenField.fill(code);
await verifyPage.twoFactorVerifyButton.click();
const adminAnalyticsPage = new AnalyticsOverviewPage(page);
await expect(adminAnalyticsPage.header).toBeVisible();
});
});
test('authenticates with 2FA token that was resent', async ({browser, baseURL,ghostAccountOwner}) => {
await withIsolatedPage(browser, {baseURL}, async ({page: page}) => {
const {email, password} = ghostAccountOwner;
const adminLoginPage = new LoginPage(page);
await adminLoginPage.goto();
await adminLoginPage.signIn(email, password);
let messages = await emailClient.search({
subject: 'verification code',
to: ghostAccountOwner.email
});
expect(messages.length).toBe(1);
const verifyPage = new LoginVerifyPage(page);
await verifyPage.resendTwoFactorCodeButton.click();
messages = await emailClient.search({
subject: 'verification code',
to: ghostAccountOwner.email
}, {numberOfMessages: 2});
expect(messages.length).toBe(2);
const code = parseCodeFromMessageSubject(messages[0]);
await verifyPage.twoFactorTokenField.fill(code);
await verifyPage.twoFactorVerifyButton.click();
const adminAnalyticsPage = new AnalyticsOverviewPage(page);
await expect(adminAnalyticsPage.header).toBeVisible();
});
});
});
+301
View File
@@ -0,0 +1,301 @@
import {WhatsNewBanner, WhatsNewMenu} from '@/admin-pages';
import {expect, test} from '@/helpers/playwright/fixture';
import type {Page} from '@playwright/test';
// Local type definition matching the API response format
type RawChangelogEntry = {
slug: string;
title: string;
custom_excerpt: string;
published_at: string;
url: string;
featured: string;
feature_image?: string;
html?: string;
};
function daysAgo(days: number): Date {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}
function daysFromNow(days: number): Date {
const date = new Date();
date.setDate(date.getDate() + days);
return date;
}
function createEntry(publishedAt: Date, options: {
featured?: boolean;
title?: string;
excerpt?: string;
feature_image?: string;
} = {}): RawChangelogEntry {
const title = options.title ?? 'Test Update';
const slug = title.toLowerCase().replace(/\s+/g, '-');
return {
slug,
title,
custom_excerpt: options.excerpt ?? 'Test feature',
published_at: publishedAt.toISOString(),
url: `https://ghost.org/changelog/${slug}`,
featured: (options.featured ?? false) ? 'true' : 'false',
...(options.feature_image && {feature_image: options.feature_image})
};
}
async function mockChangelog(page: Page, entries: RawChangelogEntry[]): Promise<void> {
await page.route('https://ghost.org/changelog.json', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
posts: entries,
changelogUrl: 'https://ghost.org/changelog/'
})
});
});
}
test.describe('Ghost Admin - What\'s New', () => {
test.describe('banner notification', () => {
test('shows banner for new entries the user has not seen', async ({page}) => {
await mockChangelog(page, [
createEntry(daysFromNow(1), {
title: 'New Update',
excerpt: 'This is an exciting new feature'
})
]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await banner.waitForBanner();
await expect(banner.container).toBeVisible();
await expect(banner.title).toHaveText('New Update');
await expect(banner.excerpt).toHaveText('This is an exciting new feature');
});
test('does not show banner for entries from before user joined', async ({page}) => {
await mockChangelog(page, [createEntry(daysAgo(30))]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await expect(banner.container).toBeHidden();
});
test('does not show banner when there are no entries', async ({page}) => {
await mockChangelog(page, []);
const banner = new WhatsNewBanner(page);
await banner.goto();
await expect(banner.container).toBeHidden();
});
test.describe('dismissal behavior', () => {
test('hides banner immediately when close button is clicked', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await banner.waitForBanner();
await expect(banner.container).toBeVisible();
await banner.dismiss();
await expect(banner.container).toBeHidden();
});
test('hides banner immediately when link is clicked', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await banner.waitForBanner();
await expect(banner.container).toBeVisible();
await banner.clickLinkAndClosePopup();
await expect(banner.container).toBeHidden();
});
test('hides banner immediately when modal is opened', async ({page}) => {
await mockChangelog(page, [
createEntry(daysFromNow(1), {feature_image: 'https://ghost.org/image1.jpg'}),
createEntry(daysAgo(5))
]);
const banner = new WhatsNewBanner(page);
const menu = new WhatsNewMenu(page);
await banner.goto();
await banner.waitForBanner();
await expect(banner.container).toBeVisible();
const modal = await menu.openWhatsNewModal();
await modal.close();
await expect(banner.container).toBeHidden();
});
test('banner remains hidden after reload when dismissed', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await banner.waitForBanner();
await banner.dismiss();
await banner.goto();
await expect(banner.container).toBeHidden();
});
test('banner reappears when a new entry is published after dismissal', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const banner = new WhatsNewBanner(page);
await banner.goto();
await banner.waitForBanner();
await banner.dismiss();
await banner.goto();
await expect(banner.container).toBeHidden();
await mockChangelog(page, [
createEntry(daysFromNow(2), {
title: 'Second Update'
})
]);
await banner.goto();
await banner.waitForBanner();
await expect(banner.container).toBeVisible();
await expect(banner.title).toHaveText('Second Update');
});
});
});
test.describe('modal', () => {
test('shows modal with all entries when opened from user menu', async ({page}) => {
await mockChangelog(page, [
createEntry(daysFromNow(1), {
title: 'Latest Update',
excerpt: 'Latest feature',
feature_image: 'https://ghost.org/image1.jpg'
}),
createEntry(daysAgo(5), {
title: 'Previous Update',
excerpt: 'Previous feature'
})
]);
const menu = new WhatsNewMenu(page);
await menu.goto();
const modal = await menu.openWhatsNewModal();
await expect(modal.modal).toBeVisible();
await expect(modal.title).toBeVisible();
const entries = await modal.getEntries();
expect(entries.length).toBe(2);
expect(entries[0].title).toBe('Latest Update');
expect(entries[0].excerpt).toBe('Latest feature');
expect(entries[0].hasImage).toBe(true);
expect(entries[1].title).toBe('Previous Update');
expect(entries[1].excerpt).toBe('Previous feature');
});
});
test.describe('badge indicators', () => {
test('shows badge for new non-featured entries the user has not seen', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
await expect(menu.avatarBadge).toBeVisible();
});
test('shows badge in user menu when there are new entries', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
await menu.openUserMenu();
await expect(menu.menuBadge).toBeVisible();
});
test('does not show badges for entries from before user joined', async ({page}) => {
await mockChangelog(page, [createEntry(daysAgo(30))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
await expect(menu.avatarBadge).toBeHidden();
await menu.openUserMenu();
await expect(menu.menuBadge).toBeHidden();
});
test.describe('dismissal behavior', () => {
test('hides badges immediately when What\'s new modal is opened', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
await expect(menu.avatarBadge).toBeVisible();
const modal = await menu.openWhatsNewModal();
await modal.close();
await expect(menu.avatarBadge).toBeHidden();
});
test('badges remain hidden after reload when What\'s new has been viewed', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
const modal = await menu.openWhatsNewModal();
await modal.close();
await menu.goto();
await expect(menu.avatarBadge).toBeHidden();
});
test('badges reappear when a new entry is published after viewing', async ({page}) => {
await mockChangelog(page, [createEntry(daysFromNow(1))]);
const menu = new WhatsNewMenu(page);
await menu.goto();
const modal = await menu.openWhatsNewModal();
await modal.close();
await menu.goto();
await expect(menu.avatarBadge).toBeHidden();
await mockChangelog(page, [createEntry(daysFromNow(2))]);
await menu.goto();
await expect(menu.avatarBadge).toBeVisible();
});
});
});
});
+10
View File
@@ -0,0 +1,10 @@
import {getEnvironmentManager} from '@/helpers/environment';
import {test as setup} from '@playwright/test';
const TIMEOUT = 2 * 60 * 1000; // 2 minutes
setup('global environment setup', async () => {
setup.setTimeout(TIMEOUT);
const manager = await getEnvironmentManager();
await manager.globalSetup();
});
+7
View File
@@ -0,0 +1,7 @@
import {getEnvironmentManager} from '@/helpers/environment';
import {test as teardown} from '@playwright/test';
teardown('global environment cleanup', async () => {
const manager = await getEnvironmentManager();
await manager.globalTeardown();
});
+126
View File
@@ -0,0 +1,126 @@
import {APIRequestContext, Page} from '@playwright/test';
import {HomePage, MemberDetailsPage, MembersPage} from '@/helpers/pages';
import {MemberFactory, createMemberFactory} from '@/data-factory';
import {PortalAccountHomePage, PortalNewsletterManagementPage} from '@/portal-pages';
import {expect, test} from '@/helpers/playwright';
import {usePerTestIsolation} from '@/helpers/playwright/isolation';
usePerTestIsolation();
async function getNewsletterIds(request: APIRequestContext): Promise<string[]> {
const response = await request.get('/ghost/api/admin/newsletters/?status=active&limit=all');
const data = await response.json();
return data.newsletters.map((n: {id: string}) => n.id);
}
async function createNewsletter(request: APIRequestContext, name: string): Promise<string> {
const response = await request.post('/ghost/api/admin/newsletters/', {
data: {newsletters: [{name}]}
});
const data = await response.json();
return data.newsletters[0].id;
}
async function createSubscribedMember(request: APIRequestContext, memberFactory: MemberFactory) {
const newsletterIds = await getNewsletterIds(request);
const newsletters = newsletterIds.map(id => ({id}));
const member = await memberFactory.create({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
newsletters: newsletters as any
});
return member;
}
async function impersonateMember(page: Page, memberName: string): Promise<void> {
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.getMemberByName(memberName).click();
const memberDetailsPage = new MemberDetailsPage(page);
await memberDetailsPage.settingsSection.memberActionsButton.click();
await memberDetailsPage.settingsSection.impersonateButton.click();
await expect(memberDetailsPage.magicLinkInput).not.toHaveValue('');
const magicLink = await memberDetailsPage.magicLinkInput.inputValue();
await memberDetailsPage.goto(magicLink);
const homePage = new HomePage(page);
await homePage.waitUntilLoaded();
}
async function getMemberNewsletters(request: APIRequestContext, memberId: string): Promise<{id: string}[]> {
const response = await request.get(`/ghost/api/admin/members/${memberId}/`);
const data = await response.json();
return data.members[0].newsletters;
}
test.describe('Portal - Member Actions', () => {
let memberFactory: MemberFactory;
test.beforeEach(async ({page}) => {
memberFactory = createMemberFactory(page.request);
});
test('can log out', async ({page}) => {
const member = await memberFactory.create();
await impersonateMember(page, member.name!);
const homePage = new HomePage(page);
await homePage.openAccountPortal();
const accountHome = new PortalAccountHomePage(page);
await accountHome.signOut();
await homePage.openPortal();
await expect(accountHome.signinSwitchButton).toBeVisible();
});
test('can unsubscribe from newsletter', async ({page}) => {
const member = await createSubscribedMember(page.request, memberFactory);
await impersonateMember(page, member.name!);
const homePage = new HomePage(page);
await homePage.openAccountPortal();
const accountHome = new PortalAccountHomePage(page);
await expect(accountHome.defaultNewsletterCheckbox).toBeChecked();
await accountHome.defaultNewsletterToggle.click();
await expect(accountHome.defaultNewsletterCheckbox).not.toBeChecked();
await expect(async () => {
const memberNewsletters = await getMemberNewsletters(page.request, member.id);
expect(memberNewsletters).toHaveLength(0);
}).toPass();
});
test('can unsubscribe from all newsletters', async ({page}) => {
await createNewsletter(page.request, 'Second newsletter');
const member = await createSubscribedMember(page.request, memberFactory);
await impersonateMember(page, member.name!);
const homePage = new HomePage(page);
await homePage.openAccountPortal();
const accountHome = new PortalAccountHomePage(page);
await accountHome.manageNewslettersButton.click();
const newsletterManagement = new PortalNewsletterManagementPage(page);
await expect(newsletterManagement.newsletterToggles).toHaveCount(2);
await expect(newsletterManagement.newsletterToggleCheckbox(0)).toBeChecked();
await expect(newsletterManagement.newsletterToggleCheckbox(1)).toBeChecked();
await newsletterManagement.unsubscribeFromAllButton.click();
await expect(newsletterManagement.successNotification).toBeVisible();
await expect(newsletterManagement.newsletterToggleCheckbox(0)).not.toBeChecked();
await expect(newsletterManagement.newsletterToggleCheckbox(1)).not.toBeChecked();
const memberNewsletters = await getMemberNewsletters(page.request, member.id);
expect(memberNewsletters).toHaveLength(0);
});
});
+56
View File
@@ -0,0 +1,56 @@
import {PostPage} from '@/helpers/pages';
import {PostsPage} from '@/admin-pages';
import {createPostFactory} from '@/data-factory';
import {expect, test} from '@/helpers/playwright';
import type {PostFactory} from '@/data-factory';
test.describe('Post Factory API Integration', () => {
let postFactory: PostFactory;
test.beforeEach(async ({page}) => {
postFactory = createPostFactory(page.request);
});
test('create a post and view it on the frontend', async ({page}) => {
const post = await postFactory.create({
title: 'Test Post from Factory',
status: 'published'
});
expect(post.id).toBeTruthy();
expect(post.slug).toBeTruthy();
expect(post.status).toBe('published');
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await expect(postPage.postTitle).toContainText('Test Post from Factory');
});
test('create a post visible in Ghost Admin', async ({page}) => {
const uniqueTitle = `Admin Test Post ${Date.now()}`;
const post = await postFactory.create({
title: uniqueTitle,
status: 'published'
});
const postsPage = new PostsPage(page);
await postsPage.goto();
await expect(postsPage.getPostByTitle(post.title)).toBeVisible();
});
test('create draft post that is not accessible on frontend', async ({page}) => {
const draftPost = await postFactory.create({
title: 'Draft Post from Factory',
status: 'draft'
});
expect(draftPost.status).toBe('draft');
expect(draftPost.published_at).toBeNull();
// TODO: Replace this with a 404 page object
const response = await page.goto(`/${draftPost.slug}/`, {
waitUntil: 'domcontentloaded'
});
expect(response?.status()).toBe(404);
});
});
+134
View File
@@ -0,0 +1,134 @@
import {
CommentFactory,
MemberFactory,
PostFactory,
TierFactory,
createFactories
} from '@/data-factory';
import {PostPage} from '@/public-pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, signInAsMember, test} from '@/helpers/playwright';
test.describe('Ghost Public - Comments - Replies', () => {
let commentFactory: CommentFactory;
let postFactory: PostFactory;
let memberFactory: MemberFactory;
let tierFactory: TierFactory;
let settingsService: SettingsService;
test.beforeEach(async ({page}) => {
({postFactory, memberFactory, commentFactory, tierFactory} = createFactories(page.request));
settingsService = new SettingsService(page.request);
});
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('all');
});
test('reply to top comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create({status: 'free'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const comment = await commentFactory.create({
html: 'Main comment',
post_id: post.id,
member_id: member.id
});
await commentFactory.create({
html: 'Reply to main comment',
post_id: post.id,
member_id: paidMember.id,
parent_id: comment.id
});
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const postCommentsSection = postPage.commentsSection;
await expect(postCommentsSection.comments).toHaveCount(2);
await expect(postCommentsSection.comments.first()).toContainText('Main comment');
await expect(postCommentsSection.comments.last()).toContainText('Reply to main comment');
await postCommentsSection.replyToComment('Main comment', 'Reply to main comment 2');
await expect(postCommentsSection.comments).toHaveCount(3);
await expect(postCommentsSection.comments.first()).toContainText('Main comment');
await expect(postCommentsSection.comments.last()).toContainText('Reply to main comment 2');
});
test('reply to reply comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create({status: 'free'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const comment = await commentFactory.create({
html: 'Main comment',
post_id: post.id,
member_id: member.id
});
await commentFactory.create({
html: 'Reply to main comment',
post_id: post.id,
member_id: paidMember.id,
parent_id: comment.id
});
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const postCommentsSection = postPage.commentsSection;
await expect(postCommentsSection.comments).toHaveCount(2);
await postCommentsSection.replyToComment('Reply to main comment', 'My reply');
await expect(postCommentsSection.comments).toHaveCount(3);
await expect(postCommentsSection.comments.first()).toContainText('Main comment');
await expect(postCommentsSection.comments.last()).toContainText('My reply');
await expect(postCommentsSection.comments.last()).toContainText('Replied to: Reply to main comment');
});
test('show replies and load more replies', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create({status: 'free'});
const comment = await commentFactory.create({
html: 'Test comment 1',
post_id: post.id,
member_id: member.id
});
const replies = Array.from({length: 5}, (_, index) => {
return {
html: `reply ${index + 1} to comment 1`,
post_id: post.id,
member_id: member.id,
parent_id: comment.id
};
});
await commentFactory.createMany(replies);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const postCommentsSection = postPage.commentsSection;
await expect(postCommentsSection.comments).toHaveCount(4);
await expect(postCommentsSection.comments.last()).toContainText('reply 3 to comment 1');
await expect(postCommentsSection.showMoreRepliesButton).toBeVisible();
await expect(postCommentsSection.showMoreRepliesButton).toContainText('Show 2 more replies');
await postCommentsSection.showMoreRepliesButton.click();
await expect(postCommentsSection.comments.last()).toContainText('reply 5 to comment 1');
await expect(postCommentsSection.comments).toHaveCount(6);
});
});
+109
View File
@@ -0,0 +1,109 @@
import {
CommentFactory,
MemberFactory,
PostFactory,
TierFactory,
createFactories
} from '@/data-factory';
import {PostPage} from '@/public-pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, signInAsMember, test} from '@/helpers/playwright';
test.describe('Ghost Public - Comments - Manage', () => {
let commentFactory: CommentFactory;
let postFactory: PostFactory;
let memberFactory: MemberFactory;
let tierFactory: TierFactory;
let settingsService: SettingsService;
test.beforeEach(async ({page}) => {
({postFactory, memberFactory, commentFactory, tierFactory} = createFactories(page.request));
settingsService = new SettingsService(page.request);
});
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('all');
});
test('no comment management buttons for non comment author', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const anotherPaidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
await commentFactory.create({
html: 'Comment to edit',
post_id: post.id,
member_id: paidMember.id
});
await signInAsMember(page, anotherPaidMember);
const postPage = new PostPage(page);
const postCommentsSection = postPage.commentsSection;
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const {
editCommentButton, deleteCommentButton, hideCommentButton, showCommentButton
} = await postCommentsSection.getCommentActionButtons('Comment to edit');
await expect(editCommentButton).toBeHidden();
await expect(deleteCommentButton).toBeHidden();
await expect(hideCommentButton).toBeVisible();
await expect(showCommentButton).toBeHidden();
});
test('edit comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
await commentFactory.create({
html: 'Comment to edit',
post_id: post.id,
member_id: paidMember.id
});
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
const postCommentsSection = postPage.commentsSection;
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
await postCommentsSection.editComment('Comment to edit', 'Updated comment');
await expect(postCommentsSection.comments).toHaveCount(1);
await expect(postCommentsSection.comments.first()).toContainText('Updated comment');
});
test('delete comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
await commentFactory.create({
html: 'First comment',
post_id: post.id,
member_id: paidMember.id
});
await commentFactory.create({
html: 'Comment to delete',
post_id: post.id,
member_id: paidMember.id
});
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const postCommentsSection = postPage.commentsSection;
await postCommentsSection.deleteComment('Comment to delete');
await expect(postCommentsSection.comments).toHaveCount(1);
await expect(postCommentsSection.comments.first()).toContainText('First comment');
});
});
@@ -0,0 +1,136 @@
import {MemberFactory, PostFactory, TierFactory, createMemberFactory, createPostFactory, createTierFactory} from '@/data-factory';
import {PostPage} from '@/public-pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, test} from '@/helpers/playwright';
import {signInAsMember} from '@/helpers/playwright/flows/sign-in';
test.describe('Ghost Public - Comments - Permission', () => {
let postFactory: PostFactory;
let memberFactory: MemberFactory;
let tierFactory: TierFactory;
let settingsService: SettingsService;
test.beforeEach(async ({page}) => {
postFactory = createPostFactory(page.request);
memberFactory = createMemberFactory(page.request);
tierFactory = createTierFactory(page.request);
settingsService = new SettingsService(page.request);
});
test.describe('comments enabled for all members', () => {
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('all');
});
test('anonymous user - can not add a comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.commentsSection.waitForCommentsToLoad();
await expect(postPage.commentsSection.ctaBox).toBeVisible();
await expect(postPage.commentsSection.signUpButton).toBeVisible();
await expect(postPage.commentsSection.signInButton).toBeVisible();
await expect(postPage.commentsSection.mainForm).toBeHidden();
});
test('free member - can add a comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const freeMember = await memberFactory.create({status: 'free'});
const commentTexts = ['Test comment by free member', 'Another Test comment by free member'];
await signInAsMember(page, freeMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.commentsSection.waitForCommentsToLoad();
await postPage.commentsSection.addComment(commentTexts[0]);
await postPage.commentsSection.addComment(commentTexts[1]);
await expect(postPage.commentsSection.mainForm).toBeVisible();
await expect(postPage.commentsSection.ctaBox).toBeHidden();
// assert comment details
await expect(postPage.commentsSection.commentCountText).toHaveText('2 comments');
await expect(postPage.commentsSection.comments).toHaveCount(2);
await expect(postPage.commentsSection.comments.first()).toContainText(commentTexts[1]);
await expect(postPage.commentsSection.comments.last()).toContainText(commentTexts[0]);
});
test('paid member - can add a comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const commentText = 'This is a test comment from a paid member';
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForPostToLoad();
await postPage.commentsSection.waitForCommentsToLoad();
await postPage.commentsSection.addComment(commentText);
await expect(postPage.commentsSection.mainForm).toBeVisible();
await expect(postPage.commentsSection.ctaBox).toBeHidden();
// assert comment details
await expect(postPage.commentsSection.comments).toHaveCount(1);
await expect(postPage.commentsSection.comments.first()).toContainText(commentText);
});
});
test.describe('comments enabled for paid members only', () => {
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('paid');
});
test('free member - can not add a comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create({status: 'free'});
await signInAsMember(page, member);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForPostToLoad();
await postPage.commentsSection.waitForCommentsToLoad();
await expect(postPage.commentsSection.ctaBox).toBeVisible();
await expect(postPage.commentsSection.mainForm).toBeHidden();
});
test('paid member - can add a comment', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const member = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const commentText = 'This is a test comment from a paid member';
await signInAsMember(page, member);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForPostToLoad();
await postPage.commentsSection.waitForCommentsToLoad();
await postPage.commentsSection.addComment(commentText);
await expect(postPage.commentsSection.mainForm).toBeVisible();
await expect(postPage.commentsSection.ctaBox).toBeHidden();
await expect(postPage.commentsSection.comments.first()).toContainText(commentText);
});
});
test.describe('comments disabled', () => {
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('off');
});
test('comments section is not visible', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForPostToLoad();
await expect(postPage.commentsSection.commentsIframe).toBeHidden();
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import {
CommentFactory,
MemberFactory,
PostFactory,
TierFactory,
createCommentFactory,
createMemberFactory,
createPostFactory,
createTierFactory
} from '@/data-factory';
import {PostPage} from '@/public-pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, test} from '@/helpers/playwright';
test.describe('Ghost Public - Comments - Sorting', () => {
let commentFactory: CommentFactory;
let postFactory: PostFactory;
let memberFactory: MemberFactory;
let tierFactory: TierFactory;
let settingsService: SettingsService;
test.beforeEach(async ({page}) => {
postFactory = createPostFactory(page.request);
memberFactory = createMemberFactory(page.request);
commentFactory = createCommentFactory(page.request);
tierFactory = createTierFactory(page.request);
settingsService = new SettingsService(page.request);
});
test.beforeEach(async () => {
await settingsService.setCommentsEnabled('all');
});
test('sort comments by date and show more', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create({status: 'free'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({status: 'comped', tiers: [{id: paidTier.id}]});
const comments = Array.from({length: 25}, (_, index) => {
return {
html: `Test comment ${index + 1}`,
post_id: post.id,
member_id: Math.random() > 0.5 ? member.id : paidMember.id,
created_at: new Date(Date.now() - index * 1000).toISOString()
};
});
await commentFactory.createMany(comments);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await postPage.waitForCommentsToLoad();
const postCommentsSection = postPage.commentsSection;
// verify sorting by oldest comments and load more comments
await postCommentsSection.sortBy('Oldest');
await expect(postCommentsSection.sortingButton).toContainText('Oldest');
await expect(postCommentsSection.comments.first()).toContainText('Test comment 25');
await expect(postCommentsSection.comments.last()).toContainText('Test comment 6');
await expect(postCommentsSection.showMoreCommentsButton).toBeVisible();
await expect(postCommentsSection.showMoreCommentsButton).toContainText('Load more (5)');
await postCommentsSection.showMoreCommentsButton.click();
await expect(postCommentsSection.comments).toHaveCount(25);
await expect(postCommentsSection.comments.last()).toContainText('Test comment 1');
// verify sorting by newest comments and load more comments
await postCommentsSection.sortBy('Newest');
await expect(postCommentsSection.sortingButton).toContainText('Newest');
await expect(postCommentsSection.comments.first()).toContainText('Test comment 1');
await expect(postCommentsSection.comments.last()).toContainText('Test comment 20');
await expect(postCommentsSection.showMoreCommentsButton).toBeVisible();
await expect(postCommentsSection.showMoreCommentsButton).toContainText('Load more (5)');
await postCommentsSection.showMoreCommentsButton.click();
await expect(postCommentsSection.comments).toHaveCount(25);
await expect(postCommentsSection.comments.last()).toContainText('Test comment 25');
});
});
+12
View File
@@ -0,0 +1,12 @@
import {HomePage} from '@/public-pages';
import {expect, test} from '@/helpers/playwright';
test.describe('Ghost Public - Homepage', () => {
test('loads correctly', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
await expect(homePage.title).toBeVisible();
await expect(homePage.mainSubscribeButton).toBeVisible();
});
});
@@ -0,0 +1,132 @@
import {EmailClient, MailPit} from '@/helpers/services/email/mail-pit';
import {HomePage, PublicPage} from '@/public-pages';
import {MemberDetailsPage, MembersPage} from '@/admin-pages';
import {Page} from '@playwright/test';
import {PostFactory, createPostFactory} from '@/data-factory';
import {expect, signupViaPortal, test} from '@/helpers/playwright';
import {extractMagicLink} from '@/helpers/services/email/utils';
test.describe('Ghost Public - Member Signup - Types', () => {
let emailClient: EmailClient;
test.beforeEach(async () => {
emailClient = new MailPit();
});
async function finishSignupByMagicLinkInEmail(page: Page, emailAddress: string) {
const messages = await emailClient.searchByRecipient(emailAddress);
const latestMessage = await emailClient.getMessageDetailed(messages[0]);
const emailTextBody = latestMessage.Text;
const magicLink = extractMagicLink(emailTextBody);
const publicPage = new PublicPage(page);
await publicPage.goto(magicLink);
await new HomePage(page).waitUntilLoaded();
}
test('signed up with magic link - direct', async ({page}) => {
await new HomePage(page).goto();
const {emailAddress, name} = await signupViaPortal(page);
await finishSignupByMagicLinkInEmail(page, emailAddress);
const homePage = new HomePage(page);
await expect(homePage.accountButton).toBeVisible();
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(emailAddress);
const membersDetailsPage = new MemberDetailsPage(page);
await expect(membersDetailsPage.body).toContainText(/Source.*—.*Direct/);
await expect(membersDetailsPage.body).toContainText(/Page.*—.*homepage/);
await expect(membersDetailsPage.nameInput).toHaveValue(name);
});
test('signed up with magic link - direct from post', async ({page}) => {
const postFactory: PostFactory = createPostFactory(page.request);
const post = await postFactory.create({title: 'Test Post', status: 'published'});
const homePage = new HomePage(page);
await homePage.goto();
await homePage.linkWithPostName(post.title).click();
const {emailAddress, name} = await signupViaPortal(page);
await finishSignupByMagicLinkInEmail(page, emailAddress);
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(emailAddress);
const membersDetailsPage = new MemberDetailsPage(page);
await expect(membersDetailsPage.body).toContainText(/Source.*—.*Direct/);
await expect(membersDetailsPage.body).toContainText(/Page.*—.*Test Post/);
await expect(membersDetailsPage.nameInput).toHaveValue(name);
});
test('signed up with magic link - from referrer', async ({page}) => {
const postFactory: PostFactory = createPostFactory(page.request);
const post = await postFactory.create({title: 'Google Test Post', status: 'published'});
const homePage = new HomePage(page);
await homePage.goto('/', {referer: 'https://www.google.com', waitUntil: 'domcontentloaded'});
await homePage.linkWithPostName(post.title).click();
const {emailAddress, name} = await signupViaPortal(page);
await finishSignupByMagicLinkInEmail(page, emailAddress);
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(emailAddress);
const membersDetailsPage = new MemberDetailsPage(page);
await expect(membersDetailsPage.body).toContainText(/Source.*—.*Google/);
await expect(membersDetailsPage.body).toContainText(/Page.*—.*Google Test Post/);
await expect(membersDetailsPage.nameInput).toHaveValue(name);
});
test('signed up with magic link - direct from newsletter', async ({page}) => {
const postFactory: PostFactory = createPostFactory(page.request);
const post = await postFactory.create({title: 'Newsletter Post', status: 'published'});
const homePage = new HomePage(page);
await homePage.goto(`${post.slug}?ref=ghost-newsletter`);
const {emailAddress, name} = await signupViaPortal(page);
await finishSignupByMagicLinkInEmail(page, emailAddress);
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(emailAddress);
const membersDetailsPage = new MemberDetailsPage(page);
await expect(membersDetailsPage.body).toContainText(/Source.*—.*ghost newsletter/);
await expect(membersDetailsPage.body).toContainText(/Page.*—.*Newsletter Post/);
await expect(membersDetailsPage.nameInput).toHaveValue(name);
});
test('signed up with magic link - utm_source=twitter', async ({page}) => {
const postFactory: PostFactory = createPostFactory(page.request);
const post = await postFactory.create({title: 'UTM Source Post', status: 'published'});
const homePage = new HomePage(page);
await homePage.goto(`${post.slug}?utm_source=twitter`);
const {emailAddress, name} = await signupViaPortal(page);
await finishSignupByMagicLinkInEmail(page, emailAddress);
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(emailAddress);
const membersDetailsPage = new MemberDetailsPage(page);
await expect(membersDetailsPage.body).toContainText(/Source.*—.*Twitter/);
await expect(membersDetailsPage.body).toContainText(/Page.*—.*UTM Source Post/);
await expect(membersDetailsPage.nameInput).toHaveValue(name);
});
});
+44
View File
@@ -0,0 +1,44 @@
import {EmailClient, MailPit} from '@/helpers/services/email/mail-pit';
import {HomePage, PublicPage} from '@/public-pages';
import {expect, test} from '@/helpers/playwright';
import {extractMagicLink} from '@/helpers/services/email/utils';
import {signupViaPortal} from '@/helpers/playwright/flows/signup';
test.describe('Ghost Public - Member Signup', () => {
let emailClient: EmailClient;
test.beforeEach(async () => {
emailClient = new MailPit();
});
async function retrieveLatestEmailMessage(emailAddress: string, timeoutMs: number = 10000) {
const messages = await emailClient.searchByRecipient(emailAddress, {timeoutMs: timeoutMs});
return await emailClient.getMessageDetailed(messages[0]);
}
test('signed up with magic link in email', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
const {emailAddress} = await signupViaPortal(page);
const latestMessage = await retrieveLatestEmailMessage(emailAddress);
const emailTextBody = latestMessage.Text;
const magicLink = extractMagicLink(emailTextBody);
const publicPage = new PublicPage(page);
await publicPage.goto(magicLink);
await homePage.waitUntilLoaded();
await expect(homePage.accountButton).toBeVisible();
});
test('received complete the signup email', async ({page}) => {
await new HomePage(page).goto();
const {emailAddress} = await signupViaPortal(page);
const latestMessage = await retrieveLatestEmailMessage(emailAddress);
expect(latestMessage.Subject.toLowerCase()).toContain('complete');
const emailTextBody = latestMessage.Text;
expect(emailTextBody).toContain('complete the signup process');
});
});
+92
View File
@@ -0,0 +1,92 @@
import {
FakeStripeCheckoutPage,
HomePage,
SignUpPage,
SupportNotificationPage,
SupportSuccessPage
} from '@/helpers/pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {
completeDonationViaFakeCheckout,
expect,
signInAsMember,
test
} from '@/helpers/playwright';
import {createMemberFactory} from '@/data-factory';
test.describe('Ghost Public - Portal Donations', () => {
test.use({stripeEnabled: true});
test('anonymous donation completes in portal - shows donation success page', async ({page, stripe}) => {
const homePage = new HomePage(page);
await homePage.gotoPortalSupport();
const checkoutPage = new FakeStripeCheckoutPage(page);
await checkoutPage.waitUntilDonationReady();
await expect(checkoutPage.totalAmount).toHaveText('$5.00');
await completeDonationViaFakeCheckout(page, stripe!, {
amount: '12.50',
email: `member-donation-${Date.now()}@ghost.org`,
name: 'Test Member Donations'
});
const supportSuccessPage = new SupportSuccessPage(page);
await supportSuccessPage.waitForPortalToOpen();
await expect(supportSuccessPage.title).toBeVisible();
await supportSuccessPage.signUpButton.click();
const signUpPage = new SignUpPage(page);
await expect(signUpPage.emailInput).toBeVisible();
});
test('free member donation completes in portal - shows donation notification', async ({page, stripe}) => {
const memberFactory = createMemberFactory(page.request);
const member = await memberFactory.create({
email: `test.member.donations.${Date.now()}@example.com`,
name: 'Test Member Donations',
note: 'Test Member',
status: 'free'
});
await signInAsMember(page, member);
const homePage = new HomePage(page);
await homePage.gotoPortalSupport();
const checkoutPage = new FakeStripeCheckoutPage(page);
await checkoutPage.waitUntilDonationReady();
await expect(checkoutPage.emailInput).toHaveValue(member.email);
await completeDonationViaFakeCheckout(page, stripe!, {
amount: '12.50',
name: member.name ?? 'Test Member Donations'
});
const notificationPage = new SupportNotificationPage(page);
await expect(notificationPage.successMessage).toBeVisible();
});
test('fixed donation amount and currency open donation checkout - shows fixed euro amount', async ({page, stripe}) => {
const settingsService = new SettingsService(page.request);
await settingsService.setDonationsSuggestedAmount(9800);
await settingsService.setDonationsCurrency('EUR');
const homePage = new HomePage(page);
await homePage.gotoPortalSupport();
const checkoutPage = new FakeStripeCheckoutPage(page);
await checkoutPage.waitUntilDonationReady();
await expect(checkoutPage.totalAmount).toHaveText('€98.00');
await completeDonationViaFakeCheckout(page, stripe!, {
email: `member-donation-fixed-${Date.now()}@ghost.org`,
name: 'Fixed Amount Donor'
});
const supportSuccessPage = new SupportSuccessPage(page);
await supportSuccessPage.waitForPortalToOpen();
await expect(supportSuccessPage.title).toBeVisible();
});
});
+70
View File
@@ -0,0 +1,70 @@
import {HomePage, SignInPage, SignUpPage} from '@/helpers/pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {createPaidPortalTier, expect, test} from '@/helpers/playwright';
test.describe('Portal Loading', () => {
test.describe('opened Portal', function () {
test('via Subscribe button', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openPortalViaSubscribeButton();
const signUpPage = new SignUpPage(page);
await expect(signUpPage.emailInput).toBeVisible();
await expect(signUpPage.signupButton).toBeVisible();
});
test('via Sign in link', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openPortalViaSignInLink();
const signInPage = new SignInPage(page);
await expect(signInPage.emailInput).toBeVisible();
await expect(signInPage.continueButton).toBeVisible();
});
});
test('switch between signup and sign in modes', async ({page}) => {
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openPortalViaSubscribeButton();
const signUpPage = new SignUpPage(page);
await expect(signUpPage.emailInput).toBeVisible();
await expect(signUpPage.signupButton).toBeVisible();
await signUpPage.signinLink.click();
const signInPage = new SignInPage(page);
await expect(signInPage.emailInput).toBeVisible();
await expect(signInPage.continueButton).toBeVisible();
});
test.describe('signup access', () => {
test.use({stripeEnabled: true});
test('invite-only access with paid trial tier - hides free trial message', async ({page}) => {
const settingsService = new SettingsService(page.request);
await createPaidPortalTier(page.request, {
name: `Invite Only Trial Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 100,
yearly_price: 1000,
trial_days: 5
});
await settingsService.setMembersSignupAccess('invite');
const homePage = new HomePage(page);
await homePage.gotoPortalSignup();
const signUpPage = new SignUpPage(page);
await signUpPage.waitForPortalToOpen();
await expect(signUpPage.inviteOnlyNotification).toHaveText(/contact the owner for access/i);
await expect(signUpPage.freeTrialNotification).toBeHidden();
});
});
});
+223
View File
@@ -0,0 +1,223 @@
import {type AdminOffer, createOfferFactory} from '@/data-factory';
import {PortalOfferPage, PublicPage} from '@/helpers/pages';
import {createPaidPortalTier, createPortalSignupOffer, expect, redeemOfferViaPortal, test} from '@/helpers/playwright';
const MEMBER_NAME = 'Testy McTesterson';
type OfferLandingExpectation = {
title: string;
discountLabel: string | RegExp;
message: string | RegExp;
updatedPrice?: string | RegExp;
};
type RedeemedOfferResult = Awaited<ReturnType<typeof redeemOfferViaPortal>>;
type DiscountOfferExpectation = {
duration: 'once' | 'repeating' | 'forever';
durationInMonths?: number | null;
priceLabel: string;
timingLabel: string;
};
async function expectOfferLandingPage(offerPage: PortalOfferPage, expected: OfferLandingExpectation): Promise<void> {
await offerPage.waitForOfferPage();
await expect(offerPage.offerTitle).toHaveText(expected.title);
await expect(offerPage.discountLabel).toContainText(expected.discountLabel);
await expect(offerPage.offerMessage).toContainText(expected.message);
if (expected.updatedPrice) {
await expect(offerPage.updatedPrice).toContainText(expected.updatedPrice);
}
}
function expectOfferMetadata(result: RedeemedOfferResult, offer: AdminOffer): void {
expect(result.subscription.offer?.id).toBe(offer.id);
expect(result.subscription.offer_redemptions?.some(item => item.id === offer.id)).toBe(true);
}
async function expectTrialOfferRedemption(result: RedeemedOfferResult, offer: AdminOffer): Promise<void> {
await expect(result.accountPage.freeTrialLabel).toBeVisible();
expectOfferMetadata(result, offer);
expect(result.subscription.status).toBe('trialing');
}
async function expectDiscountOfferRedemption(
result: RedeemedOfferResult,
offer: AdminOffer,
expected: DiscountOfferExpectation
): Promise<void> {
await expect(result.accountPage.offerLabel).toContainText(expected.priceLabel);
await expect(result.accountPage.offerLabel).toContainText(expected.timingLabel);
expectOfferMetadata(result, offer);
expect(result.subscription.offer?.duration).toBe(expected.duration);
if (expected.durationInMonths !== undefined) {
expect(result.subscription.offer?.duration_in_months).toBe(expected.durationInMonths);
}
}
test.describe('Ghost Public - Portal Offers', () => {
test.use({stripeEnabled: true});
test('archived offer link opens site - does not open portal offer flow', async ({page, stripe}) => {
const offerFactory = createOfferFactory(page.request);
const publicPage = new PublicPage(page);
const tier = await createPaidPortalTier(page.request, {
name: `Archived Offer Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 600,
yearly_price: 6000
}, {
stripe: stripe!
});
const offer = await offerFactory.create({
name: 'Archived Offer',
code: `archived-offer-${Date.now()}`,
cadence: 'month',
amount: 10,
duration: 'once',
type: 'percent',
tierId: tier.id
});
await offerFactory.update(offer.id, {status: 'archived'});
await publicPage.gotoOfferCode(offer.code);
await publicPage.portalRoot.waitFor({state: 'attached'});
await expect(publicPage.portalPopupFrame).toHaveCount(0);
await expect(page).not.toHaveURL(/#\/portal\/offers\//);
});
test('retention offer link opens site - does not open portal offer flow', async ({page}) => {
const offerFactory = createOfferFactory(page.request);
const publicPage = new PublicPage(page);
const offer = await offerFactory.create({
name: 'Retention Offer',
code: `retention-offer-${Date.now()}`,
cadence: 'month',
amount: 10,
duration: 'once',
type: 'percent',
redemption_type: 'retention',
tierId: null
});
await publicPage.gotoOfferCode(offer.code);
await publicPage.portalRoot.waitFor({state: 'attached'});
await expect(publicPage.portalPopupFrame).toHaveCount(0);
await expect(page).not.toHaveURL(/#\/portal\/offers\//);
});
test('free trial offer opens in portal - redemption shows free trial state', async ({page, stripe}) => {
const offer = await createPortalSignupOffer(page.request, stripe!, {
amount: 14,
codePrefix: 'trial-offer',
duration: 'trial',
tierNamePrefix: 'Trial Offer Tier',
type: 'trial'
});
const publicPage = new PublicPage(page);
await publicPage.gotoOfferCode(offer.code);
const offerPage = new PortalOfferPage(page);
await expectOfferLandingPage(offerPage, {
title: offer.display_title ?? offer.name,
discountLabel: '14 days free',
message: 'Try free for 14 days'
});
const redemption = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
await expectTrialOfferRedemption(redemption, offer);
});
test('one-time discount offer opens in portal - redemption shows discounted plan label', async ({page, stripe}) => {
const offer = await createPortalSignupOffer(page.request, stripe!, {
amount: 10,
codePrefix: 'once-offer',
duration: 'once',
tierNamePrefix: 'One-time Offer Tier',
type: 'percent'
});
const publicPage = new PublicPage(page);
await publicPage.gotoOfferCode(offer.code);
const offerPage = new PortalOfferPage(page);
await expectOfferLandingPage(offerPage, {
title: offer.display_title ?? offer.name,
discountLabel: '10% off',
message: '10% off for first month',
updatedPrice: /\$5\.40/
});
const redemption = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
await expectDiscountOfferRedemption(redemption, offer, {
duration: 'once',
priceLabel: '$5.40/month',
timingLabel: 'Ends'
});
});
test('repeating discount offer opens in portal - redemption shows discounted plan label', async ({page, stripe}) => {
const offer = await createPortalSignupOffer(page.request, stripe!, {
amount: 10,
codePrefix: 'repeating-offer',
duration: 'repeating',
duration_in_months: 3,
tierNamePrefix: 'Repeating Offer Tier',
type: 'percent'
});
const publicPage = new PublicPage(page);
await publicPage.gotoOfferCode(offer.code);
const offerPage = new PortalOfferPage(page);
await expectOfferLandingPage(offerPage, {
title: offer.display_title ?? offer.name,
discountLabel: '10% off',
message: '10% off for first 3 months',
updatedPrice: /\$5\.40/
});
const redemption = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
await expectDiscountOfferRedemption(redemption, offer, {
duration: 'repeating',
durationInMonths: 3,
priceLabel: '$5.40/month',
timingLabel: 'Ends'
});
});
test('forever discount offer opens in portal - redemption shows discounted plan label', async ({page, stripe}) => {
const offer = await createPortalSignupOffer(page.request, stripe!, {
amount: 10,
codePrefix: 'forever-offer',
duration: 'forever',
tierNamePrefix: 'Forever Offer Tier',
type: 'percent'
});
const publicPage = new PublicPage(page);
await publicPage.gotoOfferCode(offer.code);
const offerPage = new PortalOfferPage(page);
await expectOfferLandingPage(offerPage, {
title: offer.display_title ?? offer.name,
discountLabel: '10% off',
message: '10% off forever',
updatedPrice: /\$5\.40/
});
const redemption = await redeemOfferViaPortal(page, stripe!, {name: MEMBER_NAME});
await expectDiscountOfferRedemption(redemption, offer, {
duration: 'forever',
priceLabel: '$5.40/month',
timingLabel: 'Forever'
});
});
});
@@ -0,0 +1,42 @@
import {HomePage} from '@/helpers/pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, test} from '@/helpers/playwright';
test.describe('Portal Script Loading', () => {
test('memberships enabled - loads portal script', async ({page}) => {
const settingsService = new SettingsService(page.request);
await settingsService.setMembersSignupAccess('all');
const homePage = new HomePage(page);
await homePage.gotoPortalSignup();
await expect(homePage.portalScript).toHaveAttribute('src', /\/portal\.min\.js$/);
await expect(homePage.portalIframe).toHaveCount(1);
});
test.describe('with stripe connected', () => {
test.use({stripeEnabled: true});
test('memberships disabled - loads portal script', async ({page}) => {
const settingsService = new SettingsService(page.request);
await settingsService.setMembersSignupAccess('none');
const homePage = new HomePage(page);
await homePage.gotoPortalSignup();
await expect(homePage.portalScript).toHaveAttribute('src', /\/portal\.min\.js$/);
await expect(homePage.portalIframe).toHaveCount(1);
});
});
test('memberships and donations disabled - does not load portal script', async ({page}) => {
const settingsService = new SettingsService(page.request);
await settingsService.setMembersSignupAccess('none');
const homePage = new HomePage(page);
await homePage.gotoPortalSignup();
await expect(homePage.portalScript).toHaveCount(0);
await expect(homePage.portalIframe).toHaveCount(0);
});
});
+29
View File
@@ -0,0 +1,29 @@
import {HomePage, PortalAccountPage} from '@/helpers/pages';
import {completePaidSignupViaPortal, createPaidPortalTier, expect, test} from '@/helpers/playwright';
test.describe('Ghost Public - Portal Tiers', () => {
test.use({stripeEnabled: true});
test('single paid tier signup via portal completes checkout - portal shows billing info', async ({page, stripe}) => {
await createPaidPortalTier(page.request, {
name: `Portal Tier ${Date.now()}`,
visibility: 'public',
currency: 'usd',
monthly_price: 500,
yearly_price: 5000
});
const name = 'Testy McTesterson';
const {emailAddress} = await completePaidSignupViaPortal(page, stripe!, {name});
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openAccountPortal();
const portalAccountPage = new PortalAccountPage(page);
await portalAccountPage.waitForPortalToOpen();
await expect(portalAccountPage.emailText(emailAddress)).toBeVisible();
await expect(portalAccountPage.billingInfoHeading).toBeVisible();
await expect(portalAccountPage.cardLast4('4242')).toBeVisible();
});
});
+108
View File
@@ -0,0 +1,108 @@
import {HomePage, PortalAccountPage} from '@/helpers/pages';
import {
completePaidSignupViaPortal,
completePaidUpgradeViaPortal,
createPaidPortalTier,
expect,
switchPlanViaPortal,
test
} from '@/helpers/playwright';
import {createMemberFactory} from '@/data-factory';
test.describe('Ghost Public - Portal Upgrade', () => {
test.use({stripeEnabled: true});
test('free member upgrades to paid via portal - portal shows billing info', async ({page, stripe}) => {
const memberFactory = createMemberFactory(page.request);
const tier = await createPaidPortalTier(page.request, {
name: `Free Upgrade Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 500,
yearly_price: 5000
});
const member = await memberFactory.create({
email: `free-upgrade-${Date.now()}@example.com`,
name: 'Free Upgrade Member',
status: 'free'
});
await completePaidUpgradeViaPortal(page, stripe!, member, {
cadence: 'yearly',
tierName: tier.name
});
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openAccountPortal();
const portalAccountPage = new PortalAccountPage(page);
await portalAccountPage.waitForPortalToOpen();
await expect(portalAccountPage.emailText(member.email)).toBeVisible();
await expect(portalAccountPage.planPrice('$50.00/year')).toBeVisible();
await expect(portalAccountPage.billingInfoHeading).toBeVisible();
await expect(portalAccountPage.cardLast4('4242')).toBeVisible();
});
test('comped member upgrades to paid via portal - portal shows billing info', async ({page, stripe}) => {
const memberFactory = createMemberFactory(page.request);
const tier = await createPaidPortalTier(page.request, {
name: `Comped Upgrade Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 500,
yearly_price: 5000
});
const member = await memberFactory.create({
email: `comped-upgrade-${Date.now()}@example.com`,
name: 'Comped Upgrade Member',
status: 'comped',
tiers: [{id: tier.id}]
});
await completePaidUpgradeViaPortal(page, stripe!, member, {
cadence: 'yearly',
tierName: tier.name
});
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openAccountPortal();
const portalAccountPage = new PortalAccountPage(page);
await portalAccountPage.waitForPortalToOpen();
await expect(portalAccountPage.emailText(member.email)).toBeVisible();
await expect(portalAccountPage.planPrice('$50.00/year')).toBeVisible();
await expect(portalAccountPage.billingInfoHeading).toBeVisible();
await expect(portalAccountPage.cardLast4('4242')).toBeVisible();
});
test('paid member changes plan in portal - subscription switches between monthly and yearly', async ({page, stripe}) => {
const tier = await createPaidPortalTier(page.request, {
name: `Upgrade Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 500,
yearly_price: 5000
});
const name = 'Portal Plan Switch Member';
const {emailAddress} = await completePaidSignupViaPortal(page, stripe!, {
cadence: 'yearly',
name,
tierName: tier.name
});
await switchPlanViaPortal(page, {
cadence: 'monthly',
tierName: tier.name
});
const portalAccountPage = new PortalAccountPage(page);
await expect(portalAccountPage.emailText(emailAddress)).toBeVisible();
await expect(portalAccountPage.planPrice('$5.00/month')).toBeVisible();
await switchPlanViaPortal(page, {
cadence: 'yearly',
tierName: tier.name
});
await expect(portalAccountPage.planPrice('$50.00/year')).toBeVisible();
});
});
@@ -0,0 +1,93 @@
import {EmailClient, MailPit} from '@/helpers/services/email/mail-pit';
import {FakeStripeCheckoutPage} from '@/helpers/pages';
import {expect, test} from '@/helpers/playwright';
interface CheckoutSessionResponse {
url: string;
}
// This is a harness smoke test for the e2e Stripe tooling rather than a long-term
// product-facing spec. Migrated donation tests should carry the readable behavior
// coverage, and this should stay thin or be removed if it becomes redundant.
// TODO: REMOVE TEST
test.describe('Ghost Public - Stripe Donation Checkout', () => {
test.use({stripeEnabled: true});
let emailClient: EmailClient;
test.beforeEach(async () => {
emailClient = new MailPit();
});
test('donation checkout uses fake stripe payment mode - completed webhook sends staff email', async ({page, stripe}) => {
const donorName = `Donation Donor ${Date.now()}`;
const donorEmail = `donation-${Date.now()}@example.com`;
const donationMessage = `Keep building ${Date.now()}`;
const personalNote = 'Leave a note for the publisher';
const response = await page.request.post('/members/api/create-stripe-checkout-session/', {
data: {
type: 'donation',
customerEmail: donorEmail,
successUrl: 'http://localhost/success',
cancelUrl: 'http://localhost/cancel',
personalNote
}
});
expect(response.ok()).toBe(true);
const sessionResponse = await response.json() as CheckoutSessionResponse;
const products = stripe!.getProducts();
const prices = stripe!.getPrices();
const sessions = stripe!.getCheckoutSessions();
expect(products).toHaveLength(1);
expect(prices).toHaveLength(1);
expect(sessions).toHaveLength(1);
const price = prices[0];
const session = sessions[0];
expect(price.type).toBe('one_time');
expect(price.currency).toBe('usd');
expect(price.unit_amount).toBeNull();
expect(price.custom_unit_amount?.enabled).toBe(true);
expect(price.custom_unit_amount?.preset).toBe(500);
expect(session.request.line_items?.[0]?.price).toBe(price.id);
expect(session.request.submit_type).toBe('pay');
expect(session.request.invoice_creation?.enabled).toBe(true);
expect(session.request.invoice_creation?.invoice_data?.metadata.ghost_donation).toBe('true');
expect(session.request.custom_fields?.[0]?.key).toBe('donation_message');
expect(session.request.custom_fields?.[0]?.label?.custom).toBe(personalNote);
expect(session.response.mode).toBe('payment');
expect(session.response.customer).toBeNull();
expect(session.response.customer_email).toBe(donorEmail);
expect(session.response.metadata.ghost_donation).toBe('true');
expect(sessionResponse.url).toBe(session.response.url);
const fakeCheckoutPage = new FakeStripeCheckoutPage(page);
await fakeCheckoutPage.goto(sessionResponse.url);
await fakeCheckoutPage.waitUntilDonationReady();
await stripe!.completeLatestDonationCheckout({
donationMessage,
email: donorEmail,
name: donorName
});
const messages = await emailClient.search({
subject: donorName
}, {
timeoutMs: 10000
});
expect(messages.length).toBeGreaterThan(0);
const latestMessage = await emailClient.getMessageDetailed(messages[0]);
expect(latestMessage.Subject).toContain('One-time payment received');
expect(latestMessage.Subject).toContain(donorName);
expect(latestMessage.Text).toContain(donationMessage);
});
});
@@ -0,0 +1,83 @@
import {MembersService} from '@/helpers/services/members/members-service';
import {createOfferFactory, createTierFactory} from '@/data-factory';
import {expect, test} from '@/helpers/playwright';
interface CheckoutSessionResponse {
url: string;
}
// This is a harness smoke test for the e2e Stripe tooling rather than a long-term
// product-facing spec. Migrated offer tests should carry the readable behavior
// coverage, and this should stay thin or be removed if it becomes redundant.
// TODO: REMOVE TEST
test.describe('Ghost Public - Stripe Offer Checkout', () => {
test.use({stripeEnabled: true});
test('offer checkout creates a fake stripe coupon - redeemed offer is linked to the subscription', async ({page, stripe}) => {
const offerFactory = createOfferFactory(page.request);
const tierFactory = createTierFactory(page.request);
const membersService = new MembersService(page.request);
const tierName = `Offer Tier ${Date.now()}`;
const memberEmail = `offer-checkout-${Date.now()}@example.com`;
const tier = await tierFactory.create({
name: tierName,
currency: 'usd',
monthly_price: 600,
yearly_price: 6000
});
const offer = await offerFactory.create({
name: 'Spring Offer',
code: `spring-offer-${Date.now()}`,
cadence: 'month',
amount: 10,
duration: 'repeating',
duration_in_months: 3,
type: 'percent',
tierId: tier.id
});
const response = await page.request.post('/members/api/create-stripe-checkout-session/', {
data: {
customerEmail: memberEmail,
offerId: offer.id,
successUrl: 'http://localhost/success',
cancelUrl: 'http://localhost/cancel'
}
});
expect(response.ok()).toBe(true);
const sessionResponse = await response.json() as CheckoutSessionResponse;
const session = stripe!.getCheckoutSessions().at(-1);
expect(session).toBeDefined();
expect(sessionResponse.url).toBe(session?.response.url);
expect(session?.response.metadata.offer).toBe(offer.id);
const couponId = session?.request.discounts?.[0]?.coupon;
expect(couponId).toBeDefined();
const coupon = stripe!.getCoupons().find(item => item.id === couponId);
expect(coupon).toBeDefined();
expect(coupon?.duration).toBe('repeating');
expect(coupon?.duration_in_months).toBe(3);
expect(coupon?.percent_off).toBe(10);
expect(session?.request.subscription_data?.items).toHaveLength(1);
const createdMember = await stripe!.completeLatestSubscriptionCheckout({name: 'Offer Member'});
expect(createdMember.subscription.discount?.coupon.id).toBe(coupon?.id);
expect(createdMember.subscription.discount?.end).not.toBeNull();
const member = await membersService.getByEmailWithSubscriptions(memberEmail);
const subscription = member.subscriptions[0];
expect(subscription.offer?.id).toBe(offer.id);
expect(subscription.offer_redemptions?.some(item => item.id === offer.id)).toBe(true);
expect(subscription.next_payment?.original_amount).toBe(600);
expect(subscription.next_payment?.amount).toBe(540);
expect(subscription.next_payment?.discount?.offer_id).toBe(offer.id);
});
});
@@ -0,0 +1,123 @@
import {FakeStripeCheckoutPage, PublicPage} from '@/helpers/pages';
import {SignUpPage} from '@/portal-pages';
import {createPaidPortalTier, expect, test} from '@/helpers/playwright';
import type {Page} from '@playwright/test';
async function getMemberIdentityToken(page: Page): Promise<string> {
const response = await page.context().request.get('/members/api/session');
expect(response.ok()).toBe(true);
const identity = await response.text();
expect(identity).not.toBe('');
return identity;
}
function getAlternateCadence(interval: string | undefined): 'month' | 'year' {
if (interval === 'month') {
return 'year';
}
if (interval === 'year') {
return 'month';
}
throw new Error(`Unsupported subscription cadence: ${interval ?? 'missing'}`);
}
function getLatestCheckoutSuccessUrl(stripeCheckoutCount: {response: {success_url: string}}[]): string {
const successUrl = stripeCheckoutCount.at(-1)?.response.success_url;
if (!successUrl) {
throw new Error('Latest Stripe checkout session does not include a success URL');
}
return successUrl;
}
function getTargetPrice(targetCadence: 'month' | 'year', prices: {
monthly: {id: string};
yearly: {id: string};
}): {id: string} {
if (targetCadence === 'month') {
return prices.monthly;
}
return prices.yearly;
}
test.describe('Ghost Public - Stripe Subscription Mutations', () => {
test.use({stripeEnabled: true});
test('paid member subscription update via ghost - switches the fake stripe price', async ({page, stripe}) => {
const memberEmail = `stripe-mutation-${Date.now()}@example.com`;
const tier = await createPaidPortalTier(page.request, {
name: `Mutation Tier ${Date.now()}`,
currency: 'usd',
monthly_price: 500,
yearly_price: 5000
});
await expect.poll(() => {
return stripe!.getProducts().find(item => item.name === tier.name);
}, {timeout: 10000}).toBeDefined();
await expect.poll(() => {
return stripe!.getPrices().filter(item => item.product === stripe!.getProducts().find(product => product.name === tier.name)?.id).length;
}, {timeout: 10000}).toBe(2);
const product = stripe!.getProducts().find(item => item.name === tier.name);
const monthlyPrice = stripe!.getPrices().find((item) => {
return item.product === product?.id && item.recurring?.interval === 'month';
});
const yearlyPrice = stripe!.getPrices().find((item) => {
return item.product === product?.id && item.recurring?.interval === 'year';
});
expect(product).toBeDefined();
expect(monthlyPrice).toBeDefined();
expect(yearlyPrice).toBeDefined();
const publicPage = new PublicPage(page);
await publicPage.gotoPortalSignup();
const signUpPage = new SignUpPage(page);
await signUpPage.waitForPortalToOpen();
await signUpPage.fillAndSubmitPaidSignup(memberEmail, 'Stripe Mutation Member', tier.name);
const fakeCheckoutPage = new FakeStripeCheckoutPage(page);
await fakeCheckoutPage.waitUntilLoaded();
await stripe!.completeLatestSubscriptionCheckout({name: 'Stripe Mutation Member'});
await page.goto(getLatestCheckoutSuccessUrl(stripe!.getCheckoutSessions()));
const subscription = stripe!.getSubscriptions().at(-1);
expect(subscription).toBeDefined();
const currentPrice = subscription!.items.data[0]?.price;
expect(currentPrice).toBeDefined();
const targetCadence = getAlternateCadence(currentPrice!.recurring?.interval);
const targetPrice = getTargetPrice(targetCadence, {
monthly: monthlyPrice!,
yearly: yearlyPrice!
});
expect(targetPrice).toBeDefined();
const identity = await getMemberIdentityToken(page);
const response = await page.context().request.put(`/members/api/subscriptions/${subscription!.id}/`, {
data: {
identity,
tierId: tier.id,
cadence: targetCadence
}
});
expect(response.status()).toBe(204);
const updatedSubscription = stripe!.getSubscriptions().find(item => item.id === subscription!.id);
expect(updatedSubscription?.items.data[0]?.price.id).toBe(targetPrice!.id);
expect(updatedSubscription?.items.data[0]?.price.recurring?.interval).toBe(targetCadence);
});
});
@@ -0,0 +1,102 @@
import {APIRequestContext, Page} from '@playwright/test';
import {HomePage, MemberDetailsPage, MembersPage, PortalAccountPage} from '@/helpers/pages';
import {MembersService} from '@/helpers/services/members';
import {expect, test} from '@/helpers/playwright';
async function waitForMemberStatus(request: APIRequestContext, email: string, status: string) {
const membersService = new MembersService(request);
await expect.poll(async () => {
try {
const member = await membersService.getByEmail(email);
return member.status;
} catch {
return null;
}
}, {timeout: 10000}).toBe(status);
}
async function waitForCanceledSubscription(request: APIRequestContext, email: string) {
const membersService = new MembersService(request);
await expect.poll(async () => {
try {
const member = await membersService.getByEmailWithSubscriptions(email);
return member.subscriptions[0]?.cancel_at_period_end ?? null;
} catch {
return null;
}
}, {timeout: 10000}).toBe(true);
}
async function openPortalAsMember(page: Page, email: string) {
const membersPage = new MembersPage(page);
await membersPage.goto();
await membersPage.clickMemberByEmail(email);
const memberDetailsPage = new MemberDetailsPage(page);
await memberDetailsPage.settingsSection.memberActionsButton.click();
await memberDetailsPage.settingsSection.impersonateButton.click();
await expect(memberDetailsPage.magicLinkInput).not.toHaveValue('');
const magicLink = await memberDetailsPage.magicLinkInput.inputValue();
await memberDetailsPage.goto(magicLink);
const homePage = new HomePage(page);
await homePage.openAccountPortal();
const portalAccountPage = new PortalAccountPage(page);
await portalAccountPage.waitForPortalToOpen();
return portalAccountPage;
}
test.describe('Portal - Stripe Subscription Lifecycle via Webhooks', () => {
test.use({stripeEnabled: true});
test('webhook-seeded paid member - sees billing details in portal', async ({page, stripe}) => {
const email = `portal-paid-${Date.now()}@example.com`;
await stripe!.createPaidMemberViaWebhooks({email, name: 'Portal Paid Member'});
await waitForMemberStatus(page.request, email, 'paid');
const portalAccountPage = await openPortalAsMember(page, email);
await expect(portalAccountPage.title).toBeVisible();
await expect(portalAccountPage.emailText(email)).toBeVisible();
await expect(portalAccountPage.planPrice('$5.00/month')).toBeVisible();
await expect(portalAccountPage.billingInfoHeading).toBeVisible();
await expect(portalAccountPage.cardLast4('4242')).toBeVisible();
});
test('cancel-at-period-end webhook - shows canceled state in portal', async ({page, stripe}) => {
const email = `portal-cancel-${Date.now()}@example.com`;
const {subscription} = await stripe!.createPaidMemberViaWebhooks({email, name: 'Portal Cancel Member'});
await waitForMemberStatus(page.request, email, 'paid');
await stripe!.cancelSubscription({subscription});
await waitForCanceledSubscription(page.request, email);
const portalAccountPage = await openPortalAsMember(page, email);
await expect(portalAccountPage.cancellationNotice).toBeVisible();
await expect(portalAccountPage.resumeSubscriptionButton).toBeVisible();
await expect(portalAccountPage.canceledBadge).toBeVisible();
});
test('subscription-deleted webhook - shows free membership in portal', async ({page, stripe}) => {
const email = `portal-free-${Date.now()}@example.com`;
const {subscription} = await stripe!.createPaidMemberViaWebhooks({email, name: 'Portal Free Member'});
await waitForMemberStatus(page.request, email, 'paid');
await stripe!.deleteSubscription({subscription});
await waitForMemberStatus(page.request, email, 'free');
const portalAccountPage = await openPortalAsMember(page, email);
await expect(portalAccountPage.title).toBeVisible();
await expect(portalAccountPage.emailText(email)).toBeVisible();
await expect(portalAccountPage.emailNewsletterHeading).toBeVisible();
await expect(portalAccountPage.billingInfoHeading).toHaveCount(0);
await expect(portalAccountPage.planPrice('$5.00/month')).toHaveCount(0);
});
});
+90
View File
@@ -0,0 +1,90 @@
import {MemberFactory, PostFactory, TierFactory, createMemberFactory, createPostFactory, createTierFactory} from '@/data-factory';
import {PostPage} from '@/public-pages';
import {SettingsService} from '@/helpers/services/settings/settings-service';
import {expect, test} from '@/helpers/playwright';
import {signInAsMember} from '@/helpers/playwright/flows/sign-in';
test.describe('Ghost Public - Transistor', () => {
test.use({labs: {transistor: true}});
let postFactory: PostFactory;
let memberFactory: MemberFactory;
let tierFactory: TierFactory;
let settingsService: SettingsService;
test.beforeEach(async ({page}) => {
postFactory = createPostFactory(page.request);
memberFactory = createMemberFactory(page.request);
tierFactory = createTierFactory(page.request);
settingsService = new SettingsService(page.request);
await settingsService.setTransistorEnabled(true);
});
test('anonymous visitor - transistor embed is not visible', async ({page}) => {
const post = await postFactory.createWithCards('transistor', {status: 'published'});
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await expect(postPage.postContent).toContainText('Before transistor');
await expect(postPage.postContent).toContainText('After transistor');
await expect(postPage.transistorCard).toBeHidden();
await expect(postPage.transistorIframe).toBeHidden();
});
test('free member - transistor embed is visible', async ({page}) => {
const post = await postFactory.createWithCards('transistor', {status: 'published'});
const member = await memberFactory.create({status: 'free'});
await signInAsMember(page, member);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await expect(postPage.postContent).toContainText('Before transistor');
await expect(postPage.postContent).toContainText('After transistor');
await expect(postPage.transistorIframe).toBeVisible();
// The data-src should contain the member's UUID (server-side replacement of %7Buuid%7D)
const dataSrc = await postPage.transistorIframe.getAttribute('data-src');
expect(dataSrc).not.toContain('%7Buuid%7D');
expect(dataSrc).toContain(member.uuid);
});
test('paid member - transistor embed is visible', async ({page}) => {
const post = await postFactory.createWithCards('transistor', {status: 'published'});
const paidTier = await tierFactory.getFirstPaidTier();
const paidMember = await memberFactory.create({
status: 'comped',
tiers: [{id: paidTier.id}]
});
await signInAsMember(page, paidMember);
const postPage = new PostPage(page);
await postPage.gotoPost(post.slug);
await expect(postPage.postContent).toContainText('Before transistor');
await expect(postPage.postContent).toContainText('After transistor');
await expect(postPage.transistorIframe).toBeVisible();
const dataSrc = await postPage.transistorIframe.getAttribute('data-src');
expect(dataSrc).not.toContain('%7Buuid%7D');
expect(dataSrc).toContain(paidMember.uuid);
});
test('preview mode - shows placeholder instead of iframe', async ({page}) => {
const post = await postFactory.createWithCards('transistor', {status: 'draft'});
const postPage = new PostPage(page);
await postPage.goto(`/p/${post.uuid}/?member_status=free`);
await postPage.waitForPostToLoad();
await expect(postPage.postContent).toContainText('Before transistor');
await expect(postPage.postContent).toContainText('After transistor');
await expect(postPage.transistorPlaceholder).toBeVisible();
await expect(postPage.transistorPlaceholder).toContainText('Members-only podcasts');
await expect(postPage.transistorIframe).toBeHidden();
});
});