Skip to content
103 changes: 103 additions & 0 deletions packages/playwright/tests/digest-upsell.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { test, expect } from '@playwright/test';

test.describe('Digest Upsell Banners', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');

// Handle cookie consent
await page
.getByRole('button', { name: 'Accept all' })
.or(page.getByRole('button', { name: 'I understand' }))
.click();

// Log in
await page.getByRole('button', { name: 'Log in' }).click();
await page
.getByRole('textbox', { name: 'Email' })
.fill(process.env.USER_NAME);
await page.getByRole('textbox', { name: 'Password' }).press('Tab');
await page
.getByRole('textbox', { name: 'Password' })
.fill(process.env.PASSWORD);
await page.getByRole('button', { name: 'Log in' }).click();

// Wait for auth to complete
await expect(
page
.getByRole('link', { name: 'profile' })
.or(page.getByRole('button', { name: 'Profile settings' })),
).toBeVisible();
});

test('notifications page shows digest upsell banner for eligible users', async ({
page,
}) => {
await page.goto('/notifications');

// The banner should be visible if the user is non-Plus and has no digest
const banner = page.getByText('Get your personalized digest');
const enableButton = page.getByRole('button', { name: 'Enable digest' });

// If the banner is visible, verify its structure
if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) {
await expect(banner).toBeVisible();
await expect(enableButton).toBeVisible();

// Verify close button exists
const closeButton = page.getByRole('button', { name: 'Close' });
await expect(closeButton).toBeVisible();
}
});

test('bookmarks page shows digest upsell banner for eligible users', async ({
page,
}) => {
await page.goto('/bookmarks');

// The banner should be visible if the user is non-Plus and has no digest
const banner = page.getByText('Never miss the best posts');
const enableButton = page.getByRole('button', { name: 'Enable digest' });

// If the banner is visible, verify its structure
if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) {
await expect(banner).toBeVisible();
await expect(enableButton).toBeVisible();

// Verify close button exists
const closeButton = page.getByRole('button', { name: 'Close' });
await expect(closeButton).toBeVisible();
}
});

test('digest upsell banner can be dismissed on notifications page', async ({
page,
}) => {
await page.goto('/notifications');

const banner = page.getByText('Get your personalized digest');

// Only test dismiss if banner is visible (user is eligible)
if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) {
const closeButton = page.getByRole('button', { name: 'Close' });
await closeButton.click();

await expect(banner).toBeHidden();
}
});

