Skip to content

Commit 862c03e

Browse files
test: add integration tests
120 tests total (82 web + 38 shared SDK). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e82ddf6 commit 862c03e

4 files changed

Lines changed: 677 additions & 0 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { parseAttribution, attributionToProperties } from './attribution';
2+
3+
beforeEach(() => {
4+
// Reset sessionStorage between tests
5+
sessionStorage.clear();
6+
});
7+
8+
describe('parseAttribution', () => {
9+
it('parses UTM params from the URL', () => {
10+
Object.defineProperty(window, 'location', {
11+
value: {
12+
search: '?utm_source=youtube&utm_medium=influencer&utm_campaign=launch',
13+
href: 'https://studio.com/shop?utm_source=youtube&utm_medium=influencer&utm_campaign=launch',
14+
},
15+
writable: true,
16+
});
17+
18+
const ctx = parseAttribution();
19+
expect(ctx.utmSource).toBe('youtube');
20+
expect(ctx.utmMedium).toBe('influencer');
21+
expect(ctx.utmCampaign).toBe('launch');
22+
expect(ctx.landingPage).toBe(window.location.href);
23+
});
24+
25+
it('parses click IDs from the URL', () => {
26+
Object.defineProperty(window, 'location', {
27+
value: {
28+
search: '?gclid=abc&fbclid=def&ttclid=ghi&msclkid=jkl',
29+
href: 'https://studio.com/?gclid=abc&fbclid=def&ttclid=ghi&msclkid=jkl',
30+
},
31+
writable: true,
32+
});
33+
34+
const ctx = parseAttribution();
35+
expect(ctx.gclid).toBe('abc');
36+
expect(ctx.fbclid).toBe('def');
37+
expect(ctx.ttclid).toBe('ghi');
38+
expect(ctx.msclkid).toBe('jkl');
39+
});
40+
41+
it('returns cached attribution on subsequent calls within session', () => {
42+
Object.defineProperty(window, 'location', {
43+
value: {
44+
search: '?utm_source=first',
45+
href: 'https://studio.com/?utm_source=first',
46+
},
47+
writable: true,
48+
});
49+
50+
const first = parseAttribution();
51+
expect(first.utmSource).toBe('first');
52+
53+
// Change URL (simulating SPA navigation)
54+
Object.defineProperty(window, 'location', {
55+
value: {
56+
search: '',
57+
href: 'https://studio.com/shop',
58+
},
59+
writable: true,
60+
});
61+
62+
const second = parseAttribution();
63+
expect(second.utmSource).toBe('first'); // Still the original
64+
});
65+
66+
it('returns empty context with no params', () => {
67+
Object.defineProperty(window, 'location', {
68+
value: {
69+
search: '',
70+
href: 'https://studio.com/',
71+
},
72+
writable: true,
73+
});
74+
75+
const ctx = parseAttribution();
76+
expect(ctx.utmSource).toBeUndefined();
77+
expect(ctx.gclid).toBeUndefined();
78+
expect(ctx.landingPage).toBe('https://studio.com/');
79+
});
80+
});
81+
82+
describe('attributionToProperties', () => {
83+
it('converts attribution context to flat properties', () => {
84+
const props = attributionToProperties({
85+
utmSource: 'youtube',
86+
utmMedium: 'influencer',
87+
gclid: 'abc',
88+
landingPage: 'https://studio.com/',
89+
});
90+
91+
expect(props).toEqual({
92+
utm_source: 'youtube',
93+
utm_medium: 'influencer',
94+
gclid: 'abc',
95+
landing_page: 'https://studio.com/',
96+
});
97+
});
98+
99+
it('omits undefined fields', () => {
100+
const props = attributionToProperties({ utmSource: 'youtube' });
101+
expect(props).toEqual({ utm_source: 'youtube' });
102+
expect(props).not.toHaveProperty('utm_medium');
103+
});
104+
});
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { ConsentManager, detectPrivacySignal } from './consent';
2+
3+
// Mock fetch for server sync
4+
const mockFetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
5+
global.fetch = mockFetch;
6+
7+
beforeEach(() => {
8+
jest.clearAllMocks();
9+
// Clear cookies
10+
document.cookie.split(';').forEach((c) => {
11+
document.cookie = `${c.trim().split('=')[0]}=;max-age=0;path=/`;
12+
});
13+
});
14+
15+
describe('ConsentManager', () => {
16+
it('initialises with the provided consent level', () => {
17+
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK');
18+
expect(manager.getLevel()).toBe('anonymous');
19+
});
20+
21+
it('honours existing consent cookie over initial config', () => {
22+
// Set a consent cookie before init
23+
document.cookie = '_imtbl_consent=full;path=/';
24+
25+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
26+
expect(manager.getLevel()).toBe('full');
27+
});
28+
29+
it('persists consent to cookie on init', () => {
30+
// eslint-disable-next-line no-new
31+
new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK');
32+
expect(document.cookie).toContain('_imtbl_consent=anonymous');
33+
});
34+
35+
it('calls onPurgeQueue when downgrading to none', () => {
36+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
37+
const onPurge = jest.fn();
38+
const onClear = jest.fn();
39+
40+
manager.setLevel('none', 'anon-123', {
41+
onPurgeQueue: onPurge,
42+
onClearCookies: onClear,
43+
});
44+
45+
expect(onPurge).toHaveBeenCalled();
46+
expect(onClear).toHaveBeenCalled();
47+
expect(manager.getLevel()).toBe('none');
48+
});
49+
50+
it('calls onStripIdentity when downgrading from full to anonymous', () => {
51+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
52+
const onStrip = jest.fn();
53+
54+
manager.setLevel('anonymous', 'anon-123', { onStripIdentity: onStrip });
55+
56+
expect(onStrip).toHaveBeenCalled();
57+
expect(manager.getLevel()).toBe('anonymous');
58+
});
59+
60+
it('syncs consent to server via PUT on setLevel', () => {
61+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
62+
manager.setLevel('full', 'anon-123');
63+
64+
expect(mockFetch).toHaveBeenCalledWith(
65+
'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
66+
expect.objectContaining({
67+
method: 'PUT',
68+
body: JSON.stringify({
69+
anonymousId: 'anon-123',
70+
status: 'full',
71+
source: 'TestSDK',
72+
}),
73+
}),
74+
);
75+
});
76+
77+
describe('DNT / GPC', () => {
78+
it('forces consent to none when DNT is set', () => {
79+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
80+
81+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
82+
expect(manager.getLevel()).toBe('none');
83+
84+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
85+
});
86+
87+
it('forces consent to none when GPC is set', () => {
88+
Object.defineProperty(navigator, 'globalPrivacyControl', { value: true, configurable: true });
89+
90+
const manager = new ConsentManager('sandbox', 'pk_test', 'anonymous', 'TestSDK');
91+
expect(manager.getLevel()).toBe('none');
92+
93+
Object.defineProperty(navigator, 'globalPrivacyControl', { value: false, configurable: true });
94+
});
95+
96+
it('blocks setLevel upgrade when DNT is active', () => {
97+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
98+
99+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
100+
manager.setLevel('full', 'anon-123');
101+
expect(manager.getLevel()).toBe('none');
102+
103+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
104+
});
105+
106+
it('still allows downgrade to none when DNT is active', () => {
107+
// Start without DNT
108+
const manager = new ConsentManager('sandbox', 'pk_test', 'full', 'TestSDK');
109+
expect(manager.getLevel()).toBe('full');
110+
111+
// Enable DNT, then downgrade
112+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
113+
const onPurge = jest.fn();
114+
manager.setLevel('none', 'anon-123', { onPurgeQueue: onPurge });
115+
expect(manager.getLevel()).toBe('none');
116+
expect(onPurge).toHaveBeenCalled();
117+
118+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
119+
});
120+
121+
it('detectPrivacySignal returns false when no signals set', () => {
122+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
123+
Object.defineProperty(navigator, 'globalPrivacyControl', { value: false, configurable: true });
124+
expect(detectPrivacySignal()).toBe(false);
125+
});
126+
});
127+
128+
it('fetches server consent status', async () => {
129+
mockFetch.mockResolvedValueOnce({
130+
ok: true,
131+
json: async () => ({ status: 'anonymous' }),
132+
});
133+
134+
const manager = new ConsentManager('sandbox', 'pk_test', 'none', 'TestSDK');
135+
const status = await manager.fetchServerConsent('anon-123');
136+
137+
expect(status).toBe('anonymous');
138+
expect(mockFetch).toHaveBeenCalledWith(
139+
'https://api.sandbox.immutable.com/v1/audience/tracking-consent?anonymousId=anon-123',
140+
expect.objectContaining({
141+
headers: { 'x-immutable-publishable-key': 'pk_test' },
142+
}),
143+
);
144+
});
145+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { PageTracker } from './page';
2+
3+
beforeEach(() => {
4+
// Reset location for each test
5+
Object.defineProperty(window, 'location', {
6+
value: { href: 'https://studio.com/', pathname: '/' },
7+
writable: true,
8+
});
9+
});
10+
11+
describe('PageTracker', () => {
12+
it('calls onPage when pushState is invoked', () => {
13+
const onPage = jest.fn();
14+
const tracker = new PageTracker(onPage);
15+
tracker.installSPAListeners();
16+
17+
// Simulate a route change
18+
Object.defineProperty(window, 'location', {
19+
value: { href: 'https://studio.com/shop', pathname: '/shop' },
20+
writable: true,
21+
});
22+
window.history.pushState({}, '', '/shop');
23+
24+
expect(onPage).toHaveBeenCalledTimes(1);
25+
tracker.teardown();
26+
});
27+
28+
it('calls onPage when replaceState is invoked', () => {
29+
const onPage = jest.fn();
30+
const tracker = new PageTracker(onPage);
31+
tracker.installSPAListeners();
32+
33+
Object.defineProperty(window, 'location', {
34+
value: { href: 'https://studio.com/cart', pathname: '/cart' },
35+
writable: true,
36+
});
37+
window.history.replaceState({}, '', '/cart');
38+
39+
expect(onPage).toHaveBeenCalledTimes(1);
40+
tracker.teardown();
41+
});
42+
43+
it('deduplicates rapid calls for the same URL', () => {
44+
const onPage = jest.fn();
45+
const tracker = new PageTracker(onPage);
46+
tracker.installSPAListeners();
47+
48+
// Two pushState calls for the same URL within dedup threshold
49+
window.history.pushState({}, '', '/shop');
50+
window.history.pushState({}, '', '/shop');
51+
52+
expect(onPage).toHaveBeenCalledTimes(1);
53+
tracker.teardown();
54+
});
55+
56+
it('restores original history methods on teardown', () => {
57+
const originalPush = window.history.pushState;
58+
const originalReplace = window.history.replaceState;
59+
60+
const tracker = new PageTracker(jest.fn());
61+
tracker.installSPAListeners();
62+
63+
// Methods should be patched
64+
expect(window.history.pushState).not.toBe(originalPush);
65+
expect(window.history.replaceState).not.toBe(originalReplace);
66+
67+
tracker.teardown();
68+
69+
// Methods should be restored
70+
expect(window.history.pushState).toBeDefined();
71+
expect(window.history.replaceState).toBeDefined();
72+
});
73+
});

0 commit comments

Comments
 (0)