Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 0 additions & 3 deletions packages/audience/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,3 @@ 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 SESSION_START = 'session_start';
export const SESSION_END = 'session_end';
5 changes: 1 addition & 4 deletions packages/audience/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ export {
BASE_URL,
COOKIE_NAME,
SESSION_COOKIE,
SESSION_START,
SESSION_END,
DATA_PATH,
} from './config';

Expand All @@ -40,8 +38,7 @@ export { MessageQueue } from './queue';
export { collectContext } from './context';
export { isTimestampValid, isAliasValid, truncate } from './validation';

export { getOrCreateSession } from './session';
export type { SessionResult } from './session';
export { getOrCreateSessionId } from './session';

export { collectSessionAttribution, collectPageAttribution } from './attribution';
export type { Attribution } from './attribution';
Expand Down
36 changes: 10 additions & 26 deletions packages/audience/core/src/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session';
import { getOrCreateSessionId, getSessionId } from './session';

const SESSION_COOKIE = '_imtbl_sid';

Expand All @@ -21,48 +21,32 @@ beforeEach(() => {
mockGenerateId.mockReturnValue('new-session-id');
});

describe('getOrCreateSession', () => {
it('creates a new session when no cookie exists', () => {
describe('getOrCreateSessionId', () => {
it('creates a new session id when no cookie exists', () => {
mockGetCookie.mockReturnValue(undefined);

const result = getOrCreateSession();
expect(result.sessionId).toBe('new-session-id');
expect(result.isNew).toBe(true);
const id = getOrCreateSessionId();
expect(id).toBe('new-session-id');
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, undefined);
});

it('returns existing session and refreshes expiry', () => {
it('returns existing session id and refreshes expiry without generating a new id', () => {
mockGetCookie.mockReturnValue('existing-sid');

const result = getOrCreateSession();
expect(result.sessionId).toBe('existing-sid');
expect(result.isNew).toBe(false);
const id = getOrCreateSessionId();
expect(id).toBe('existing-sid');
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'existing-sid', 1800, undefined);
expect(mockGenerateId).not.toHaveBeenCalled();
});

it('passes domain to setCookie', () => {
it('passes the domain through to setCookie', () => {
mockGetCookie.mockReturnValue(undefined);

getOrCreateSession('.example.com');
getOrCreateSessionId('.example.com');
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, '.example.com');
});
});

describe('getOrCreateSessionId', () => {
it('returns the session ID string', () => {
mockGetCookie.mockReturnValue(undefined);

const id = getOrCreateSessionId();
expect(id).toBe('new-session-id');
});

it('returns existing session ID', () => {
mockGetCookie.mockReturnValue('existing-sid');
expect(getOrCreateSessionId()).toBe('existing-sid');
});
});

describe('getSessionId', () => {
it('returns the session ID from cookie', () => {
mockGetCookie.mockReturnValue('existing-sid');
Expand Down
22 changes: 5 additions & 17 deletions packages/audience/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,21 @@ import { getCookie, setCookie } from './cookie';
import { generateId } from './utils';
import { SESSION_COOKIE, SESSION_MAX_AGE } from './config';

export interface SessionResult {
sessionId: string;
isNew: boolean;
}

/**
* Get or create a session ID.
*
* The session cookie has a 30-minute rolling expiry — each call refreshes it.
* Returns whether the session is new so the caller can fire a `session_start` event.
* The session cookie has a 30-minute rolling expiry — each call refreshes it,
* so a session ends after 30 minutes of inactivity.
*/
export function getOrCreateSession(domain?: string): SessionResult {
export function getOrCreateSessionId(domain?: string): string {
const existing = getCookie(SESSION_COOKIE);
if (existing) {
// Refresh the rolling expiry
setCookie(SESSION_COOKIE, existing, SESSION_MAX_AGE, domain);
return { sessionId: existing, isNew: false };
return existing;
}

const id = generateId();
setCookie(SESSION_COOKIE, id, SESSION_MAX_AGE, domain);
return { sessionId: id, isNew: true };
}

/** Convenience wrapper that returns just the session ID string. */
export function getOrCreateSessionId(domain?: string): string {
return getOrCreateSession(domain).sessionId;
return id;
}

export function getSessionId(): string | undefined {
Expand Down
2 changes: 1 addition & 1 deletion packages/audience/pixel/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jest.mock('@imtbl/audience-core', () => ({
getCookie: jest.fn(),
setCookie: jest.fn(),
collectSessionAttribution: jest.fn().mockReturnValue({}),
getOrCreateSession: jest.fn().mockReturnValue({ sessionId: 's', isNew: false }),
getOrCreateSessionId: jest.fn().mockReturnValue('s'),
createConsentManager: jest.fn().mockReturnValue({ level: 'none', setLevel: jest.fn() }),
}));

Expand Down
100 changes: 10 additions & 90 deletions packages/audience/pixel/src/pixel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const mockStartCmpDetection = jest.fn().mockReturnValue(mockTeardownCmp);
const mockEnqueue = jest.fn();
const mockStart = jest.fn();
const mockDestroy = jest.fn();
const mockGetOrCreateSession = jest.fn().mockReturnValue({ sessionId: 'session-abc', isNew: true });
const mockGetOrCreateSessionId = jest.fn().mockReturnValue('session-abc');

jest.mock('@imtbl/audience-core', () => ({
MessageQueue: jest.fn().mockImplementation(() => ({
Expand Down Expand Up @@ -48,7 +48,7 @@ jest.mock('@imtbl/audience-core', () => ({
}),
collectThirdPartyIds: (...args: unknown[]) => mockCollectThirdPartyIds(...args),
setupAutocapture: (...args: unknown[]) => mockSetupAutocapture(...args),
getOrCreateSession: (...args: unknown[]) => mockGetOrCreateSession(...args),
getOrCreateSessionId: (...args: unknown[]) => mockGetOrCreateSessionId(...args),
startCmpDetection: (...args: unknown[]) => mockStartCmpDetection(...args),
createConsentManager: jest.fn().mockImplementation(
(
Expand All @@ -75,7 +75,7 @@ let activePixel: Pixel | null = null;

beforeEach(() => {
jest.clearAllMocks();
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: true });
mockGetOrCreateSessionId.mockReturnValue('session-abc');
});

afterEach(() => {
Expand All @@ -88,30 +88,23 @@ afterEach(() => {

describe('Pixel', () => {
describe('init', () => {
it('creates queue, starts it, and fires page view + session_start when consent is anonymous', () => {
it('creates queue, starts it, and fires page view when consent is anonymous', () => {
const pixel = new Pixel();
activePixel = pixel;
pixel.init({ key: 'pk_imapik-test-local', consent: 'anonymous' });

expect(mockStart).toHaveBeenCalled();

// Should fire session_start (new session) then page view
const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record<string, unknown>));
const pageCall = calls.find((c) => c.type === 'page');
const sessionStartCall = calls.find(
(c) => c.type === 'track' && c.eventName === 'session_start',
);

expect(pageCall).toBeDefined();
expect(pageCall!.surface).toBe('pixel');
expect(pageCall!.anonymousId).toBe('anon-123');
expect((pageCall!.properties as Record<string, unknown>).utm_source).toBe('google');

expect(sessionStartCall).toBeDefined();
expect(sessionStartCall!.sessionId).toBe('session-abc');
});

it('does not fire page view or session_start when consent is none', () => {
it('does not fire page view when consent is none', () => {
const pixel = new Pixel();
activePixel = pixel;
pixel.init({ key: 'pk_imapik-test-local', consent: 'none' });
Expand All @@ -120,23 +113,6 @@ describe('Pixel', () => {
expect(mockEnqueue).not.toHaveBeenCalled();
});

it('does not fire session_start for existing sessions', () => {
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false });

const pixel = new Pixel();
activePixel = pixel;
pixel.init({ key: 'pk_imapik-test-local', consent: 'anonymous' });

const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record<string, unknown>));
const sessionStartCall = calls.find(
(c) => c.type === 'track' && c.eventName === 'session_start',
);

expect(sessionStartCall).toBeUndefined();
// Page view should still fire
expect(calls.find((c) => c.type === 'page')).toBeDefined();
});

it('only initializes once', () => {
const pixel = new Pixel();
activePixel = pixel;
Expand All @@ -149,7 +125,7 @@ describe('Pixel', () => {

describe('page', () => {
it('enqueues a page message with attribution and session', () => {
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false });
mockGetOrCreateSessionId.mockReturnValue('session-abc');

const pixel = new Pixel();
activePixel = pixel;
Expand Down Expand Up @@ -181,7 +157,7 @@ describe('Pixel', () => {
});

it('includes GA and Meta cookies in page properties when present', () => {
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false });
mockGetOrCreateSessionId.mockReturnValue('session-abc');
mockCollectThirdPartyIds.mockReturnValue({
ga_client_id: 'GA1.2.123456.789012',
fb_click_id: 'fb.1.1234567890.AbCdEf',
Expand All @@ -202,7 +178,7 @@ describe('Pixel', () => {
});

it('omits third-party IDs when cookies are not set', () => {
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false });
mockGetOrCreateSessionId.mockReturnValue('session-abc');
mockCollectThirdPartyIds.mockReturnValue({});

const pixel = new Pixel();
Expand Down Expand Up @@ -259,72 +235,16 @@ describe('Pixel', () => {
});

describe('session id', () => {
it('stamps sessionId at the top level on both page and track (session_start) messages', () => {
// Default mock (isNew: true) means init() fires both a page view and a session_start track event.
it('stamps sessionId at the top level on the page view', () => {
const pixel = new Pixel();
activePixel = pixel;
pixel.init({ key: 'pk_imapik-test-local', consent: 'anonymous' });

const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record<string, unknown>));
const pageCall = calls.find((c) => c.type === 'page');
const trackCall = calls.find((c) => c.type === 'track');

expect(pageCall).toBeDefined();
expect(trackCall).toBeDefined();
expect(pageCall!.sessionId).toBe('session-abc');
expect(trackCall!.sessionId).toBe('session-abc');
});
});

describe('session_end', () => {
it('fires session_end on pagehide when session is active', () => {
const pixel = new Pixel();
activePixel = pixel;
pixel.init({ key: 'pk_imapik-test-local', consent: 'anonymous' });
mockEnqueue.mockClear();

// Simulate pagehide
window.dispatchEvent(new Event('pagehide'));

expect(mockEnqueue).toHaveBeenCalledWith(
expect.objectContaining({
type: 'track',
eventName: 'session_end',
sessionId: 'session-abc',
}),
);
});

it('includes duration in session_end', () => {
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000000);

const pixel = new Pixel();
activePixel = pixel;
pixel.init({ key: 'pk_imapik-test-local', consent: 'anonymous' });
mockEnqueue.mockClear();

// Advance time by 15 seconds before triggering pagehide
dateNowSpy.mockReturnValue(1015000);
window.dispatchEvent(new Event('pagehide'));

const sessionEndCall = mockEnqueue.mock.calls.find(
(c: unknown[]) => (c[0] as Record<string, unknown>).eventName === 'session_end',
);
expect(sessionEndCall).toBeDefined();
expect((sessionEndCall![0] as Record<string, unknown>).properties).toEqual(
expect.objectContaining({ duration: 15 }),
);

dateNowSpy.mockRestore();
});

it('does not fire session_end when consent is none', () => {
const pixel = new Pixel();
activePixel = pixel;
pixel.init({ key: 'pk_imapik-test-local', consent: 'none' });

window.dispatchEvent(new Event('pagehide'));
expect(mockEnqueue).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -591,7 +511,7 @@ describe('Pixel', () => {
});

it('enqueue callback fires TrackMessage with session', () => {
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-xyz', isNew: false });
mockGetOrCreateSessionId.mockReturnValue('session-xyz');

const pixel = new Pixel();
activePixel = pixel;
Expand Down
Loading