diff --git a/dotcom-rendering/src/client/userFeatures/cookies/adFree.ts b/dotcom-rendering/src/client/userFeatures/cookies/adFree.ts new file mode 100644 index 00000000000..1828f88d3d4 --- /dev/null +++ b/dotcom-rendering/src/client/userFeatures/cookies/adFree.ts @@ -0,0 +1,30 @@ +import { getCookie, removeCookie, setCookie } from '@guardian/libs'; + +const AD_FREE_USER_COOKIE = 'GU_AF1'; + +export const getAdFreeCookie = (): string | null => + getCookie({ name: AD_FREE_USER_COOKIE }); + +/* + * Set the ad free cookie + * + * @param daysToLive - number of days the cookie should be valid + */ +export const setAdFreeCookie = (daysToLive = 1): void => { + const expires = new Date(); + expires.setMonth(expires.getMonth() + 6); + setCookie({ + name: AD_FREE_USER_COOKIE, + value: expires.getTime().toString(), + daysToLive, + }); +}; + +export const adFreeDataIsPresent = (): boolean => { + const cookieVal = getAdFreeCookie(); + if (!cookieVal) return false; + return !Number.isNaN(parseInt(cookieVal, 10)); +}; + +export const removeAdFreeCookie = (): void => + removeCookie({ name: AD_FREE_USER_COOKIE }); diff --git a/dotcom-rendering/src/client/userFeatures/cookies/hideSupportMessaging.ts b/dotcom-rendering/src/client/userFeatures/cookies/hideSupportMessaging.ts new file mode 100644 index 00000000000..d17f1211428 --- /dev/null +++ b/dotcom-rendering/src/client/userFeatures/cookies/hideSupportMessaging.ts @@ -0,0 +1,16 @@ +import { getCookie, removeCookie, setCookie } from '@guardian/libs'; + +const HIDE_SUPPORT_MESSAGING_COOKIE = 'gu_hide_support_messaging'; + +export const getHideSupportMessagingCookie = (): string | null => + getCookie({ name: HIDE_SUPPORT_MESSAGING_COOKIE }); + +export const setHideSupportMessagingCookie = (value: boolean): void => { + setCookie({ + name: HIDE_SUPPORT_MESSAGING_COOKIE, + value: String(value), + }); +}; + +export const removeHideSupportMessagingCookie = (): void => + removeCookie({ name: HIDE_SUPPORT_MESSAGING_COOKIE }); diff --git a/dotcom-rendering/src/client/userFeatures/cookies/userFeaturesExpiry.ts b/dotcom-rendering/src/client/userFeatures/cookies/userFeaturesExpiry.ts new file mode 100644 index 00000000000..353dae3429e --- /dev/null +++ b/dotcom-rendering/src/client/userFeatures/cookies/userFeaturesExpiry.ts @@ -0,0 +1,20 @@ +import { getCookie, removeCookie, setCookie } from '@guardian/libs'; + +const USER_FEATURES_EXPIRY_COOKIE = 'gu_user_features_expiry'; + +export const getUserFeaturesExpiryCookie = (): string | null => + getCookie({ name: USER_FEATURES_EXPIRY_COOKIE }); + +export const setUserFeaturesExpiryCookie = (expiryTime: string): void => + setCookie({ name: USER_FEATURES_EXPIRY_COOKIE, value: expiryTime }); + +export const removeUserFeaturesExpiryCookie = (): void => + removeCookie({ name: USER_FEATURES_EXPIRY_COOKIE }); + +export const featuresDataIsOld = (): boolean => { + const cookie = getUserFeaturesExpiryCookie(); + if (!cookie) return true; + const expiryTime = parseInt(cookie, 10); + const timeNow = new Date().getTime(); + return timeNow >= expiryTime; +}; diff --git a/dotcom-rendering/src/client/userFeatures/fetchJson.ts b/dotcom-rendering/src/client/userFeatures/fetchJson.ts new file mode 100644 index 00000000000..22808c0f3de --- /dev/null +++ b/dotcom-rendering/src/client/userFeatures/fetchJson.ts @@ -0,0 +1,16 @@ +export const fetchJson = async ( + path: string, + init: RequestInit = {}, +): Promise => { + const resp = await fetch(path, init); + if (resp.ok) { + try { + return resp.json(); + } catch (ex) { + throw new Error( + `Fetch error while requesting ${path}: Invalid JSON response`, + ); + } + } + throw new Error(`Fetch error while requesting ${path}: ${resp.statusText}`); +}; diff --git a/dotcom-rendering/src/client/userFeatures/membersDataApi.ts b/dotcom-rendering/src/client/userFeatures/membersDataApi.ts index 66bcddbef84..c460f26a9f1 100644 --- a/dotcom-rendering/src/client/userFeatures/membersDataApi.ts +++ b/dotcom-rendering/src/client/userFeatures/membersDataApi.ts @@ -1,8 +1,8 @@ import { isBoolean, isObject } from '@guardian/libs'; import type { SignedInWithCookies, SignedInWithOkta } from '../../lib/identity'; import { getOptionsHeadersWithOkta } from '../../lib/identity'; -import type { UserBenefits } from './user-features-lib'; -import { fetchJson } from './user-features-lib'; +import { fetchJson } from './fetchJson'; +import type { UserBenefits } from './user-features'; const dates = { 1: '01', diff --git a/dotcom-rendering/src/client/userFeatures/user-features-lib.ts b/dotcom-rendering/src/client/userFeatures/user-features-lib.ts deleted file mode 100644 index 3d796bca950..00000000000 --- a/dotcom-rendering/src/client/userFeatures/user-features-lib.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @file Sets the user subscription and ad free cookies - * This file was migrated from: - * https://github.com/guardian/commercial/blob/1a429d6be05657f20df4ca909df7d01a5c3d7402/src/lib/user-features.ts - */ - -import { getCookie, setCookie } from '@guardian/libs'; - -const timeInDaysFromNow = (daysFromNow: number): string => { - const tmpDate = new Date(); - tmpDate.setDate(tmpDate.getDate() + daysFromNow); - return tmpDate.getTime().toString(); -}; - -const cookieIsExpiredOrMissing = (cookieName: string): boolean => { - const cookie = getCookie({ name: cookieName }); - if (!cookie) return true; - const expiryTime = parseInt(cookie, 10); - const timeNow = new Date().getTime(); - return timeNow >= expiryTime; -}; - -const AD_FREE_USER_COOKIE = 'GU_AF1'; - -const getAdFreeCookie = (): string | null => - getCookie({ name: AD_FREE_USER_COOKIE }); - -const adFreeDataIsPresent = (): boolean => { - const cookieVal = getAdFreeCookie(); - if (!cookieVal) return false; - return !Number.isNaN(parseInt(cookieVal, 10)); -}; - -/* - * Set the ad free cookie - * - * @param daysToLive - number of days the cookie should be valid - */ -const setAdFreeCookie = (daysToLive = 1): void => { - const expires = new Date(); - expires.setMonth(expires.getMonth() + 6); - setCookie({ - name: AD_FREE_USER_COOKIE, - value: expires.getTime().toString(), - daysToLive, - }); -}; - -/** - * Check that path is a path-absolute-URL string as described in https://url.spec.whatwg.org/#path-absolute-url-string - * A path-absolute-URL string is U+002F (/) followed by a path-relative-URL string, for instance `/plop` or `/plop/plop` - */ -function isPathAbsoluteURL(path: string): boolean { - return !RegExp('^(https?:)?//').exec(path); -} - -const fetchJson = async ( - resource: string, - init: RequestInit = {}, -): Promise => { - let path = resource; - if (isPathAbsoluteURL(path)) { - path = window.guardian.config.page.ajaxUrl + resource; - init.mode = 'cors'; - } - - const resp = await fetch(path, init); - if (resp.ok) { - switch (resp.status) { - case 204: - return {}; - default: - try { - return resp.json(); - } catch (ex) { - throw new Error( - `Fetch error while requesting ${path}: Invalid JSON response`, - ); - } - } - } - throw new Error(`Fetch error while requesting ${path}: ${resp.statusText}`); -}; - -export type UserBenefits = { - adFree: boolean; - hideSupportMessaging: boolean; -}; - -export { - adFreeDataIsPresent, - cookieIsExpiredOrMissing, - fetchJson, - getAdFreeCookie, - setAdFreeCookie, - timeInDaysFromNow, -}; diff --git a/dotcom-rendering/src/client/userFeatures/user-features.test.ts b/dotcom-rendering/src/client/userFeatures/user-features.test.ts index e258998622a..ee3b44c4946 100644 --- a/dotcom-rendering/src/client/userFeatures/user-features.test.ts +++ b/dotcom-rendering/src/client/userFeatures/user-features.test.ts @@ -1,11 +1,16 @@ -import { getCookie, removeCookie, setCookie } from '@guardian/libs'; import type { AuthStatus } from '../../lib/identity'; import { getAuthStatus as getAuthStatus_, isUserLoggedInOktaRefactor as isUserLoggedInOktaRefactor_, } from '../../lib/identity'; -import { refresh } from './user-features'; -import { fetchJson } from './user-features-lib'; +import { getAdFreeCookie, setAdFreeCookie } from './cookies/adFree'; +import { setHideSupportMessagingCookie } from './cookies/hideSupportMessaging'; +import { + getUserFeaturesExpiryCookie, + setUserFeaturesExpiryCookie, +} from './cookies/userFeaturesExpiry'; +import { fetchJson } from './fetchJson'; +import { deleteAllCookies, refresh } from './user-features'; const fakeUserFeatures = { showSupportMessaging: false, @@ -16,11 +21,8 @@ const fakeUserFeatures = { }, }; -jest.mock('./user-features-lib', () => { - // Only mock the fetchJson function, rather than the whole module - const original = jest.requireActual('./user-features-lib'); +jest.mock('./fetchJson', () => { return { - ...original, fetchJson: jest.fn(() => { return Promise.resolve(fakeUserFeatures); }), @@ -44,40 +46,16 @@ const getAuthStatus = getAuthStatus_ as jest.MockedFunction< typeof getAuthStatus_ >; -const PERSISTENCE_KEYS = { - USER_FEATURES_EXPIRY_COOKIE: 'gu_user_features_expiry', - AD_FREE_USER_COOKIE: 'GU_AF1', - SUPPORT_ONE_OFF_CONTRIBUTION_COOKIE: 'gu.contributions.contrib-timestamp', - HIDE_SUPPORT_MESSAGING_COOKIE: 'gu_hide_support_messaging', -}; - const setAllFeaturesData = (opts: { isExpired: boolean }) => { const currentTime = new Date().getTime(); const msInOneDay = 24 * 60 * 60 * 1000; const expiryDate = opts.isExpired ? new Date(currentTime - msInOneDay) : new Date(currentTime + msInOneDay); - const adFreeExpiryDate = opts.isExpired - ? new Date(currentTime - msInOneDay * 2) - : new Date(currentTime + msInOneDay * 2); - setCookie({ - name: PERSISTENCE_KEYS.HIDE_SUPPORT_MESSAGING_COOKIE, - value: 'true', - }); - setCookie({ - name: PERSISTENCE_KEYS.AD_FREE_USER_COOKIE, - value: adFreeExpiryDate.getTime().toString(), - }); - setCookie({ - name: PERSISTENCE_KEYS.USER_FEATURES_EXPIRY_COOKIE, - value: expiryDate.getTime().toString(), - }); -}; -const deleteAllFeaturesData = () => { - removeCookie({ name: PERSISTENCE_KEYS.USER_FEATURES_EXPIRY_COOKIE }); - removeCookie({ name: PERSISTENCE_KEYS.AD_FREE_USER_COOKIE }); - removeCookie({ name: PERSISTENCE_KEYS.HIDE_SUPPORT_MESSAGING_COOKIE }); + setHideSupportMessagingCookie(true); + setAdFreeCookie(2); + setUserFeaturesExpiryCookie(expiryDate.getTime().toString()); }; beforeAll(() => { @@ -96,7 +74,7 @@ describe('Refreshing the features data', () => { }); it('Performs an update if the user has missing data', async () => { - deleteAllFeaturesData(); + deleteAllCookies(); await refresh(); expect(fetchJsonSpy).toHaveBeenCalledTimes(1); }); @@ -110,14 +88,10 @@ describe('Refreshing the features data', () => { it('Does not delete the data just because it has expired', async () => { setAllFeaturesData({ isExpired: true }); await refresh(); - expect( - getCookie({ - name: PERSISTENCE_KEYS.USER_FEATURES_EXPIRY_COOKIE, - }), - ).toEqual(expect.stringMatching(/\d{13}/)); - expect( - getCookie({ name: PERSISTENCE_KEYS.AD_FREE_USER_COOKIE }), - ).toEqual(expect.stringMatching(/\d{13}/)); + expect(getUserFeaturesExpiryCookie()).toEqual( + expect.stringMatching(/\d{13}/), + ); + expect(getAdFreeCookie()).toEqual(expect.stringMatching(/\d{13}/)); }); it('Does not perform update if user has fresh feature data', async () => { @@ -135,7 +109,7 @@ describe('If user signed out', () => { }); it('Does not perform update, even if feature data missing', async () => { - deleteAllFeaturesData(); + deleteAllCookies(); await refresh(); expect(fetchJsonSpy).not.toHaveBeenCalled(); }); @@ -143,14 +117,8 @@ describe('If user signed out', () => { it('Deletes leftover feature data', async () => { setAllFeaturesData({ isExpired: false }); await refresh(); - expect( - getCookie({ name: PERSISTENCE_KEYS.AD_FREE_USER_COOKIE }), - ).toBeNull(); - expect( - getCookie({ - name: PERSISTENCE_KEYS.USER_FEATURES_EXPIRY_COOKIE, - }), - ).toBeNull(); + expect(getAdFreeCookie()).toBeNull(); + expect(getUserFeaturesExpiryCookie()).toBeNull(); }); }); @@ -171,7 +139,7 @@ describe('Storing new feature data', () => { jest.resetAllMocks(); fetchJsonSpy.mockReturnValue(Promise.resolve(mockResponse)); - deleteAllFeaturesData(); + deleteAllCookies(); isUserLoggedInOktaRefactor.mockResolvedValue(true); getAuthStatus.mockResolvedValue({ kind: 'SignedInWithOkta', @@ -191,9 +159,7 @@ describe('Storing new feature data', () => { }), ); return refresh().then(() => { - expect( - getCookie({ name: PERSISTENCE_KEYS.AD_FREE_USER_COOKIE }), - ).toBeNull(); + expect(getAdFreeCookie()).toBeNull(); }); }); @@ -210,16 +176,12 @@ describe('Storing new feature data', () => { }), ); return refresh().then(() => { - expect( - getCookie({ name: PERSISTENCE_KEYS.AD_FREE_USER_COOKIE }), - ).toBeTruthy(); + expect(getAdFreeCookie()).toBeTruthy(); expect( Number.isNaN( parseInt( // @ts-expect-error -- we’re testing it - getCookie({ - name: PERSISTENCE_KEYS.AD_FREE_USER_COOKIE, - }), + getAdFreeCookie(), 10, ), ), @@ -229,9 +191,7 @@ describe('Storing new feature data', () => { it('Puts an expiry date in an accompanying cookie', () => refresh().then(() => { - const expiryDate = getCookie({ - name: PERSISTENCE_KEYS.USER_FEATURES_EXPIRY_COOKIE, - }); + const expiryDate = getUserFeaturesExpiryCookie(); expect(expiryDate).toBeTruthy(); // @ts-expect-error -- we’re testing it expect(Number.isNaN(parseInt(expiryDate, 10))).toBe(false); @@ -239,9 +199,7 @@ describe('Storing new feature data', () => { it('The expiry date is in the future', () => refresh().then(() => { - const expiryDateString = getCookie({ - name: PERSISTENCE_KEYS.USER_FEATURES_EXPIRY_COOKIE, - }); + const expiryDateString = getUserFeaturesExpiryCookie(); // @ts-expect-error -- we’re testing it const expiryDateEpoch = parseInt(expiryDateString, 10); const currentTimeEpoch = new Date().getTime(); diff --git a/dotcom-rendering/src/client/userFeatures/user-features.ts b/dotcom-rendering/src/client/userFeatures/user-features.ts index a17b8ef454d..234624b6c04 100644 --- a/dotcom-rendering/src/client/userFeatures/user-features.ts +++ b/dotcom-rendering/src/client/userFeatures/user-features.ts @@ -4,54 +4,52 @@ * https://github.com/guardian/commercial/blob/1a429d6be05657f20df4ca909df7d01a5c3d7402/src/lib/user-features.ts */ -import { getCookie, removeCookie, setCookie } from '@guardian/libs'; import { getAuthStatus, isUserLoggedInOktaRefactor } from '../../lib/identity'; -import { syncDataFromMembersDataApi } from './membersDataApi'; -import type { UserBenefits } from './user-features-lib'; import { adFreeDataIsPresent, - cookieIsExpiredOrMissing, getAdFreeCookie, + removeAdFreeCookie, setAdFreeCookie, - timeInDaysFromNow, -} from './user-features-lib'; - -export const USER_FEATURES_EXPIRY_COOKIE = 'gu_user_features_expiry'; -export const HIDE_SUPPORT_MESSAGING_COOKIE = 'gu_hide_support_messaging'; -export const AD_FREE_USER_COOKIE = 'GU_AF1'; +} from './cookies/adFree'; +import { + getHideSupportMessagingCookie, + removeHideSupportMessagingCookie, + setHideSupportMessagingCookie, +} from './cookies/hideSupportMessaging'; +import { + featuresDataIsOld, + getUserFeaturesExpiryCookie, + removeUserFeaturesExpiryCookie, + setUserFeaturesExpiryCookie, +} from './cookies/userFeaturesExpiry'; +import { syncDataFromMembersDataApi } from './membersDataApi'; -export const forcedAdFreeMode = !!/[#&]noadsaf(&.*)?$/.exec( - window.location.hash, -); +export type UserBenefits = { + adFree: boolean; + hideSupportMessaging: boolean; +}; const userHasData = () => { const cookie = getAdFreeCookie() ?? - getCookie({ name: USER_FEATURES_EXPIRY_COOKIE }) ?? - getCookie({ name: HIDE_SUPPORT_MESSAGING_COOKIE }); + getUserFeaturesExpiryCookie() ?? + getHideSupportMessagingCookie(); return !!cookie; }; -const persistResponse = (userBenefitsResponse: UserBenefits) => { - setCookie({ - name: USER_FEATURES_EXPIRY_COOKIE, - value: timeInDaysFromNow(1), - }); - setCookie({ - name: HIDE_SUPPORT_MESSAGING_COOKIE, - value: String(userBenefitsResponse.hideSupportMessaging), - }); - if (userBenefitsResponse.adFree) { - setAdFreeCookie(2); - } else if (adFreeDataIsPresent() && !forcedAdFreeMode) { - removeCookie({ name: AD_FREE_USER_COOKIE }); - } -}; +const userHasDataAfterSignOut = async (): Promise => + !(await isUserLoggedInOktaRefactor()) && userHasData(); -const deleteOldData = (): void => { - removeCookie({ name: AD_FREE_USER_COOKIE }); - removeCookie({ name: USER_FEATURES_EXPIRY_COOKIE }); - removeCookie({ name: HIDE_SUPPORT_MESSAGING_COOKIE }); +export const forcedAdFreeMode = !!/[#&]noadsaf(&.*)?$/.exec( + window.location.hash, +); +const refresh = async (): Promise => { + if ((await isUserLoggedInOktaRefactor()) && featuresDataIsOld()) { + return requestNewData(); + } else if ((await userHasDataAfterSignOut()) && !forcedAdFreeMode) { + deleteAllCookies(); + } + return Promise.resolve(); }; const requestNewData = () => { @@ -69,19 +67,27 @@ const requestNewData = () => { }); }; -const featuresDataIsOld = () => - cookieIsExpiredOrMissing(USER_FEATURES_EXPIRY_COOKIE); +const timeInDaysFromNow = (daysFromNow: number): string => { + const tmpDate = new Date(); + tmpDate.setDate(tmpDate.getDate() + daysFromNow); + return tmpDate.getTime().toString(); +}; -const userHasDataAfterSignout = async (): Promise => - !(await isUserLoggedInOktaRefactor()) && userHasData(); +const persistResponse = (userBenefitsResponse: UserBenefits) => { + setUserFeaturesExpiryCookie(timeInDaysFromNow(1)); + setHideSupportMessagingCookie(userBenefitsResponse.hideSupportMessaging); -const refresh = async (): Promise => { - if ((await isUserLoggedInOktaRefactor()) && featuresDataIsOld()) { - return requestNewData(); - } else if ((await userHasDataAfterSignout()) && !forcedAdFreeMode) { - deleteOldData(); + if (userBenefitsResponse.adFree) { + setAdFreeCookie(2); + } else if (adFreeDataIsPresent() && !forcedAdFreeMode) { + removeAdFreeCookie(); } - return Promise.resolve(); +}; + +export const deleteAllCookies = (): void => { + removeAdFreeCookie(); + removeHideSupportMessagingCookie(); + removeUserFeaturesExpiryCookie(); }; export { refresh }; diff --git a/dotcom-rendering/src/client/userFeatures/userBenefitsApi.ts b/dotcom-rendering/src/client/userFeatures/userBenefitsApi.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/dotcom-rendering/src/experiments/ab-tests.ts b/dotcom-rendering/src/experiments/ab-tests.ts index e9ca399e64a..7a9613eb586 100644 --- a/dotcom-rendering/src/experiments/ab-tests.ts +++ b/dotcom-rendering/src/experiments/ab-tests.ts @@ -7,7 +7,6 @@ import { mpuWhenNoEpic } from './tests/mpu-when-no-epic'; import { optimiseSpacefinderInline } from './tests/optimise-spacefinder-inline'; import { signInGateMainControl } from './tests/sign-in-gate-main-control'; import { signInGateMainVariant } from './tests/sign-in-gate-main-variant'; -import { userBenefitsApi } from './tests/user-benefits-api'; // keep in sync with ab-tests in frontend // https://github.com/guardian/frontend/tree/main/static/src/javascripts/projects/common/modules/experiments/ab-tests.ts @@ -20,5 +19,4 @@ export const tests: ABTest[] = [ mpuWhenNoEpic, adBlockAsk, optimiseSpacefinderInline, - userBenefitsApi, ]; diff --git a/dotcom-rendering/src/experiments/tests/user-benefits-api.ts b/dotcom-rendering/src/experiments/tests/user-benefits-api.ts deleted file mode 100644 index 5ad1ecbbd3d..00000000000 --- a/dotcom-rendering/src/experiments/tests/user-benefits-api.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ABTest } from '@guardian/ab-core'; - -export const userBenefitsApi: ABTest = { - id: 'UserBenefitsApi', - start: '2020-05-20', - expiry: '2025-12-01', - author: 'Rupert Bates', - description: - 'This test is being used to roll out the user benefits API in a gradual manner', - audience: 2 / 100, // 2% - audienceOffset: 0, - successMeasure: - 'There are no new client side errors and the user benefits API copes with the load', - audienceCriteria: 'Everyone', - showForSensitive: true, - canRun: () => true, - variants: [ - { - id: 'control', - test: (): void => { - /* no-op */ - }, - }, - { - id: 'variant', - test: (): void => { - /* no-op */ - }, - }, - ], -}; diff --git a/dotcom-rendering/src/lib/useAdBlockAsk.ts b/dotcom-rendering/src/lib/useAdBlockAsk.ts index 59cdee5b28e..cabe127ff82 100644 --- a/dotcom-rendering/src/lib/useAdBlockAsk.ts +++ b/dotcom-rendering/src/lib/useAdBlockAsk.ts @@ -1,7 +1,7 @@ import { EventTimer } from '@guardian/commercial'; import { getConsentFor, onConsentChange } from '@guardian/libs'; import { useEffect, useState } from 'react'; -import { adFreeDataIsPresent } from '../client/userFeatures/user-features-lib'; +import { adFreeDataIsPresent } from '../client/userFeatures/cookies/adFree'; import { useAB } from './useAB'; import { useDetectAdBlock } from './useDetectAdBlock';