Skip to content

Commit f576f75

Browse files
committed
feat: implement frequency capping plugin
- Add frequencyPlugin with impression tracking - Auto-loads sdk-kit storage plugin for persistence - Supports session, day, and week frequency caps - Tracks impression counts and timestamps - Emits impression-recorded events - Comprehensive test coverage (25 tests) Closes #5
1 parent 6d497dc commit f576f75

File tree

8 files changed

+593
-80
lines changed

8 files changed

+593
-80
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,8 @@
4747
"volta": {
4848
"node": "24.12.0",
4949
"pnpm": "10.26.2"
50+
},
51+
"dependencies": {
52+
"@lytics/sdk-kit-plugins": "0.1.2"
5053
}
5154
}

packages/plugins/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
},
2525
"dependencies": {
2626
"@lytics/sdk-kit": "^0.1.1",
27+
"@lytics/sdk-kit-plugins": "^0.1.0",
2728
"@prosdevlab/experience-sdk": "workspace:*"
2829
},
2930
"devDependencies": {

packages/plugins/src/debug/debug.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest';
22
import { SDK } from '@lytics/sdk-kit';
3-
import { debugPlugin } from './debug';
3+
import { debugPlugin, type DebugPlugin } from './debug';
44

55
describe('Debug Plugin', () => {
6-
let sdk: SDK;
6+
let sdk: SDK & { debug: DebugPlugin };
77

88
beforeEach(() => {
9-
sdk = new SDK({ debug: { enabled: true, console: true, window: true } });
9+
sdk = new SDK({ debug: { enabled: true, console: true, window: true } }) as SDK & {
10+
debug: DebugPlugin;
11+
};
1012
});
1113

1214
describe('Plugin Registration', () => {
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { SDK } from '@lytics/sdk-kit';
3+
import { storagePlugin, type StoragePlugin } from '@lytics/sdk-kit-plugins';
4+
import { frequencyPlugin, type FrequencyPlugin } from './frequency';
5+
import type { Decision } from '@prosdevlab/experience-sdk';
6+
7+
type SDKWithFrequency = SDK & { frequency: FrequencyPlugin; storage: StoragePlugin };
8+
9+
describe('Frequency Plugin', () => {
10+
let sdk: SDKWithFrequency;
11+
12+
beforeEach(() => {
13+
// Use memory storage for tests
14+
sdk = new SDK({
15+
frequency: { enabled: true },
16+
storage: { backend: 'memory' },
17+
}) as SDKWithFrequency;
18+
19+
// Install plugins
20+
sdk.use(storagePlugin);
21+
sdk.use(frequencyPlugin);
22+
});
23+
24+
describe('Plugin Registration', () => {
25+
it('should register frequency plugin', () => {
26+
expect(sdk.frequency).toBeDefined();
27+
});
28+
29+
it('should expose frequency API methods', () => {
30+
expect(sdk.frequency.getImpressionCount).toBeTypeOf('function');
31+
expect(sdk.frequency.hasReachedCap).toBeTypeOf('function');
32+
expect(sdk.frequency.recordImpression).toBeTypeOf('function');
33+
});
34+
35+
it('should auto-load storage plugin if not present', () => {
36+
const newSdk = new SDK({ frequency: { enabled: true } }) as SDKWithFrequency;
37+
newSdk.use(frequencyPlugin);
38+
expect(newSdk.storage).toBeDefined();
39+
});
40+
});
41+
42+
describe('Configuration', () => {
43+
it('should use default config', () => {
44+
const enabled = sdk.get('frequency.enabled');
45+
const namespace = sdk.get('frequency.namespace');
46+
47+
expect(enabled).toBe(true);
48+
expect(namespace).toBe('experiences:frequency');
49+
});
50+
51+
it('should allow custom config', () => {
52+
const customSdk = new SDK({
53+
frequency: { enabled: false, namespace: 'custom:freq' },
54+
storage: { backend: 'memory' },
55+
}) as SDKWithFrequency;
56+
57+
customSdk.use(storagePlugin);
58+
customSdk.use(frequencyPlugin);
59+
60+
expect(customSdk.get('frequency.enabled')).toBe(false);
61+
expect(customSdk.get('frequency.namespace')).toBe('custom:freq');
62+
});
63+
});
64+
65+
describe('Impression Tracking', () => {
66+
it('should initialize impression count at 0', () => {
67+
const count = sdk.frequency.getImpressionCount('welcome-banner');
68+
expect(count).toBe(0);
69+
});
70+
71+
it('should record impressions', () => {
72+
sdk.frequency.recordImpression('welcome-banner');
73+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1);
74+
75+
sdk.frequency.recordImpression('welcome-banner');
76+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(2);
77+
});
78+
79+
it('should track impressions per experience independently', () => {
80+
sdk.frequency.recordImpression('banner-1');
81+
sdk.frequency.recordImpression('banner-2');
82+
sdk.frequency.recordImpression('banner-1');
83+
84+
expect(sdk.frequency.getImpressionCount('banner-1')).toBe(2);
85+
expect(sdk.frequency.getImpressionCount('banner-2')).toBe(1);
86+
});
87+
88+
it('should emit impression-recorded event', () => {
89+
const handler = vi.fn();
90+
sdk.on('experiences:impression-recorded', handler);
91+
92+
sdk.frequency.recordImpression('welcome-banner');
93+
94+
expect(handler).toHaveBeenCalledWith({
95+
experienceId: 'welcome-banner',
96+
count: 1,
97+
timestamp: expect.any(Number),
98+
});
99+
});
100+
101+
it('should not record impressions when disabled', () => {
102+
sdk.set('frequency.enabled', false);
103+
sdk.frequency.recordImpression('welcome-banner');
104+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(0);
105+
});
106+
});
107+
108+
describe('Session Frequency Caps', () => {
109+
it('should not reach cap below limit', () => {
110+
sdk.frequency.recordImpression('welcome-banner');
111+
expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'session')).toBe(false);
112+
});
113+
114+
it('should reach cap at limit', () => {
115+
sdk.frequency.recordImpression('welcome-banner');
116+
sdk.frequency.recordImpression('welcome-banner');
117+
expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'session')).toBe(true);
118+
});
119+
120+
it('should reach cap above limit', () => {
121+
sdk.frequency.recordImpression('welcome-banner');
122+
sdk.frequency.recordImpression('welcome-banner');
123+
sdk.frequency.recordImpression('welcome-banner');
124+
expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'session')).toBe(true);
125+
});
126+
});
127+
128+
describe('Time-Based Frequency Caps', () => {
129+
it('should count impressions within day window', () => {
130+
const now = Date.now();
131+
132+
// Mock Date.now() for first impression (25 hours ago - outside window)
133+
vi.spyOn(Date, 'now').mockReturnValue(now - 25 * 60 * 60 * 1000);
134+
sdk.frequency.recordImpression('welcome-banner');
135+
136+
// Mock Date.now() for second impression (now - inside window)
137+
vi.spyOn(Date, 'now').mockReturnValue(now);
138+
sdk.frequency.recordImpression('welcome-banner');
139+
140+
// Only 1 impression within last 24 hours
141+
expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'day')).toBe(false);
142+
expect(sdk.frequency.hasReachedCap('welcome-banner', 1, 'day')).toBe(true);
143+
144+
vi.restoreAllMocks();
145+
});
146+
147+
it('should count impressions within week window', () => {
148+
const now = Date.now();
149+
150+
// Record 2 impressions 8 days ago (outside week window)
151+
vi.spyOn(Date, 'now').mockReturnValue(now - 8 * 24 * 60 * 60 * 1000);
152+
sdk.frequency.recordImpression('welcome-banner');
153+
sdk.frequency.recordImpression('welcome-banner');
154+
155+
// Record 1 impression 3 days ago (inside week window)
156+
vi.spyOn(Date, 'now').mockReturnValue(now - 3 * 24 * 60 * 60 * 1000);
157+
sdk.frequency.recordImpression('welcome-banner');
158+
159+
// Current time
160+
vi.spyOn(Date, 'now').mockReturnValue(now);
161+
162+
// Only 1 impression within last 7 days
163+
expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'week')).toBe(false);
164+
expect(sdk.frequency.hasReachedCap('welcome-banner', 1, 'week')).toBe(true);
165+
166+
vi.restoreAllMocks();
167+
});
168+
169+
it('should handle multiple impressions within time window', () => {
170+
const now = Date.now();
171+
172+
// Record 3 impressions within last day
173+
for (let i = 0; i < 3; i++) {
174+
vi.spyOn(Date, 'now').mockReturnValue(now - i * 60 * 60 * 1000); // Each hour
175+
sdk.frequency.recordImpression('welcome-banner');
176+
}
177+
178+
vi.spyOn(Date, 'now').mockReturnValue(now);
179+
180+
expect(sdk.frequency.hasReachedCap('welcome-banner', 3, 'day')).toBe(true);
181+
expect(sdk.frequency.hasReachedCap('welcome-banner', 4, 'day')).toBe(false);
182+
183+
vi.restoreAllMocks();
184+
});
185+
});
186+
187+
describe('Event Integration', () => {
188+
it('should auto-record impression on experiences:evaluated event when show=true', () => {
189+
const decision: Decision = {
190+
show: true,
191+
experienceId: 'welcome-banner',
192+
reasons: ['URL matches'],
193+
trace: [],
194+
context: {
195+
url: 'https://example.com',
196+
timestamp: Date.now(),
197+
},
198+
metadata: {
199+
evaluatedAt: Date.now(),
200+
totalDuration: 10,
201+
experiencesEvaluated: 1,
202+
},
203+
};
204+
205+
sdk.emit('experiences:evaluated', decision);
206+
207+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1);
208+
});
209+
210+
it('should not record impression when show=false', () => {
211+
const decision: Decision = {
212+
show: false,
213+
reasons: ['Frequency cap reached'],
214+
trace: [],
215+
context: {
216+
url: 'https://example.com',
217+
timestamp: Date.now(),
218+
},
219+
metadata: {
220+
evaluatedAt: Date.now(),
221+
totalDuration: 10,
222+
experiencesEvaluated: 1,
223+
},
224+
};
225+
226+
sdk.emit('experiences:evaluated', decision);
227+
228+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(0);
229+
});
230+
231+
it('should not record impression when experienceId is missing', () => {
232+
const decision: Decision = {
233+
show: true,
234+
reasons: ['No matching experience'],
235+
trace: [],
236+
context: {
237+
url: 'https://example.com',
238+
timestamp: Date.now(),
239+
},
240+
metadata: {
241+
evaluatedAt: Date.now(),
242+
totalDuration: 10,
243+
experiencesEvaluated: 0,
244+
},
245+
};
246+
247+
sdk.emit('experiences:evaluated', decision);
248+
249+
// Should not throw or record
250+
expect(sdk.frequency.getImpressionCount('any-experience')).toBe(0);
251+
});
252+
253+
it('should not auto-record when frequency plugin is disabled', () => {
254+
sdk.set('frequency.enabled', false);
255+
256+
const decision: Decision = {
257+
show: true,
258+
experienceId: 'welcome-banner',
259+
reasons: ['URL matches'],
260+
trace: [],
261+
context: {
262+
url: 'https://example.com',
263+
timestamp: Date.now(),
264+
},
265+
metadata: {
266+
evaluatedAt: Date.now(),
267+
totalDuration: 10,
268+
experiencesEvaluated: 1,
269+
},
270+
};
271+
272+
sdk.emit('experiences:evaluated', decision);
273+
274+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(0);
275+
});
276+
});
277+
278+
describe('Storage Integration', () => {
279+
it('should persist impressions across SDK instances', () => {
280+
// Record impression in first instance
281+
sdk.frequency.recordImpression('welcome-banner');
282+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1);
283+
284+
// Create second instance with same storage backend
285+
const sdk2 = new SDK({
286+
frequency: { enabled: true },
287+
storage: { backend: 'memory' },
288+
}) as SDKWithFrequency;
289+
sdk2.use(storagePlugin);
290+
sdk2.use(frequencyPlugin);
291+
292+
// Impressions should NOT persist (memory backend is per-instance)
293+
expect(sdk2.frequency.getImpressionCount('welcome-banner')).toBe(0);
294+
});
295+
296+
it('should use namespaced storage keys', () => {
297+
sdk.frequency.recordImpression('welcome-banner');
298+
299+
// Check storage directly
300+
const storageData = sdk.storage.get('experiences:frequency:welcome-banner');
301+
expect(storageData).toBeDefined();
302+
expect((storageData as { count: number }).count).toBe(1);
303+
});
304+
});
305+
306+
describe('Edge Cases', () => {
307+
it('should handle empty experience ID gracefully', () => {
308+
expect(() => sdk.frequency.recordImpression('')).not.toThrow();
309+
expect(sdk.frequency.getImpressionCount('')).toBe(1);
310+
});
311+
312+
it('should handle very large impression counts', () => {
313+
for (let i = 0; i < 1000; i++) {
314+
sdk.frequency.recordImpression('welcome-banner');
315+
}
316+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1000);
317+
});
318+
319+
it('should clean up old impressions (beyond 7 days)', () => {
320+
const now = Date.now();
321+
322+
// Record 5 impressions: 3 old (>7 days), 2 recent
323+
vi.spyOn(Date, 'now').mockReturnValue(now - 10 * 24 * 60 * 60 * 1000);
324+
sdk.frequency.recordImpression('welcome-banner');
325+
326+
vi.spyOn(Date, 'now').mockReturnValue(now - 8 * 24 * 60 * 60 * 1000);
327+
sdk.frequency.recordImpression('welcome-banner');
328+
329+
vi.spyOn(Date, 'now').mockReturnValue(now - 9 * 24 * 60 * 60 * 1000);
330+
sdk.frequency.recordImpression('welcome-banner');
331+
332+
vi.spyOn(Date, 'now').mockReturnValue(now - 2 * 24 * 60 * 60 * 1000);
333+
sdk.frequency.recordImpression('welcome-banner');
334+
335+
vi.spyOn(Date, 'now').mockReturnValue(now);
336+
sdk.frequency.recordImpression('welcome-banner');
337+
338+
// Total count should be 5, but old impressions cleaned up
339+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(5);
340+
341+
// Check storage for cleaned impressions array
342+
const storageData = sdk.storage.get('experiences:frequency:welcome-banner') as {
343+
impressions: number[];
344+
};
345+
// Should only keep impressions from last 7 days (2 recent ones)
346+
expect(storageData.impressions.length).toBe(2);
347+
348+
vi.restoreAllMocks();
349+
});
350+
});
351+
});
352+

0 commit comments

Comments
 (0)