diff --git a/.changeset/gold-impalas-kneel.md b/.changeset/gold-impalas-kneel.md new file mode 100644 index 000000000..99b461a22 --- /dev/null +++ b/.changeset/gold-impalas-kneel.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-consent-tools': patch +--- + +Refactor consent wrapper; export GetCategoriesFunction diff --git a/packages/consent/consent-tools/src/domain/__tests__/consent-stamping.test.ts b/packages/consent/consent-tools/src/domain/__tests__/consent-stamping.test.ts index 4900bdc61..1860ba1b7 100644 --- a/packages/consent/consent-tools/src/domain/__tests__/consent-stamping.test.ts +++ b/packages/consent/consent-tools/src/domain/__tests__/consent-stamping.test.ts @@ -5,7 +5,6 @@ describe(createConsentStampingMiddleware, () => { let middlewareFn: MiddlewareFunction const nextFn = jest.fn() const getCategories = jest.fn() - // @ts-ignore const payload = { obj: { type: 'track', @@ -42,4 +41,30 @@ describe(createConsentStampingMiddleware, () => { Advertising: true, }) }) + + it('should throw an error if getCategories returns an invalid value', async () => { + middlewareFn = createConsentStampingMiddleware(getCategories) + getCategories.mockReturnValue(null as any) + await expect(() => + middlewareFn({ + next: nextFn, + // @ts-ignore + payload, + }) + ).rejects.toThrowError(/Validation/) + expect(nextFn).not.toHaveBeenCalled() + }) + + it('should throw an error if getCategories returns an invalid async value', async () => { + middlewareFn = createConsentStampingMiddleware(getCategories) + getCategories.mockResolvedValue(null as any) + await expect(() => + middlewareFn({ + next: nextFn, + // @ts-ignore + payload, + }) + ).rejects.toThrowError(/Validation/) + expect(nextFn).not.toHaveBeenCalled() + }) }) 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 acb2a117d..d8da2bb38 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 @@ -487,45 +487,6 @@ describe(createWrapper, () => { expect(analyticsLoadSpy).not.toBeCalled() }) }) - test.each([ - { - getCategories: () => - ({ - invalidCategory: 'hello', - } as any), - returnVal: 'Categories', - }, - { - getCategories: () => - Promise.resolve({ - invalidCategory: 'hello', - }) as any, - returnVal: 'Promise', - }, - ])( - 'should throw an error if getCategories() returns invalid categories during consent stamping ($returnVal))', - async ({ getCategories }) => { - const fn = jest.spyOn(ConsentStamping, 'createConsentStampingMiddleware') - const mockCdnSettings = settingsBuilder.build() - - wrapTestAnalytics({ - getCategories, - shouldLoadSegment: () => { - // on first load, we should not get an error because this is a valid category setting - return { invalidCategory: true } - }, - }) - await analytics.load({ - ...DEFAULT_LOAD_SETTINGS, - cdnSettings: mockCdnSettings, - }) - - const getCategoriesFn = fn.mock.lastCall[0] - await expect(getCategoriesFn()).rejects.toMatchInlineSnapshot( - `[ValidationError: [Validation] Consent Categories should be {[categoryName: string]: boolean} (Received: {"invalidCategory":"hello"})]` - ) - } - ) describe('shouldEnableIntegration', () => { it('should let user customize the logic that determines whether or not a destination is enabled', async () => { diff --git a/packages/consent/consent-tools/src/domain/consent-stamping.ts b/packages/consent/consent-tools/src/domain/consent-stamping.ts index 955aa4484..bbfa11838 100644 --- a/packages/consent/consent-tools/src/domain/consent-stamping.ts +++ b/packages/consent/consent-tools/src/domain/consent-stamping.ts @@ -1,4 +1,5 @@ import { AnyAnalytics, Categories } from '../types' +import { validateCategories } from './validation' type CreateConsentMw = ( getCategories: () => Promise @@ -11,6 +12,7 @@ export const createConsentStampingMiddleware: CreateConsentMw = (getCategories) => async ({ payload, next }) => { const categories = await getCategories() + validateCategories(categories) payload.obj.context.consent = { ...payload.obj.context.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 4fb8a5424..582aea8b4 100644 --- a/packages/consent/consent-tools/src/domain/create-wrapper.ts +++ b/packages/consent/consent-tools/src/domain/create-wrapper.ts @@ -12,10 +12,10 @@ import { validateSettings, } from './validation' import { createConsentStampingMiddleware } from './consent-stamping' -import { pipe, pick, uniq } from '../utils' +import { pipe } from '../utils' import { AbortLoadError, LoadContext } from './load-cancellation' -import { ValidationError } from './validation/validation-error' import { validateAndSendConsentChangedEvent } from './consent-changed' +import { getPrunedCategories } from './pruned-categories' export const createWrapper = ( ...[createWrapperOptions]: Parameters> @@ -79,56 +79,28 @@ export const createWrapper = ( validateCategories(initialCategories) - const getPrunedCategories = async ( - cdnSettingsP: Promise - ): Promise => { - const cdnSettings = await cdnSettingsP - // we don't want to send _every_ category to segment, only the ones that the user has explicitly configured in their integrations - let allCategories: string[] - // We need to get all the unique categories so we can prune the consent object down to only the categories that are configured - // There can be categories that are not included in any integration in the integrations object (e.g. 2 cloud mode categories), which is why we need a special allCategories array - if (integrationCategoryMappings) { - allCategories = uniq( - Object.values(integrationCategoryMappings).reduce((p, n) => - p.concat(n) - ) - ) - } else { - allCategories = cdnSettings.consentSettings?.allCategories || [] - } + // we need to register the listener before .load is called so we don't miss it. + // note: the 'initialize' API event is emitted so before the final flushing of events, so this promise won't block the pipeline. + const cdnSettings = new Promise((resolve) => + analytics.on('initialize', resolve) + ) - if (!allCategories.length) { - // No configured integrations found, so no categories will be sent (should not happen unless there's a configuration error) - throw new ValidationError( - 'Invariant: No consent categories defined in Segment', - [] + // normalize getCategories pruning is turned on or off + const getCategoriesForConsentStamping = async (): Promise => { + if (pruneUnmappedCategories) { + return getPrunedCategories( + getCategories, + await cdnSettings, + integrationCategoryMappings ) + } else { + return getCategories() } - - const categories = await getCategories() - - return pick(categories, allCategories) } - // create getCategories and validate them regardless of whether pruning is turned on or off - const getValidCategoriesForConsentStamping = pipe( - pruneUnmappedCategories - ? getPrunedCategories.bind( - this, - new Promise((resolve) => - analytics.on('initialize', resolve) - ) - ) - : getCategories, - async (categories) => { - validateCategories(await categories) - return categories - } - ) as () => Promise - // register listener to stamp all events with latest consent information analytics.addSourceMiddleware( - createConsentStampingMiddleware(getValidCategoriesForConsentStamping) + createConsentStampingMiddleware(getCategoriesForConsentStamping) ) const updateCDNSettings: InitOptions['updateCDNSettings'] = ( @@ -150,7 +122,7 @@ export const createWrapper = ( ...options, updateCDNSettings: pipe( updateCDNSettings, - options?.updateCDNSettings ? options.updateCDNSettings : (f) => f + options?.updateCDNSettings || ((id) => id) ), }) } diff --git a/packages/consent/consent-tools/src/domain/pruned-categories.ts b/packages/consent/consent-tools/src/domain/pruned-categories.ts new file mode 100644 index 000000000..2f8a92761 --- /dev/null +++ b/packages/consent/consent-tools/src/domain/pruned-categories.ts @@ -0,0 +1,38 @@ +import { uniq, pick } from 'lodash' +import { + CDNSettings, + CreateWrapperSettings, + Categories, + GetCategoriesFunction, +} from '../types' +import { ValidationError } from './validation/validation-error' + +export const getPrunedCategories = async ( + getCategories: GetCategoriesFunction, + cdnSettings: CDNSettings, + integrationCategoryMappings?: CreateWrapperSettings['integrationCategoryMappings'] +): Promise => { + // we don't want to send _every_ category to segment, only the ones that the user has explicitly configured in their integrations + let allCategories: string[] + // We need to get all the unique categories so we can prune the consent object down to only the categories that are configured + // There can be categories that are not included in any integration in the integrations object (e.g. 2 cloud mode categories), which is why we need a special allCategories array + if (integrationCategoryMappings) { + allCategories = uniq( + Object.values(integrationCategoryMappings).reduce((p, n) => p.concat(n)) + ) + } else { + allCategories = cdnSettings.consentSettings?.allCategories || [] + } + + if (!allCategories.length) { + // No configured integrations found, so no categories will be sent (should not happen unless there's a configuration error) + throw new ValidationError( + 'Invariant: No consent categories defined in Segment', + [] + ) + } + + const categories = await getCategories() + + return pick(categories, allCategories) +} diff --git a/packages/consent/consent-tools/src/index.ts b/packages/consent/consent-tools/src/index.ts index 6019d8e91..fdca76598 100644 --- a/packages/consent/consent-tools/src/index.ts +++ b/packages/consent/consent-tools/src/index.ts @@ -11,6 +11,7 @@ export type { CreateWrapperSettings, IntegrationCategoryMappings, Categories, + GetCategoriesFunction, RegisterOnConsentChangedFunction, AnyAnalytics, } from './types' diff --git a/packages/consent/consent-tools/src/types/settings.ts b/packages/consent/consent-tools/src/types/settings.ts index 0c2b13b15..0fc6e7d41 100644 --- a/packages/consent/consent-tools/src/types/settings.ts +++ b/packages/consent/consent-tools/src/types/settings.ts @@ -5,10 +5,18 @@ import type { CDNSettingsRemotePlugin, } from './wrapper' +/** + * See {@link CreateWrapperSettings.registerOnConsentChanged} + */ export type RegisterOnConsentChangedFunction = ( categoriesChangedCb: (categories: Categories) => void ) => void +/** + * See {@link CreateWrapperSettings.getCategories} + */ +export type GetCategoriesFunction = () => Categories | Promise + /** * Consent wrapper function configuration */ @@ -55,11 +63,9 @@ export interface CreateWrapperSettings { /** * Fetch the categories which stamp every event. Called each time a new Segment event is dispatched. * @example - * ```ts * () => ({ "Advertising": true, "Analytics": false }) - * ``` **/ - getCategories: () => Categories | Promise + getCategories: GetCategoriesFunction /** * Function to register a listener for consent changes to programatically send a "Segment Consent Preference" event to Segment when consent preferences change.