diff --git a/dotcom-rendering/src/client/userFeatures/membersDataApi.ts b/dotcom-rendering/src/client/userFeatures/membersDataApi.ts new file mode 100644 index 00000000000..66bcddbef84 --- /dev/null +++ b/dotcom-rendering/src/client/userFeatures/membersDataApi.ts @@ -0,0 +1,120 @@ +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'; + +const dates = { + 1: '01', + 2: '02', + 3: '03', + 4: '04', + 5: '05', + 6: '06', + 7: '07', + 8: '08', + 9: '09', + 10: '10', + 11: '11', + 12: '12', + 13: '13', + 14: '14', + 15: '15', + 16: '16', + 17: '17', + 18: '18', + 19: '19', + 20: '20', + 21: '21', + 22: '22', + 23: '23', + 24: '24', + 25: '25', + 26: '26', + 27: '27', + 28: '28', + 29: '29', + 30: '30', + 31: '31', +} as const; + +const months = { + 1: '01', + 2: '02', + 3: '03', + 4: '04', + 5: '05', + 6: '06', + 7: '07', + 8: '08', + 9: '09', + 10: '10', + 11: '11', + 12: '12', +} as const; + +type LocalDate = + `${number}-${(typeof months)[keyof typeof months]}-${(typeof dates)[keyof typeof dates]}`; + +/** + * This type is manually kept in sync with the Membership API: + * https://github.com/guardian/members-data-api/blob/a48acdebed6a334ceb4336ece275b9cf9b3d6bb7/membership-attribute-service/app/models/Attributes.scala#L134-L151 + */ +type UserFeaturesResponse = { + userId: string; + tier?: string; + recurringContributionPaymentPlan?: string; + oneOffContributionDate?: LocalDate; + membershipJoinDate?: LocalDate; + digitalSubscriptionExpiryDate?: LocalDate; + paperSubscriptionExpiryDate?: LocalDate; + guardianWeeklyExpiryDate?: LocalDate; + liveAppSubscriptionExpiryDate?: LocalDate; + alertAvailableFor?: string; + showSupportMessaging: boolean; + contentAccess: { + member: boolean; + paidMember: boolean; + recurringContributor: boolean; + digitalPack: boolean; + paperSubscriber: boolean; + guardianWeeklySubscriber: boolean; + }; +}; + +export const syncDataFromMembersDataApi: ( + signedInAuthStatus: SignedInWithOkta | SignedInWithCookies, +) => Promise = async ( + signedInAuthStatus: SignedInWithOkta | SignedInWithCookies, +) => { + const response = await fetchJson( + `${ + window.guardian.config.page.userAttributesApiUrl ?? + '/USER_ATTRIBUTE_API_NOT_FOUND' + }/me`, + { + mode: 'cors', + ...getOptionsHeadersWithOkta(signedInAuthStatus), + }, + ); + if (!validateResponse(response)) { + throw new Error('invalid response'); + } + return { + hideSupportMessaging: !response.showSupportMessaging, + adFree: response.contentAccess.digitalPack, + }; +}; + +const validateResponse = ( + response: unknown, +): response is UserFeaturesResponse => { + return ( + isObject(response) && + isBoolean(response.showSupportMessaging) && + isObject(response.contentAccess) && + isBoolean(response.contentAccess.paidMember) && + isBoolean(response.contentAccess.recurringContributor) && + isBoolean(response.contentAccess.digitalPack) + ); +}; diff --git a/dotcom-rendering/src/client/userFeatures/user-features-lib.ts b/dotcom-rendering/src/client/userFeatures/user-features-lib.ts index 542c19ee190..3d796bca950 100644 --- a/dotcom-rendering/src/client/userFeatures/user-features-lib.ts +++ b/dotcom-rendering/src/client/userFeatures/user-features-lib.ts @@ -58,10 +58,6 @@ const fetchJson = async ( resource: string, init: RequestInit = {}, ): Promise => { - if (typeof resource !== 'string') { - throw new Error('First argument should be of type `string`'); - } - let path = resource; if (isPathAbsoluteURL(path)) { path = window.guardian.config.page.ajaxUrl + resource; @@ -86,86 +82,11 @@ const fetchJson = async ( throw new Error(`Fetch error while requesting ${path}: ${resp.statusText}`); }; -const dates = { - 1: '01', - 2: '02', - 3: '03', - 4: '04', - 5: '05', - 6: '06', - 7: '07', - 8: '08', - 9: '09', - 10: '10', - 11: '11', - 12: '12', - 13: '13', - 14: '14', - 15: '15', - 16: '16', - 17: '17', - 18: '18', - 19: '19', - 20: '20', - 21: '21', - 22: '22', - 23: '23', - 24: '24', - 25: '25', - 26: '26', - 27: '27', - 28: '28', - 29: '29', - 30: '30', - 31: '31', -} as const; - -const months = { - 1: '01', - 2: '02', - 3: '03', - 4: '04', - 5: '05', - 6: '06', - 7: '07', - 8: '08', - 9: '09', - 10: '10', - 11: '11', - 12: '12', -} as const; - -type LocalDate = - `${number}-${(typeof months)[keyof typeof months]}-${(typeof dates)[keyof typeof dates]}`; - -/** - * This type is manually kept in sync with the Membership API: - * https://github.com/guardian/members-data-api/blob/a48acdebed6a334ceb4336ece275b9cf9b3d6bb7/membership-attribute-service/app/models/Attributes.scala#L134-L151 - */ -type UserFeaturesResponse = { - userId: string; - tier?: string; - recurringContributionPaymentPlan?: string; - oneOffContributionDate?: LocalDate; - membershipJoinDate?: LocalDate; - digitalSubscriptionExpiryDate?: LocalDate; - paperSubscriptionExpiryDate?: LocalDate; - guardianWeeklyExpiryDate?: LocalDate; - liveAppSubscriptionExpiryDate?: LocalDate; - alertAvailableFor?: string; - showSupportMessaging: boolean; - contentAccess: { - member: boolean; - paidMember: boolean; - recurringContributor: boolean; - digitalPack: boolean; - paperSubscriber: boolean; - guardianWeeklySubscriber: boolean; - }; +export type UserBenefits = { + adFree: boolean; + hideSupportMessaging: boolean; }; -export type { UserFeaturesResponse }; - export { adFreeDataIsPresent, cookieIsExpiredOrMissing, diff --git a/dotcom-rendering/src/client/userFeatures/user-features.test.ts b/dotcom-rendering/src/client/userFeatures/user-features.test.ts index fc58d6b6108..e258998622a 100644 --- a/dotcom-rendering/src/client/userFeatures/user-features.test.ts +++ b/dotcom-rendering/src/client/userFeatures/user-features.test.ts @@ -7,12 +7,23 @@ import { import { refresh } from './user-features'; import { fetchJson } from './user-features-lib'; +const fakeUserFeatures = { + showSupportMessaging: false, + contentAccess: { + digitalPack: true, + recurringContributor: false, + paidMember: true, + }, +}; + jest.mock('./user-features-lib', () => { // Only mock the fetchJson function, rather than the whole module const original = jest.requireActual('./user-features-lib'); return { ...original, - fetchJson: jest.fn(() => Promise.resolve()), + fetchJson: jest.fn(() => { + return Promise.resolve(fakeUserFeatures); + }), }; }); @@ -81,7 +92,7 @@ describe('Refreshing the features data', () => { getAuthStatus.mockResolvedValue({ kind: 'SignedInWithOkta', } as AuthStatus); - fetchJsonSpy.mockReturnValue(Promise.resolve()); + fetchJsonSpy.mockReturnValue(Promise.resolve(fakeUserFeatures)); }); it('Performs an update if the user has missing data', async () => { diff --git a/dotcom-rendering/src/client/userFeatures/user-features.ts b/dotcom-rendering/src/client/userFeatures/user-features.ts index 7b40852dd97..a17b8ef454d 100644 --- a/dotcom-rendering/src/client/userFeatures/user-features.ts +++ b/dotcom-rendering/src/client/userFeatures/user-features.ts @@ -4,33 +4,25 @@ * https://github.com/guardian/commercial/blob/1a429d6be05657f20df4ca909df7d01a5c3d7402/src/lib/user-features.ts */ -import { - getCookie, - isBoolean, - isObject, - removeCookie, - setCookie, -} from '@guardian/libs'; -import { - getAuthStatus, - getOptionsHeadersWithOkta, - isUserLoggedInOktaRefactor, -} from '../../lib/identity'; +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, - fetchJson, getAdFreeCookie, setAdFreeCookie, timeInDaysFromNow, } from './user-features-lib'; -import type { UserFeaturesResponse } from './user-features-lib'; -const USER_FEATURES_EXPIRY_COOKIE = 'gu_user_features_expiry'; -const HIDE_SUPPORT_MESSAGING_COOKIE = 'gu_hide_support_messaging'; -const AD_FREE_USER_COOKIE = 'GU_AF1'; +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'; -const forcedAdFreeMode = !!/[#&]noadsaf(&.*)?$/.exec(window.location.hash); +export const forcedAdFreeMode = !!/[#&]noadsaf(&.*)?$/.exec( + window.location.hash, +); const userHasData = () => { const cookie = @@ -39,31 +31,17 @@ const userHasData = () => { getCookie({ name: HIDE_SUPPORT_MESSAGING_COOKIE }); return !!cookie; }; - -const validateResponse = ( - response: unknown, -): response is UserFeaturesResponse => { - return ( - isObject(response) && - isBoolean(response.showSupportMessaging) && - isObject(response.contentAccess) && - isBoolean(response.contentAccess.paidMember) && - isBoolean(response.contentAccess.recurringContributor) && - isBoolean(response.contentAccess.digitalPack) - ); -}; - -const persistResponse = (JsonResponse: UserFeaturesResponse) => { +const persistResponse = (userBenefitsResponse: UserBenefits) => { setCookie({ name: USER_FEATURES_EXPIRY_COOKIE, value: timeInDaysFromNow(1), }); setCookie({ name: HIDE_SUPPORT_MESSAGING_COOKIE, - value: String(!JsonResponse.showSupportMessaging), + value: String(userBenefitsResponse.hideSupportMessaging), }); - if (JsonResponse.contentAccess.digitalPack) { + if (userBenefitsResponse.adFree) { setAdFreeCookie(2); } else if (adFreeDataIsPresent() && !forcedAdFreeMode) { removeCookie({ name: AD_FREE_USER_COOKIE }); @@ -85,27 +63,9 @@ const requestNewData = () => { : Promise.reject('The user is not signed in'), ) .then((signedInAuthStatus) => { - return fetchJson( - `${ - window.guardian.config.page.userAttributesApiUrl ?? - '/USER_ATTRIBUTE_API_NOT_FOUND' - }/me`, - { - mode: 'cors', - ...getOptionsHeadersWithOkta(signedInAuthStatus), - }, - ) - .then((response) => { - if (!validateResponse(response)) { - throw new Error('invalid response'); - } - return response; - }) - .then(persistResponse) - .catch(() => { - // eslint-disable-next-line no-console -- error logging - console.error('Error fetching user data'); - }); + return syncDataFromMembersDataApi(signedInAuthStatus).then( + persistResponse, + ); }); }; diff --git a/dotcom-rendering/src/client/userFeatures/userBenefitsApi.ts b/dotcom-rendering/src/client/userFeatures/userBenefitsApi.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dotcom-rendering/src/experiments/ab-tests.ts b/dotcom-rendering/src/experiments/ab-tests.ts index 7a9613eb586..e9ca399e64a 100644 --- a/dotcom-rendering/src/experiments/ab-tests.ts +++ b/dotcom-rendering/src/experiments/ab-tests.ts @@ -7,6 +7,7 @@ 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 @@ -19,4 +20,5 @@ 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 new file mode 100644 index 00000000000..5ad1ecbbd3d --- /dev/null +++ b/dotcom-rendering/src/experiments/tests/user-benefits-api.ts @@ -0,0 +1,31 @@ +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 */ + }, + }, + ], +};