test('digest upsell banner can be dismissed on bookmarks page', async ({
page,
}) => {
await page.goto('/bookmarks');

const banner = page.getByText('Never miss the best posts');

// Only test dismiss if banner is visible (user is eligible)
if (await banner.isVisible({ timeout: 5000 }).catch(() => false)) {
const closeButton = page.getByRole('button', { name: 'Close' });
await closeButton.click();

await expect(banner).toBeHidden();
}
});
});
14 changes: 12 additions & 2 deletions packages/shared/src/components/BookmarkFeedLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { PropsWithChildren, ReactElement, ReactNode } from 'react';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import dynamic from 'next/dynamic';
import classNames from 'classnames';
import {
Expand All @@ -21,6 +27,7 @@ import { generateQueryKey, OtherFeedPage, RequestKey } from '../lib/query';
import { useFeedLayout, useViewSize, ViewSize } from '../hooks';
import { BookmarkSection } from './sidebar/sections/BookmarkSection';
import PlusMobileEntryBanner from './banners/PlusMobileEntryBanner';
import { DigestBookmarkBanner } from './notifications/DigestBookmarkBanner';
import {
Typography,
TypographyTag,
Expand Down Expand Up @@ -117,6 +124,8 @@ export default function BookmarkFeedLayout({
],
);
const { plusEntryBookmark } = usePlusEntry();
const [isEmptyFeed, setIsEmptyFeed] = useState(false);
const onEmptyFeed = useCallback(() => setIsEmptyFeed(true), []);
const feedProps = useMemo<FeedProps<unknown>>(() => {
if (isSearchResults) {
return {
Expand Down Expand Up @@ -245,8 +254,9 @@ export default function BookmarkFeedLayout({
/>
)}
</div>
{!plusEntryBookmark && isEmptyFeed && <DigestBookmarkBanner />}
{tokenRefreshed && (isSearchResults || loadedSort) && (
<Feed {...feedProps} />
<Feed {...feedProps} onEmptyFeed={onEmptyFeed} />
)}
</FeedPageLayoutComponent>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { DigestBookmarkBanner } from './DigestBookmarkBanner';
import { LogEvent, TargetId } from '../../lib/log';
import { UserPersonalizedDigestType } from '../../graphql/users';
import { ActionType } from '../../graphql/actions';

const mockLogEvent = jest.fn();
const mockGetPersonalizedDigest = jest.fn();
const mockSubscribePersonalizedDigest = jest.fn().mockResolvedValue({});
const mockCompleteAction = jest.fn().mockResolvedValue(undefined);
const mockCheckHasCompleted = jest.fn();
const mockUsePlusSubscription = jest.fn();
const mockSetNotificationStatuses = jest.fn();
const mockDisplayToast = jest.fn();

jest.mock('../../contexts/LogContext', () => ({
useLogContext: () => ({ logEvent: mockLogEvent }),
}));

jest.mock('../../contexts/AuthContext', () => ({
useAuthContext: () => ({ isAuthReady: true }),
}));

jest.mock('../../hooks/usePlusSubscription', () => ({
usePlusSubscription: () => mockUsePlusSubscription(),
}));

jest.mock('../../hooks/usePersonalizedDigest', () => ({
usePersonalizedDigest: () => ({
getPersonalizedDigest: mockGetPersonalizedDigest,
subscribePersonalizedDigest: mockSubscribePersonalizedDigest,
}),
SendType: { Workdays: 'workdays', Daily: 'daily', Weekly: 'weekly' },
}));

jest.mock('../../hooks/useActions', () => ({
useActions: () => ({
checkHasCompleted: mockCheckHasCompleted,
completeAction: mockCompleteAction,
isActionsFetched: true,
}),
}));

jest.mock('../../hooks/notifications/useNotificationSettings', () => ({
__esModule: true,
default: () => ({
setNotificationStatusBulk: mockSetNotificationStatuses,
}),
}));

jest.mock('../../hooks/useToastNotification', () => ({
useToastNotification: () => ({
displayToast: mockDisplayToast,
}),
}));

const client = new QueryClient();

const renderComponent = () =>
render(
<QueryClientProvider client={client}>
<DigestBookmarkBanner />
</QueryClientProvider>,
);

describe('DigestBookmarkBanner', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUsePlusSubscription.mockReturnValue({ isPlus: false });
mockGetPersonalizedDigest.mockReturnValue(null);
mockCheckHasCompleted.mockReturnValue(false);
});

it('should render banner for non-Plus user without digest', () => {
renderComponent();

expect(
screen.getByText('Not sure what to read? Let us pick for you'),
).toBeInTheDocument();
expect(screen.getByText('Enable digest')).toBeInTheDocument();
});

it('should not render for Plus users', () => {
mockUsePlusSubscription.mockReturnValue({ isPlus: true });

renderComponent();

expect(
screen.queryByText('Not sure what to read? Let us pick for you'),
).not.toBeInTheDocument();
});

it('should not render when user has digest subscription', () => {
mockGetPersonalizedDigest.mockImplementation(
(type: UserPersonalizedDigestType) =>
type === UserPersonalizedDigestType.Digest
? {
type: UserPersonalizedDigestType.Digest,
preferredHour: 9,
flags: { sendType: 'workdays' },
}
: null,
);

renderComponent();

expect(
screen.queryByText('Not sure what to read? Let us pick for you'),
).not.toBeInTheDocument();
});

it('should not render when dismissed via action', () => {
mockCheckHasCompleted.mockReturnValue(true);

renderComponent();

expect(
screen.queryByText('Not sure what to read? Let us pick for you'),
).not.toBeInTheDocument();
});

it('should log impression on render', () => {
renderComponent();

expect(mockLogEvent).toHaveBeenCalledWith({
event_name: LogEvent.Impression,
target_id: TargetId.DigestUpsellBookmarks,
});
});

it('should subscribe, complete action, and log click on CTA', async () => {
renderComponent();

const ctaButton = screen.getByText('Enable digest');
fireEvent.click(ctaButton);

expect(mockLogEvent).toHaveBeenCalledWith({
event_name: LogEvent.Click,
target_id: TargetId.DigestUpsellBookmarks,
});

await waitFor(() => {
expect(mockSubscribePersonalizedDigest).toHaveBeenCalledWith({
hour: 9,
sendType: 'workdays',
type: UserPersonalizedDigestType.Digest,
});
});

await waitFor(() => {
expect(mockSetNotificationStatuses).toHaveBeenCalled();
});

await waitFor(() => {
expect(mockCompleteAction).toHaveBeenCalledWith(ActionType.DigestUpsell);
});

await waitFor(() => {
expect(mockDisplayToast).toHaveBeenCalledWith(
'Digest enabled! Check your inbox tomorrow.',
);
});
});

it('should log dismiss and complete action on dismiss', () => {
renderComponent();

const closeButton = screen.getByRole('button', { name: 'Close' });
fireEvent.click(closeButton);

expect(mockLogEvent).toHaveBeenCalledWith({
event_name: LogEvent.Click,
target_id: TargetId.DigestUpsellBookmarks,
extra: JSON.stringify({ action: 'dismiss' }),
});
expect(mockCompleteAction).toHaveBeenCalledWith(ActionType.DigestUpsell);
});
});
Loading