diff --git a/components/x-privacy-manager/src/__tests__/helpers.js b/components/x-privacy-manager/src/__tests__/helpers.js index b68fae2ab..5ed83eec2 100644 --- a/components/x-privacy-manager/src/__tests__/helpers.js +++ b/components/x-privacy-manager/src/__tests__/helpers.js @@ -1,3 +1,7 @@ +/** + * @typedef {import('../../typings/x-privacy-manager').BasePrivacyManagerProps} BasePrivacyManagerProps + */ + const React = require('react') // eslint-disable-next-line no-unused-vars @@ -10,7 +14,6 @@ export const CONSENT_PROXY_HOST = 'https://consent.ft.com' export const CONSENT_PROXY_ENDPOINT = 'https://consent.ft.com/__consent/consent-record/FTPINK/abcde' /** - * * @param {{ * setConsentCookie: boolean, * consent: boolean @@ -18,41 +21,28 @@ export const CONSENT_PROXY_ENDPOINT = 'https://consent.ft.com/__consent/consent- * @returns */ export const buildPayload = ({ setConsentCookie, consent }) => { + const categoryPayload = { + onsite: { + fow: 'privacyCCPA/H0IeyQBalorD.6nTqqzhNTKECSgOPJCG', + lbi: true, + source: 'consuming-app', + status: consent + } + } return { setConsentCookie, consentSource: 'consuming-app', data: { - behaviouralAds: { - onsite: { - fow: 'privacyCCPA/H0IeyQBalorD.6nTqqzhNTKECSgOPJCG', - lbi: true, - source: 'consuming-app', - status: consent - } - }, - demographicAds: { - onsite: { - fow: 'privacyCCPA/H0IeyQBalorD.6nTqqzhNTKECSgOPJCG', - lbi: true, - source: 'consuming-app', - status: consent - } - }, - programmaticAds: { - onsite: { - fow: 'privacyCCPA/H0IeyQBalorD.6nTqqzhNTKECSgOPJCG', - lbi: true, - source: 'consuming-app', - status: consent - } - } + behaviouralAds: categoryPayload, + demographicAds: categoryPayload, + programmaticAds: categoryPayload }, cookieDomain: '.ft.com', formOfWordsId: 'privacyCCPA' } } -/** @type {XPrivacyManager.BasePrivacyManagerProps} */ +/** @type {BasePrivacyManagerProps} */ export const defaultProps = { userId: 'abcde', legislationId: 'ccpa', @@ -75,11 +65,11 @@ export const defaultProps = { * - Handlers for submit events * - Post-submission callbacks * - * @param {Partial} propOverrides + * @param {Partial} propOverrides * * @returns {{ * subject: ReactWrapper, React.Component<{}, {}, any>>; - * callbacks: XPrivacyManager.OnSaveCallback[] | jest.Mock[]; + * callbacks: OnSaveCallback[] | jest.Mock[]; * submitConsent(value: boolean): Promise; * }} */ diff --git a/components/x-privacy-manager/src/__tests__/state.test.jsx b/components/x-privacy-manager/src/__tests__/state.test.jsx index 00f60de77..08d2c7db2 100644 --- a/components/x-privacy-manager/src/__tests__/state.test.jsx +++ b/components/x-privacy-manager/src/__tests__/state.test.jsx @@ -39,9 +39,9 @@ describe('x-privacy-manager', () => { expect(callbacks[0]).toHaveBeenCalledWith(null, { payload: expectedPayload, consent: true }) expect(callbacks[1]).toHaveBeenCalledWith(null, { payload: expectedPayload, consent: true }) - // Verify that confimatory nmessage is displayed - const message = subject.find('[data-o-component="o-message"]').first() - const link = message.find('[data-component="redirect-link"]') + // Verify that confimatory message is displayed + const message = await subject.find('[data-o-component="o-message"]').first() + const link = await message.find('[data-component="redirect-link"]') expect(message).toHaveClassName('o-message--success') expect(link).toHaveProp('href', '/') expect(optInInput).toHaveProp('checked', true) diff --git a/components/x-privacy-manager/src/__tests__/utils.test.js b/components/x-privacy-manager/src/__tests__/utils.test.js index 306250c23..8b80e43fa 100644 --- a/components/x-privacy-manager/src/__tests__/utils.test.js +++ b/components/x-privacy-manager/src/__tests__/utils.test.js @@ -1,4 +1,40 @@ -const { getTrackingKeys, getConsentProxyEndpoints } = require('../utils') +const { getTrackingKeys, getConsentProxyEndpoints, getPayload, onConsentSavedFn } = require('../utils') + +const fixtures = { + fow: { id: 'xxxx', version: 1 }, + consentSource: 'test-source', + getCategoryPayload(status) { + return { + onsite: { + lbi: true, + status, + source: fixtures.consentSource, + fow: `${fixtures.fow.id}/${fixtures.fow.version}` + } + } + } +} + +function getExpectedPayload({ consent }) { + const input = { + consent, + consentSource: fixtures.consentSource, + fow: fixtures.fow, + setConsentCookie: true + } + + const expectedCategoryPayload = fixtures.getCategoryPayload(input.consent) + return { + setConsentCookie: input.setConsentCookie, + formOfWordsId: fixtures.fow.id, + consentSource: fixtures.consentSource, + data: { + behaviouralAds: expectedCategoryPayload, + demographicAds: expectedCategoryPayload, + programmaticAds: expectedCategoryPayload + } + } +} describe('getTrackingKeys', () => { it('Creates legislation-specific tracking event names', () => { @@ -45,4 +81,53 @@ describe('getConsentProxyEndpoints', () => { createOrUpdateRecord: defaultEndpoint }) }) + + it('generates a payload', () => { + const input = { + fow: fixtures.fow, + consentSource: fixtures.consentSource, + consent: true, + setConsentCookie: false + } + + const expectedCategoryPayload = fixtures.getCategoryPayload(input.consent) + + const expected = { + setConsentCookie: input.setConsentCookie, + formOfWordsId: fixtures.fow.id, + consentSource: fixtures.consentSource, + data: { + behaviouralAds: expectedCategoryPayload, + demographicAds: expectedCategoryPayload, + programmaticAds: expectedCategoryPayload + } + } + + expect(getPayload(input)).toEqual(expected) + }) + + describe('Runs callbacks with user input', () => { + test.each([ + ['{ payload: null }', { consent: false, payload: null }], + ['{ consent: true }', { consent: true, payload: getExpectedPayload({ consent: true }) }], + ['{ consent: false }', { consent: false, payload: getExpectedPayload({ consent: false }) }], + [ + "{ err: 'error', ok: false }", + { consent: true, err: 'error', ok: false, payload: getExpectedPayload({ consent: true }) } + ] + ])('onConsentSaved(%s)', (_label, input) => { + const fnA = jest.fn() + const fnB = jest.fn() + const fnC = jest.fn() + const onConsentSaved = onConsentSavedFn([fnA, fnB, fnC]) + + const { consent, payload, err = null, ok = true } = input + const { _response } = onConsentSaved(input) + + expect(fnA).toHaveBeenCalledWith(err, { consent, payload }) + expect(fnB).toHaveBeenCalledWith(err, { consent, payload }) + expect(fnC).toHaveBeenCalledWith(err, { consent, payload }) + expect(_response).toEqual({ ok }) + }) + }) }) diff --git a/components/x-privacy-manager/src/actions.js b/components/x-privacy-manager/src/actions.js index 3b67172cd..078bf059e 100644 --- a/components/x-privacy-manager/src/actions.js +++ b/components/x-privacy-manager/src/actions.js @@ -1,4 +1,10 @@ +/** + * @typedef {import('../typings/x-privacy-manager').SendConsentProps} SendConsentProps + * @typedef {import('../typings/x-privacy-manager').SendConsentResponse} SendConsentResponse + */ + import { withActions } from '@financial-times/x-interaction' +import { getPayload } from './utils' function onConsentChange(consent) { return () => ({ consent }) @@ -9,41 +15,24 @@ function onConsentChange(consent) { * - consentSource: (e.g. 'next-control-centre') * - cookieDomain: (e.g. '.thebanker.com') * - * @param {XPrivacyManager.SendConsentProps} args - * @returns {({ isLoading, consent }: { isLoading: boolean, consent: boolean }) => Promise<{_response: _Response}>} + * @param {SendConsentProps} props + * @returns {SendConsentResponse} */ -function sendConsent({ - setConsentCookie, - consentApiUrl, - onConsentSavedCallbacks, - consentSource, - cookieDomain, - fow -}) { +function sendConsent({ setConsentCookie, consentApiUrl, onConsentSaved, consentSource, cookieDomain, fow }) { let res return async ({ isLoading, consent }) => { if (isLoading) return - const categoryPayload = { - onsite: { - status: consent, - lbi: true, - source: consentSource, - fow: `${fow.id}/${fow.version}` - } + /** + * FoW will be undefined if a user is anonymous (i.e. not logged in) + * In this case there is no need to send anything to the ConsentProxy + */ + if (typeof fow === 'undefined') { + return onConsentSaved({ consent }) } - const payload = { - setConsentCookie, - formOfWordsId: fow.id, - consentSource, - data: { - behaviouralAds: categoryPayload, - demographicAds: categoryPayload, - programmaticAds: categoryPayload - } - } + const payload = getPayload({ fow, consent, consentSource, setConsentCookie }) if (cookieDomain) { // Optionally specify the domain for the cookie to set on the Consent API @@ -53,33 +42,18 @@ function sendConsent({ try { res = await fetch(consentApiUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload), - credentials: 'include' + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) }) - // On response call any externally defined handlers following Node's convention: - // 1. Either an error object or `null` as the first argument - // 2. An object containing `consent` and `payload` as the second - // Allows callbacks to decide how to handle a failure scenario - if (res.ok === false) { throw new Error(res.statusText || String(res.status)) } - for (const fn of onConsentSavedCallbacks) { - fn(null, { consent, payload }) - } - - return { _response: { ok: true } } + return onConsentSaved({ consent, payload }) } catch (err) { - for (const fn of onConsentSavedCallbacks) { - fn(err, { consent, payload }) - } - - return { _response: { ok: false } } + return onConsentSaved({ consent, payload, err, ok: false }) } } } diff --git a/components/x-privacy-manager/src/privacy-manager.jsx b/components/x-privacy-manager/src/privacy-manager.jsx index 72ef1abbf..5f2e44017 100644 --- a/components/x-privacy-manager/src/privacy-manager.jsx +++ b/components/x-privacy-manager/src/privacy-manager.jsx @@ -1,3 +1,8 @@ +/** + * @typedef {import('../typings/x-privacy-manager').BasePrivacyManagerProps} BasePrivacyManagerProps + * @typedef {import('../typings/x-privacy-manager').FormProps} FormProps + */ + import { h } from '@financial-times/x-engine' import { renderLoggedOutWarning, renderMessage } from './components/messages' @@ -13,7 +18,7 @@ const defaultButtonText = { } /** - * @param {XPrivacyManager.BasePrivacyManagerProps} Props + * @param {BasePrivacyManagerProps} Props */ export function BasePrivacyManager({ userId, @@ -58,7 +63,9 @@ export function BasePrivacyManager({ onChange: onConsentChange }) - /** @type {XPrivacyManager.FormProps} */ + const onConsentSaved = utils.onConsentSavedFn(onConsentSavedCallbacks) + + /** @type {FormProps} */ const formProps = { consent, consentApiUrl, @@ -68,7 +75,7 @@ export function BasePrivacyManager({ return sendConsent({ setConsentCookie: legislationId === 'gdpr', consentApiUrl, - onConsentSavedCallbacks, + onConsentSaved, consentSource, cookieDomain, fow diff --git a/components/x-privacy-manager/src/utils.js b/components/x-privacy-manager/src/utils.js index 01f760fba..b5fff9da9 100644 --- a/components/x-privacy-manager/src/utils.js +++ b/components/x-privacy-manager/src/utils.js @@ -1,5 +1,9 @@ -/** @type {XPrivacyManager.TrackingKey[]} */ -const trackingKeys = [ +/** + * @typedef {import("../typings/x-privacy-manager").TrackingKey} TrackingKey + * @typedef {import("../typings/x-privacy-manager").TrackingKeys} TrackingKeys + */ + +export const trackingKeys = /** @type {const} */ [ 'advertising-toggle-block', 'advertising-toggle-allow', 'consent-allow', @@ -12,7 +16,7 @@ const trackingKeys = [ * * @param {string} legislationId * - * @returns {XPrivacyManager.TrackingKeys} + * @returns {TrackingKeys} */ export function getTrackingKeys(legislationId) { /** @type Record */ @@ -25,14 +29,13 @@ export function getTrackingKeys(legislationId) { } /** - * @param {{ - * userId: string; - * consentProxyApiHost: string; - * cookiesOnly?: boolean; - * cookieDomain?: string; - * }} param + * @param {Object} props + * @param {string} props.userId + * @param {string} props.consentProxyApiHost + * @param {boolean} [props.cookiesOnly] + * @param {string} [props.cookieDomain] * - * @returns {XPrivacyManager.ConsentProxyEndpoint} + * @returns {ConsentProxyEndpoint} */ export function getConsentProxyEndpoints({ userId, @@ -64,3 +67,52 @@ export function getConsentProxyEndpoints({ createOrUpdateRecord: endpointDefault } } + +/** + * + * @param {Object} props + * @param {FoWConfig} props.fow + * @param {boolean} props.consent + * @param {string} props.consentSource + * @param {boolean} props.setConsentCookie + * @returns + */ +export function getPayload({ fow, consent, consentSource, setConsentCookie }) { + const categoryPayload = { + onsite: { + status: consent, + lbi: true, + source: consentSource, + fow: `${fow.id}/${fow.version}` + } + } + + return { + setConsentCookie, + formOfWordsId: fow.id, + consentSource, + data: { + behaviouralAds: categoryPayload, + demographicAds: categoryPayload, + programmaticAds: categoryPayload + } + } +} + +/** + * On response call any externally defined handlers following Node's convention: + * 1. Either an error object or `null` as the first argument + * 2. An object containing `consent` and `payload` as the second + * Allows callbacks to decide how to handle a failure scenario + * + * @param {ConsentSavedCallback[]} onConsentSavedCallbacks + */ +export function onConsentSavedFn(onConsentSavedCallbacks) { + return function onConsentSaved({ consent, payload = null, err = null, ok = true }) { + for (const fn of onConsentSavedCallbacks) { + fn(err, { consent, payload }) + } + + return { _response: { ok } } + } +} diff --git a/components/x-privacy-manager/typings/x-privacy-manager.d.ts b/components/x-privacy-manager/typings/x-privacy-manager.d.ts index 5f088401c..5f2670688 100644 --- a/components/x-privacy-manager/typings/x-privacy-manager.d.ts +++ b/components/x-privacy-manager/typings/x-privacy-manager.d.ts @@ -1,14 +1,10 @@ import * as React from 'react' +import { trackingKeys } from '../src/utils' export type ConsentProxyEndpoint = Record<'core' | 'enhanced' | 'createOrUpdateRecord', string> export type ConsentProxyEndpoints = Partial<{ [key in keyof ConsentProxyEndpoint]: string }> -export type TrackingKey = - | 'advertising-toggle-allow' - | 'advertising-toggle-block' - | 'consent-allow' - | 'consent-block' - +export type TrackingKey = typeof trackingKeys[number] export type TrackingKeys = Record export interface CategoryPayload { @@ -29,17 +25,34 @@ interface ConsentPayload { data: ConsentData } -export type OnSaveCallback = (err: null | Error, data: { consent: boolean; payload: ConsentPayload }) => void +export type ConsentSavedCallback = ( + err: Error | null, + data: { consent: boolean; payload: ConsentPayload | null } +) => void + +export interface ConsentSavedProps { + consent: boolean + payload: ConsentPayload | null + err: Error | null + ok: boolean +} export interface SendConsentProps { - setConsentCookie: boolean consentApiUrl: string - onConsentSavedCallbacks: OnSaveCallback[] consentSource: string cookieDomain: string fow: FoWConfig + setConsentCookie: boolean + onConsentSaved: (props: ConsentSavedProps) => _Response } +interface ConsentResponseProps { + isLoading: boolean + consent: boolean +} + +export type SendConsentResponse = (props: ConsentResponseProps) => Promise<{ _response: _Response }> + export interface _Response { ok: boolean status?: number @@ -95,7 +108,3 @@ export interface FormProps { buttonText: ButtonText children: React.ReactElement } - -export { PrivacyManager } from '../src/privacy-manager' - -export as namespace XPrivacyManager