Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions packages/audience/core/src/attribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { collectAttribution, clearAttribution } from './attribution';

const STORAGE_KEY = '__imtbl_attribution';

beforeEach(() => {
sessionStorage.clear();
jest.restoreAllMocks();
});

function setLocation(url: string) {
Object.defineProperty(window, 'location', {
value: new URL(url),
writable: true,
configurable: true,
});
}

describe('collectAttribution', () => {
it('parses UTM parameters from the URL', () => {
setLocation(
'https://example.com/?utm_source=google&utm_medium=cpc&utm_campaign=spring&utm_content=banner&utm_term=nft',
);

const result = collectAttribution();
expect(result.utm_source).toBe('google');
expect(result.utm_medium).toBe('cpc');
expect(result.utm_campaign).toBe('spring');
expect(result.utm_content).toBe('banner');
expect(result.utm_term).toBe('nft');
});

it('parses ad network click IDs', () => {
setLocation(
'https://example.com/?gclid=abc&dclid=dc1&fbclid=fb2&ttclid=tt3&msclkid=ms4&li_fat_id=li5',
);

const result = collectAttribution();
expect(result.gclid).toBe('abc');
expect(result.dclid).toBe('dc1');
expect(result.fbclid).toBe('fb2');
expect(result.ttclid).toBe('tt3');
expect(result.msclkid).toBe('ms4');
expect(result.li_fat_id).toBe('li5');
});

it('captures referrer and landing page', () => {
setLocation('https://game.example.com/landing');
Object.defineProperty(document, 'referrer', {
value: 'https://google.com/search?q=nft',
configurable: true,
});

const result = collectAttribution();
expect(result.referrer).toBe('https://google.com/search?q=nft');
expect(result.landing_page).toBe('https://game.example.com/landing');
});

it('caches in sessionStorage and returns cached on second call', () => {
setLocation('https://example.com/?utm_source=google');

const first = collectAttribution();
expect(first.utm_source).toBe('google');

// Change URL — should still return cached value
setLocation('https://example.com/?utm_source=facebook');
const second = collectAttribution();
expect(second.utm_source).toBe('google');
});

it('parses referral_code from the URL', () => {
setLocation('https://example.com/?referral_code=PARTNER42');

const result = collectAttribution();
expect(result.referral_code).toBe('PARTNER42');
});

it('sets touchpoint_type to click when UTMs are present', () => {
setLocation('https://example.com/?utm_source=google');

const result = collectAttribution();
expect(result.touchpoint_type).toBe('click');
});

it('sets touchpoint_type to click when a click ID is present', () => {
setLocation('https://example.com/?gclid=abc123');

const result = collectAttribution();
expect(result.touchpoint_type).toBe('click');
});

it('does not set touchpoint_type when no UTMs or click IDs are present', () => {
setLocation('https://example.com/');
Object.defineProperty(document, 'referrer', { value: 'https://other.com', configurable: true });

const result = collectAttribution();
expect(result.touchpoint_type).toBeUndefined();
});

it('returns empty attribution when no params are present', () => {
setLocation('https://example.com/');
Object.defineProperty(document, 'referrer', { value: '', configurable: true });

const result = collectAttribution();
expect(result.utm_source).toBeUndefined();
expect(result.gclid).toBeUndefined();
expect(result.referrer).toBeUndefined();
});

it('handles sessionStorage being unavailable', () => {
setLocation('https://example.com/?utm_source=twitter');
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage disabled');
});
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('storage disabled');
});

const result = collectAttribution();
expect(result.utm_source).toBe('twitter');
});
});

describe('clearAttribution', () => {
it('removes cached attribution from sessionStorage', () => {
setLocation('https://example.com/?utm_source=google');
collectAttribution();
expect(sessionStorage.getItem(STORAGE_KEY)).not.toBeNull();

clearAttribution();
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
114 changes: 114 additions & 0 deletions packages/audience/core/src/attribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
const UTM_PARAMS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
] as const;

const CLICK_ID_PARAMS = [
'gclid',
'dclid',
'fbclid',
'ttclid',
'msclkid',
'li_fat_id',
] as const;

const STORAGE_KEY = '__imtbl_attribution';

export interface Attribution {
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_content?: string;
utm_term?: string;
gclid?: string;
dclid?: string;
fbclid?: string;
ttclid?: string;
msclkid?: string;
li_fat_id?: string;
referral_code?: string;
referrer?: string;
landing_page?: string;
touchpoint_type?: string;
}

type AttributionKey = keyof Attribution;

function parseParams(url: string): Attribution {
let params: URLSearchParams;
try {
params = new URL(url).searchParams;
} catch {
return {};
}

const result: Attribution = {};
for (const key of [...UTM_PARAMS, ...CLICK_ID_PARAMS]) {
const value = params.get(key);
if (value) {
result[key as AttributionKey] = value;
}
}

const referralCode = params.get('referral_code');
if (referralCode) {
result.referral_code = referralCode;
}

return result;
}

function loadFromStorage(): Attribution | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Attribution) : null;
} catch {
return null;
}
}

