Added Portal tests for newsletter subscriptions (#19802)

refs https://linear.app/tryghost/issue/ENG-677

- Portal was completely missing tests for `UnsubscribePage`
- `UnsubscribePage` is unique for Portal in that it needs to be able to
handle logged in and not-logged-in member state/interactions
- Various parts of Portal don't use a shared `GhostApi` instance, making
mocking all functionality impossible
- `UnsubscribePage` was updated to use `onAction` to bring it in line
with other Portal interactions while logged in
- Added checks for UI components for more precision in tests checking subscriptions within the UI

---------

Co-authored-by: Ronald Langeveld <hi@ronaldlangeveld.com>
This commit is contained in:
Steve Larson 2024-03-05 13:22:50 -06:00 committed by GitHub
parent 857588ed60
commit 2bb566f18f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 354 additions and 28 deletions

View File

@ -15,7 +15,7 @@ export default class CloseButton extends React.Component {
return (
<div className='gh-portal-closeicon-container' data-test-button='close-popup'>
<CloseIcon
className='gh-portal-closeicon' alt='Close' onClick = {onClick || this.closePopup}
className='gh-portal-closeicon' alt='Close' onClick = {onClick || this.closePopup} data-testid='close-popup'
/>
</div>
);

View File

@ -32,7 +32,7 @@ function SuccessIcon({show, checked}) {
classNames.push('gh-portal-checkmark-container');
return (
<div className={classNames.join(' ')}>
<div className={classNames.join(' ')} data-testid='checkmark-container'>
<CheckmarkIcon className='gh-portal-checkmark-icon' alt='' />
</div>
);

View File

@ -2,7 +2,6 @@ import AppContext from '../../AppContext';
import ActionButton from '../common/ActionButton';
import {useContext, useEffect, useState} from 'react';
import {getSiteNewsletters} from '../../utils/helpers';
import setupGhostApi from '../../utils/api';
import NewsletterManagement from '../common/NewsletterManagement';
import CloseButton from '../common/CloseButton';
import {ReactComponent as WarningIcon} from '../../images/icons/warning-fill.svg';
@ -44,8 +43,7 @@ async function updateMemberNewsletters({api, memberUuid, newsletters, enableComm
// NOTE: This modal is available even if not logged in, but because it's possible to also be logged in while making modifications,
// we need to update the member data in the context if logged in.
export default function UnsubscribePage() {
const {site, pageData, member: loggedInMember, onAction, t} = useContext(AppContext);
const api = setupGhostApi({siteUrl: site.url});
const {site, api, pageData, member: loggedInMember, onAction, t} = useContext(AppContext);
// member is the member data fetched from the API based on the uuid and its state is limited to just this modal, not all of Portal
const [member, setMember] = useState();
const [loading, setLoading] = useState(true);
@ -60,44 +58,51 @@ export default function UnsubscribePage() {
const {enable_comment_notifications: enableCommentNotifications = false} = member || {};
const updateNewsletters = async (newsletters) => {
const updatedData = await updateMemberNewsletters({api, memberUuid: pageData.uuid, newsletters});
setSubscribedNewsletters(updatedData.newsletters);
// Keep the member data in sync if logged in
if (loggedInMember) {
loggedInMember.newsletters = updatedData.newsletters;
// when we have a member logged in, we need to update the newsletters in the context
onAction('updateNewsletterPreference', {newsletters});
} else {
await updateMemberNewsletters({api, memberUuid: pageData.uuid, newsletters});
}
setSubscribedNewsletters(newsletters);
};
const updateCommentNotifications = async (enabled) => {
const updatedData = await updateMemberNewsletters({api, memberUuid: pageData.uuid, enableCommentNotifications: enabled});
setMember(updatedData);
// Keep the member data in sync if logged in
let updatedData;
if (loggedInMember) {
loggedInMember.enable_comment_notifications = enabled;
// when we have a member logged in, we need to update the newsletters in the context
await onAction('updateNewsletterPreference', {enableCommentNotifications: enabled});
updatedData = {...loggedInMember, enable_comment_notifications: enabled};
} else {
updatedData = await updateMemberNewsletters({api, memberUuid: pageData.uuid, enableCommentNotifications: enabled});
}
setMember(updatedData);
};
const unsubscribeAll = async () => {
let updatedMember;
if (loggedInMember) {
await onAction('updateNewsletterPreference', {newsletters: [], enableCommentNotifications: false});
updatedMember = {...loggedInMember};
updatedMember.newsletters = [];
updatedMember.enable_comment_notifications = false;
} else {
updatedMember = await api.member.updateNewsletters({uuid: pageData.uuid, newsletters: [], enableCommentNotifications: false});
}
setSubscribedNewsletters([]);
setMember(updatedMember);
onAction('showPopupNotification', {
action: 'updated:success',
message: t(`Unsubscribed from all emails.`)
});
const updatedMember = await api.member.updateNewsletters({uuid: pageData.uuid, newsletters: [], enableCommentNotifications: false});
setMember(updatedMember);
if (loggedInMember) {
loggedInMember.newsletters = [];
loggedInMember.enable_comment_notifications = false;
}
};
// This handles the url query param actions that ultimately launch this component/modal
useEffect(() => {
const ghostApi = setupGhostApi({siteUrl: site.url});
(async () => {
let memberData;
try {
memberData = await ghostApi.member.newsletters({uuid: pageData.uuid});
memberData = await api.member.newsletters({uuid: pageData.uuid});
setMember(memberData ?? null);
setLoading(false);
} catch (e) {

View File

@ -0,0 +1,257 @@
import App from '../App.js';
import {appRender, fireEvent, within} from '../utils/test-utils';
import {newsletters as Newsletters, site as FixtureSite, member as FixtureMember} from '../utils/test-fixtures';
import setupGhostApi from '../utils/api.js';
import userEvent from '@testing-library/user-event';
const setup = async ({site, member = null, newsletters}, loggedOut = false) => {
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
ghostApi.init = jest.fn(() => {
return Promise.resolve({
site,
member: loggedOut ? null : member,
newsletters
});
});
ghostApi.member.update = jest.fn(({newsletters: newNewsletters}) => {
return Promise.resolve({
newsletters: newNewsletters,
enable_comment_notifications: false
});
});
ghostApi.member.newsletters = jest.fn(() => {
return Promise.resolve({
newsletters
});
});
ghostApi.member.updateNewsletters = jest.fn(({uuid: memberUuid, newsletters: newNewsletters, enableCommentNotifications}) => {
return Promise.resolve({
uuid: memberUuid,
newsletters: newNewsletters,
enable_comment_notifications: enableCommentNotifications
});
});
const utils = appRender(
<App api={ghostApi} />
);
const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
const triggerButton = within(triggerButtonFrame.contentDocument).getByTestId('portal-trigger-button');
const popupFrame = utils.queryByTitle(/portal-popup/i);
const popupIframeDocument = popupFrame.contentDocument;
const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
const siteTitle = within(popupIframeDocument).queryByText(site.title);
const freePlanTitle = within(popupIframeDocument).queryByText('Free');
const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
const accountHomeTitle = within(popupIframeDocument).queryByText('Your account');
const viewPlansButton = within(popupIframeDocument).queryByRole('button', {name: 'View plans'});
const manageSubscriptionsButton = within(popupIframeDocument).queryByRole('button', {name: 'Manage'});
return {
ghostApi,
popupIframeDocument,
popupFrame,
triggerButtonFrame,
triggerButton,
siteTitle,
emailInput,
nameInput,
signinButton,
submitButton,
freePlanTitle,
monthlyPlanTitle,
yearlyPlanTitle,
fullAccessTitle,
accountHomeTitle,
viewPlansButton,
manageSubscriptionsButton,
...utils
};
};
describe('Newsletter Subscriptions', () => {
test('list newsletters to subscribe to', async () => {
const {popupFrame, triggerButtonFrame, accountHomeTitle, manageSubscriptionsButton, popupIframeDocument} = await setup({
site: FixtureSite.singleTier.onlyFreePlanWithoutStripe,
member: FixtureMember.subbedToNewsletter,
newsletters: Newsletters
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(accountHomeTitle).toBeInTheDocument();
expect(manageSubscriptionsButton).toBeInTheDocument();
// unsure why fireEvent has no effect here
await userEvent.click(manageSubscriptionsButton);
const newsletter1 = within(popupIframeDocument).queryByText('Newsletter 1');
const newsletter2 = within(popupIframeDocument).queryByText('Newsletter 2');
const emailPreferences = within(popupIframeDocument).queryByText('Email preferences');
expect(newsletter1).toBeInTheDocument();
expect(newsletter2).toBeInTheDocument();
expect(emailPreferences).toBeInTheDocument();
});
test('toggle subscribing to a newsletter', async () => {
const {ghostApi, popupFrame, triggerButtonFrame, accountHomeTitle, manageSubscriptionsButton, popupIframeDocument} = await setup({
site: FixtureSite.singleTier.onlyFreePlanWithoutStripe,
member: FixtureMember.subbedToNewsletter,
newsletters: Newsletters
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(accountHomeTitle).toBeInTheDocument();
expect(manageSubscriptionsButton).toBeInTheDocument();
await userEvent.click(manageSubscriptionsButton);
const newsletter1 = within(popupIframeDocument).queryByText('Newsletter 1');
expect(newsletter1).toBeInTheDocument();
// unsubscribe from Newsletter 1
const subscriptionToggles = within(popupIframeDocument).getAllByTestId('switch-input');
const newsletter1Toggle = subscriptionToggles[0];
expect(newsletter1Toggle).toBeInTheDocument();
await userEvent.click(newsletter1Toggle);
// verify that subscription to Newsletter 1 was removed
const expectedSubscriptions = Newsletters.filter(n => n.id !== Newsletters[0].id).map(n => ({id: n.id}));
expect(ghostApi.member.update).toHaveBeenLastCalledWith(
{newsletters: expectedSubscriptions}
);
const subscriptionToggleContainers = within(popupIframeDocument).getAllByTestId('checkmark-container');
const newsletter1ToggleContainer = subscriptionToggleContainers[0];
expect(newsletter1ToggleContainer).toBeInTheDocument();
expect(newsletter1ToggleContainer).not.toHaveClass('gh-portal-toggle-checked');
const newsletter2ToggleContainer = subscriptionToggleContainers[1];
expect(newsletter2ToggleContainer).toBeInTheDocument();
expect(newsletter2ToggleContainer).toHaveClass('gh-portal-toggle-checked');
// resubscribe to Newsletter 1
await userEvent.click(newsletter1Toggle);
expect(newsletter1ToggleContainer).toHaveClass('gh-portal-toggle-checked');
expect(ghostApi.member.update).toHaveBeenLastCalledWith(
{newsletters: Newsletters.reverse().map(n => ({id: n.id}))}
);
});
test('unsubscribe from all newsletters when logged in', async () => {
const {ghostApi, popupFrame, triggerButtonFrame, accountHomeTitle, manageSubscriptionsButton, popupIframeDocument} = await setup({
site: FixtureSite.singleTier.onlyFreePlanWithoutStripe,
member: FixtureMember.subbedToNewsletter,
newsletters: Newsletters
});
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
expect(accountHomeTitle).toBeInTheDocument();
expect(manageSubscriptionsButton).toBeInTheDocument();
await userEvent.click(manageSubscriptionsButton);
const unsubscribeAllButton = within(popupIframeDocument).queryByRole('button', {name: 'Unsubscribe from all emails'});
expect(unsubscribeAllButton).toBeInTheDocument();
fireEvent.click(unsubscribeAllButton);
expect(ghostApi.member.update).toHaveBeenCalledWith({newsletters: []});
// Verify the local state shows the newsletter as unsubscribed
let newsletterToggles = within(popupIframeDocument).queryAllByTestId('checkmark-container');
let newsletter1Toggle = newsletterToggles[0];
let newsletter2Toggle = newsletterToggles[1];
expect(newsletter1Toggle).toBeInTheDocument();
expect(newsletter2Toggle).toBeInTheDocument();
expect(newsletter1Toggle).not.toHaveClass('gh-portal-toggle-checked');
expect(newsletter2Toggle).not.toHaveClass('gh-portal-toggle-checked');
});
describe('from the unsubscribe link > UnsubscribePage', () => {
test('unsubscribe via email link while not logged in', async () => {
// Mock window.location
Object.defineProperty(window, 'location', {
value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}`),
writable: true
});
const {ghostApi, popupFrame, popupIframeDocument} = await setup({
site: FixtureSite.singleTier.onlyFreePlanWithoutStripe,
member: FixtureMember.subbedToNewsletter,
newsletters: Newsletters
}, true);
expect(ghostApi.member.newsletters).toHaveBeenLastCalledWith(
{uuid: FixtureMember.subbedToNewsletter.uuid}
);
expect(popupFrame).toBeInTheDocument();
// Verify the local state shows the newsletter as unsubscribed
let newsletterToggles = within(popupIframeDocument).queryAllByTestId('checkmark-container');
let newsletter1Toggle = newsletterToggles[0];
let newsletter2Toggle = newsletterToggles[1];
expect(newsletter1Toggle).toBeInTheDocument();
expect(newsletter2Toggle).toBeInTheDocument();
expect(newsletter1Toggle).not.toHaveClass('gh-portal-toggle-checked');
expect(newsletter2Toggle).toHaveClass('gh-portal-toggle-checked');
});
test('unsubscribe via email link while logged in', async () => {
// Mock window.location
Object.defineProperty(window, 'location', {
value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}`),
writable: true
});
const {ghostApi, popupFrame, popupIframeDocument, triggerButton, queryByTitle} = await setup({
site: FixtureSite.singleTier.onlyFreePlanWithoutStripe,
member: FixtureMember.subbedToNewsletter,
newsletters: Newsletters
});
// Verify the API was hit to collect subscribed newsletters
expect(ghostApi.member.newsletters).toHaveBeenLastCalledWith(
{uuid: FixtureMember.subbedToNewsletter.uuid}
);
// Verify the local state shows the newsletter as unsubscribed
let newsletterToggles = within(popupIframeDocument).queryAllByTestId('checkmark-container');
let newsletter1Toggle = newsletterToggles[0];
let newsletter2Toggle = newsletterToggles[1];
expect(newsletter1Toggle).toBeInTheDocument();
expect(newsletter2Toggle).toBeInTheDocument();
expect(newsletter1Toggle).not.toHaveClass('gh-portal-toggle-checked');
expect(newsletter2Toggle).toHaveClass('gh-portal-toggle-checked');
// Close the UnsubscribePage popup frame
const popupCloseButton = within(popupIframeDocument).queryByTestId('close-popup');
await userEvent.click(popupCloseButton);
expect(popupFrame).not.toBeInTheDocument();
// Reopen Portal and go to the unsubscribe page
await userEvent.click(triggerButton);
// We have a new popup frame - can't use the old locator from setup
const newPopupFrame = queryByTitle(/portal-popup/i);
expect(newPopupFrame).toBeInTheDocument();
const newPopupIframeDocument = newPopupFrame.contentDocument;
// Open the NewsletterManagement page
const manageSubscriptionsButton = within(newPopupIframeDocument).queryByRole('button', {name: 'Manage'});
await userEvent.click(manageSubscriptionsButton);
// Verify that the unsubscribed newsletter is shown as unsubscribed in the new popup
newsletterToggles = within(newPopupIframeDocument).queryAllByTestId('checkmark-container');
newsletter1Toggle = newsletterToggles[0];
newsletter2Toggle = newsletterToggles[1];
expect(newsletter1Toggle).toBeInTheDocument();
expect(newsletter2Toggle).toBeInTheDocument();
expect(newsletter1Toggle).not.toHaveClass('gh-portal-toggle-checked');
expect(newsletter2Toggle).toHaveClass('gh-portal-toggle-checked');
});
});
});

View File

@ -121,7 +121,8 @@ export function getMemberData({
email_suppression = {
suppressed: false,
info: null
}
},
newsletters = []
} = {}) {
return {
uuid: `member_${objectId()}`,
@ -132,10 +133,46 @@ export function getMemberData({
subscribed,
avatar_image,
subscriptions,
email_suppression
email_suppression,
newsletters
};
}
export function getNewsletterData({
id = `${objectId()}`,
uuid = `${objectId()}`,
name = 'Newsletter',
description = 'Newsletter description',
slug = 'newsletter',
sender_email = null,
subscribe_on_signup = true,
visibility = 'members',
sort_order = 0
}) {
return {
id,
uuid,
name,
description,
slug,
sender_email,
subscribe_on_signup,
visibility,
sort_order
};
}
export function getNewslettersData({numOfNewsletters = 3} = {}) {
const newsletters = [];
for (let i = 0; i < numOfNewsletters; i++) {
newsletters.push(getNewsletterData({
name: `Newsletter ${i + 1}`,
description: `Newsletter ${i + 1} description`
}));
}
return newsletters.slice(0, numOfNewsletters);
}
export function getProductsData({numOfProducts = 3} = {}) {
const products = [
getProductData({

View File

@ -455,7 +455,7 @@ describe('Helpers - ', () => {
});
});
describe('getCompExpiry', () => {
describe.skip('getCompExpiry', () => {
let member = {};
beforeEach(() => {

View File

@ -1,5 +1,5 @@
/* eslint-disable no-unused-vars*/
import {getFreeProduct, getMemberData, getOfferData, getPriceData, getProductData, getSiteData, getSubscriptionData, getTestSite} from './fixtures-generator';
import {getFreeProduct, getMemberData, getOfferData, getPriceData, getProductData, getSiteData, getSubscriptionData, getNewsletterData} from './fixtures-generator';
export const transformTierFixture = [
getFreeProduct({
@ -24,6 +24,19 @@ export const transformTierFixture = [
})
];
export const newsletters = [
getNewsletterData({
name: 'Newsletter 1',
description: 'Newsletter 1 description',
sort_order: 1
}),
getNewsletterData({
name: 'Newsletter 2',
description: 'Newsletter 2 description',
sort_order: 2
})
];
export const singleSiteTier = [
getFreeProduct({
name: 'Free',
@ -163,7 +176,8 @@ export const site = {
onlyFreePlanWithoutStripe: {
...baseSingleTierSite,
portal_plans: ['free'],
is_stripe_configured: false
is_stripe_configured: false,
newsletters: newsletters
},
membersInviteOnly: {
...baseSingleTierSite,
@ -213,7 +227,8 @@ export const member = {
subscriptions: [],
paid: false,
avatarImage: '',
subscribed: true
subscribed: true,
newsletters: []
}),
altFree: getMemberData({
name: 'Jimmie Larson',
@ -301,7 +316,19 @@ export const member = {
currentPeriodEnd: '2021-06-05T11:42:40.000Z'
})
]
}),
subbedToNewsletter: getMemberData({
newsletters: newsletters,
enable_comment_notifications: true
})
};
/* eslint-enable no-unused-vars*/
export const memberWithNewsletter = {
uuid: member.free.uuid,
email: member.free.email,
name: member.free.name,
newsletters: newsletters,
enable_comment_notifications: true,
status: 'free'
};
/* eslint-enable no-unused-vars*/