diff --git a/dotcom-rendering/src/model/enhanceCards.test.ts b/dotcom-rendering/src/model/enhanceCards.test.ts index bbe0c8e3aa2..3de556355b7 100644 --- a/dotcom-rendering/src/model/enhanceCards.test.ts +++ b/dotcom-rendering/src/model/enhanceCards.test.ts @@ -1,8 +1,14 @@ -import type { FEMediaAsset, FEMediaAtom } from '../frontend/feFront'; +import type { + FEFrontCardStyle, + FEMediaAsset, + FEMediaAtom, +} from '../frontend/feFront'; import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import type { EditorialTest, VariantMeta } from '../types/front'; import type { MainMedia } from '../types/mainMedia'; import { decideArticleMedia, + decideHeadline, decideReplacementMedia, getActiveMediaAtom, getMediaMetadata, @@ -527,4 +533,239 @@ describe('Enhance Cards', () => { }); }); }); + + describe('decideHeadline', () => { + const cardWithNoEditorialTest = { + properties: { + isBreaking: false, + showKickerTag: false, + showByline: false, + isLiveBlog: false, + isCrossword: false, + webTitle: '', + editionBrandings: [], + tests: [], + }, + header: { + isVideo: false, + isComment: false, + isGallery: false, + isAudio: false, + headline: 'Headline', + url: '', + hasMainVideoElement: false, + }, + card: { + id: '', + cardStyle: { + type: 'DefaultCardstyle' as FEFrontCardStyle, + }, + shortUrl: '', + group: '', + isLive: false, + }, + discussion: { + isCommentable: false, + isClosedForComments: false, + }, + display: { + isBoosted: false, + showBoostedHeadline: false, + showQuotedHeadline: false, + imageHide: false, + showLivePlayable: false, + }, + type: '', + }; + + const oneHourInMilliseconds = 60 * 60 * 1000; + + const cardWithEditorialTest = { + ...cardWithNoEditorialTest, + properties: { + ...cardWithNoEditorialTest.properties, + tests: [ + { + testUuid: 'uuid', + variantMeta: [ + { + id: 'A', + meta: { + headline: 'Headline A', + }, + }, + { + id: 'B', + meta: { + headline: 'Headline B', + }, + }, + ] as VariantMeta[], + startDate: Date.now() - oneHourInMilliseconds, + expiryDate: Date.now() + oneHourInMilliseconds, + frontsThisTestCanRunOn: ['test-front'], + hasManuallyEndedOnThisTrail: false, + }, + ], + }, + }; + + const cardWithEditorialTestWithUndefinedVariantMeta = { + ...cardWithEditorialTest, + properties: { + ...cardWithEditorialTest.properties, + tests: [ + { + ...cardWithEditorialTest.properties.tests[0], + variantMeta: [ + { + id: 'A', + meta: { + headline: undefined, + }, + } as VariantMeta, + ], + } as EditorialTest, + ], + }, + }; + + const cardWithExpiredEditorialTest = { + ...cardWithEditorialTest, + properties: { + ...cardWithEditorialTest.properties, + tests: [ + { + ...cardWithEditorialTest.properties.tests[0], + expiryDate: Date.now() - oneHourInMilliseconds, + } as EditorialTest, + ], + }, + }; + + const cardWithManuallyEndedEditorialTest = { + ...cardWithEditorialTest, + properties: { + ...cardWithEditorialTest.properties, + tests: [ + { + ...cardWithEditorialTest.properties.tests[0], + hasManuallyEndedOnThisTrail: true, + } as EditorialTest, + ], + }, + }; + + it('returns the default headline if no editorial test exists on the card, page is not in allowed fronts list, and user is not in a test bucket', () => { + expect( + decideHeadline( + cardWithNoEditorialTest, + {}, + 'invalid-test-front', + ), + ).toEqual('Headline'); + }); + + it('returns the default headline if editorial test exists and page is in allowed fronts list, but user is not in a test bucket', () => { + expect( + decideHeadline(cardWithEditorialTest, {}, 'test-front'), + ).toEqual('Headline'); + }); + + it('returns the default headline if user is in a test bucket and page is in allowed fronts list, but editorial test does not exist', () => { + expect( + decideHeadline( + cardWithNoEditorialTest, + { + 'fronts-and-curation-editorial-headline-test': 'a', + }, + 'test-front', + ), + ).toEqual('Headline'); + }); + + it('returns the default headline if editorial test exists and user is in a test bucket, but page is not in allowed fronts list', () => { + expect( + decideHeadline( + cardWithEditorialTest, + { + 'fronts-and-curation-editorial-headline-test': 'a', + }, + 'invalid-test-front', + ), + ).toEqual('Headline'); + }); + + it('returns headline A if editorial test exists, page is in allowed fronts list, and user is in bucket A', () => { + expect( + decideHeadline( + cardWithEditorialTest, + { + 'fronts-and-curation-editorial-headline-test': 'a', + }, + 'test-front', + ), + ).toEqual('Headline A'); + }); + + it('returns headline B if editorial test exists, page is in allowed fronts list, and user is in bucket B', () => { + expect( + decideHeadline( + cardWithEditorialTest, + { + 'fronts-and-curation-editorial-headline-test': 'b', + }, + 'test-front', + ), + ).toEqual('Headline B'); + }); + + it('returns the default headline if the bucket name does not match a variant meta id', () => { + expect( + decideHeadline( + cardWithEditorialTest, + { + 'fronts-and-curation-editorial-headline-test': 'c', + }, + 'test-front', + ), + ).toEqual('Headline'); + }); + + it('returns the default headline if the variant headline is undefined', () => { + expect( + decideHeadline( + cardWithEditorialTestWithUndefinedVariantMeta, + { + 'fronts-and-curation-editorial-headline-test': 'a', + }, + 'test-front', + ), + ).toEqual('Headline'); + }); + + it('returns the default headline if an editorial test has expired', () => { + expect( + decideHeadline( + cardWithExpiredEditorialTest, + { + 'fronts-and-curation-editorial-headline-test': 'a', + }, + 'test-front', + ), + ).toEqual('Headline'); + }); + + it('returns the default headline if an editorial test has been manually ended', () => { + expect( + decideHeadline( + cardWithManuallyEndedEditorialTest, + { + 'fronts-and-curation-editorial-headline-test': 'a', + }, + 'test-front', + ), + ).toEqual('Headline'); + }); + }); }); diff --git a/dotcom-rendering/src/model/enhanceCards.ts b/dotcom-rendering/src/model/enhanceCards.ts index 7a67fe1eeca..86bdadba041 100644 --- a/dotcom-rendering/src/model/enhanceCards.ts +++ b/dotcom-rendering/src/model/enhanceCards.ts @@ -25,6 +25,7 @@ import type { DCRFrontCard, DCRSlideshowImage, DCRSupportingContent, + EditorialTest, } from '../types/front'; import type { ArticleMedia, MainMedia } from '../types/mainMedia'; import type { PodcastSeriesImage, TagType } from '../types/tag'; @@ -183,6 +184,63 @@ const decideVideoAtomImage = ( return undefined; }; +/** + * Checks if an editorial test is active by making sure it has not been manually ended, + * that it has a valid expiry date, and that the expiry date is in the future + */ +const isActiveEditorialTest = (test: EditorialTest) => + !test.hasManuallyEndedOnThisTrail && + !!test.expiryDate && + test.expiryDate > Date.now(); + +/** + * Looks through a list of editorial tests to see if there is an active test. If no active + * test is found, return undefined + */ +const findActiveEditorialTest = ( + tests: EditorialTest[] | undefined, +): EditorialTest | undefined => { + return tests?.find((test) => isActiveEditorialTest(test)); +}; + +/** + * Decide the headline to be shown for a given card. If there is an active editorial test on a card, + * return the variant headline matching the user test group. Otherwise, return the default headline + */ +export const decideHeadline = ( + faciaCard: FEFrontCard, + serverSideABTests: Record, + pageId?: string, +): string => { + const defaultHeadline = faciaCard.header.headline; + + const testBucket = + serverSideABTests['fronts-and-curation-editorial-headline-test']; + + const activeEditorialTest = findActiveEditorialTest( + faciaCard.properties.tests, + ); + + if (isUndefined(testBucket) || !activeEditorialTest) { + return defaultHeadline; + } + + const testCanRunOnPage = + !isUndefined(pageId) && + activeEditorialTest.frontsThisTestCanRunOn.includes(pageId); + + if (!testCanRunOnPage) return defaultHeadline; + + const variantMeta = activeEditorialTest.variantMeta.find( + (variant) => variant.id.toLowerCase() === testBucket, + ); + + // make sure the variant headline isn't undefined and that it is of type string + if (typeof variantMeta?.meta.headline !== 'string') return defaultHeadline; + + return variantMeta.meta.headline; +}; + /** * While the first Media Atom is *not* guaranteed to be the main media, * it *happens to be* correct in the majority of cases. @@ -363,6 +421,7 @@ export const enhanceCards = ( pageId, discussionApiUrl, stripBranding = false, + serverSideABTests, }: { cardInTagPage: boolean; /** Used for the data link name to indicate card position in container */ @@ -372,6 +431,7 @@ export const enhanceCards = ( discussionApiUrl: string; /** We strip branding from cards if the branding will appear at the collection level instead */ stripBranding?: boolean; + serverSideABTests: Record; }, ): DCRFrontCard[] => collections.map((faciaCard, index) => { @@ -448,7 +508,7 @@ export const enhanceCards = ( format, dataLinkName, url: decideUrl(faciaCard), - headline: faciaCard.header.headline, + headline: decideHeadline(faciaCard, serverSideABTests, pageId), trailText: faciaCard.card.trailText, starRating: faciaCard.card.starRating, webPublicationDate: !isUndefined( diff --git a/dotcom-rendering/src/model/enhanceCollections.ts b/dotcom-rendering/src/model/enhanceCollections.ts index 1fefac96915..8e711c06c4a 100644 --- a/dotcom-rendering/src/model/enhanceCollections.ts +++ b/dotcom-rendering/src/model/enhanceCollections.ts @@ -54,6 +54,7 @@ export const enhanceCollections = ({ frontBranding, onPageDescription, isOnPaidContentFront, + serverSideABTests, }: { collections: FECollection[]; editionId: EditionId; @@ -62,6 +63,7 @@ export const enhanceCollections = ({ frontBranding: Branding | undefined; onPageDescription?: string; isOnPaidContentFront?: boolean; + serverSideABTests: Record; }): DCRCollectionType[] => { const indexToShowFrontBranding = findCollectionSuitableForFrontBranding(collections); @@ -128,18 +130,24 @@ export const enhanceCollections = ({ editionId, discussionApiUrl, stripBrandingFromCards, + serverSideABTests, + pageId, ), curated: enhanceCards(collection.curated, { cardInTagPage: false, editionId, + pageId, discussionApiUrl, stripBranding: stripBrandingFromCards, + serverSideABTests, }), backfill: enhanceCards(collection.backfill, { cardInTagPage: false, editionId, + pageId, discussionApiUrl, stripBranding: stripBrandingFromCards, + serverSideABTests, }), treats: enhanceTreats( collection.treats, diff --git a/dotcom-rendering/src/model/groupCards.ts b/dotcom-rendering/src/model/groupCards.ts index db1be083621..7c0b9f29067 100644 --- a/dotcom-rendering/src/model/groupCards.ts +++ b/dotcom-rendering/src/model/groupCards.ts @@ -25,6 +25,8 @@ export const groupCards = ( editionId: EditionId, discussionApiUrl: string, stripBranding: boolean = false, + serverSideABTests: Record, + pageId: string, ): DCRGroupedTrails => { switch (container) { case 'flexible/general': { @@ -42,9 +44,11 @@ export const groupCards = ( const enhanceOptions = (offset = 0) => ({ cardInTagPage: false, editionId, + pageId, discussionApiUrl, offset, stripBranding, + serverSideABTests, }); return { @@ -64,9 +68,11 @@ export const groupCards = ( const enhanceOptions = (offset = 0) => ({ cardInTagPage: false, editionId, + pageId, discussionApiUrl, offset, stripBranding, + serverSideABTests, }); return { diff --git a/dotcom-rendering/src/server/handler.front.web.ts b/dotcom-rendering/src/server/handler.front.web.ts index 6e94ff02f33..1ce0925d6de 100644 --- a/dotcom-rendering/src/server/handler.front.web.ts +++ b/dotcom-rendering/src/server/handler.front.web.ts @@ -35,6 +35,7 @@ const enhanceFront = (body: unknown): Front => { data.pressedPage.frontProperties.commercial.editionBrandings, data.editionId, ), + serverSideABTests: data.config.serverSideABTests, }); return { @@ -122,6 +123,7 @@ const enhanceTagPage = (body: unknown): TagPage => { discussionApiUrl: data.config.discussionApiUrl, editionId: data.editionId, stripBranding: !!tagPageBranding, + serverSideABTests: data.config.serverSideABTests, }); const speed = getSpeedFromTrails(data.contents);