Skip to content

Commit 05eafd9

Browse files
bkboothclaude
andauthored
feat(audience): automatic CMP consent detection (GCM v2 + IAB TCF) (#2842)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d76c8e2 commit 05eafd9

File tree

5 files changed

+907
-7
lines changed

5 files changed

+907
-7
lines changed
Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
import { detectCmp, startCmpDetection } from './cmp';
2+
3+
// Helper: set up a fake dataLayer with a GCM consent command
4+
function setupGcm(
5+
analytics: 'granted' | 'denied' = 'granted',
6+
ad: 'granted' | 'denied' = 'denied',
7+
command: 'default' | 'update' = 'default',
8+
): unknown[] {
9+
const dataLayer: unknown[] = [
10+
['consent', command, { analytics_storage: analytics, ad_storage: ad }],
11+
];
12+
(window as unknown as Record<string, unknown>).dataLayer = dataLayer;
13+
return dataLayer;
14+
}
15+
16+
// Helper: set up a fake __tcfapi
17+
function setupTcf(
18+
purposes: Record<number, boolean> = {},
19+
gdprApplies = true,
20+
): { fire: (purposes: Record<number, boolean>) => void } {
21+
const listeners: Array<{ id: number; cb: (data: unknown, success: boolean) => void }> = [];
22+
let nextId = 1;
23+
24+
const tcfapi = (
25+
command: string,
26+
_version: number,
27+
callback: (data: unknown, success: boolean) => void,
28+
listenerId?: number,
29+
) => {
30+
if (command === 'addEventListener') {
31+
const id = nextId++;
32+
listeners.push({ id, cb: callback });
33+
// Fire immediately with current state (simulates CMP already loaded)
34+
callback(
35+
{
36+
gdprApplies, purpose: { consents: purposes }, listenerId: id, eventStatus: 'tcloaded',
37+
},
38+
true,
39+
);
40+
}
41+
if (command === 'removeEventListener' && listenerId !== undefined) {
42+
const idx = listeners.findIndex((l) => l.id === listenerId);
43+
if (idx >= 0) listeners.splice(idx, 1);
44+
}
45+
};
46+
47+
// eslint-disable-next-line no-underscore-dangle
48+
(window as unknown as Record<string, unknown>).__tcfapi = tcfapi;
49+
50+
return {
51+
fire(newPurposes: Record<number, boolean>) {
52+
for (const l of listeners) {
53+
l.cb(
54+
{
55+
gdprApplies, purpose: { consents: newPurposes }, listenerId: l.id, eventStatus: 'useractioncomplete',
56+
},
57+
true,
58+
);
59+
}
60+
},
61+
};
62+
}
63+
64+
function cleanup(): void {
65+
delete (window as unknown as Record<string, unknown>).dataLayer;
66+
// eslint-disable-next-line no-underscore-dangle
67+
delete (window as unknown as Record<string, unknown>).__tcfapi;
68+
}
69+
70+
afterEach(cleanup);
71+
72+
describe('CMP detection', () => {
73+
describe('Google Consent Mode v2', () => {
74+
it('detects GCM and returns correct source', () => {
75+
setupGcm('granted', 'denied');
76+
const onUpdate = jest.fn();
77+
const detector = detectCmp(onUpdate);
78+
79+
expect(detector).not.toBeNull();
80+
expect(detector!.source).toBe('gcm');
81+
});
82+
83+
it('maps analytics_storage denied to none', () => {
84+
setupGcm('denied', 'denied');
85+
const detector = detectCmp(jest.fn());
86+
87+
expect(detector!.level).toBe('none');
88+
});
89+
90+
it('maps analytics granted + ad denied to anonymous', () => {
91+
setupGcm('granted', 'denied');
92+
const detector = detectCmp(jest.fn());
93+
94+
expect(detector!.level).toBe('anonymous');
95+
});
96+
97+
it('maps analytics granted + ad granted to full', () => {
98+
setupGcm('granted', 'granted');
99+
const detector = detectCmp(jest.fn());
100+
101+
expect(detector!.level).toBe('full');
102+
});
103+
104+
it('reads the most recent consent command from dataLayer', () => {
105+
const dataLayer: unknown[] = [
106+
['consent', 'default', { analytics_storage: 'denied', ad_storage: 'denied' }],
107+
['consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted' }],
108+
];
109+
(window as unknown as Record<string, unknown>).dataLayer = dataLayer;
110+
111+
const detector = detectCmp(jest.fn());
112+
expect(detector!.level).toBe('full');
113+
});
114+
115+
it('ignores non-consent dataLayer entries', () => {
116+
const dataLayer: unknown[] = [
117+
['event', 'page_view', {}],
118+
{ event: 'gtm.js' },
119+
['consent', 'default', { analytics_storage: 'granted', ad_storage: 'denied' }],
120+
];
121+
(window as unknown as Record<string, unknown>).dataLayer = dataLayer;
122+
123+
const detector = detectCmp(jest.fn());
124+
expect(detector!.level).toBe('anonymous');
125+
});
126+
127+
it('returns null when dataLayer has no consent commands', () => {
128+
(window as unknown as Record<string, unknown>).dataLayer = [
129+
['event', 'page_view'],
130+
];
131+
132+
const detector = detectCmp(jest.fn());
133+
expect(detector).toBeNull();
134+
});
135+
136+
it('returns null when dataLayer does not exist', () => {
137+
const detector = detectCmp(jest.fn());
138+
expect(detector).toBeNull();
139+
});
140+
141+
it('intercepts dataLayer.push for consent updates', () => {
142+
const dataLayer = setupGcm('granted', 'denied');
143+
const onUpdate = jest.fn();
144+
detectCmp(onUpdate);
145+
146+
// Simulate a consent update via dataLayer.push
147+
dataLayer.push(['consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted' }]);
148+
149+
expect(onUpdate).toHaveBeenCalledWith('full', 'gcm');
150+
});
151+
152+
it('does not fire callback for non-consent dataLayer pushes', () => {
153+
const dataLayer = setupGcm('granted', 'denied');
154+
const onUpdate = jest.fn();
155+
detectCmp(onUpdate);
156+
157+
dataLayer.push(['event', 'page_view']);
158+
expect(onUpdate).not.toHaveBeenCalled();
159+
});
160+
161+
it('stops intercepting after destroy', () => {
162+
const dataLayer = setupGcm('granted', 'denied');
163+
const onUpdate = jest.fn();
164+
const detector = detectCmp(onUpdate);
165+
detector!.destroy();
166+
167+
dataLayer.push(['consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted' }]);
168+
expect(onUpdate).not.toHaveBeenCalled();
169+
});
170+
171+
it('restores original push on destroy', () => {
172+
const dataLayer = setupGcm('granted', 'denied');
173+
const originalPush = dataLayer.push;
174+
detectCmp(jest.fn());
175+
176+
// push was replaced
177+
expect(dataLayer.push).not.toBe(originalPush);
178+
179+
// After destroy, original push should be restored
180+
const detector = detectCmp(jest.fn());
181+
detector!.destroy();
182+
});
183+
});
184+
185+
describe('IAB TCF v2', () => {
186+
it('detects TCF and returns correct source', () => {
187+
setupTcf({ 1: true });
188+
const detector = detectCmp(jest.fn());
189+
190+
expect(detector).not.toBeNull();
191+
expect(detector!.source).toBe('tcf');
192+
});
193+
194+
it('maps no purposes to none', () => {
195+
setupTcf({});
196+
const detector = detectCmp(jest.fn());
197+
198+
expect(detector!.level).toBe('none');
199+
});
200+
201+
it('maps purpose 1 only to anonymous', () => {
202+
setupTcf({
203+
1: true, 3: false, 4: false, 5: false,
204+
});
205+
const detector = detectCmp(jest.fn());
206+
207+
expect(detector!.level).toBe('anonymous');
208+
});
209+
210+
it('maps purpose 1 + purpose 3 to full', () => {
211+
setupTcf({ 1: true, 3: true });
212+
const detector = detectCmp(jest.fn());
213+
214+
expect(detector!.level).toBe('full');
215+
});
216+
217+
it('maps purpose 1 + purpose 4 to full', () => {
218+
setupTcf({ 1: true, 4: true });
219+
const detector = detectCmp(jest.fn());
220+
221+
expect(detector!.level).toBe('full');
222+
});
223+
224+
it('maps purpose 1 + purpose 5 to full', () => {
225+
setupTcf({ 1: true, 5: true });
226+
const detector = detectCmp(jest.fn());
227+
228+
expect(detector!.level).toBe('full');
229+
});
230+
231+
it('maps no purpose 1 to none even with other purposes', () => {
232+
setupTcf({ 1: false, 3: true, 4: true });
233+
const detector = detectCmp(jest.fn());
234+
235+
expect(detector!.level).toBe('none');
236+
});
237+
238+
it('reacts to TCF consent changes', () => {
239+
const tcf = setupTcf({ 1: true });
240+
const onUpdate = jest.fn();
241+
detectCmp(onUpdate);
242+
243+
// Simulate user changing consent
244+
tcf.fire({ 1: true, 3: true, 4: true });
245+
246+
expect(onUpdate).toHaveBeenCalledWith('full', 'tcf');
247+
});
248+
249+
it('reacts to TCF consent downgrade', () => {
250+
const tcf = setupTcf({ 1: true, 3: true });
251+
const onUpdate = jest.fn();
252+
detectCmp(onUpdate);
253+
254+
tcf.fire({ 1: true, 3: false });
255+
expect(onUpdate).toHaveBeenCalledWith('anonymous', 'tcf');
256+
});
257+
258+
it('returns null when __tcfapi does not exist', () => {
259+
const detector = detectCmp(jest.fn());
260+
expect(detector).toBeNull();
261+
});
262+
});
263+
264+
describe('Detection priority', () => {
265+
it('prefers GCM over TCF when both are present', () => {
266+
setupGcm('granted', 'granted');
267+
setupTcf({ 1: true }); // would be 'anonymous'
268+
269+
const detector = detectCmp(jest.fn());
270+
expect(detector!.source).toBe('gcm');
271+
expect(detector!.level).toBe('full');
272+
});
273+
274+
it('falls back to TCF when GCM has no consent commands', () => {
275+
(window as unknown as Record<string, unknown>).dataLayer = [['event', 'page_view']];
276+
setupTcf({ 1: true, 3: true });
277+
278+
const detector = detectCmp(jest.fn());
279+
expect(detector!.source).toBe('tcf');
280+
expect(detector!.level).toBe('full');
281+
});
282+
});
283+
284+
describe('startCmpDetection (polling)', () => {
285+
beforeEach(() => {
286+
jest.useFakeTimers();
287+
});
288+
289+
afterEach(() => {
290+
jest.useRealTimers();
291+
});
292+
293+
it('detects CMP immediately when available', () => {
294+
setupGcm('granted', 'denied');
295+
const onUpdate = jest.fn();
296+
const onDetected = jest.fn();
297+
298+
startCmpDetection(onUpdate, onDetected);
299+
300+
expect(onDetected).toHaveBeenCalledWith(
301+
expect.objectContaining({ source: 'gcm', level: 'anonymous' }),
302+
);
303+
});
304+
305+
it('polls and detects CMP that loads asynchronously', () => {
306+
const onUpdate = jest.fn();
307+
const onDetected = jest.fn();
308+
309+
startCmpDetection(onUpdate, onDetected);
310+
expect(onDetected).not.toHaveBeenCalled();
311+
312+
// CMP loads after 1 poll interval
313+
setupGcm('granted', 'granted');
314+
jest.advanceTimersByTime(800);
315+
316+
expect(onDetected).toHaveBeenCalledWith(
317+
expect.objectContaining({ source: 'gcm', level: 'full' }),
318+
);
319+
});
320+
321+
it('stops polling after max attempts', () => {
322+
const onUpdate = jest.fn();
323+
const onDetected = jest.fn();
324+
325+
startCmpDetection(onUpdate, onDetected);
326+
327+
// Advance past all 3 poll attempts (3 * 800ms = 2400ms)
328+
jest.advanceTimersByTime(3000);
329+
330+
expect(onDetected).not.toHaveBeenCalled();
331+
332+
// Even if CMP loads later, polling has stopped
333+
setupGcm('granted', 'granted');
334+
jest.advanceTimersByTime(1000);
335+
expect(onDetected).not.toHaveBeenCalled();
336+
});
337+
338+
it('cleanup function stops polling', () => {
339+
const onUpdate = jest.fn();
340+
const onDetected = jest.fn();
341+
342+
const teardown = startCmpDetection(onUpdate, onDetected);
343+
teardown();
344+
345+
setupGcm('granted', 'granted');
346+
jest.advanceTimersByTime(1000);
347+
expect(onDetected).not.toHaveBeenCalled();
348+
});
349+
350+
it('calls onTimeout when no CMP is found after all polls', () => {
351+
const onUpdate = jest.fn();
352+
const onDetected = jest.fn();
353+
const onTimeout = jest.fn();
354+
355+
startCmpDetection(onUpdate, onDetected, onTimeout);
356+
357+
// Advance past all 3 poll attempts
358+
jest.advanceTimersByTime(3000);
359+
360+
expect(onDetected).not.toHaveBeenCalled();
361+
expect(onTimeout).toHaveBeenCalledTimes(1);
362+
});
363+
364+
it('does not call onTimeout when CMP is detected', () => {
365+
const onUpdate = jest.fn();
366+
const onDetected = jest.fn();
367+
const onTimeout = jest.fn();
368+
369+
startCmpDetection(onUpdate, onDetected, onTimeout);
370+
371+
// CMP loads after 1 poll
372+
setupGcm('granted', 'denied');
373+
jest.advanceTimersByTime(800);
374+
375+
expect(onDetected).toHaveBeenCalled();
376+
expect(onTimeout).not.toHaveBeenCalled();
377+
378+
// Advance past remaining polls — onTimeout should still not fire
379+
jest.advanceTimersByTime(3000);
380+
expect(onTimeout).not.toHaveBeenCalled();
381+
});
382+
383+
it('cleanup function destroys detected CMP listener', () => {
384+
const dataLayer = setupGcm('granted', 'denied');
385+
const onUpdate = jest.fn();
386+
const onDetected = jest.fn();
387+
388+
const teardown = startCmpDetection(onUpdate, onDetected);
389+
teardown();
390+
391+
// After teardown, consent updates should not fire
392+
dataLayer.push(['consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted' }]);
393+
expect(onUpdate).not.toHaveBeenCalled();
394+
});
395+
});
396+
});

0 commit comments

Comments
 (0)