diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json index c45dc366190..8dccd8978ef 100644 --- a/dotcom-rendering/package.json +++ b/dotcom-rendering/package.json @@ -22,7 +22,7 @@ "dependencies": { "@aws-sdk/client-cloudwatch": "3.995.0", "@babel/helper-compilation-targets": "7.29.7", - "@braze/web-sdk": "6.5.0", + "@braze/web-sdk": "6.9.0", "@creditkarma/thrift-server-core": "1.0.4", "@emotion/cache": "11.14.0", "@emotion/react": "11.14.0", diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx new file mode 100644 index 00000000000..8e4caa28796 --- /dev/null +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from '@testing-library/react'; +import { isPlacementStale } from '../lib/braze/BrazeBannersSystem'; +import type { BrazeInstance } from '../lib/braze/initialiseBraze'; +import { useAB } from '../lib/useAB'; +import { useBraze } from '../lib/useBraze'; +import type { RecipeBlockElement } from '../types/content'; +import { ConfigProvider } from './ConfigContext'; +import { FeastContextualNudge } from './FeastContextualNudge.island'; + +jest.mock('../lib/useAB'); +jest.mock('../lib/useBraze'); +jest.mock('../lib/braze/BrazeBannersSystem', () => ({ + BrazeBannersSystemPlacementId: { + FeastContextualNudge1: 'dotcom-rendering_feast-contextual-nudge-1', + FeastContextualNudge2: 'dotcom-rendering_feast-contextual-nudge-2', + FeastContextualNudge3: 'dotcom-rendering_feast-contextual-nudge-3', + FeastContextualNudge4: 'dotcom-rendering_feast-contextual-nudge-4', + FeastContextualNudge5: 'dotcom-rendering_feast-contextual-nudge-5', + }, + isPlacementStale: jest.fn(), + BrazeBannersSystemDisplay: ({ meta }: { meta: { id: string } }) => ( +
+ ), +})); + +const recipe: RecipeBlockElement = { + _type: 'model.dotcomrendering.pageElements.RecipeBlockElement', + id: 'recipe-id', + title: 'Test recipe', +}; + +const braze = { + getBanner: jest.fn(), +} as unknown as BrazeInstance; + +const renderNudge = (idApiUrl: string | undefined) => + render( + + + , + ); + +describe('FeastContextualNudge Braze fallback', () => { + beforeEach(() => { + jest.mocked(useAB).mockReturnValue({ + isUserInTestGroup: () => true, + isUserInTest: () => true, + getParticipations: () => ({}), + trackABTests: () => ({}), + }); + jest.mocked(useBraze).mockReturnValue({ + braze, + brazeCards: undefined, + brazeMessages: undefined, + }); + jest.mocked(isPlacementStale).mockReturnValue(false); + jest.mocked(braze.getBanner).mockReset(); + }); + + it('renders the Braze banner for a fresh eligible placement', () => { + jest.mocked(braze.getBanner).mockReturnValue({} as never); + + renderNudge('https://id.test'); + + expect(screen.getByTestId('braze-feast-banner')).toHaveAttribute( + 'data-banner-id', + 'feast-contextual-nudge-1', + ); + expect(screen.queryByText('Download the app')).not.toBeInTheDocument(); + }); + + it('uses the native Feast card instead of a cached banner when stale', () => { + jest.mocked(isPlacementStale).mockReturnValue(true); + jest.mocked(braze.getBanner).mockReturnValue({} as never); + + renderNudge('https://id.test'); + + expect(screen.getByText('Download the app')).toBeInTheDocument(); + expect( + screen.queryByTestId('braze-feast-banner'), + ).not.toBeInTheDocument(); + expect(braze.getBanner).not.toHaveBeenCalled(); + }); + + it('uses the native Feast card when no banner is eligible', () => { + jest.mocked(braze.getBanner).mockReturnValue(null); + + renderNudge('https://id.test'); + + expect(screen.getByText('Download the app')).toBeInTheDocument(); + }); + + it('does not query Braze when the identity API URL is unavailable', () => { + renderNudge(undefined); + + expect(screen.getByText('Download the app')).toBeInTheDocument(); + expect(braze.getBanner).not.toHaveBeenCalled(); + }); +}); diff --git a/dotcom-rendering/src/lib/ArticleRenderer.test.tsx b/dotcom-rendering/src/lib/ArticleRenderer.test.tsx index a9f2534f829..6d9e2693691 100644 --- a/dotcom-rendering/src/lib/ArticleRenderer.test.tsx +++ b/dotcom-rendering/src/lib/ArticleRenderer.test.tsx @@ -13,13 +13,23 @@ import { type ArticleFormat, Pillar, } from './articleFormat'; -import { ArticleRenderer } from './ArticleRenderer'; +import { ArticleRenderer, getFeastNudgeIndex } from './ArticleRenderer'; // ── Mocks ───────────────────────────────────────────────────────────────────── jest.mock('../components/FeastContextualNudge.island', () => ({ - FeastContextualNudge: ({ recipe }: { recipe: { id: string } }) => ( -
+ FeastContextualNudge: ({ + recipe, + nudgeIndex, + }: { + recipe: { id: string }; + nudgeIndex: number; + }) => ( +
), })); @@ -215,6 +225,40 @@ describe('ArticleRenderer — augmentedElements', () => { expect(nudges).toHaveLength(2); expect(nudges[0]).toHaveAttribute('data-recipe-id', 'recipe-001'); expect(nudges[1]).toHaveAttribute('data-recipe-id', 'recipe-002'); + expect(nudges[0]).toHaveAttribute('data-nudge-index', '1'); + expect(nudges[1]).toHaveAttribute('data-nudge-index', '2'); + }); + + it('distributes exactly five numbered placements across a long recipe article', () => { + const elements: FEElement[] = Array.from({ length: 10 }, (_, index) => [ + makeSubheading(`

Recipe ${index}

`, index), + makeRecipe(`recipe-${index}`), + ]).flat(); + + renderWithConfig( + , + ); + + const nudges = screen.getAllByTestId('feast-nudge'); + expect(nudges).toHaveLength(5); + expect(nudges.map((nudge) => nudge.dataset.recipeId)).toEqual([ + 'recipe-0', + 'recipe-2', + 'recipe-5', + 'recipe-7', + 'recipe-9', + ]); + expect(nudges.map((nudge) => nudge.dataset.nudgeIndex)).toEqual([ + '1', + '2', + '3', + '4', + '5', + ]); }); it('renders body elements within each section in order', () => { @@ -237,3 +281,17 @@ describe('ArticleRenderer — augmentedElements', () => { expect(screen.getByTestId('article-element-3')).toBeInTheDocument(); }); }); + +describe('getFeastNudgeIndex', () => { + it('assigns every section when there are at most five', () => { + expect([0, 1, 2].map((index) => getFeastNudgeIndex(index, 3))).toEqual([ + 1, 2, 3, + ]); + }); + + it('returns null for invalid section indexes', () => { + expect(getFeastNudgeIndex(-1, 3)).toBeNull(); + expect(getFeastNudgeIndex(3, 3)).toBeNull(); + expect(getFeastNudgeIndex(0, 0)).toBeNull(); + }); +}); diff --git a/dotcom-rendering/src/lib/ArticleRenderer.tsx b/dotcom-rendering/src/lib/ArticleRenderer.tsx index d7fd529642a..d04336cef97 100644 --- a/dotcom-rendering/src/lib/ArticleRenderer.tsx +++ b/dotcom-rendering/src/lib/ArticleRenderer.tsx @@ -18,6 +18,33 @@ const commercialPosition = css` position: relative; `; +const MAX_FEAST_NUDGES = 5; + +/** + * Assigns up to five numbered Braze placements across recipe sections, + * including the first and last section when there are more than five. + */ +export const getFeastNudgeIndex = ( + sectionIndex: number, + sectionCount: number, +): number | null => { + if (sectionIndex < 0 || sectionIndex >= sectionCount || sectionCount < 1) { + return null; + } + + const nudgeCount = Math.min(MAX_FEAST_NUDGES, sectionCount); + if (nudgeCount === sectionCount) return sectionIndex + 1; + + for (let slot = 0; slot < nudgeCount; slot++) { + const targetIndex = Math.round( + (slot * (sectionCount - 1)) / (nudgeCount - 1), + ); + if (targetIndex === sectionIndex) return slot + 1; + } + + return null; +}; + type Props = { format: ArticleFormat; elements: FEElement[]; @@ -158,21 +185,11 @@ export const ArticleRenderer = ({ const result: (JSX.Element | null | undefined)[] = [...preSection]; - /** - * Distribute up to 5 Braze placement slots evenly across all sections. - * interval = ceil(sections.length / 5) - * A section at 0-based index i gets nudgeIndex = (i+1)/interval - * only when (i+1) is an exact multiple of interval (and ≤ 5). - * All other sections get nudgeIndex = null (no nudge rendered). - */ - const MAX_NUDGES = 5; - const interval = Math.ceil(sections.length / MAX_NUDGES); - for (const section of sections) { - const position = section.index + 1; // 1-based - const nudgeIndex = - position % interval === 0 ? position / interval : null; - + const nudgeIndex = getFeastNudgeIndex( + section.index, + sections.length, + ); result.push( {section.subheadingEl} diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.test.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.test.tsx new file mode 100644 index 00000000000..e2a6eb42123 --- /dev/null +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.test.tsx @@ -0,0 +1,239 @@ +import type { Banner } from '@braze/web-sdk'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ConfigProvider } from '../../components/ConfigContext'; +import { + BrazeBannersSystemDisplay, + BrazeBannersSystemPlacementId, + canShowBrazeBannersSystem, + getPagePlacements, + isPlacementStale, + refreshBanners, +} from './BrazeBannersSystem'; +import type { BrazeInstance } from './initialiseBraze'; + +jest.mock('../useAuthStatus', () => ({ + useAuthStatus: () => ({ kind: 'SignedOut' }), +})); + +jest.mock('../useIsInView', () => ({ + useIsInView: () => [false, jest.fn()], +})); + +jest.mock('../../client/ophan/ophan', () => ({ + submitComponentEvent: jest.fn(), +})); + +const makeBraze = (requestBannersRefresh = jest.fn()): BrazeInstance => + ({ requestBannersRefresh }) as unknown as BrazeInstance; + +const makeBanner = ( + placementId = BrazeBannersSystemPlacementId.Banner, +): Banner => + ({ + placementId, + html: '
', + properties: {}, + getStringProperty: jest.fn(() => null), + getBooleanProperty: jest.fn((key: string) => + key === 'wrapperModeEnabled' ? true : null, + ), + }) as unknown as Banner; + +describe('Braze Banner placement refreshes', () => { + afterEach(() => { + document.body.innerHTML = ''; + jest.useRealTimers(); + }); + + it('requests only placements represented by islands on the page', () => { + document.body.innerHTML = ` + + + `; + + expect(getPagePlacements()).toEqual([ + BrazeBannersSystemPlacementId.Banner, + BrazeBannersSystemPlacementId.FeastContextualNudge1, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + BrazeBannersSystemPlacementId.FeastContextualNudge3, + BrazeBannersSystemPlacementId.FeastContextualNudge4, + BrazeBannersSystemPlacementId.FeastContextualNudge5, + ]); + }); + + it('distinguishes a successful refresh with no banner from a failed refresh', async () => { + const placement = BrazeBannersSystemPlacementId.Banner; + const request = jest + .fn() + .mockImplementationOnce( + (_ids: string[], _success: () => void, error: () => void) => + error(), + ) + .mockImplementationOnce((_ids: string[], success: () => void) => + success(), + ); + const braze = makeBraze(request); + + await refreshBanners(braze, [placement]); + expect(isPlacementStale(placement)).toBe(true); + + await refreshBanners(braze, [placement]); + expect(isPlacementStale(placement)).toBe(false); + expect(request).toHaveBeenCalledWith( + expect.arrayContaining([placement]), + expect.any(Function), + expect.any(Function), + ); + }); + + it('marks both Feast and Designable placements stale when a refresh fails', async () => { + const placements = [ + BrazeBannersSystemPlacementId.Banner, + BrazeBannersSystemPlacementId.EndOfArticle, + ] as const; + const braze = makeBraze( + jest.fn((_ids: string[], _success: () => void, error: () => void) => + error(), + ), + ); + + await refreshBanners(braze, [...placements]); + + expect(isPlacementStale(placements[0])).toBe(true); + expect(isPlacementStale(placements[1])).toBe(true); + }); + + it('marks a placement stale on timeout, then fresh after a late success', async () => { + jest.useFakeTimers(); + const placement = BrazeBannersSystemPlacementId.EndOfArticle; + let completeRefresh: (() => void) | undefined; + const braze = makeBraze( + jest.fn((_ids: string[], success: () => void) => { + completeRefresh = success; + }), + ); + + const refresh = refreshBanners(braze, [placement]); + jest.advanceTimersByTime(2000); + await refresh; + expect(isPlacementStale(placement)).toBe(true); + + completeRefresh?.(); + expect(isPlacementStale(placement)).toBe(false); + }); + + it('does not read a cached banner for a stale Designable placement', async () => { + const placement = BrazeBannersSystemPlacementId.Banner; + const getBanner = jest.fn(() => makeBanner(placement)); + const braze = { + ...makeBraze( + jest.fn( + (_ids: string[], _success: () => void, error: () => void) => + error(), + ), + ), + getBanner, + } as BrazeInstance; + await refreshBanners(braze, [placement]); + + await expect( + canShowBrazeBannersSystem( + 'test-banner', + braze, + placement, + 'Article', + false, + [], + ), + ).resolves.toEqual({ show: false }); + expect(getBanner).not.toHaveBeenCalled(); + }); +}); + +describe('Braze Banner dismissal', () => { + it('logs an SDK dismissal and existing analytics when the wrapper is closed', async () => { + const banner = makeBanner(); + const braze = { + insertBanner: jest.fn(), + dismissBanner: jest.fn(() => true), + logBannerClick: jest.fn(), + logCustomEvent: jest.fn(), + logBannerImpressions: jest.fn(), + } as unknown as BrazeInstance; + + render( + + + , + ); + + const closeButton = await screen.findByRole('button', { + name: 'Close banner', + }); + fireEvent.click(closeButton); + + expect(braze.dismissBanner).toHaveBeenCalledWith(banner); + expect(braze.logBannerClick).toHaveBeenCalledWith( + banner, + 'dismiss_button', + ); + expect(braze.logCustomEvent).toHaveBeenCalledWith( + 'braze_banner_dismissed', + { placementId: banner.placementId }, + ); + await waitFor(() => + expect( + screen.queryByRole('button', { name: 'Close banner' }), + ).not.toBeInTheDocument(), + ); + }); + + it('ignores dismissal messages from another origin', async () => { + const banner = makeBanner(); + const braze = { + insertBanner: jest.fn(), + dismissBanner: jest.fn(), + logBannerClick: jest.fn(), + logCustomEvent: jest.fn(), + logBannerImpressions: jest.fn(), + } as unknown as BrazeInstance; + + render( + + + , + ); + + await screen.findByRole('button', { name: 'Close banner' }); + window.dispatchEvent( + new MessageEvent('message', { + origin: 'https://attacker.example', + data: { type: 'BRAZE_BANNERS_SYSTEM:DISMISS_BANNER' }, + }), + ); + + expect(braze.dismissBanner).not.toHaveBeenCalled(); + }); +}); diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index b7871958656..4858b88824e 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -411,7 +411,7 @@ export const canShowBrazeBannersSystem = async ( * campaigns. Only placements with suppressOnStale: true in * ISLAND_PLACEMENT_MAP can ever be stale (currently: Banner, EndOfArticle). */ - if (stalePlacements.has(placementId)) { + if (isPlacementStale(placementId)) { brazeBannersSystemLogger.info( `Placement "${placementId}" is stale (last refresh was rate-limited). Not showing banner.`, ); @@ -661,6 +661,7 @@ export const BrazeBannersSystemDisplay = ({ const [showBanner, setShowBanner] = useState(true); // Tracks whether banner:open has been dispatched so we only fire banner:close if the open event was sent first. const hasDispatchedOpenRef = useRef(false); + const hasDismissedRef = useRef(false); const [minHeight, setMinHeight] = useState('0px'); const [wrapperModeEnabled, setWrapperModeEnabled] = @@ -801,7 +802,10 @@ export const BrazeBannersSystemDisplay = ({ * commercial code can release the mobile sticky ad slot. */ const dismissBanner = useCallback(() => { + if (hasDismissedRef.current) return; + hasDismissedRef.current = true; setShowBanner(false); + meta.braze.dismissBanner(meta.banner); meta.braze.logBannerClick(meta.banner, 'dismiss_button'); if (hasDispatchedOpenRef.current) { document.dispatchEvent( @@ -971,16 +975,18 @@ export const BrazeBannersSystemDisplay = ({ >, ) => { if ( - event.origin === window.location.origin && - Object.values(BrazeBannersSystemMessageType).includes( - event.data.type, + event.origin !== window.location.origin || + !Object.values(BrazeBannersSystemMessageType).includes( + event.data?.type, ) ) { - brazeBannersSystemLogger.log( - '📥 Received message from Braze Banner:', - event.data, - ); + return; } + + brazeBannersSystemLogger.log( + '📥 Received message from Braze Banner:', + event.data, + ); switch (event.data.type) { case BrazeBannersSystemMessageType.GetAuthStatus: postMessageToBrazeBanner( @@ -1113,9 +1119,7 @@ export const BrazeBannersSystemDisplay = ({ case BrazeBannersSystemMessageType.GetContext: postMessageToBrazeBanner( BrazeBannersSystemMessageType.GetContext, - { - context, - }, + { context }, ); break; } diff --git a/dotcom-rendering/src/lib/braze/README.md b/dotcom-rendering/src/lib/braze/README.md index 8385fd95698..5d6d0ee3c72 100644 --- a/dotcom-rendering/src/lib/braze/README.md +++ b/dotcom-rendering/src/lib/braze/README.md @@ -35,10 +35,11 @@ The system is encapsulated primarily in `BrazeBannersSystem.tsx` and interacts w We map DCR-specific slots to Braze Placement IDs. This abstraction allows us to change the underlying ID without refactoring the components. -| DCR Placement ID | Description | -| :------------------------------------------- | :------------------------------------------------------------------ | -| `BrazeBannersSystemPlacementId.EndOfArticle` | Appears at the bottom of the article body (Epic slot). | -| `BrazeBannersSystemPlacementId.Banner` | Appears fixed at the bottom of the viewport (Sticky Bottom Banner). | +| DCR Placement ID | Description | +| :-------------------------------------------------------- | :------------------------------------------------------------------ | +| `BrazeBannersSystemPlacementId.EndOfArticle` | Appears at the bottom of the article body (Epic slot). | +| `BrazeBannersSystemPlacementId.Banner` | Appears fixed at the bottom of the viewport (Sticky Bottom Banner). | +| `BrazeBannersSystemPlacementId.FeastContextualNudge1`–`5` | Replaces up to five evenly distributed Feast contextual nudges. | ### 2. Concurrency & Slot Priority @@ -96,7 +97,8 @@ Braze enforces a "Token Bucket" algorithm for refreshing banners (re-checking el - **Capacity**: 5 tokens per session. - **Refill**: 1 token every 3 minutes. -- **Implementation**: The `refreshBanners()` function creates a race condition with a timeout. If the network is slow or tokens are empty, DCR proceeds without blocking the render. +- **Page-aware requests**: DCR requests only placements whose `gu-island` exists on the current page. +- **Implementation**: The `refreshBanners()` function creates a race condition with a timeout. A failed or timed-out placement is marked stale so cached eligibility is not rendered. Designable slots continue through message-picker fallbacks; Feast slots render the native Feast card. A later successful refresh marks the placements fresh again. ### 6. Wrapper Mode & Styling @@ -160,7 +162,7 @@ All user interactions with the banner are tracked with both a Braze banner click | Dismiss Banner | `dismiss_button` | `braze_banner_dismissed` | `placementId` | | CSS Validation Fail | — | `braze_banner_css_validation_failed` | `placementId` | -> The Braze click (`logBannerClick`) feeds native Braze campaign reports, while the custom event (`logCustomEvent`) provides granular analytics available in Braze Data Export and BigQuery. +> Dismissal also calls the Web SDK's `dismissBanner(banner)`. This records Braze's native dismissal and suppresses that Banner for the user. The Braze click and custom event remain for the existing reporting streams. ## Communication Protocol @@ -168,15 +170,16 @@ The banner uses a `postMessage` protocol to interact with the host DCR page. ### Supported Message Types -| Message Type | Function | -| :------------------------------------------------- | :---------------------------------------------------- | -| `BRAZE_BANNERS_SYSTEM:GET_AUTH_STATUS` | Checks if user is `SignedIn`. | -| `BRAZE_BANNERS_SYSTEM:GET_EMAIL_ADDRESS` | Requests email (if signed in). | -| `BRAZE_BANNERS_SYSTEM:NEWSLETTER_SUBSCRIBE` | Subscribes to a newsletter ID. | -| `BRAZE_BANNERS_SYSTEM:REMINDER_SUBSCRIBE` | Creates a one-off reminder for contribution requests. | -| `BRAZE_BANNERS_SYSTEM:NAVIGATE_TO_URL` | Navigates the host page to a URL. | -| `BRAZE_BANNERS_SYSTEM:DISMISS_BANNER` | Removes the banner from the DOM. | -| `BRAZE_BANNERS_SYSTEM:GET_SETTINGS_PROPERTY_VALUE` | Reads Key-Value pairs from the Campaign config. | +| Message Type | Function | +| :------------------------------------------------- | :----------------------------------------------------- | +| `BRAZE_BANNERS_SYSTEM:GET_AUTH_STATUS` | Checks if user is `SignedIn`. | +| `BRAZE_BANNERS_SYSTEM:GET_EMAIL_ADDRESS` | Requests email (if signed in). | +| `BRAZE_BANNERS_SYSTEM:NEWSLETTER_SUBSCRIBE` | Subscribes to a newsletter ID. | +| `BRAZE_BANNERS_SYSTEM:REMINDER_SUBSCRIBE` | Creates a one-off reminder for contribution requests. | +| `BRAZE_BANNERS_SYSTEM:NAVIGATE_TO_URL` | Navigates the host page to a URL. | +| `BRAZE_BANNERS_SYSTEM:DISMISS_BANNER` | Removes the banner from the DOM. | +| `BRAZE_BANNERS_SYSTEM:GET_CONTEXT` | Reads the page context supplied by the host component. | +| `BRAZE_BANNERS_SYSTEM:GET_SETTINGS_PROPERTY_VALUE` | Reads Key-Value pairs from the Campaign config. | ### Feature Details @@ -295,7 +298,7 @@ Removes the banner from the DOM. **Response**: None (the banner is immediately removed) -**Description**: Programmatically dismisses the banner, removing it from the page. Use this when the user clicks a close button or completes an action that should hide the banner. +**Description**: Programmatically dismisses the banner, removes it from the page, and calls Braze's native `dismissBanner` API so the Banner is suppressed for that user. Duplicate SDK dismissals are safe and ignored by Braze. --- diff --git a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.test.ts b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.test.ts new file mode 100644 index 00000000000..d8b02446e10 --- /dev/null +++ b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.test.ts @@ -0,0 +1,102 @@ +import { getOphan } from '../../client/ophan/ophan'; +import { buildBrazeMessaging } from './buildBrazeMessaging'; +import { checkBrazeDependencies } from './checkBrazeDependencies'; +import type { BrazeInstance } from './initialiseBraze'; +import { getInitialisedBraze } from './initialiseBraze'; + +jest.mock('@guardian/libs', () => ({ + ...jest.requireActual('@guardian/libs'), + log: jest.fn(), + startPerformanceMeasure: () => ({ endPerformanceMeasure: () => 0 }), + storage: { local: { isAvailable: () => true } }, +})); + +jest.mock('@guardian/braze-components/logic', () => ({ + BrazeCards: jest.fn().mockImplementation(() => ({})), + BrazeMessages: jest.fn().mockImplementation(() => ({})), + canRenderBrazeMsg: jest.fn(), + LocalMessageCache: { clear: jest.fn() }, + NullBrazeCards: jest.fn().mockImplementation(() => ({})), + NullBrazeMessages: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock('../../client/ophan/ophan', () => ({ + getOphan: jest.fn(), +})); + +jest.mock('../hasCurrentBrazeUser', () => ({ + clearHasCurrentBrazeUser: jest.fn(), + hasCurrentBrazeUser: jest.fn(() => false), + setHasCurrentBrazeUser: jest.fn(), +})); + +jest.mock('./checkBrazeDependencies'); +jest.mock('./initialiseBraze', () => ({ + getInitialisedBraze: jest.fn(), +})); + +const dependencyResult = { + isSuccessful: true as const, + data: { + apiKey: 'api-key', + brazeUuid: 'braze-user', + consent: true, + brazeSwitch: true, + }, +}; + +const makeBraze = (events: string[]): BrazeInstance => + ({ + changeUser: jest.fn(() => events.push('changeUser')), + requestBannersRefresh: jest.fn( + (_placements: string[], success: () => void) => { + events.push('requestBannersRefresh'); + success(); + }, + ), + openSession: jest.fn(() => events.push('openSession')), + subscribeToBannersUpdates: jest.fn(() => 'subscription-id'), + }) as unknown as BrazeInstance; + +describe('buildBrazeMessaging banner initialisation', () => { + beforeEach(() => { + document.body.innerHTML = ''; + jest.mocked(checkBrazeDependencies).mockResolvedValue(dependencyResult); + jest.mocked(getOphan).mockResolvedValue({ + record: jest.fn(), + } as never); + window.guardian.config.switches.brazeContentCards = false; + }); + + it('orders changeUser, banner refresh, then openSession', async () => { + document.body.innerHTML = + ''; + const events: string[] = []; + const braze = makeBraze(events); + jest.mocked(getInitialisedBraze).mockResolvedValue(braze); + + await buildBrazeMessaging('https://id.test', true, 'Web'); + + expect(events).toEqual([ + 'changeUser', + 'requestBannersRefresh', + 'openSession', + ]); + expect(braze.requestBannersRefresh).toHaveBeenCalledWith( + expect.arrayContaining(['dotcom-rendering_banner']), + expect.any(Function), + expect.any(Function), + ); + }); + + it('does not spend a refresh token when the page has no banner islands', async () => { + const events: string[] = []; + const braze = makeBraze(events); + jest.mocked(getInitialisedBraze).mockResolvedValue(braze); + + await buildBrazeMessaging('https://id.test', true, 'Web'); + + expect(events).toEqual(['changeUser', 'openSession']); + expect(braze.requestBannersRefresh).not.toHaveBeenCalled(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b9e89d895a..f3122444735 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,8 +297,8 @@ importers: specifier: 7.29.7 version: 7.29.7 '@braze/web-sdk': - specifier: 6.5.0 - version: 6.5.0 + specifier: 6.9.0 + version: 6.9.0 '@creditkarma/thrift-server-core': specifier: 1.0.4 version: 1.0.4 @@ -1695,8 +1695,8 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true - '@braze/web-sdk@6.5.0': - resolution: {integrity: sha512-MdfwZn+tfe7+tR8Ir5468/njQDGhgVB9tBthq7gwSETsjv6Q+Hw2bXiQy3scDcACI2RLqiq+dNXKh6fD+pN4/Q==} + '@braze/web-sdk@6.9.0': + resolution: {integrity: sha512-JiYpRJsx/qJT3VFunt4i2aiSo2jfwT1z5KCUM3Mytah3F6VMdGDW3CLUo+aU12QRSTkdbsHezD0ZRxOJEgtFmA==} '@cacheable/memory@2.0.8': resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} @@ -11358,7 +11358,7 @@ snapshots: dependencies: css-tree: 3.2.1 - '@braze/web-sdk@6.5.0': {} + '@braze/web-sdk@6.9.0': {} '@cacheable/memory@2.0.8': dependencies: