Skip to content

Commit 97fc84b

Browse files
feat(audience): add ImmutableAudienceSDK class with session lifecycle
Main SDK entry point with page/track/identify/alias/setConsent methods. Consumes core's createConsentManager for queue-integrated consent, getOrCreateSession for session tracking, and collectAttribution for UTM/click ID capture. Fires session_start/session_end events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a5c1ea2 commit 97fc84b

3 files changed

Lines changed: 507 additions & 0 deletions

File tree

packages/audience/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { ImmutableAudienceSDK } from './sdk';
12
export type { AudienceSDKConfig } from './types';
23
export { DebugLogger } from './debug';
34
export { collectContext } from './context';
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import {
3+
MessageQueue,
4+
createConsentManager,
5+
getOrCreateSession,
6+
deleteCookie,
7+
} from '@imtbl/audience-core';
8+
import { ImmutableAudienceSDK } from './sdk';
9+
10+
// Mock core modules
11+
jest.mock('@imtbl/audience-core', () => {
12+
const actual = jest.requireActual('@imtbl/audience-core');
13+
14+
const mockQueue = {
15+
start: jest.fn(),
16+
stop: jest.fn(),
17+
destroy: jest.fn(),
18+
enqueue: jest.fn(),
19+
flush: jest.fn(),
20+
flushUnload: jest.fn(),
21+
purge: jest.fn(),
22+
transform: jest.fn(),
23+
clear: jest.fn(),
24+
get length() { return 0; },
25+
};
26+
27+
let consentLevel = 'none';
28+
const mockConsent = {
29+
get level() { return consentLevel as 'none' | 'anonymous' | 'full'; },
30+
setLevel: jest.fn((next: string) => { consentLevel = next; }),
31+
};
32+
33+
return {
34+
...actual,
35+
MessageQueue: jest.fn(() => mockQueue),
36+
createConsentManager: jest.fn(() => {
37+
consentLevel = 'none';
38+
return mockConsent;
39+
}),
40+
getOrCreateAnonymousId: jest.fn(() => 'anon-123'),
41+
getOrCreateSession: jest.fn(() => ({ sessionId: 'sess-456', isNew: false })),
42+
collectAttribution: jest.fn(() => ({})),
43+
deleteCookie: jest.fn(),
44+
httpTransport: { send: jest.fn() },
45+
isBrowser: jest.fn(() => true),
46+
generateId: actual.generateId,
47+
getTimestamp: actual.getTimestamp,
48+
getBaseUrl: actual.getBaseUrl,
49+
};
50+
});
51+
52+
beforeEach(() => {
53+
jest.clearAllMocks();
54+
});
55+
56+
function createSDK(overrides: Record<string, any> = {}) {
57+
return new ImmutableAudienceSDK({
58+
publishableKey: 'pk_test',
59+
environment: 'sandbox',
60+
...overrides,
61+
});
62+
}
63+
64+
describe('ImmutableAudienceSDK', () => {
65+
it('initialises with default consent none', () => {
66+
createSDK();
67+
expect(createConsentManager).toHaveBeenCalledWith(
68+
expect.anything(),
69+
'pk_test',
70+
'anon-123',
71+
'sandbox',
72+
'none',
73+
);
74+
});
75+
76+
it('initialises with provided consent level', () => {
77+
createSDK({ consent: 'full' });
78+
expect(createConsentManager).toHaveBeenCalledWith(
79+
expect.anything(),
80+
'pk_test',
81+
'anon-123',
82+
'sandbox',
83+
'full',
84+
);
85+
});
86+
87+
it('starts the queue on construction', () => {
88+
createSDK();
89+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
90+
expect(queue.start).toHaveBeenCalled();
91+
});
92+
93+
it('does not enqueue when consent is none', () => {
94+
const sdk = createSDK();
95+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
96+
97+
sdk.page();
98+
sdk.track('click');
99+
100+
expect(queue.enqueue).not.toHaveBeenCalled();
101+
});
102+
103+
it('enqueues page event when consent allows', () => {
104+
const sdk = createSDK({ consent: 'anonymous' });
105+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
106+
consent.setLevel('anonymous');
107+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
108+
109+
sdk.page({ section: 'shop' });
110+
111+
expect(queue.enqueue).toHaveBeenCalledWith(
112+
expect.objectContaining({
113+
type: 'page',
114+
surface: 'web',
115+
anonymousId: 'anon-123',
116+
}),
117+
);
118+
});
119+
120+
it('enqueues track event with eventName', () => {
121+
const sdk = createSDK({ consent: 'anonymous' });
122+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
123+
consent.setLevel('anonymous');
124+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
125+
126+
sdk.track('purchase', { value: 9.99 });
127+
128+
expect(queue.enqueue).toHaveBeenCalledWith(
129+
expect.objectContaining({
130+
type: 'track',
131+
eventName: 'purchase',
132+
}),
133+
);
134+
});
135+
136+
it('only allows identify at full consent', () => {
137+
const sdk = createSDK();
138+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
139+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
140+
141+
consent.setLevel('anonymous');
142+
sdk.identify('user-1', { name: 'Test' });
143+
expect(queue.enqueue).not.toHaveBeenCalled();
144+
145+
consent.setLevel('full');
146+
sdk.identify('user-1', { name: 'Test' });
147+
expect(queue.enqueue).toHaveBeenCalledWith(
148+
expect.objectContaining({
149+
type: 'identify',
150+
userId: 'user-1',
151+
}),
152+
);
153+
});
154+
155+
it('clears cookies when consent set to none', () => {
156+
const sdk = createSDK();
157+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
158+
consent.setLevel('full');
159+
160+
sdk.setConsent('none');
161+
162+
expect(consent.setLevel).toHaveBeenCalledWith('none');
163+
expect(deleteCookie).toHaveBeenCalledTimes(2);
164+
});
165+
166+
it('destroys queue on destroy', () => {
167+
const sdk = createSDK();
168+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
169+
170+
sdk.destroy();
171+
expect(queue.destroy).toHaveBeenCalled();
172+
173+
sdk.track('late-event');
174+
expect(queue.enqueue).not.toHaveBeenCalled();
175+
});
176+
177+
describe('session lifecycle', () => {
178+
it('fires session_start on new session', () => {
179+
(getOrCreateSession as jest.Mock).mockReturnValue({ sessionId: 'new-sess', isNew: true });
180+
181+
const sdk = createSDK({ consent: 'anonymous' });
182+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
183+
consent.setLevel('anonymous');
184+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
185+
186+
sdk.page();
187+
188+
const enqueued = queue.enqueue.mock.calls.map((c: any[]) => c[0]);
189+
const sessionStart = enqueued.find((m: any) => m.eventName === 'session_start');
190+
expect(sessionStart).toBeDefined();
191+
expect(sessionStart.properties.sessionId).toBe('new-sess');
192+
});
193+
194+
it('does not fire session_start on existing session', () => {
195+
(getOrCreateSession as jest.Mock).mockReturnValue({ sessionId: 'old-sess', isNew: false });
196+
197+
const sdk = createSDK({ consent: 'anonymous' });
198+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
199+
consent.setLevel('anonymous');
200+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
201+
202+
sdk.page();
203+
204+
const enqueued = queue.enqueue.mock.calls.map((c: any[]) => c[0]);
205+
const sessionStart = enqueued.find((m: any) => m.eventName === 'session_start');
206+
expect(sessionStart).toBeUndefined();
207+
});
208+
});
209+
});

0 commit comments

Comments
 (0)