From dd43dba8de03990e14a1be803c29e1ec42d7a395 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Fri, 18 Aug 2023 17:38:44 -0500 Subject: [PATCH 01/20] add consent changed event --- .changeset/mean-geese-wash.md | 6 ++ .../package.json | 2 +- .../src/page-bundles/onetrust/index.ts | 2 + .../domain/__tests__/create-wrapper.test.ts | 74 ++++++++++++++++++- .../src/domain/consent-changed.ts | 36 +++++++++ .../src/domain/create-wrapper.ts | 19 ++++- .../__tests__/options-validators.test.ts | 34 ++++++--- .../domain/validation/options-validators.ts | 8 +- .../consent/consent-tools/src/types/errors.ts | 4 +- .../consent-tools/src/types/settings.ts | 30 ++++++++ .../consent-tools/src/types/wrapper.ts | 1 + .../consent-wrapper-onetrust/package.json | 2 + .../src/domain/__tests__/wrapper.test.ts | 66 ++++++++++++----- .../src/domain/wrapper.ts | 56 +++++--------- .../src/lib/__tests__/onetrust-api.test.ts | 11 +-- .../src/lib/onetrust-api.ts | 64 ++++++++++++++-- .../src/test-helpers/mocks.ts | 16 ++++ .../onetrust-globals.d.ts | 2 +- .../src/test-helpers/utils.ts | 3 + .../tsconfig.build.json | 2 +- 20 files changed, 350 insertions(+), 88 deletions(-) create mode 100644 .changeset/mean-geese-wash.md create mode 100644 packages/consent/consent-tools/src/domain/consent-changed.ts create mode 100644 packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts rename packages/consent/consent-wrapper-onetrust/src/{lib/__tests__ => test-helpers}/onetrust-globals.d.ts (83%) create mode 100644 packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts diff --git a/.changeset/mean-geese-wash.md b/.changeset/mean-geese-wash.md new file mode 100644 index 000000000..989b12884 --- /dev/null +++ b/.changeset/mean-geese-wash.md @@ -0,0 +1,6 @@ +--- +'@segment/analytics-consent-tools': minor +'@segment/analytics-consent-wrapper-onetrust': minor +--- + +Add consent changed event diff --git a/packages/consent/consent-tools-integration-tests/package.json b/packages/consent/consent-tools-integration-tests/package.json index bec31854e..3d3b8d9db 100644 --- a/packages/consent/consent-tools-integration-tests/package.json +++ b/packages/consent/consent-tools-integration-tests/package.json @@ -2,7 +2,7 @@ "name": "@internal/consent-tools-integration-tests", "private": true, "scripts": { - ".": "yarn -T turbo run --filter=@internal/consent-tools-integration-tests", + ".": "yarn run -T turbo run --filter=@internal/consent-tools-integration-tests...", "dev": "yarn concurrently 'yarn watch' 'yarn build serve --open'", "build": "webpack", "watch": "yarn build --watch", diff --git a/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts b/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts index 7a0b2be2f..295771012 100644 --- a/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts +++ b/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts @@ -4,11 +4,13 @@ import { oneTrust } from '@segment/analytics-consent-wrapper-onetrust' export const analytics = new AnalyticsBrowser() oneTrust(analytics, { + disableConsentChangedEvent: false, integrationCategoryMappings: { Fullstory: ['C0001'], 'Actions Amplitude': ['C0004'], }, }) +console.log('loaded?') ;(window as any).analytics = analytics analytics.load({ writeKey: '9lSrez3BlfLAJ7NOChrqWtILiATiycoc' }) diff --git a/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts b/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts index f7071f3af..d3ec7ee2c 100644 --- a/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts +++ b/packages/consent/consent-tools/src/domain/__tests__/create-wrapper.test.ts @@ -1,4 +1,5 @@ import * as ConsentStamping from '../consent-stamping' +import * as ConsentChanged from '../consent-changed' import { createWrapper } from '../create-wrapper' import { AbortLoadError, LoadContext } from '../load-cancellation' import type { @@ -6,6 +7,7 @@ import type { AnyAnalytics, CDNSettings, AnalyticsBrowserSettings, + Categories, } from '../../types' import { CDNSettingsBuilder } from '@internal/test-helpers' import { assertIntegrationsContainOnly } from './assertions/integrations-assertions' @@ -29,6 +31,7 @@ const mockGetCategories: jest.MockedFn = const analyticsLoadSpy: jest.MockedFn = jest.fn() const addSourceMiddlewareSpy = jest.fn() let analyticsOnSpy: jest.MockedFn +const analyticsTrackSpy: jest.MockedFn = jest.fn() let consoleErrorSpy: jest.SpiedFunction const getAnalyticsLoadLastCall = () => { @@ -61,6 +64,7 @@ beforeEach(() => { }) class MockAnalytics implements AnyAnalytics { + track = analyticsTrackSpy on = analyticsOnSpy load = analyticsLoadSpy addSourceMiddleware = addSourceMiddlewareSpy @@ -300,8 +304,16 @@ describe(createWrapper, () => { ) }) - describe('Validation', () => { - it('should throw an error if categories are in the wrong format', async () => { + describe('Settings Validation', () => { + /* NOTE: This test suite is meant to be minimal -- please see validation/__tests__ */ + + test('createWrapper should throw if user-defined settings/configuration/options are invalid', () => { + expect(() => + wrapTestAnalytics({ getCategories: {} as any }) + ).toThrowError(/validation/i) + }) + + test('analytics.load should reject if categories are in the wrong format', async () => { wrapTestAnalytics({ shouldLoad: () => Promise.resolve('sup' as any), }) @@ -310,7 +322,7 @@ describe(createWrapper, () => { ) }) - it('should throw an error if categories are undefined', async () => { + test('analytics.load should reject if categories are undefined', async () => { wrapTestAnalytics({ getCategories: () => undefined as any, shouldLoad: () => undefined, @@ -736,4 +748,60 @@ describe(createWrapper, () => { }) }) }) + + describe('registerOnConsentChanged', () => { + const sendConsentChangedEventSpy = jest.spyOn( + ConsentChanged, + 'sendConsentChangedEvent' + ) + + let categoriesChangedCb: (categories: Categories) => void = () => { + throw new Error('Not implemented') + } + + const registerOnConsentChanged = jest.fn( + (consentChangedCb: (c: Categories) => void) => { + // simulate a OneTrust.onConsentChanged event callback + categoriesChangedCb = jest.fn((categories: Categories) => + consentChangedCb(categories) + ) + } + ) + it('should expect a callback', async () => { + wrapTestAnalytics({ + registerOnConsentChanged: registerOnConsentChanged, + }) + await analytics.load(DEFAULT_LOAD_SETTINGS) + + expect(sendConsentChangedEventSpy).not.toBeCalled() + expect(registerOnConsentChanged).toBeCalledTimes(1) + categoriesChangedCb({ C0001: true, C0002: false }) + expect(registerOnConsentChanged).toBeCalledTimes(1) + expect(sendConsentChangedEventSpy).toBeCalledTimes(1) + + // if OnConsentChanged callback is called with categories, it should send event + expect(analyticsTrackSpy).toBeCalledWith( + 'Segment Consent Preference', + undefined, + { consent: { categoryPreferences: { C0001: true, C0002: false } } } + ) + }) + it('should throw an error if categories are invalid', async () => { + consoleErrorSpy.mockImplementationOnce(() => undefined) + + wrapTestAnalytics({ + registerOnConsentChanged: registerOnConsentChanged, + }) + + await analytics.load(DEFAULT_LOAD_SETTINGS) + expect(consoleErrorSpy).not.toBeCalled() + categoriesChangedCb(['OOPS'] as any) + expect(consoleErrorSpy).toBeCalledTimes(1) + const err = consoleErrorSpy.mock.lastCall[0] + expect(err.toString()).toMatch(/validation/i) + // if OnConsentChanged callback is called with categories, it should send event + expect(sendConsentChangedEventSpy).not.toBeCalled() + expect(analyticsTrackSpy).not.toBeCalled() + }) + }) }) diff --git a/packages/consent/consent-tools/src/domain/consent-changed.ts b/packages/consent/consent-tools/src/domain/consent-changed.ts new file mode 100644 index 000000000..5a67a707d --- /dev/null +++ b/packages/consent/consent-tools/src/domain/consent-changed.ts @@ -0,0 +1,36 @@ +import { AnyAnalytics, Categories } from '../types' + +/** + * Dispatch an event that looks like: + * ```ts + * { + * "type": "track", + * "event": "Segment Consent Preference", + * "context": { + * "consent": { + * "categoryPreferences" : { + * "C0001": true, + * "C0002": false, + * } + * } + * ... + * ``` + */ +export const sendConsentChangedEvent = ( + analytics: AnyAnalytics, + categories: Categories +): void => { + analytics.track( + CONSENT_CHANGED_EVENT, + undefined, + createConsentChangedCtxDto(categories) + ) +} + +const CONSENT_CHANGED_EVENT = 'Segment Consent Preference' + +const createConsentChangedCtxDto = (categories: Categories) => ({ + consent: { + categoryPreferences: categories, + }, +}) diff --git a/packages/consent/consent-tools/src/domain/create-wrapper.ts b/packages/consent/consent-tools/src/domain/create-wrapper.ts index 7e582ba1b..b62d4869a 100644 --- a/packages/consent/consent-tools/src/domain/create-wrapper.ts +++ b/packages/consent/consent-tools/src/domain/create-wrapper.ts @@ -6,14 +6,15 @@ import { CreateWrapperSettings, CDNSettings, } from '../types' -import { validateCategories, validateOptions } from './validation' +import { validateCategories, validateSettings } from './validation' import { createConsentStampingMiddleware } from './consent-stamping' import { pipe, pick, uniq } from '../utils' import { AbortLoadError, LoadContext } from './load-cancellation' import { ValidationError } from './validation/validation-error' +import { sendConsentChangedEvent } from './consent-changed' export const createWrapper: CreateWrapper = (createWrapperOptions) => { - validateOptions(createWrapperOptions) + validateSettings(createWrapperOptions) const { shouldDisableSegment, @@ -23,6 +24,7 @@ export const createWrapper: CreateWrapper = (createWrapperOptions) => { integrationCategoryMappings, shouldEnableIntegration, pruneUnmappedCategories, + registerOnConsentChanged, } = createWrapperOptions return (analytics) => { @@ -118,6 +120,19 @@ export const createWrapper: CreateWrapper = (createWrapperOptions) => { createConsentStampingMiddleware(getValidCategoriesForConsentStamping) ) + if (registerOnConsentChanged) { + // whenever consent changes, dispatch a new event with the latest consent information + registerOnConsentChanged((categories) => { + try { + validateCategories(categories) + sendConsentChangedEvent(analytics, categories) + } catch (err) { + // Not sure if there's a better way to handle this, but this makes testing a bit easier. + console.error(err) + } + }) + } + const updateCDNSettings: InitOptions['updateCDNSettings'] = ( cdnSettings ) => { diff --git a/packages/consent/consent-tools/src/domain/validation/__tests__/options-validators.test.ts b/packages/consent/consent-tools/src/domain/validation/__tests__/options-validators.test.ts index 58c21ad72..0d31a74d6 100644 --- a/packages/consent/consent-tools/src/domain/validation/__tests__/options-validators.test.ts +++ b/packages/consent/consent-tools/src/domain/validation/__tests__/options-validators.test.ts @@ -1,36 +1,52 @@ -import { validateCategories, validateOptions } from '../options-validators' +import { CreateWrapperSettings } from '../../../types' +import { validateCategories, validateSettings } from '../options-validators' import { ValidationError } from '../validation-error' -describe(validateOptions, () => { +const DEFAULT_OPTIONS: CreateWrapperSettings = { + getCategories: () => ({}), +} + +describe(validateSettings, () => { it('should throw if options is not a plain object', () => { - expect(() => validateOptions(null as any)).toThrow() - expect(() => validateOptions(undefined as any)).toThrow() - expect(() => validateOptions('hello' as any)).toThrow() + expect(() => validateSettings(null as any)).toThrow() + expect(() => validateSettings(undefined as any)).toThrow() + expect(() => validateSettings('hello' as any)).toThrow() }) it('should throw an instance of ValidationError', () => { - expect(() => validateOptions(null as any)).toThrowError(ValidationError) + expect(() => validateSettings(null as any)).toThrowError(ValidationError) }) it('should throw with the expected error', () => { expect(() => - validateOptions(null as any) + validateSettings(null as any) ).toThrowErrorMatchingInlineSnapshot( `"[Validation] Options should be an object (Received: null)"` ) }) it('should throw if required property(s) are not included', () => { - expect(() => validateOptions({} as any)).toThrow() + expect(() => validateSettings({} as any)).toThrow() }) it('should throw if getCategories() is not a function', () => { expect(() => - validateOptions({ + validateSettings({ getCategories: {}, }) ).toThrow() }) + + it('should throw if registerOnChanged() is not a function', () => { + expect(() => + validateSettings({ + ...DEFAULT_OPTIONS, + registerOnConsentChanged: {} as any, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[Validation] registerOnConsentChanged is not a function (Received: {})"` + ) + }) }) describe(validateCategories, () => { diff --git a/packages/consent/consent-tools/src/domain/validation/options-validators.ts b/packages/consent/consent-tools/src/domain/validation/options-validators.ts index 504663a27..5ef0b5a98 100644 --- a/packages/consent/consent-tools/src/domain/validation/options-validators.ts +++ b/packages/consent/consent-tools/src/domain/validation/options-validators.ts @@ -23,7 +23,7 @@ export function validateCategories( } } -export function validateOptions(options: { +export function validateSettings(options: { [k in keyof CreateWrapperSettings]: unknown }): asserts options is CreateWrapperSettings { if (typeof options !== 'object' || !options) { @@ -51,4 +51,10 @@ export function validateOptions(options: { options.integrationCategoryMappings, 'integrationCategoryMappings' ) + + options.registerOnConsentChanged && + assertIsFunction( + options.registerOnConsentChanged, + 'registerOnConsentChanged' + ) } diff --git a/packages/consent/consent-tools/src/types/errors.ts b/packages/consent/consent-tools/src/types/errors.ts index 40f9845d9..724b4065e 100644 --- a/packages/consent/consent-tools/src/types/errors.ts +++ b/packages/consent/consent-tools/src/types/errors.ts @@ -1,12 +1,12 @@ /** - * Base consent + * Base Consent Error */ export abstract class AnalyticsConsentError extends Error { /** * * @param name - Pass the name explicitly to work around the limitation that 'name' is automatically set to the parent class. * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/extends#using_extends - * @param message + * @param message - Error message */ constructor(public name: string, message: string) { super(message) diff --git a/packages/consent/consent-tools/src/types/settings.ts b/packages/consent/consent-tools/src/types/settings.ts index 3800ab9a0..c44ce31e3 100644 --- a/packages/consent/consent-tools/src/types/settings.ts +++ b/packages/consent/consent-tools/src/types/settings.ts @@ -27,6 +27,36 @@ export interface CreateWrapperSettings { **/ getCategories: () => Categories | Promise + /** + * Programatically send a "Segment Consent Preference" event to Segment when consent preferences change. + * An event will be sent to Segment whenever this function is called. + * + * #### Note: The callback requires the categories to be in the shape of { "C0001": true, "C0002": false }, so some normalization may be needed. + * @example + * ```ts + * (categoriesChangedCb) => { + * window.MyCMP.OnConsentChanged((ctg) => categoriesChangedCb(normalizeCategories(ctg)) + * } + * + * /* event payload + * { + * "type": "track", + * "event": "Segment Consent Preference", + * "context": { + * "consent": { + * "version": 2, + * "categoryPreferences" : { + * "C0001": true, + * "C0002": false, + * } + * } + * .. + * ``` + */ + registerOnConsentChanged?: ( + categoriesChangedCb: (categories: Categories) => void + ) => void + /** * This permanently disables any consent requirement (i.e device mode gating, event pref stamping). * Called on wrapper initialization. **shouldLoad will never be called** diff --git a/packages/consent/consent-tools/src/types/wrapper.ts b/packages/consent/consent-tools/src/types/wrapper.ts index 96d9fdbc9..d4244b225 100644 --- a/packages/consent/consent-tools/src/types/wrapper.ts +++ b/packages/consent/consent-tools/src/types/wrapper.ts @@ -23,6 +23,7 @@ export interface InitOptions { export interface AnyAnalytics { addSourceMiddleware(...args: any[]): any on(event: 'initialize', callback: (settings: CDNSettings) => void): void + track(event: string, properties?: unknown, ...args: any[]): void /** * This interface is meant to be compatible with both the snippet (`window.analytics.load`) diff --git a/packages/consent/consent-wrapper-onetrust/package.json b/packages/consent/consent-wrapper-onetrust/package.json index f6ef1de30..70a59c8bd 100644 --- a/packages/consent/consent-wrapper-onetrust/package.json +++ b/packages/consent/consent-wrapper-onetrust/package.json @@ -6,9 +6,11 @@ "types": "./dist/types/index.d.ts", "sideEffects": false, "files": [ + "LICENSE", "dist/", "src/", "!**/__tests__/**", + "!**/test-helpers/**", "!*.tsbuildinfo" ], "scripts": { diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts index 2184f5db7..67c78d507 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts @@ -1,6 +1,8 @@ import * as ConsentTools from '@segment/analytics-consent-tools' import * as OneTrustAPI from '../../lib/onetrust-api' import { sleep } from '@internal/test-helpers' +import { oneTrust } from '../wrapper' +import { OneTrustMockGlobal } from '../../test-helpers/mocks' const throwNotImplemented = (): never => { throw new Error('not implemented') @@ -18,43 +20,35 @@ const grpFixture = { }, } -/** - * This can be used to mock the OneTrust global object in individual tests - */ -const OneTrustMockGlobal: jest.Mocked = { - GetDomainData: jest.fn().mockImplementationOnce(throwNotImplemented), - IsAlertBoxClosed: jest.fn().mockImplementationOnce(() => false), - onConsentChanged: jest.fn(), // not implemented atm -} - const getConsentedGroupIdsSpy = jest .spyOn(OneTrustAPI, 'getConsentedGroupIds') .mockImplementationOnce(throwNotImplemented) -const helpers = { - _createWrapperSpy: jest.spyOn(ConsentTools, 'createWrapper'), +const createWrapperSpyHelper = { + _spy: jest.spyOn(ConsentTools, 'createWrapper'), get shouldLoad() { - return helpers._createWrapperSpy.mock.lastCall[0].shouldLoad! + return createWrapperSpyHelper._spy.mock.lastCall[0].shouldLoad! }, get getCategories() { - return helpers._createWrapperSpy.mock.lastCall[0].getCategories! + return createWrapperSpyHelper._spy.mock.lastCall[0].getCategories! + }, + get registerOnConsentChanged() { + return createWrapperSpyHelper._spy.mock.lastCall[0] + .registerOnConsentChanged! }, } -import { oneTrust } from '../wrapper' - /** * These tests are not meant to be comprehensive, but they should cover the most important cases. * We should prefer unit tests for most functionality (see lib/__tests__) */ describe('High level "integration" tests', () => { beforeEach(() => { - oneTrust({} as any) - getConsentedGroupIdsSpy.mockReset() - Object.values(OneTrustMockGlobal).forEach((fn) => fn.mockReset()) jest .spyOn(OneTrustAPI, 'getOneTrustGlobal') .mockImplementation(() => OneTrustMockGlobal) + getConsentedGroupIdsSpy.mockReset() + Object.values(OneTrustMockGlobal).forEach((fn) => fn.mockReset()) }) describe('shouldLoad', () => { @@ -75,13 +69,16 @@ describe('High level "integration" tests', () => { }) it('should be resolved successfully', async () => { + oneTrust({} as any) OneTrustMockGlobal.GetDomainData.mockReturnValueOnce({ Groups: [grpFixture.StrictlyNeccessary, grpFixture.Performance], }) getConsentedGroupIdsSpy.mockImplementation(() => [ grpFixture.StrictlyNeccessary.CustomGroupId, ]) - const shouldLoadP = Promise.resolve(helpers.shouldLoad({} as any)) + const shouldLoadP = Promise.resolve( + createWrapperSpyHelper.shouldLoad({} as any) + ) let shouldLoadResolved = false void shouldLoadP.then(() => (shouldLoadResolved = true)) await sleep(0) @@ -95,6 +92,7 @@ describe('High level "integration" tests', () => { describe('getCategories', () => { it('should get categories successfully', async () => { + oneTrust({} as any) OneTrustMockGlobal.GetDomainData.mockReturnValue({ Groups: [ grpFixture.StrictlyNeccessary, @@ -105,7 +103,7 @@ describe('High level "integration" tests', () => { getConsentedGroupIdsSpy.mockImplementation(() => [ grpFixture.StrictlyNeccessary.CustomGroupId, ]) - const categories = helpers.getCategories() + const categories = createWrapperSpyHelper.getCategories() // contain both consented and denied category expect(categories).toEqual({ C0001: true, @@ -114,4 +112,32 @@ describe('High level "integration" tests', () => { }) }) }) + + describe('Consent changed', () => { + it('should enable consent changed by default', async () => { + oneTrust({} as any) + OneTrustMockGlobal.GetDomainData.mockReturnValue({ + Groups: [ + grpFixture.StrictlyNeccessary, + grpFixture.Performance, + grpFixture.Targeting, + ], + }) + const onCategoriesChangedCb = jest.fn() + createWrapperSpyHelper.registerOnConsentChanged(onCategoriesChangedCb) + onCategoriesChangedCb() + const onConsentChangedArg = + OneTrustMockGlobal.OnConsentChanged.mock.lastCall[0] + onConsentChangedArg([ + grpFixture.StrictlyNeccessary.CustomGroupId, + grpFixture.Performance.CustomGroupId, + ]) + // expect to be normalized! + expect(onCategoriesChangedCb.mock.lastCall[0]).toEqual({ + [grpFixture.StrictlyNeccessary.CustomGroupId]: true, + [grpFixture.Performance.CustomGroupId]: true, + [grpFixture.Targeting.CustomGroupId]: false, + }) + }) + }) }) diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts index bb4013b1d..b44795df1 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts @@ -1,25 +1,26 @@ import { AnyAnalytics, - Categories, createWrapper, CreateWrapperSettings, resolveWhen, } from '@segment/analytics-consent-tools' import { + getNormalizedCategoriesFromGroupData, + getNormalizedCategoriesFromGroupIds, getConsentedGroupIds, - getGroupData, getOneTrustGlobal, } from '../lib/onetrust-api' -interface OneTrustOptions { +interface OneTrustSettings { integrationCategoryMappings?: CreateWrapperSettings['integrationCategoryMappings'] + disableConsentChangedEvent?: boolean } export const oneTrust = ( analytics: AnyAnalytics, - options: OneTrustOptions = {} -) => + settings: OneTrustSettings = {} +) => { createWrapper({ shouldLoad: async () => { await resolveWhen(() => { @@ -31,37 +32,18 @@ export const oneTrust = ( ) }, 500) }, - getCategories: (): Categories => { - // so basically, if a user has 2 categories defined in the UI: [Functional, Advertising], - // we need _all_ those categories to be valid - - // Scenarios: - // - if the user is being asked to select categories, so the popup is still visible - // - if user has no categories selected because they deliberately do not consent to anything and the popup has been dismissed in this session - // - if user has selected categories in a past session - // - if the user has selected categories this session - // - if user has no categories selected because they deliberately do not consent to anything and the popup was dismissed in a previous session - const { userSetConsentGroups, userDeniedConsentGroups } = getGroupData() - const consentedCategories = userSetConsentGroups.reduce( - (acc, c) => { - return { - ...acc, - [c.groupId]: true, - } - }, - {} - ) - - const deniedCategories = userDeniedConsentGroups.reduce( - (acc, c) => { - return { - ...acc, - [c.groupId]: false, - } - }, - {} - ) - return { ...consentedCategories, ...deniedCategories } + getCategories: () => { + const results = getNormalizedCategoriesFromGroupData() + return results }, - integrationCategoryMappings: options.integrationCategoryMappings, + registerOnConsentChanged: settings.disableConsentChangedEvent + ? undefined + : (onCategoriesChangedCb) => + getOneTrustGlobal()?.OnConsentChanged((categories) => { + const normalizedCategories = + getNormalizedCategoriesFromGroupIds(categories) + onCategoriesChangedCb(normalizedCategories) + }), + integrationCategoryMappings: settings.integrationCategoryMappings, })(analytics) +} diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts index 0b93ea680..a5cdbcd7a 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts @@ -1,6 +1,7 @@ -import './onetrust-globals.d.ts' +import '../../test-helpers/onetrust-globals.js' -import { getConsentedGroupIds, getGroupData } from '../onetrust-api' +import { getConsentedGroupIds, getGroupDataFromGroupIds } from '../onetrust-api' +import { OneTrustMockGlobal } from '../../test-helpers/mocks' beforeEach(() => { // @ts-ignore @@ -40,11 +41,11 @@ describe(getConsentedGroupIds, () => { }) }) -describe(getGroupData, () => { +describe(getGroupDataFromGroupIds, () => { it('should partition groups into consent/deny', () => { window.OnetrustActiveGroups = ',C0001,C0004' window.OneTrust = { - ...window.OneTrust, + ...OneTrustMockGlobal, GetDomainData: () => ({ Groups: [ { @@ -59,7 +60,7 @@ describe(getGroupData, () => { ], }), } - const data = getGroupData() + const data = getGroupDataFromGroupIds() expect(data.userSetConsentGroups).toEqual([ { diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts b/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts index 7e740dc77..527783c17 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts @@ -1,3 +1,5 @@ +import { Categories } from '@segment/analytics-consent-tools' + /** * @example * ",CAT001,FOO456" => ["CAT001", "FOO456"] @@ -23,12 +25,28 @@ export interface OneTrustGlobal { * - if a user makes a selection * - if a user rejects all */ - onConsentChanged: (cb: (groupIds: string[]) => void) => void + OnConsentChanged: (cb: (groupIds: string[]) => void) => void IsAlertBoxClosed: () => boolean } -export const getOneTrustGlobal = (): OneTrustGlobal | undefined => - (window as any).OneTrust +export const getOneTrustGlobal = (): OneTrustGlobal | undefined => { + const oneTrust = (window as any).OneTrust + if (!oneTrust) return undefined + if ( + typeof oneTrust === 'object' && + 'OnConsentChanged' in oneTrust && + 'IsAlertBoxClosed' in oneTrust && + 'GetDomainData' in oneTrust + ) { + return oneTrust + } + + throw new Error( + `OneTrust global object is not in expected format. Received ${JSON.stringify( + oneTrust + )}` + ) +} const getOneTrustActiveGroups = (): string | undefined => (window as any).OnetrustActiveGroups @@ -64,9 +82,9 @@ type UserConsentGroupData = { } // derive the groupIds from the active groups -export const getGroupData = (): UserConsentGroupData => { - const userSetConsentGroupIds = getConsentedGroupIds() - +export const getGroupDataFromGroupIds = ( + userSetConsentGroupIds = getConsentedGroupIds() +): UserConsentGroupData => { // partition all groups into "consent" or "deny" const userConsentGroupData = getAllGroups().reduce( (acc, group) => { @@ -82,3 +100,37 @@ export const getGroupData = (): UserConsentGroupData => { return userConsentGroupData } + +export const getNormalizedCategoriesFromGroupData = ( + groupData = getGroupDataFromGroupIds() +): Categories => { + const { userSetConsentGroups, userDeniedConsentGroups } = groupData + const consentedCategories = userSetConsentGroups.reduce( + (acc, c) => { + return { + ...acc, + [c.groupId]: true, + } + }, + {} + ) + + const deniedCategories = userDeniedConsentGroups.reduce( + (acc, c) => { + return { + ...acc, + [c.groupId]: false, + } + }, + {} + ) + return { ...consentedCategories, ...deniedCategories } +} + +export const getNormalizedCategoriesFromGroupIds = ( + groupIds: string[] +): Categories => { + return getNormalizedCategoriesFromGroupData( + getGroupDataFromGroupIds(groupIds) + ) +} diff --git a/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts new file mode 100644 index 000000000..b77a012bb --- /dev/null +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts @@ -0,0 +1,16 @@ +import { OneTrustGlobal } from '../lib/onetrust-api' +import { throwNotImplemented } from './utils' + +/** + * This can be used to mock the OneTrust global object in individual tests + * @example + * ```ts + * import * as OneTrustAPI from '../onetrust-api' + * jest.spyOn(OneTrustAPI, 'getOneTrustGlobal').mockImplementation(() => OneTrustMockGlobal) + * ```` + */ +export const OneTrustMockGlobal: jest.Mocked = { + GetDomainData: jest.fn().mockImplementation(throwNotImplemented), + IsAlertBoxClosed: jest.fn().mockImplementation(throwNotImplemented), + OnConsentChanged: jest.fn().mockImplementation(throwNotImplemented), // not implemented atm +} diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-globals.d.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/onetrust-globals.d.ts similarity index 83% rename from packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-globals.d.ts rename to packages/consent/consent-wrapper-onetrust/src/test-helpers/onetrust-globals.d.ts index 738eeb6a2..fae1f3074 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-globals.d.ts +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/onetrust-globals.d.ts @@ -1,4 +1,4 @@ -import { OneTrustGlobal } from '../onetrust-api' +import { OneTrustGlobal } from '../lib/onetrust-api' /** * ALERT: It's OK to declare ambient globals in test code, but __not__ in library code * This file should not be included in the final package diff --git a/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts new file mode 100644 index 000000000..dca42f3ae --- /dev/null +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts @@ -0,0 +1,3 @@ +export const throwNotImplemented = (): never => { + throw new Error('not implemented') +} diff --git a/packages/consent/consent-wrapper-onetrust/tsconfig.build.json b/packages/consent/consent-wrapper-onetrust/tsconfig.build.json index b74e28ced..c11c2b9dd 100644 --- a/packages/consent/consent-wrapper-onetrust/tsconfig.build.json +++ b/packages/consent/consent-wrapper-onetrust/tsconfig.build.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "include": ["src"], - "exclude": ["**/__tests__/**"], + "exclude": ["**/__tests__/**", "**/test-helpers/**"], "compilerOptions": { "outDir": "./dist/esm", "declarationDir": "./dist/types" From a9474c5dc588e29e890cc577028b4cc05f10cd76 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Fri, 18 Aug 2023 18:24:56 -0500 Subject: [PATCH 02/20] wip --- .../src/page-bundles/onetrust/index.ts | 1 - .../consent-tools/src/types/settings.ts | 2 +- .../src/domain/wrapper.ts | 7 ++-- .../src/lib/onetrust-api.ts | 42 +++++++++++++------ .../src/lib/validation/index.ts | 1 + .../src/lib/validation/onetrust-api-error.ts | 11 +++++ 6 files changed, 47 insertions(+), 17 deletions(-) create mode 100644 packages/consent/consent-wrapper-onetrust/src/lib/validation/index.ts create mode 100644 packages/consent/consent-wrapper-onetrust/src/lib/validation/onetrust-api-error.ts diff --git a/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts b/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts index 295771012..62da28671 100644 --- a/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts +++ b/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts @@ -10,7 +10,6 @@ oneTrust(analytics, { 'Actions Amplitude': ['C0004'], }, }) -console.log('loaded?') ;(window as any).analytics = analytics analytics.load({ writeKey: '9lSrez3BlfLAJ7NOChrqWtILiATiycoc' }) diff --git a/packages/consent/consent-tools/src/types/settings.ts b/packages/consent/consent-tools/src/types/settings.ts index c44ce31e3..a369b4e74 100644 --- a/packages/consent/consent-tools/src/types/settings.ts +++ b/packages/consent/consent-tools/src/types/settings.ts @@ -35,7 +35,7 @@ export interface CreateWrapperSettings { * @example * ```ts * (categoriesChangedCb) => { - * window.MyCMP.OnConsentChanged((ctg) => categoriesChangedCb(normalizeCategories(ctg)) + * window.MyCMP.OnConsentChanged((event.detail) => categoriesChangedCb(normalizeCategories(event.detail)) * } * * /* event payload diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts index b44795df1..5c24f5f9d 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts @@ -39,9 +39,10 @@ export const oneTrust = ( registerOnConsentChanged: settings.disableConsentChangedEvent ? undefined : (onCategoriesChangedCb) => - getOneTrustGlobal()?.OnConsentChanged((categories) => { - const normalizedCategories = - getNormalizedCategoriesFromGroupIds(categories) + getOneTrustGlobal()?.OnConsentChanged((event) => { + const normalizedCategories = getNormalizedCategoriesFromGroupIds( + event.detail + ) onCategoriesChangedCb(normalizedCategories) }), integrationCategoryMappings: settings.integrationCategoryMappings, diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts b/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts index 527783c17..11ac33a17 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts @@ -1,16 +1,26 @@ import { Categories } from '@segment/analytics-consent-tools' +import { OneTrustApiValidationError } from './validation' +/** + * @example ["C0001", "C0002"] + */ +type ConsentGroupIds = string[] /** * @example - * ",CAT001,FOO456" => ["CAT001", "FOO456"] + * ",C0001,C0002" => ["C0001", "C0002"] */ -const normalizeActiveGroupIds = (c: string): string[] => - c.trim().split(',').filter(Boolean) +const normalizeActiveGroupIds = ( + oneTrustActiveGroups: string +): ConsentGroupIds => { + return oneTrustActiveGroups.trim().split(',').filter(Boolean) +} type GroupInfoDto = { CustomGroupId: string } +type OtConsentChangedEvent = CustomEvent + /** * The data model used by the OneTrust lib */ @@ -25,7 +35,7 @@ export interface OneTrustGlobal { * - if a user makes a selection * - if a user rejects all */ - OnConsentChanged: (cb: (groupIds: string[]) => void) => void + OnConsentChanged: (cb: (event: OtConsentChangedEvent) => void) => void IsAlertBoxClosed: () => boolean } @@ -41,17 +51,25 @@ export const getOneTrustGlobal = (): OneTrustGlobal | undefined => { return oneTrust } - throw new Error( - `OneTrust global object is not in expected format. Received ${JSON.stringify( - oneTrust - )}` + throw new OneTrustApiValidationError( + 'window.OneTrust is not in expected format', + oneTrust ) } -const getOneTrustActiveGroups = (): string | undefined => - (window as any).OnetrustActiveGroups +const getOneTrustActiveGroups = (): string | undefined => { + const groups = (window as any).OnetrustActiveGroups + if (!groups) return undefined + if (typeof groups !== 'string') { + throw new OneTrustApiValidationError( + `window.OnetrustActiveGroups is not a string`, + groups + ) + } + return groups +} -export const getConsentedGroupIds = (): string[] => { +export const getConsentedGroupIds = (): ConsentGroupIds => { const groups = getOneTrustActiveGroups() if (!groups) { return [] @@ -128,7 +146,7 @@ export const getNormalizedCategoriesFromGroupData = ( } export const getNormalizedCategoriesFromGroupIds = ( - groupIds: string[] + groupIds: ConsentGroupIds ): Categories => { return getNormalizedCategoriesFromGroupData( getGroupDataFromGroupIds(groupIds) diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/validation/index.ts b/packages/consent/consent-wrapper-onetrust/src/lib/validation/index.ts new file mode 100644 index 000000000..8edb0657d --- /dev/null +++ b/packages/consent/consent-wrapper-onetrust/src/lib/validation/index.ts @@ -0,0 +1 @@ +export * from './onetrust-api-error' diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/validation/onetrust-api-error.ts b/packages/consent/consent-wrapper-onetrust/src/lib/validation/onetrust-api-error.ts new file mode 100644 index 000000000..9eb1ac974 --- /dev/null +++ b/packages/consent/consent-wrapper-onetrust/src/lib/validation/onetrust-api-error.ts @@ -0,0 +1,11 @@ +/** + * An Errot that represents that the OneTrust API is not in the expected format. + * This is not something that could happen unless our API types are wrong and something is very wonky. + * Not a recoverable error. + */ +export class OneTrustApiValidationError extends Error { + name = 'OtConsentWrapperValidationError' + constructor(message: string, received: any) { + super(`Invariant: ${message} (Received: ${JSON.stringify(received)})`) + } +} From 1f1cf9cbfc721ca3e50bc88949985a8234655318 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Fri, 18 Aug 2023 18:27:29 -0500 Subject: [PATCH 03/20] wip --- .../src/domain/__tests__/wrapper.test.ts | 12 ++++++++---- .../src/lib/__tests__/onetrust-api.test.ts | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts index 67c78d507..1149f9aab 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts @@ -128,10 +128,14 @@ describe('High level "integration" tests', () => { onCategoriesChangedCb() const onConsentChangedArg = OneTrustMockGlobal.OnConsentChanged.mock.lastCall[0] - onConsentChangedArg([ - grpFixture.StrictlyNeccessary.CustomGroupId, - grpFixture.Performance.CustomGroupId, - ]) + onConsentChangedArg( + new CustomEvent('foo', { + detail: [ + grpFixture.StrictlyNeccessary.CustomGroupId, + grpFixture.Performance.CustomGroupId, + ], + }) + ) // expect to be normalized! expect(onCategoriesChangedCb.mock.lastCall[0]).toEqual({ [grpFixture.StrictlyNeccessary.CustomGroupId]: true, diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts index a5cdbcd7a..3ec42bdcc 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts @@ -1,4 +1,4 @@ -import '../../test-helpers/onetrust-globals.js' +import '../../test-helpers/onetrust-globals.d.ts' import { getConsentedGroupIds, getGroupDataFromGroupIds } from '../onetrust-api' import { OneTrustMockGlobal } from '../../test-helpers/mocks' From 76e1920e7b20dc71696547cb0a2f4e3f7bbd898f Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Fri, 18 Aug 2023 18:27:50 -0500 Subject: [PATCH 04/20] wip --- .../src/domain/__tests__/wrapper.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts index 1149f9aab..9d803f410 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts @@ -129,7 +129,7 @@ describe('High level "integration" tests', () => { const onConsentChangedArg = OneTrustMockGlobal.OnConsentChanged.mock.lastCall[0] onConsentChangedArg( - new CustomEvent('foo', { + new CustomEvent('', { detail: [ grpFixture.StrictlyNeccessary.CustomGroupId, grpFixture.Performance.CustomGroupId, From 70dffed4c4f2e92e33b8315206753d7a07641a9a Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Sun, 20 Aug 2023 12:57:36 -0500 Subject: [PATCH 05/20] wip --- packages/consent/consent-tools/README.md | 31 ++++++++++++------- .../consent-wrapper-onetrust/package.json | 2 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/consent/consent-tools/README.md b/packages/consent/consent-tools/README.md index 475800b5e..3461491fa 100644 --- a/packages/consent/consent-tools/README.md +++ b/packages/consent/consent-tools/README.md @@ -1,16 +1,17 @@ # @segment/analytics-consent-tools - - ## Quick Start + ```ts // wrapper.js import { createWrapper, resolveWhen } from '@segment/analytics-consent-tools' export const withCMP = createWrapper({ shouldLoad: (ctx) => { - await resolveWhen(() => - window.CMP !== undefined && !window.CMP.popUpVisible(), 500) + await resolveWhen( + () => window.CMP !== undefined && !window.CMP.popUpVisible(), + 500 + ) if (noConsentNeeded) { ctx.abort({ loadSegmentNormally: true }) @@ -18,23 +19,24 @@ export const withCMP = createWrapper({ ctx.abort({ loadSegmentNormally: false }) } }, - getCategories: () => { + getCategories: () => { // e.g. { Advertising: true, Functional: false } - return normalizeCategories(window.CMP.consentedCategories()) - } + return normalizeCategories(window.CMP.consentedCategories()) + }, }) ``` - ## Wrapper Usage API + ## `npm` + ```js import { withCMP } from './wrapper' import { AnalyticsBrowser } from '@segment/analytics-next' export const analytics = new AnalyticsBrowser() -withCmp(analytics) +withCMP(analytics) analytics.load({ writeKey: ' @@ -43,6 +45,7 @@ analytics.load({ ``` ## Snippet users (window.analytics) + 1. Delete the `analytics.load()` line from the snippet ```diff @@ -50,29 +53,33 @@ analytics.load({ ``` 2. Import Analytics + ```js import { withCMP } from './wrapper' -withCmp(window.analytics) +withCMP(window.analytics) window.analytics.load(' Date: Sun, 20 Aug 2023 13:25:00 -0500 Subject: [PATCH 06/20] wip --- .../consent-tools/src/domain/create-wrapper.ts | 16 ++++++++++++++++ .../consent/consent-tools/src/types/wrapper.ts | 16 ++++++---------- .../src/domain/wrapper.ts | 12 ++++++++---- .../consent-wrapper-onetrust/src/index.ts | 1 + 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/consent/consent-tools/src/domain/create-wrapper.ts b/packages/consent/consent-tools/src/domain/create-wrapper.ts index b62d4869a..97ef91dc6 100644 --- a/packages/consent/consent-tools/src/domain/create-wrapper.ts +++ b/packages/consent/consent-tools/src/domain/create-wrapper.ts @@ -13,6 +13,21 @@ import { AbortLoadError, LoadContext } from './load-cancellation' import { ValidationError } from './validation/validation-error' import { sendConsentChangedEvent } from './consent-changed' +function assertAnalyticsInstance( + analytics: unknown +): asserts analytics is AnyAnalytics { + if ( + analytics && + typeof analytics === 'object' && + 'load' in analytics && + 'on' in analytics && + 'addSourceMiddleware' in analytics + ) { + return + } + throw new Error('analytics is not an Analytics instance') +} + export const createWrapper: CreateWrapper = (createWrapperOptions) => { validateSettings(createWrapperOptions) @@ -28,6 +43,7 @@ export const createWrapper: CreateWrapper = (createWrapperOptions) => { } = createWrapperOptions return (analytics) => { + assertAnalyticsInstance(analytics) const ogLoad = analytics.load const loadWithConsent: AnyAnalytics['load'] = async ( diff --git a/packages/consent/consent-tools/src/types/wrapper.ts b/packages/consent/consent-tools/src/types/wrapper.ts index d4244b225..0b90769b8 100644 --- a/packages/consent/consent-tools/src/types/wrapper.ts +++ b/packages/consent/consent-tools/src/types/wrapper.ts @@ -36,19 +36,15 @@ export interface AnyAnalytics { } /** - * This function returns a "wrapped" version of analytics. - */ -export interface Wrapper { - // Returns void rather than analytics to emphasize that this function replaces the .load function of the underlying instance. - (analytics: AnyAnalytics): void -} + * This function modifies an analytics instance to add consent management. + * This is an analytics instance (either window.analytics, new AnalyticsBrowser(), or the instance returned by `AnalyticsBrowser.load({...})` + **/ +export type Wrapper = (analyticsInstance: object) => void /** - * This function returns a function which returns a "wrapped" version of analytics + * Create a function which wraps analytics instances to add consent management. */ -export interface CreateWrapper { - (options: CreateWrapperSettings): Wrapper -} +export type CreateWrapper = (options: CreateWrapperSettings) => Wrapper export interface Categories { [category: string]: boolean diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts index 5c24f5f9d..a2209a1fc 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts @@ -1,5 +1,4 @@ import { - AnyAnalytics, createWrapper, CreateWrapperSettings, resolveWhen, @@ -12,13 +11,18 @@ import { getOneTrustGlobal, } from '../lib/onetrust-api' -interface OneTrustSettings { +export interface OneTrustSettings { integrationCategoryMappings?: CreateWrapperSettings['integrationCategoryMappings'] disableConsentChangedEvent?: boolean } +/** + * + * @param analyticsInstance - An analytics instance. Either `window.analytics`, or the instance returned by `new AnalyticsBrowser()` or `AnalyticsBrowser.load({...})` + * @param settings - Optional settings for configuring your OneTrust wrapper + */ export const oneTrust = ( - analytics: AnyAnalytics, + analyticsInstance: object, settings: OneTrustSettings = {} ) => { createWrapper({ @@ -46,5 +50,5 @@ export const oneTrust = ( onCategoriesChangedCb(normalizedCategories) }), integrationCategoryMappings: settings.integrationCategoryMappings, - })(analytics) + })(analyticsInstance) } diff --git a/packages/consent/consent-wrapper-onetrust/src/index.ts b/packages/consent/consent-wrapper-onetrust/src/index.ts index ccd646d80..74e16597c 100644 --- a/packages/consent/consent-wrapper-onetrust/src/index.ts +++ b/packages/consent/consent-wrapper-onetrust/src/index.ts @@ -3,3 +3,4 @@ * We avoid using splat (*) exports so that we can control what is exposed. */ export { oneTrust } from './domain/wrapper' +export type { OneTrustSettings } from './domain/wrapper' From 43534d29771e932665758f3cbc3b21f8f15545ad Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Sun, 20 Aug 2023 13:26:44 -0500 Subject: [PATCH 07/20] wip --- .../src/domain/create-wrapper.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/consent/consent-tools/src/domain/create-wrapper.ts b/packages/consent/consent-tools/src/domain/create-wrapper.ts index 97ef91dc6..631d5b036 100644 --- a/packages/consent/consent-tools/src/domain/create-wrapper.ts +++ b/packages/consent/consent-tools/src/domain/create-wrapper.ts @@ -13,21 +13,6 @@ import { AbortLoadError, LoadContext } from './load-cancellation' import { ValidationError } from './validation/validation-error' import { sendConsentChangedEvent } from './consent-changed' -function assertAnalyticsInstance( - analytics: unknown -): asserts analytics is AnyAnalytics { - if ( - analytics && - typeof analytics === 'object' && - 'load' in analytics && - 'on' in analytics && - 'addSourceMiddleware' in analytics - ) { - return - } - throw new Error('analytics is not an Analytics instance') -} - export const createWrapper: CreateWrapper = (createWrapperOptions) => { validateSettings(createWrapperOptions) @@ -250,3 +235,18 @@ const disableIntegrations = ( ) return results } + +function assertAnalyticsInstance( + analytics: unknown +): asserts analytics is AnyAnalytics { + if ( + analytics && + typeof analytics === 'object' && + 'load' in analytics && + 'on' in analytics && + 'addSourceMiddleware' in analytics + ) { + return + } + throw new Error('analytics is not an Analytics instance') +} From 24f35f4f85e3dde38a7101f553dc0b5cdd717eb7 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Sun, 20 Aug 2023 13:29:40 -0500 Subject: [PATCH 08/20] wip --- .../src/domain/create-wrapper.ts | 23 +++++-------------- .../domain/validation/common-validators.ts | 2 +- .../domain/validation/options-validators.ts | 16 ++++++++++++- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/consent/consent-tools/src/domain/create-wrapper.ts b/packages/consent/consent-tools/src/domain/create-wrapper.ts index 631d5b036..13d435b27 100644 --- a/packages/consent/consent-tools/src/domain/create-wrapper.ts +++ b/packages/consent/consent-tools/src/domain/create-wrapper.ts @@ -6,7 +6,11 @@ import { CreateWrapperSettings, CDNSettings, } from '../types' -import { validateCategories, validateSettings } from './validation' +import { + validateAnalyticsInstance, + validateCategories, + validateSettings, +} from './validation' import { createConsentStampingMiddleware } from './consent-stamping' import { pipe, pick, uniq } from '../utils' import { AbortLoadError, LoadContext } from './load-cancellation' @@ -28,7 +32,7 @@ export const createWrapper: CreateWrapper = (createWrapperOptions) => { } = createWrapperOptions return (analytics) => { - assertAnalyticsInstance(analytics) + validateAnalyticsInstance(analytics) const ogLoad = analytics.load const loadWithConsent: AnyAnalytics['load'] = async ( @@ -235,18 +239,3 @@ const disableIntegrations = ( ) return results } - -function assertAnalyticsInstance( - analytics: unknown -): asserts analytics is AnyAnalytics { - if ( - analytics && - typeof analytics === 'object' && - 'load' in analytics && - 'on' in analytics && - 'addSourceMiddleware' in analytics - ) { - return - } - throw new Error('analytics is not an Analytics instance') -} diff --git a/packages/consent/consent-tools/src/domain/validation/common-validators.ts b/packages/consent/consent-tools/src/domain/validation/common-validators.ts index f33837500..4d24e64e7 100644 --- a/packages/consent/consent-tools/src/domain/validation/common-validators.ts +++ b/packages/consent/consent-tools/src/domain/validation/common-validators.ts @@ -11,7 +11,7 @@ export function assertIsFunction( export function assertIsObject( val: unknown, - variableName: string + variableName = 'value' ): asserts val is object { if (val === null || typeof val !== 'object') { throw new ValidationError(`${variableName} is not an object`, val) diff --git a/packages/consent/consent-tools/src/domain/validation/options-validators.ts b/packages/consent/consent-tools/src/domain/validation/options-validators.ts index 5ef0b5a98..2abaf28c3 100644 --- a/packages/consent/consent-tools/src/domain/validation/options-validators.ts +++ b/packages/consent/consent-tools/src/domain/validation/options-validators.ts @@ -1,4 +1,4 @@ -import { Categories, CreateWrapperSettings } from '../../types' +import { AnyAnalytics, Categories, CreateWrapperSettings } from '../../types' import { assertIsFunction, assertIsObject } from './common-validators' import { ValidationError } from './validation-error' @@ -58,3 +58,17 @@ export function validateSettings(options: { 'registerOnConsentChanged' ) } + +export function validateAnalyticsInstance( + analytics: unknown +): asserts analytics is AnyAnalytics { + assertIsObject(analytics) + if ( + 'load' in analytics && + 'on' in analytics && + 'addSourceMiddleware' in analytics + ) { + return + } + throw new ValidationError('analytics is not an Analytics instance', analytics) +} From 4b625f97a7ebb7c292d441248079488be8564ba3 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Mon, 21 Aug 2023 16:16:53 -0500 Subject: [PATCH 09/20] wip --- .../src/domain/__tests__/wrapper.test.ts | 8 ++++---- .../consent-wrapper-onetrust/src/test-helpers/mocks.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts index 9d803f410..cf18d33ec 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts @@ -2,7 +2,7 @@ import * as ConsentTools from '@segment/analytics-consent-tools' import * as OneTrustAPI from '../../lib/onetrust-api' import { sleep } from '@internal/test-helpers' import { oneTrust } from '../wrapper' -import { OneTrustMockGlobal } from '../../test-helpers/mocks' +import { OneTrustMockGlobal, analyticsMock } from '../../test-helpers/mocks' const throwNotImplemented = (): never => { throw new Error('not implemented') @@ -69,7 +69,7 @@ describe('High level "integration" tests', () => { }) it('should be resolved successfully', async () => { - oneTrust({} as any) + oneTrust(analyticsMock) OneTrustMockGlobal.GetDomainData.mockReturnValueOnce({ Groups: [grpFixture.StrictlyNeccessary, grpFixture.Performance], }) @@ -92,7 +92,7 @@ describe('High level "integration" tests', () => { describe('getCategories', () => { it('should get categories successfully', async () => { - oneTrust({} as any) + oneTrust(analyticsMock) OneTrustMockGlobal.GetDomainData.mockReturnValue({ Groups: [ grpFixture.StrictlyNeccessary, @@ -115,7 +115,7 @@ describe('High level "integration" tests', () => { describe('Consent changed', () => { it('should enable consent changed by default', async () => { - oneTrust({} as any) + oneTrust(analyticsMock) OneTrustMockGlobal.GetDomainData.mockReturnValue({ Groups: [ grpFixture.StrictlyNeccessary, diff --git a/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts index b77a012bb..000facad9 100644 --- a/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts @@ -1,6 +1,6 @@ import { OneTrustGlobal } from '../lib/onetrust-api' import { throwNotImplemented } from './utils' - +import type { AnyAnalytics } from '@segment/analytics-consent-tools' /** * This can be used to mock the OneTrust global object in individual tests * @example @@ -14,3 +14,10 @@ export const OneTrustMockGlobal: jest.Mocked = { IsAlertBoxClosed: jest.fn().mockImplementation(throwNotImplemented), OnConsentChanged: jest.fn().mockImplementation(throwNotImplemented), // not implemented atm } + +export const analyticsMock: jest.Mocked = { + addSourceMiddleware: jest.fn(), + load: jest.fn(), + on: jest.fn(), + track: jest.fn(), +} From cb0cbada3f34d4f728be6e66bee60faee9f97ce1 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Mon, 21 Aug 2023 16:18:23 -0500 Subject: [PATCH 10/20] wip --- .../consent-wrapper-onetrust/src/test-helpers/mocks.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts index 000facad9..32640d75b 100644 --- a/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts @@ -16,8 +16,8 @@ export const OneTrustMockGlobal: jest.Mocked = { } export const analyticsMock: jest.Mocked = { - addSourceMiddleware: jest.fn(), - load: jest.fn(), - on: jest.fn(), - track: jest.fn(), + addSourceMiddleware: jest.fn().mockImplementation(throwNotImplemented), + load: jest.fn().mockImplementation(throwNotImplemented), + on: jest.fn().mockImplementation(throwNotImplemented), + track: jest.fn().mockImplementation(throwNotImplemented), } From 7e9a70310079001489a6c7ac6a20bdef658ccb65 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Mon, 21 Aug 2023 16:27:52 -0500 Subject: [PATCH 11/20] wip --- .../consent-tools/src/domain/__tests__/typedef-tests.ts | 9 ++++++++- packages/consent/consent-tools/src/types/wrapper.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/consent/consent-tools/src/domain/__tests__/typedef-tests.ts b/packages/consent/consent-tools/src/domain/__tests__/typedef-tests.ts index c7cab6332..06b69090a 100644 --- a/packages/consent/consent-tools/src/domain/__tests__/typedef-tests.ts +++ b/packages/consent/consent-tools/src/domain/__tests__/typedef-tests.ts @@ -2,10 +2,17 @@ import type { AnalyticsSnippet, AnalyticsBrowser, } from '@segment/analytics-next' -import { createWrapper } from '../../index' +import { createWrapper, AnyAnalytics } from '../../index' + +type Extends = T extends U ? true : false { const wrap = createWrapper({ getCategories: () => ({ foo: true }) }) wrap({} as AnalyticsBrowser) wrap({} as AnalyticsSnippet) + + // see AnalyticsSnippet and AnalyticsBrowser extend AnyAnalytics + const f: Extends = true + const g: Extends = true + console.log(f, g) } diff --git a/packages/consent/consent-tools/src/types/wrapper.ts b/packages/consent/consent-tools/src/types/wrapper.ts index 0b90769b8..075545754 100644 --- a/packages/consent/consent-tools/src/types/wrapper.ts +++ b/packages/consent/consent-tools/src/types/wrapper.ts @@ -44,7 +44,7 @@ export type Wrapper = (analyticsInstance: object) => void /** * Create a function which wraps analytics instances to add consent management. */ -export type CreateWrapper = (options: CreateWrapperSettings) => Wrapper +export type CreateWrapper = (settings: CreateWrapperSettings) => Wrapper export interface Categories { [category: string]: boolean From 471cf096fa66fdeb38cbcda7852c18950622ef22 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Mon, 21 Aug 2023 17:41:21 -0500 Subject: [PATCH 12/20] update tests --- .../src/lib/__tests__/onetrust-api.test.ts | 140 +++++++++++++++--- .../src/lib/onetrust-api.ts | 9 +- 2 files changed, 126 insertions(+), 23 deletions(-) diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts index 3ec42bdcc..bbb94b59c 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts @@ -1,7 +1,16 @@ import '../../test-helpers/onetrust-globals.d.ts' -import { getConsentedGroupIds, getGroupDataFromGroupIds } from '../onetrust-api' +import { + getConsentedGroupIds, + getGroupDataFromGroupIds, + getNormalizedCategoriesFromGroupData, + getOneTrustActiveGroups, + getNormalizedCategoriesFromGroupIds, + getOneTrustGlobal, + getAllGroups, +} from '../onetrust-api' import { OneTrustMockGlobal } from '../../test-helpers/mocks' +import { OneTrustApiValidationError } from '../validation' beforeEach(() => { // @ts-ignore @@ -10,31 +19,101 @@ beforeEach(() => { delete window.OneTrust }) -describe(getConsentedGroupIds, () => { - it('should return formatted groups', () => { - window.OnetrustActiveGroups = ',C0001,C0004,C0003,STACK42,' - expect(getConsentedGroupIds()).toEqual([ - 'C0001', - 'C0004', - 'C0003', - 'STACK42', +describe(getOneTrustGlobal, () => { + it('should get the global', () => { + ;(window as any).OneTrust = OneTrustMockGlobal + expect(getOneTrustGlobal()).toEqual(OneTrustMockGlobal) + }) + + it('should throw an error if the global is missing fields', () => { + ;(window as any).OneTrust = {} + expect(() => getOneTrustGlobal()).toThrow(OneTrustApiValidationError) + }) +}) + +describe(getAllGroups, () => { + it('works if OneTrust global is not available', () => { + ;(window as any).OneTrust = undefined + expect(getAllGroups()).toEqual([]) + }) + it('get the normalized groups', () => { + ;(window as any).OneTrust = OneTrustMockGlobal + window.OneTrust = { + ...OneTrustMockGlobal, + GetDomainData: () => ({ + Groups: [ + { + CustomGroupId: 'C0001', + }, + { + CustomGroupId: 'C0004', + }, + { + CustomGroupId: ' C0005', + }, + { + CustomGroupId: 'C0006 ', + }, + ], + }), + } + expect(getAllGroups()).toEqual([ + { groupId: 'C0001' }, + { groupId: 'C0004' }, + { groupId: 'C0005' }, + { groupId: 'C0006' }, ]) }) - it('should work even without the strange leading/trailing commas that onetrust adds', () => { - window.OnetrustActiveGroups = 'C0001,C0004' - expect(getConsentedGroupIds()).toEqual(['C0001', 'C0004']) +}) + +describe(getNormalizedCategoriesFromGroupData, () => { + it('should return a set of groups', () => { + expect( + getNormalizedCategoriesFromGroupData({ + userSetConsentGroups: [{ groupId: 'C0003' }], + userDeniedConsentGroups: [{ groupId: 'C0001' }, { groupId: 'C0002' }], + }) + ).toEqual({ C0003: true, C0001: false, C0002: false }) }) +}) - it('should return an array with only 1 active group if that is the only one consented', () => { - window.OnetrustActiveGroups = ',C0001,' - expect(getConsentedGroupIds()).toEqual(['C0001']) +describe(getOneTrustActiveGroups, () => { + it('should return the global string', () => { + window.OnetrustActiveGroups = 'hello' + expect(getOneTrustActiveGroups()).toBe('hello') + }) + it('should return undefined if no groups are defined', () => { + // @ts-ignore + window.OnetrustActiveGroups = undefined + expect(getOneTrustActiveGroups()).toBe(undefined) + + // @ts-ignore + window.OnetrustActiveGroups = null + expect(getOneTrustActiveGroups()).toBe(undefined) + + window.OnetrustActiveGroups = '' + expect(getOneTrustActiveGroups()).toBe(undefined) + }) + + it('should throw an error if OneTrustActiveGroups is invalid', () => { + // @ts-ignore + window.OnetrustActiveGroups = [] + expect(() => getOneTrustActiveGroups()).toThrow() + }) +}) + +describe(getConsentedGroupIds, () => { + it('should normalize groupIds', () => { + expect(getConsentedGroupIds(',C0001,')).toEqual(['C0001']) + expect(getConsentedGroupIds('C0001,C0004')).toEqual(['C0001', 'C0004']) + expect(getConsentedGroupIds(',C0001,C0004')).toEqual(['C0001', 'C0004']) + expect(getConsentedGroupIds(',')).toEqual([]) + expect(getConsentedGroupIds(',,')).toEqual([]) + expect(getConsentedGroupIds('')).toEqual([]) + expect(getConsentedGroupIds(',,')).toEqual([]) }) it('should return an empty array if no groups are defined', () => { - window.OnetrustActiveGroups = ',,' - expect(getConsentedGroupIds()).toEqual([]) - window.OnetrustActiveGroups = ',' - expect(getConsentedGroupIds()).toEqual([]) // @ts-ignore window.OnetrustActiveGroups = undefined expect(getConsentedGroupIds()).toEqual([]) @@ -78,3 +157,26 @@ describe(getGroupDataFromGroupIds, () => { ]) }) }) + +describe(getNormalizedCategoriesFromGroupIds, () => { + it('should get normalized categories', () => { + window.OneTrust = { + ...OneTrustMockGlobal, + GetDomainData: () => ({ + Groups: [ + { + CustomGroupId: 'C0001', + }, + { + CustomGroupId: 'C0004', + }, + { + CustomGroupId: 'SOME_OTHER_GROUP', + }, + ], + }), + } + const ids = getNormalizedCategoriesFromGroupIds(['C0001']) + expect(ids).toEqual({ C0001: true, C0004: false, SOME_OTHER_GROUP: false }) + }) +}) diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts b/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts index 11ac33a17..25c81ccc9 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts @@ -57,7 +57,7 @@ export const getOneTrustGlobal = (): OneTrustGlobal | undefined => { ) } -const getOneTrustActiveGroups = (): string | undefined => { +export const getOneTrustActiveGroups = (): string | undefined => { const groups = (window as any).OnetrustActiveGroups if (!groups) return undefined if (typeof groups !== 'string') { @@ -69,8 +69,9 @@ const getOneTrustActiveGroups = (): string | undefined => { return groups } -export const getConsentedGroupIds = (): ConsentGroupIds => { - const groups = getOneTrustActiveGroups() +export const getConsentedGroupIds = ( + groups = getOneTrustActiveGroups() +): ConsentGroupIds => { if (!groups) { return [] } @@ -88,7 +89,7 @@ const normalizeGroupInfo = (groupInfo: GroupInfoDto): GroupInfo => ({ /** * get *all* groups / categories, not just active ones */ -const getAllGroups = (): GroupInfo[] => { +export const getAllGroups = (): GroupInfo[] => { const oneTrustGlobal = getOneTrustGlobal() if (!oneTrustGlobal) return [] return oneTrustGlobal.GetDomainData().Groups.map(normalizeGroupInfo) From a628dfc3319ed195c55820cca2cff2dcc20a4462 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Mon, 21 Aug 2023 17:41:46 -0500 Subject: [PATCH 13/20] wip --- .../src/lib/__tests__/onetrust-api.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts index bbb94b59c..526a3b1ab 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts @@ -108,7 +108,6 @@ describe(getConsentedGroupIds, () => { expect(getConsentedGroupIds('C0001,C0004')).toEqual(['C0001', 'C0004']) expect(getConsentedGroupIds(',C0001,C0004')).toEqual(['C0001', 'C0004']) expect(getConsentedGroupIds(',')).toEqual([]) - expect(getConsentedGroupIds(',,')).toEqual([]) expect(getConsentedGroupIds('')).toEqual([]) expect(getConsentedGroupIds(',,')).toEqual([]) }) From a59bfca95dcd81a1a8faa14adb294f12936c757b Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Mon, 21 Aug 2023 17:46:04 -0500 Subject: [PATCH 14/20] wip --- .../src/lib/__tests__/onetrust-api.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts index 526a3b1ab..fdce55850 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts @@ -98,7 +98,7 @@ describe(getOneTrustActiveGroups, () => { it('should throw an error if OneTrustActiveGroups is invalid', () => { // @ts-ignore window.OnetrustActiveGroups = [] - expect(() => getOneTrustActiveGroups()).toThrow() + expect(() => getOneTrustActiveGroups()).toThrow(OneTrustApiValidationError) }) }) From 4107e9d59b199ede40553a1f7f008ea1b03f1955 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Mon, 21 Aug 2023 17:57:56 -0500 Subject: [PATCH 15/20] wip --- packages/consent/consent-tools/src/types/wrapper.ts | 2 ++ packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/consent/consent-tools/src/types/wrapper.ts b/packages/consent/consent-tools/src/types/wrapper.ts index 075545754..793660e85 100644 --- a/packages/consent/consent-tools/src/types/wrapper.ts +++ b/packages/consent/consent-tools/src/types/wrapper.ts @@ -39,6 +39,8 @@ export interface AnyAnalytics { * This function modifies an analytics instance to add consent management. * This is an analytics instance (either window.analytics, new AnalyticsBrowser(), or the instance returned by `AnalyticsBrowser.load({...})` **/ +// The chance of a false positive is higher than the chance that someone will pass in an object that is not an analytics instance. +// We have an assertion function that throws an error if the analytics instance is not compatible. export type Wrapper = (analyticsInstance: object) => void /** diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts index a2209a1fc..9596959a1 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts @@ -22,7 +22,7 @@ export interface OneTrustSettings { * @param settings - Optional settings for configuring your OneTrust wrapper */ export const oneTrust = ( - analyticsInstance: object, + analyticsInstance: object, // typing this as 'object', rather than AnyAnalytics to avoid misc type mismatches. createWrapper will throw an error if the analytics instance is not compatible. settings: OneTrustSettings = {} ) => { createWrapper({ From 3ff17d4070196e020b71a929ee8c64cf25e3f769 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Mon, 21 Aug 2023 17:58:40 -0500 Subject: [PATCH 16/20] wip --- packages/consent/consent-tools/src/types/wrapper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/consent/consent-tools/src/types/wrapper.ts b/packages/consent/consent-tools/src/types/wrapper.ts index 793660e85..5d8267a4f 100644 --- a/packages/consent/consent-tools/src/types/wrapper.ts +++ b/packages/consent/consent-tools/src/types/wrapper.ts @@ -39,7 +39,7 @@ export interface AnyAnalytics { * This function modifies an analytics instance to add consent management. * This is an analytics instance (either window.analytics, new AnalyticsBrowser(), or the instance returned by `AnalyticsBrowser.load({...})` **/ -// The chance of a false positive is higher than the chance that someone will pass in an object that is not an analytics instance. +// Why type this as 'object' rather than 'AnyAnalytics'? IMO, the chance of a false positive is much higher than the chance that someone will pass in an object that is not an analytics instance. // We have an assertion function that throws an error if the analytics instance is not compatible. export type Wrapper = (analyticsInstance: object) => void From 2f3e80d3485cd00acfba7c8fb2ee011ab77c79e7 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Tue, 22 Aug 2023 12:45:50 -0500 Subject: [PATCH 17/20] address comment --- packages/consent/consent-tools/src/types/settings.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/consent/consent-tools/src/types/settings.ts b/packages/consent/consent-tools/src/types/settings.ts index a369b4e74..9b535be1e 100644 --- a/packages/consent/consent-tools/src/types/settings.ts +++ b/packages/consent/consent-tools/src/types/settings.ts @@ -28,8 +28,7 @@ export interface CreateWrapperSettings { getCategories: () => Categories | Promise /** - * Programatically send a "Segment Consent Preference" event to Segment when consent preferences change. - * An event will be sent to Segment whenever this function is called. + * Function to register a listener for consent changes to programatically send a "Segment Consent Preference" event to Segment when consent preferences change. * * #### Note: The callback requires the categories to be in the shape of { "C0001": true, "C0002": false }, so some normalization may be needed. * @example From f695c5431566fbbf7c73e1d12d4e12d734e204a9 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Tue, 22 Aug 2023 12:47:11 -0500 Subject: [PATCH 18/20] address comment to use optional chaining --- .../src/domain/create-wrapper.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/consent/consent-tools/src/domain/create-wrapper.ts b/packages/consent/consent-tools/src/domain/create-wrapper.ts index 13d435b27..7ef94e588 100644 --- a/packages/consent/consent-tools/src/domain/create-wrapper.ts +++ b/packages/consent/consent-tools/src/domain/create-wrapper.ts @@ -125,18 +125,16 @@ export const createWrapper: CreateWrapper = (createWrapperOptions) => { createConsentStampingMiddleware(getValidCategoriesForConsentStamping) ) - if (registerOnConsentChanged) { - // whenever consent changes, dispatch a new event with the latest consent information - registerOnConsentChanged((categories) => { - try { - validateCategories(categories) - sendConsentChangedEvent(analytics, categories) - } catch (err) { - // Not sure if there's a better way to handle this, but this makes testing a bit easier. - console.error(err) - } - }) - } + // whenever consent changes, dispatch a new event with the latest consent information + registerOnConsentChanged?.((categories) => { + try { + validateCategories(categories) + sendConsentChangedEvent(analytics, categories) + } catch (err) { + // Not sure if there's a better way to handle this, but this makes testing a bit easier. + console.error(err) + } + }) const updateCDNSettings: InitOptions['updateCDNSettings'] = ( cdnSettings From 7f7d4ceb09048b63a8f7124638006b7a6fa5c48c Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Tue, 22 Aug 2023 12:59:37 -0500 Subject: [PATCH 19/20] add validation stuff --- .../src/domain/validation/common-validators.ts | 2 +- .../src/domain/validation/options-validators.ts | 2 +- .../src/domain/validation/validation-error.ts | 11 ++++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/consent/consent-tools/src/domain/validation/common-validators.ts b/packages/consent/consent-tools/src/domain/validation/common-validators.ts index 4d24e64e7..f33837500 100644 --- a/packages/consent/consent-tools/src/domain/validation/common-validators.ts +++ b/packages/consent/consent-tools/src/domain/validation/common-validators.ts @@ -11,7 +11,7 @@ export function assertIsFunction( export function assertIsObject( val: unknown, - variableName = 'value' + variableName: string ): asserts val is object { if (val === null || typeof val !== 'object') { throw new ValidationError(`${variableName} is not an object`, val) diff --git a/packages/consent/consent-tools/src/domain/validation/options-validators.ts b/packages/consent/consent-tools/src/domain/validation/options-validators.ts index 2abaf28c3..a07af6ac0 100644 --- a/packages/consent/consent-tools/src/domain/validation/options-validators.ts +++ b/packages/consent/consent-tools/src/domain/validation/options-validators.ts @@ -62,7 +62,7 @@ export function validateSettings(options: { export function validateAnalyticsInstance( analytics: unknown ): asserts analytics is AnyAnalytics { - assertIsObject(analytics) + assertIsObject(analytics, 'analytics') if ( 'load' in analytics && 'on' in analytics && diff --git a/packages/consent/consent-tools/src/domain/validation/validation-error.ts b/packages/consent/consent-tools/src/domain/validation/validation-error.ts index 174a3b31c..ccab434c2 100644 --- a/packages/consent/consent-tools/src/domain/validation/validation-error.ts +++ b/packages/consent/consent-tools/src/domain/validation/validation-error.ts @@ -1,10 +1,11 @@ import { AnalyticsConsentError } from '../../types/errors' export class ValidationError extends AnalyticsConsentError { - constructor(message: string, received: any) { - super( - 'ValidationError', - `[Validation] ${message} (${`Received: ${JSON.stringify(received)})`}` - ) + constructor(message: string, received?: any) { + if (arguments.length === 2) { + // to ensure that explicitly passing undefined as second argument still works + message += `(Received: ${JSON.stringify(received)})` + } + super('ValidationError', `[Validation] ${message}`) } } From ddec874592c50ecab1d4017462d2da17af659476 Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Tue, 22 Aug 2023 13:00:18 -0500 Subject: [PATCH 20/20] wip --- .../consent-tools/src/domain/validation/validation-error.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/consent/consent-tools/src/domain/validation/validation-error.ts b/packages/consent/consent-tools/src/domain/validation/validation-error.ts index ccab434c2..68dd9d370 100644 --- a/packages/consent/consent-tools/src/domain/validation/validation-error.ts +++ b/packages/consent/consent-tools/src/domain/validation/validation-error.ts @@ -4,7 +4,7 @@ export class ValidationError extends AnalyticsConsentError { constructor(message: string, received?: any) { if (arguments.length === 2) { // to ensure that explicitly passing undefined as second argument still works - message += `(Received: ${JSON.stringify(received)})` + message += ` (Received: ${JSON.stringify(received)})` } super('ValidationError', `[Validation] ${message}`) }