Skip to content

Commit

Permalink
Merge pull request #1271 from guardian/cc/targeting-by-country
Browse files Browse the repository at this point in the history
Target by specific countries and/or country group
  • Loading branch information
charleycampbell authored Feb 7, 2025
2 parents 4e93587 + cdc5600 commit 8a2b0de
Show file tree
Hide file tree
Showing 16 changed files with 564 additions and 95 deletions.
6 changes: 3 additions & 3 deletions src/server/tests/amp/ampEpicModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import {
} from '../../../shared/types';
import { AMPTicker } from './ampTicker';
import { z } from 'zod';

import { countryGroupIdSchema } from '../../../shared/lib';
import { countryGroupIdSchema, targetedRegionsSchema } from '../../../shared/lib';

/**
* Models for the data returned to AMP
Expand Down Expand Up @@ -52,7 +51,8 @@ const ampEpicTestVariantSchema = z.object({

export const ampEpicTestSchema = testSchema.extend({
nickname: z.string().optional(),
locations: z.array(countryGroupIdSchema),
locations: z.array(countryGroupIdSchema).optional(),
regionTargeting: targetedRegionsSchema.optional(),
variants: z.array(ampEpicTestVariantSchema),
});

Expand Down
49 changes: 37 additions & 12 deletions src/server/tests/amp/ampEpicSelection.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { CountryGroupId } from '../../../shared/lib';
import { TickerCountType, TickerEndType, TickerSettings } from '../../../shared/types';
import { AmpVariantAssignments } from '../../lib/ampVariantAssignments';
import { AMPEpic, AmpEpicTest } from './ampEpicModels';
Expand All @@ -24,6 +23,10 @@ const epicTest: AmpEpicTest = {
nickname: 'TEST1',
status: 'Live',
locations: [],
regionTargeting: {
targetedCountryGroups: [],
targetedCountryCodes: [],
},
variants: [
{
name: 'CONTROL',
Expand Down Expand Up @@ -97,21 +100,43 @@ describe('ampEpicTests', () => {
expect(result).toEqual(null);
});

it('should select test with matching locations', async () => {
const tests = [
{ ...epicTest, locations: ['UnitedStates' as CountryGroupId] },
{ ...epicTest, name: 'TEST2', nickname: 'TEST2' },
it('should select test based on region targeting', async () => {
const tests: AmpEpicTest[] = [
{
...epicTest,
regionTargeting: {
targetedCountryGroups: ['UnitedStates'],
targetedCountryCodes: ['US'],
},
},
];
const result = await selectAmpEpic(tests, ampVariantAssignments, tickerDataReloader, 'GB');
expect(result).toEqual({
...expectedAmpEpic,
testName: 'TEST2',

// User in targeted country group (US)
let result = await selectAmpEpic(tests, ampVariantAssignments, tickerDataReloader, 'US');
expect(result).toMatchObject({
testName: 'TEST1',
variantName: 'CONTROL',
heading: 'a',
paragraphs: ['b'],
highlightedText: expect.stringContaining('Support the Guardian from as little as $1'),
cta: {
...expectedAmpEpic.cta,
componentId: 'AMP__TEST2__CONTROL',
campaignCode: 'AMP__TEST2__CONTROL',
text: 'Show your support',
url: 'https://support.theguardian.com/contribute',
},
ticker: {
percentage: '99.9',
topLeft: '$999',
topRight: '$1,000',
},
});

// Ensure optional properties are handled correctly otherwise test fails
expect(result?.secondaryCta).toBeUndefined();
expect(result?.showChoiceCards).toBeUndefined();
expect(result?.defaultChoiceCardFrequency).toBeUndefined();

// User not in targeted country group (GB)
result = await selectAmpEpic(tests, ampVariantAssignments, tickerDataReloader, 'GB');
expect(result).toBeNull();
});
});
18 changes: 16 additions & 2 deletions src/server/tests/amp/ampEpicSelection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { inCountryGroups, replaceNonArticleCountPlaceholders } from '../../../shared/lib';
import { inTargetedCountry, replaceNonArticleCountPlaceholders } from '../../../shared/lib';
import { AmpVariantAssignments } from '../../lib/ampVariantAssignments';
import { AMPEpic, AmpEpicTest } from './ampEpicModels';
import { ampTicker } from './ampTicker';
Expand All @@ -16,6 +16,20 @@ type AmpExperiments = Record<

// ---- Functions --- //

export const isCountryTargetedForAmpEpic = (test: AmpEpicTest, countryCode?: string): boolean => {
const targetedCountryGroups = test.regionTargeting
? test.regionTargeting.targetedCountryGroups
: test.locations;
const targetedCountryCodes = test.regionTargeting
? test.regionTargeting.targetedCountryCodes
: [];
return inTargetedCountry(
countryCode,
targetedCountryGroups, // Country groups/region
targetedCountryCodes, // Individual country codes
);
};

export const getAmpExperimentData = async (tests: AmpEpicTest[]): Promise<AmpExperiments> => {
const ampExperiments: AmpExperiments = {
FALLBACK: {
Expand Down Expand Up @@ -60,7 +74,7 @@ const selectAmpEpicTestAndVariant = async (
countryCode?: string,
): Promise<AMPEpic | null> => {
const test = tests.find(
(test) => test.status === 'Live' && inCountryGroups(countryCode, test.locations),
(test) => test.status === 'Live' && isCountryTargetedForAmpEpic(test, countryCode),
);

if (test && test.variants) {
Expand Down
95 changes: 95 additions & 0 deletions src/server/tests/banners/bannerSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ describe('selectBannerTest', () => {
periodInWeeks: 52,
},
locations: [],
regionTargeting: {
targetedCountryGroups: [],
targetedCountryCodes: [],
},
contextTargeting: {
tagIds: [],
sectionIds: [],
Expand Down Expand Up @@ -117,6 +121,81 @@ describe('selectBannerTest', () => {
expect(result && result.test.name).toBe('test');
});

it('returns test if regionTargeting (country code) matches country code from payload (targeting)', () => {
const testWithRegionTargeting: BannerTest = {
...test,
regionTargeting: {
targetedCountryGroups: ['UnitedStates'],
targetedCountryCodes: ['AU'],
},
};

const result = selectBannerTest(
targeting,
tracking,
userDeviceType,
'',
[testWithRegionTargeting],
bannerDeployTimes,
enableHardcodedBannerTests,
enableScheduledBannerDeploys,
banditData,
undefined,
now,
);
expect(result && result.test.name).toBe('test');
});

it('returns test if regionTargeting (country group) matches country code from payload (targeting)', () => {
const testWithRegionTargeting: BannerTest = {
...test,
regionTargeting: {
targetedCountryGroups: ['AUDCountries', 'GBPCountries'],
targetedCountryCodes: ['CA', 'DE'],
},
};

const result = selectBannerTest(
targeting,
tracking,
userDeviceType,
'',
[testWithRegionTargeting],
bannerDeployTimes,
enableHardcodedBannerTests,
enableScheduledBannerDeploys,
banditData,
undefined,
now,
);
expect(result && result.test.name).toBe('test');
});

it('returns null if regionTargeting does not match country code from payload (targeting)', () => {
const testWithRegionTargeting: BannerTest = {
...test,
regionTargeting: {
targetedCountryGroups: ['NZDCountries'],
targetedCountryCodes: ['DE', 'FR'],
},
};

const result = selectBannerTest(
targeting,
tracking,
userDeviceType,
'',
[testWithRegionTargeting],
bannerDeployTimes,
enableHardcodedBannerTests,
enableScheduledBannerDeploys,
banditData,
undefined,
now,
);
expect(result).toBe(null);
});

it('returns null if hardcoded tests disabled', () => {
const result = selectBannerTest(
Object.assign(targeting, {
Expand Down Expand Up @@ -276,6 +355,10 @@ describe('selectBannerTest', () => {
periodInWeeks: 52,
},
locations: [],
regionTargeting: {
targetedCountryGroups: [],
targetedCountryCodes: [],
},
contextTargeting: {
tagIds: [],
sectionIds: [],
Expand Down Expand Up @@ -388,6 +471,10 @@ describe('selectBannerTest', () => {
},
],
locations: [],
regionTargeting: {
targetedCountryGroups: [],
targetedCountryCodes: [],
},
contextTargeting: {
tagIds: [],
sectionIds: [],
Expand Down Expand Up @@ -517,6 +604,10 @@ describe('selectBannerTest', () => {
},
],
locations: [],
regionTargeting: {
targetedCountryGroups: [],
targetedCountryCodes: [],
},
contextTargeting: {
tagIds: [],
sectionIds: [],
Expand Down Expand Up @@ -618,6 +709,10 @@ describe('selectBannerTest', () => {
userCohort: 'Everyone',
variants: [],
locations: [],
regionTargeting: {
targetedCountryGroups: [],
targetedCountryCodes: [],
},
contextTargeting: {
tagIds: [],
sectionIds: [],
Expand Down
29 changes: 23 additions & 6 deletions src/server/tests/banners/bannerSelection.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
import { countryCodeToCountryGroupId, inCountryGroups } from '../../../shared/lib';
import { countryCodeToCountryGroupId, inTargetedCountry } from '../../../shared/lib';
import {
BannerTargeting,
BannerTest,
BannerTestSelection,
BannerVariant,
PageTracking,
UserDeviceType,
uiIsDesign,
UserDeviceType,
} from '../../../shared/types';
import { selectVariant } from '../../lib/ab';
import { historyWithinArticlesViewedSettings } from '../../lib/history';
import { TestVariant } from '../../lib/params';
import {
abandonedBasketMatches,
audienceMatches,
consentStatusMatches,
correctSignedInStatus,
deviceTypeMatches,
consentStatusMatches,
pageContextMatches,
abandonedBasketMatches,
} from '../../lib/targeting';
import { BannerDeployTimesProvider, ReaderRevenueRegion } from './bannerDeployTimes';
import { selectTargetingTest } from '../../lib/targetingTesting';
import { bannerTargetingTests } from './bannerTargetingTests';
import {
defaultDeploySchedule,
getLastScheduledDeploy,
ScheduledBannerDeploys,
defaultDeploySchedule,
} from './bannerDeploySchedule';
import { daysSince } from '../../lib/dates';
import { isAfter, subDays } from 'date-fns';
Expand Down Expand Up @@ -58,6 +58,23 @@ function canShowAbandonedBasketBanner(
return daysSince(new Date(abandonedBasketBannerLastClosedAt), now) > 0;
}

export const isCountryTargetedForBanner = (
test: BannerTest,
targeting: BannerTargeting,
): boolean => {
const targetedCountryGroups = test.regionTargeting
? test.regionTargeting.targetedCountryGroups
: test.locations;
const targetedCountryCodes = test.regionTargeting
? test.regionTargeting.targetedCountryCodes
: [];
return inTargetedCountry(
targeting.countryCode,
targetedCountryGroups, // Country groups/region
targetedCountryCodes, // Individual country codes
);
};

/**
* If the banner has been closed previously, can we show it again?
* Takes into account both the manual deploys (from RRCP) and the scheduled deploys.
Expand Down Expand Up @@ -211,7 +228,7 @@ export const selectBannerTest = (
!targeting.shouldHideReaderRevenue &&
!targeting.isPaidContent &&
audienceMatches(targeting.showSupportMessaging, test.userCohort) &&
inCountryGroups(targeting.countryCode, test.locations) &&
isCountryTargetedForBanner(test, targeting) &&
!(test.articlesViewedSettings && targeting.hasOptedOutOfArticleCount) &&
historyWithinArticlesViewedSettings(
test.articlesViewedSettings,
Expand Down
Loading

0 comments on commit 8a2b0de

Please sign in to comment.