function saveToStorage(attribution: Attribution): void {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(attribution));
} catch {
// sessionStorage may be unavailable (private browsing, storage full)
}
}

export function collectAttribution(): Attribution {
const cached = loadFromStorage();
if (cached) return cached;

const urlParams = typeof window !== 'undefined' && window.location
? parseParams(window.location.href)
: {};

const referrer = typeof document !== 'undefined' ? document.referrer || undefined : undefined;
const landingPage = typeof window !== 'undefined' && window.location
? window.location.href
: undefined;

const hasClickId = CLICK_ID_PARAMS.some((key) => key in urlParams);
const hasUtm = UTM_PARAMS.some((key) => key in urlParams);

const attribution: Attribution = {
...urlParams,
referrer,
landing_page: landingPage,
touchpoint_type: hasClickId || hasUtm ? 'click' : undefined,
};

saveToStorage(attribution);
return attribution;
}

export function clearAttribution(): void {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// noop
}
}
1 change: 1 addition & 0 deletions packages/audience/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ export const FLUSH_SIZE = 20;
export const COOKIE_NAME = 'imtbl_anon_id';
export const SESSION_COOKIE = '_imtbl_sid';
export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years
export const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds

export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment];
115 changes: 115 additions & 0 deletions packages/audience/core/src/consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { createConsentManager } from './consent';

// Mock fetch globally
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
global.fetch = mockFetch;

function createMockQueue() {
return {
purge: jest.fn(),
transform: jest.fn(),
enqueue: jest.fn(),
flush: jest.fn(),
flushUnload: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
destroy: jest.fn(),
clear: jest.fn(),
get length() { return 0; },
} as any;
}

beforeEach(() => {
jest.clearAllMocks();
});

describe('createConsentManager', () => {
it('defaults to none when no initial level provided', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev');
expect(manager.level).toBe('none');
});

it('uses the initial level when provided', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'anonymous');
expect(manager.level).toBe('anonymous');
});

it('upgrades consent without modifying queue', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'none');

manager.setLevel('anonymous');
expect(manager.level).toBe('anonymous');
expect(queue.purge).not.toHaveBeenCalled();
expect(queue.transform).not.toHaveBeenCalled();
});

it('purges queue on downgrade to none', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'full');

manager.setLevel('none');
expect(manager.level).toBe('none');
expect(queue.purge).toHaveBeenCalledWith(expect.any(Function));

// Verify the purge predicate matches all messages
const purgeFn = queue.purge.mock.calls[0][0];
expect(purgeFn({ type: 'page' })).toBe(true);
});

it('strips userId on downgrade from full to anonymous', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'full');

manager.setLevel('anonymous');
expect(manager.level).toBe('anonymous');
expect(queue.transform).toHaveBeenCalledWith(expect.any(Function));

// Verify the transform strips userId
const transformFn = queue.transform.mock.calls[0][0];
const withUserId = { type: 'page', userId: 'u-1', anonymousId: 'a-1' };
const result = transformFn(withUserId);
expect(result.userId).toBeUndefined();
expect(result.anonymousId).toBe('a-1');
});

it('fires PUT to consent endpoint on level change', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'none');

manager.setLevel('anonymous');

expect(mockFetch).toHaveBeenCalledWith(
'https://api.dev.immutable.com/v1/audience/tracking-consent',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
'Content-Type': 'application/json',
'x-immutable-publishable-key': 'pk_test',
}),
}),
);
});

it('does nothing when setting the same level', () => {
const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'anonymous');

manager.setLevel('anonymous');
expect(queue.purge).not.toHaveBeenCalled();
expect(queue.transform).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
});

it('respects DNT by defaulting to none', () => {
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });

const queue = createMockQueue();
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev');
expect(manager.level).toBe('none');

Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
});
});
Loading
Loading