Skip to content

Commit 68de603

Browse files
feat(audience): add ImmutableWebSDK class with identity and session lifecycle
- SDK class: init, track, page, identify, alias, setConsent, reset, shutdown - Auto-tracked session_start on new session, session_end on shutdown - Consent-aware: none=inert, anonymous=no PII, full=all events - Consent downgrade purges identify/alias, strips userId - Uses core's MessageQueue with httpTransport and __imtbl_web_ storage prefix - 45 tests covering all methods and consent transitions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 739a16a commit 68de603

3 files changed

Lines changed: 786 additions & 0 deletions

File tree

packages/audience/web/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { ImmutableWebSDK } from './sdk';
2+
export type { WebSDKConfig } from './types';
3+
export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core';
Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
import { ImmutableWebSDK } from './sdk';
2+
3+
function createSDK(overrides: Record<string, unknown> = {}) {
4+
return ImmutableWebSDK.init({
5+
publishableKey: 'pk_imtbl_test',
6+
environment: 'sandbox',
7+
consent: 'full',
8+
...overrides,
9+
});
10+
}
11+
12+
const fetchCalls: { url: string; init: RequestInit }[] = [];
13+
14+
const mockFetch = jest.fn().mockImplementation(
15+
async (url: string, init?: RequestInit) => {
16+
fetchCalls.push({ url: url as string, init: init ?? {} });
17+
return { ok: true, json: async () => ({}) };
18+
},
19+
);
20+
global.fetch = mockFetch;
21+
22+
function sentMessages(): any[] {
23+
return fetchCalls
24+
.filter((c) => c.url.includes('/v1/audience/messages'))
25+
.flatMap((c) => {
26+
try {
27+
return JSON.parse(c.init.body as string).messages;
28+
} catch {
29+
return [];
30+
}
31+
});
32+
}
33+
34+
beforeEach(() => {
35+
jest.clearAllMocks();
36+
fetchCalls.length = 0;
37+
jest.useFakeTimers();
38+
document.cookie.split(';').forEach((c) => {
39+
document.cookie = `${c.trim().split('=')[0]}=;max-age=0;path=/`;
40+
});
41+
localStorage.clear();
42+
sessionStorage.clear();
43+
});
44+
45+
afterEach(() => {
46+
jest.useRealTimers();
47+
});
48+
49+
describe('ImmutableWebSDK', () => {
50+
describe('init', () => {
51+
it('creates an SDK instance via static init()', () => {
52+
const sdk = createSDK();
53+
expect(sdk).toBeInstanceOf(ImmutableWebSDK);
54+
sdk.shutdown();
55+
});
56+
57+
it('sets consent cookie on init', () => {
58+
const sdk = createSDK({ consent: 'anonymous' });
59+
expect(document.cookie).toContain('_imtbl_consent=anonymous');
60+
sdk.shutdown();
61+
});
62+
63+
it('creates anonymous ID cookie when consent allows', () => {
64+
const sdk = createSDK({ consent: 'anonymous' });
65+
expect(document.cookie).toContain('imtbl_anon_id=');
66+
sdk.shutdown();
67+
});
68+
69+
it('does not create identity cookies at none consent', () => {
70+
const sdk = createSDK({ consent: 'none' });
71+
expect(document.cookie).toContain('_imtbl_consent=none');
72+
sdk.shutdown();
73+
});
74+
});
75+
76+
describe('track', () => {
77+
it('enqueues an event and flushes', async () => {
78+
const sdk = createSDK();
79+
80+
sdk.track('purchase', {
81+
currency: 'USD',
82+
value: 9.99,
83+
itemId: 'sword_01',
84+
});
85+
86+
await sdk.flush();
87+
88+
const msgs = sentMessages();
89+
const msg = msgs.find(
90+
(m: any) => m.type === 'track' && m.eventName === 'purchase',
91+
);
92+
93+
expect(msg).toBeDefined();
94+
expect(msg.properties).toEqual({
95+
currency: 'USD',
96+
value: 9.99,
97+
itemId: 'sword_01',
98+
});
99+
expect(msg.surface).toBe('web');
100+
expect(msg.context.library).toBe('@imtbl/audience-web-sdk');
101+
102+
sdk.shutdown();
103+
});
104+
105+
it('is a no-op at none consent', async () => {
106+
const sdk = createSDK({ consent: 'none' });
107+
108+
sdk.track('sign_up', { method: 'google' });
109+
await sdk.flush();
110+
111+
expect(sentMessages()).toHaveLength(0);
112+
sdk.shutdown();
113+
});
114+
115+
it('excludes userId at anonymous consent', async () => {
116+
const sdk = createSDK({ consent: 'anonymous' });
117+
118+
sdk.track('sign_in', { method: 'passport' });
119+
await sdk.flush();
120+
121+
const msg = sentMessages().find(
122+
(m: any) => m.type === 'track' && m.eventName === 'sign_in',
123+
);
124+
expect(msg).toBeDefined();
125+
expect(msg.userId).toBeUndefined();
126+
127+
sdk.shutdown();
128+
});
129+
});
130+
131+
describe('page', () => {
132+
it('enqueues a page message', async () => {
133+
const sdk = createSDK();
134+
135+
sdk.page({ section: 'shop' });
136+
await sdk.flush();
137+
138+
const msg = sentMessages().find((m: any) => m.type === 'page');
139+
expect(msg).toBeDefined();
140+
expect(msg.properties).toMatchObject({ section: 'shop' });
141+
142+
sdk.shutdown();
143+
});
144+
145+
it('attaches attribution to the first page view', async () => {
146+
Object.defineProperty(window, 'location', {
147+
value: {
148+
...window.location,
149+
search: '?utm_source=youtube',
150+
href: 'https://studio.com/?utm_source=youtube',
151+
protocol: 'https:',
152+
pathname: '/',
153+
},
154+
writable: true,
155+
configurable: true,
156+
});
157+
sessionStorage.clear();
158+
159+
const sdk = createSDK();
160+
sdk.page();
161+
sdk.page();
162+
await sdk.flush();
163+
164+
const pages = sentMessages().filter(
165+
(m: any) => m.type === 'page',
166+
);
167+
expect(pages[0].properties).toHaveProperty(
168+
'utm_source',
169+
'youtube',
170+
);
171+
if (pages[1]) {
172+
expect(pages[1].properties?.utm_source).toBeUndefined();
173+
}
174+
175+
sdk.shutdown();
176+
});
177+
});
178+
179+
describe('identify', () => {
180+
it('sends an identify message at full consent', async () => {
181+
const sdk = createSDK({ consent: 'full' });
182+
183+
sdk.identify('user@example.com', 'email', {
184+
name: 'Player One',
185+
});
186+
await sdk.flush();
187+
188+
const msg = sentMessages().find(
189+
(m: any) => m.type === 'identify',
190+
);
191+
expect(msg).toBeDefined();
192+
expect(msg.userId).toBe('user@example.com');
193+
expect(msg.identityType).toBe('email');
194+
expect(msg.traits).toEqual({ name: 'Player One' });
195+
196+
sdk.shutdown();
197+
});
198+
199+
it('is a no-op at anonymous consent', async () => {
200+
const sdk = createSDK({ consent: 'anonymous' });
201+
202+
sdk.identify('user@example.com', 'email');
203+
await sdk.flush();
204+
205+
const ids = sentMessages().filter(
206+
(m: any) => m.type === 'identify',
207+
);
208+
expect(ids).toHaveLength(0);
209+
sdk.shutdown();
210+
});
211+
212+
it('sends anonymous identify with traits only', async () => {
213+
const sdk = createSDK({ consent: 'full' });
214+
215+
sdk.identify({
216+
source: 'steam',
217+
steamId: '76561198012345',
218+
});
219+
await sdk.flush();
220+
221+
const msg = sentMessages().find(
222+
(m: any) => m.type === 'identify',
223+
);
224+
expect(msg).toBeDefined();
225+
expect(msg.userId).toBeUndefined();
226+
expect(msg.traits).toEqual({
227+
source: 'steam',
228+
steamId: '76561198012345',
229+
});
230+
231+
sdk.shutdown();
232+
});
233+
234+
it('includes userId on subsequent track calls', async () => {
235+
const sdk = createSDK({ consent: 'full' });
236+
237+
sdk.identify('player42', 'steam');
238+
sdk.track('purchase', { currency: 'USD', value: 9.99 });
239+
await sdk.flush();
240+
241+
const trackMsg = sentMessages().find(
242+
(m: any) => m.type === 'track' && m.eventName === 'purchase',
243+
);
244+
expect(trackMsg).toBeDefined();
245+
expect(trackMsg.userId).toBe('player42');
246+
247+
sdk.shutdown();
248+
});
249+
});
250+
251+
describe('alias', () => {
252+
it('sends alias with fromId/fromType/toId/toType', async () => {
253+
const sdk = createSDK({ consent: 'full' });
254+
255+
sdk.alias(
256+
{ uid: '76561198012345', provider: 'steam' },
257+
{ uid: 'user@example.com', provider: 'email' },
258+
);
259+
await sdk.flush();
260+
261+
const msg = sentMessages().find(
262+
(m: any) => m.type === 'alias',
263+
);
264+
expect(msg).toBeDefined();
265+
expect(msg.fromId).toBe('76561198012345');
266+
expect(msg.fromType).toBe('steam');
267+
expect(msg.toId).toBe('user@example.com');
268+
expect(msg.toType).toBe('email');
269+
270+
sdk.shutdown();
271+
});
272+
});
273+
274+
describe('setConsent', () => {
275+
it('starts queue when upgrading from none', async () => {
276+
const sdk = createSDK({ consent: 'none' });
277+
278+
sdk.track('sign_up', { method: 'google' });
279+
await sdk.flush();
280+
expect(sentMessages()).toHaveLength(0);
281+
282+
sdk.setConsent('anonymous');
283+
sdk.track('sign_up', { method: 'google' });
284+
await sdk.flush();
285+
286+
const tracks = sentMessages().filter(
287+
(m: any) => m.type === 'track',
288+
);
289+
expect(tracks.length).toBeGreaterThan(0);
290+
291+
sdk.shutdown();
292+
});
293+
294+
it('purges identify/alias, strips userId on downgrade', async () => {
295+
const sdk = createSDK({ consent: 'full' });
296+
297+
sdk.identify('user@example.com', 'email');
298+
sdk.alias(
299+
{ uid: '76561198012345', provider: 'steam' },
300+
{ uid: 'user@example.com', provider: 'email' },
301+
);
302+
sdk.track('purchase', { currency: 'USD', value: 9.99 });
303+
304+
sdk.setConsent('anonymous');
305+
await sdk.flush();
306+
307+
const msgs = sentMessages();
308+
expect(
309+
msgs.every((m: any) => m.type !== 'identify'),
310+
).toBe(true);
311+
expect(
312+
msgs.every((m: any) => m.type !== 'alias'),
313+
).toBe(true);
314+
const trackMsg = msgs.find(
315+
(m: any) => m.type === 'track' && m.eventName === 'purchase',
316+
);
317+
expect(trackMsg).toBeDefined();
318+
expect(trackMsg.userId).toBeUndefined();
319+
320+
sdk.shutdown();
321+
});
322+
323+
it('alias requires full consent, not anonymous', async () => {
324+
const sdk = createSDK({ consent: 'anonymous' });
325+
326+
sdk.alias(
327+
{ uid: '76561198012345', provider: 'steam' },
328+
{ uid: 'user@example.com', provider: 'email' },
329+
);
330+
await sdk.flush();
331+
332+
const aliases = sentMessages().filter(
333+
(m: any) => m.type === 'alias',
334+
);
335+
expect(aliases).toHaveLength(0);
336+
sdk.shutdown();
337+
});
338+
});
339+
340+
describe('reset', () => {
341+
it('clears userId and generates new anonymousId', async () => {
342+
const sdk = createSDK({ consent: 'full' });
343+
344+
sdk.track('sign_in', { method: 'passport' });
345+
await sdk.flush();
346+
const originalAnonId = sentMessages().find(
347+
(m: any) => m.type === 'track',
348+
)?.anonymousId;
349+
fetchCalls.length = 0;
350+
351+
sdk.identify('user@example.com', 'email');
352+
await sdk.flush();
353+
fetchCalls.length = 0;
354+
355+
sdk.reset();
356+
sdk.track('sign_up', { method: 'google' });
357+
await sdk.flush();
358+
359+
const msg = sentMessages().find(
360+
(m: any) => m.type === 'track',
361+
);
362+
expect(msg).toBeDefined();
363+
expect(msg.userId).toBeUndefined();
364+
expect(msg.anonymousId).toBeDefined();
365+
expect(msg.anonymousId).not.toBe(originalAnonId);
366+
367+
sdk.shutdown();
368+
});
369+
});
370+
});

0 commit comments

Comments
 (0)