Skip to content

Commit e82ddf6

Browse files
test: add unit tests
cookie, queue, transport, context, validation modules. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a355689 commit e82ddf6

5 files changed

Lines changed: 399 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { collectContext } from './context';
2+
3+
describe('collectContext', () => {
4+
it('includes required library fields', () => {
5+
const ctx = collectContext();
6+
expect(ctx.library).toBe('@imtbl/audience-web-sdk');
7+
expect(ctx.libraryVersion).toBeDefined();
8+
});
9+
10+
it('collects browser signals in jsdom', () => {
11+
const ctx = collectContext();
12+
expect(ctx.userAgent).toBeDefined();
13+
expect(ctx.locale).toBeDefined();
14+
expect(ctx.timezone).toBeDefined();
15+
expect(ctx.screen).toMatch(/\d+x\d+/);
16+
expect(ctx.pageUrl).toBeDefined();
17+
expect(ctx.pagePath).toBeDefined();
18+
});
19+
20+
it('collects page title', () => {
21+
document.title = 'Test Page';
22+
const ctx = collectContext();
23+
expect(ctx.pageTitle).toBe('Test Page');
24+
});
25+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {
2+
getCookie,
3+
setCookie,
4+
deleteCookie,
5+
getOrCreateAnonymousId,
6+
getOrCreateSessionId,
7+
touchSession,
8+
getConsentCookie,
9+
setConsentCookie,
10+
ANON_ID_COOKIE,
11+
SESSION_COOKIE,
12+
} from './cookie';
13+
14+
beforeEach(() => {
15+
document.cookie.split(';').forEach((c) => {
16+
document.cookie = `${c.trim().split('=')[0]}=;max-age=0;path=/`;
17+
});
18+
});
19+
20+
describe('getCookie / setCookie', () => {
21+
it('sets and reads a cookie', () => {
22+
setCookie('test_key', 'test_value', 3600);
23+
expect(getCookie('test_key')).toBe('test_value');
24+
});
25+
26+
it('returns undefined for missing cookie', () => {
27+
expect(getCookie('nonexistent')).toBeUndefined();
28+
});
29+
30+
it('handles special characters in values', () => {
31+
setCookie('encoded', 'hello world&foo=bar', 3600);
32+
expect(getCookie('encoded')).toBe('hello world&foo=bar');
33+
});
34+
});
35+
36+
describe('deleteCookie', () => {
37+
it('removes a cookie', () => {
38+
setCookie('to_delete', 'value', 3600);
39+
expect(getCookie('to_delete')).toBe('value');
40+
deleteCookie('to_delete');
41+
expect(getCookie('to_delete')).toBeUndefined();
42+
});
43+
});
44+
45+
describe('getOrCreateAnonymousId', () => {
46+
it('creates a new anonymous ID on first call', () => {
47+
const id = getOrCreateAnonymousId();
48+
expect(id).toBeDefined();
49+
expect(typeof id).toBe('string');
50+
expect(id.length).toBeGreaterThan(0);
51+
expect(getCookie(ANON_ID_COOKIE)).toBe(id);
52+
});
53+
54+
it('returns the same ID on subsequent calls', () => {
55+
const first = getOrCreateAnonymousId();
56+
const second = getOrCreateAnonymousId();
57+
expect(second).toBe(first);
58+
});
59+
});
60+
61+
describe('getOrCreateSessionId', () => {
62+
it('creates a new session ID', () => {
63+
const sid = getOrCreateSessionId();
64+
expect(sid).toBeDefined();
65+
expect(getCookie(SESSION_COOKIE)).toBe(sid);
66+
});
67+
68+
it('returns the same session ID within the session', () => {
69+
const first = getOrCreateSessionId();
70+
const second = getOrCreateSessionId();
71+
expect(second).toBe(first);
72+
});
73+
});
74+
75+
describe('touchSession', () => {
76+
it('does nothing if no session exists', () => {
77+
touchSession();
78+
expect(getCookie(SESSION_COOKIE)).toBeUndefined();
79+
});
80+
81+
it('preserves existing session cookie', () => {
82+
const sid = getOrCreateSessionId();
83+
touchSession();
84+
expect(getCookie(SESSION_COOKIE)).toBe(sid);
85+
});
86+
});
87+
88+
describe('consent cookie', () => {
89+
it('sets and reads consent level', () => {
90+
setConsentCookie('full');
91+
expect(getConsentCookie()).toBe('full');
92+
});
93+
94+
it('returns undefined when not set', () => {
95+
expect(getConsentCookie()).toBeUndefined();
96+
});
97+
});
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { MessageQueue } from './queue';
2+
import * as transport from './transport';
3+
import type { Message } from './types';
4+
5+
jest.mock('./transport', () => ({
6+
sendMessages: jest.fn().mockResolvedValue(true),
7+
}));
8+
9+
const mockSend = transport.sendMessages as jest.MockedFunction<typeof transport.sendMessages>;
10+
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
jest.useFakeTimers();
14+
localStorage.clear();
15+
});
16+
17+
afterEach(() => {
18+
jest.useRealTimers();
19+
});
20+
21+
function makeMessage(overrides: Partial<Message> = {}): Message {
22+
return {
23+
type: 'track',
24+
messageId: `msg-${Math.random()}`,
25+
eventTimestamp: new Date().toISOString(),
26+
anonymousId: 'anon-1',
27+
context: { library: '@imtbl/audience-web-sdk', libraryVersion: '0.1.0' },
28+
eventName: 'test',
29+
surface: 'web',
30+
...overrides,
31+
} as Message;
32+
}
33+
34+
describe('MessageQueue', () => {
35+
it('enqueues and flushes messages', async () => {
36+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 20);
37+
queue.enqueue(makeMessage());
38+
queue.enqueue(makeMessage());
39+
40+
await queue.flush();
41+
42+
expect(mockSend).toHaveBeenCalledTimes(1);
43+
const payload = mockSend.mock.calls[0][2];
44+
expect(payload.messages).toHaveLength(2);
45+
});
46+
47+
it('auto-flushes when reaching flushSize', () => {
48+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 3);
49+
50+
queue.enqueue(makeMessage());
51+
queue.enqueue(makeMessage());
52+
expect(mockSend).not.toHaveBeenCalled();
53+
54+
queue.enqueue(makeMessage()); // triggers flush at size 3
55+
expect(mockSend).toHaveBeenCalledTimes(1);
56+
});
57+
58+
it('flushes on timer interval', () => {
59+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 1000, 100);
60+
queue.start();
61+
62+
queue.enqueue(makeMessage());
63+
jest.advanceTimersByTime(1000);
64+
65+
expect(mockSend).toHaveBeenCalledTimes(1);
66+
queue.stop();
67+
});
68+
69+
it('does not flush when empty', async () => {
70+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 20);
71+
await queue.flush();
72+
expect(mockSend).not.toHaveBeenCalled();
73+
});
74+
75+
it('retains messages on failed flush', async () => {
76+
mockSend.mockResolvedValueOnce(false);
77+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 20);
78+
queue.enqueue(makeMessage());
79+
80+
await queue.flush();
81+
expect(queue.length).toBe(1); // still in queue
82+
83+
mockSend.mockResolvedValueOnce(true);
84+
await queue.flush();
85+
expect(queue.length).toBe(0); // now drained
86+
});
87+
88+
it('clears all messages', () => {
89+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 20);
90+
queue.enqueue(makeMessage());
91+
queue.enqueue(makeMessage());
92+
expect(queue.length).toBe(2);
93+
94+
queue.clear();
95+
expect(queue.length).toBe(0);
96+
});
97+
98+
it('purges messages matching a predicate', () => {
99+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 20);
100+
queue.enqueue(makeMessage({ type: 'track' } as any));
101+
queue.enqueue(makeMessage({ type: 'identify' } as any));
102+
queue.enqueue(makeMessage({ type: 'track' } as any));
103+
104+
queue.purge((m) => m.type === 'identify');
105+
expect(queue.length).toBe(2);
106+
});
107+
108+
it('transforms messages in place', async () => {
109+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 20);
110+
queue.enqueue(makeMessage({ userId: 'should-strip' } as any));
111+
112+
queue.transform((m) => {
113+
const cleaned = { ...m };
114+
delete (cleaned as any).userId;
115+
return cleaned;
116+
});
117+
118+
await queue.flush();
119+
const msg = mockSend.mock.calls[0][2].messages[0];
120+
expect((msg as any).userId).toBeUndefined();
121+
});
122+
123+
it('calls onFlush callback', async () => {
124+
const onFlush = jest.fn();
125+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 20, onFlush);
126+
queue.enqueue(makeMessage());
127+
128+
await queue.flush();
129+
expect(onFlush).toHaveBeenCalledWith(true, 1);
130+
});
131+
132+
it('persists messages to localStorage', () => {
133+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 20);
134+
queue.enqueue(makeMessage());
135+
136+
const stored = localStorage.getItem('__imtbl_web_queue');
137+
expect(stored).not.toBeNull();
138+
expect(JSON.parse(stored!)).toHaveLength(1);
139+
});
140+
141+
it('restores messages from localStorage on construction', () => {
142+
// Pre-populate storage
143+
const msg = makeMessage();
144+
localStorage.setItem('__imtbl_web_queue', JSON.stringify([msg]));
145+
146+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 20);
147+
expect(queue.length).toBe(1);
148+
});
149+
150+
it('flushUnload sends with keepalive', () => {
151+
const queue = new MessageQueue('https://test.com/messages', 'pk_test', 5000, 20);
152+
queue.enqueue(makeMessage());
153+
154+
queue.flushUnload();
155+
156+
expect(mockSend).toHaveBeenCalledWith(
157+
'https://test.com/messages',
158+
'pk_test',
159+
expect.objectContaining({ messages: expect.any(Array) }),
160+
true, // keepalive
161+
);
162+
expect(queue.length).toBe(0);
163+
});
164+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { sendMessages } from './transport';
2+
import type { MessagesRequest } from './types';
3+
4+
const mockFetch = jest.fn();
5+
global.fetch = mockFetch;
6+
7+
beforeEach(() => {
8+
jest.clearAllMocks();
9+
});
10+
11+
const payload: MessagesRequest = {
12+
messages: [{
13+
type: 'track',
14+
messageId: '123',
15+
eventTimestamp: '2026-04-03T00:00:00Z',
16+
anonymousId: 'anon-1',
17+
context: { library: '@imtbl/audience-web-sdk', libraryVersion: '0.1.0' },
18+
eventName: 'test',
19+
surface: 'web',
20+
}],
21+
};
22+
23+
describe('sendMessages', () => {
24+
it('sends a POST with correct headers and body', async () => {
25+
mockFetch.mockResolvedValueOnce({ ok: true });
26+
27+
const result = await sendMessages('https://api.test.com/v1/audience/messages', 'pk_test', payload);
28+
29+
expect(result).toBe(true);
30+
expect(mockFetch).toHaveBeenCalledWith(
31+
'https://api.test.com/v1/audience/messages',
32+
expect.objectContaining({
33+
method: 'POST',
34+
headers: {
35+
'Content-Type': 'application/json',
36+
'x-immutable-publishable-key': 'pk_test',
37+
},
38+
body: JSON.stringify(payload),
39+
keepalive: false,
40+
}),
41+
);
42+
});
43+
44+
it('returns false on HTTP error', async () => {
45+
mockFetch.mockResolvedValueOnce({ ok: false, status: 400 });
46+
47+
const result = await sendMessages('https://api.test.com/v1/audience/messages', 'pk_test', payload);
48+
expect(result).toBe(false);
49+
});
50+
51+
it('returns false on network error', async () => {
52+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
53+
54+
const result = await sendMessages('https://api.test.com/v1/audience/messages', 'pk_test', payload);
55+
expect(result).toBe(false);
56+
});
57+
58+
it('passes keepalive flag for unload flushes', async () => {
59+
mockFetch.mockResolvedValueOnce({ ok: true });
60+
61+
await sendMessages('https://api.test.com/v1/audience/messages', 'pk_test', payload, true);
62+
63+
expect(mockFetch).toHaveBeenCalledWith(
64+
expect.any(String),
65+
expect.objectContaining({ keepalive: true }),
66+
);
67+
});
68+
});

0 commit comments

Comments
 (0)