This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user