@agent-analytics/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -0
- package/schema.sql +32 -0
- package/src/db/adapter.js +54 -0
- package/src/db/d1.js +417 -0
- package/src/handler.js +267 -0
- package/src/index.js +4 -0
- package/src/tracker.js +198 -0
- package/src/ulid.js +28 -0
- package/test/sessions.test.mjs +494 -0
- package/test/sqli-propkey.test.mjs +38 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test, describe, beforeEach } from 'node:test';
|
|
3
|
+
import { D1Adapter } from '../src/db/d1.js';
|
|
4
|
+
import { createAnalyticsHandler } from '../src/handler.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// In-memory SQLite-like mock for D1
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
class MockD1 {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.tables = { events: [], sessions: [] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
prepare(sql) {
|
|
16
|
+
const self = this;
|
|
17
|
+
return {
|
|
18
|
+
bind(...params) {
|
|
19
|
+
// Return a NEW bound statement (like real D1)
|
|
20
|
+
return {
|
|
21
|
+
bind(...p) { return this.constructor ? self.prepare(sql).bind(...p) : this; },
|
|
22
|
+
async run() { return self._exec(sql, params); },
|
|
23
|
+
async first() {
|
|
24
|
+
const r = self._exec(sql, params);
|
|
25
|
+
return r.results ? r.results[0] || null : null;
|
|
26
|
+
},
|
|
27
|
+
async all() { return self._exec(sql, params); },
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
async run() { return self._exec(sql, []); },
|
|
31
|
+
async first() {
|
|
32
|
+
const r = self._exec(sql, []);
|
|
33
|
+
return r.results ? r.results[0] || null : null;
|
|
34
|
+
},
|
|
35
|
+
async all() { return self._exec(sql, []); },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async batch(stmts) {
|
|
40
|
+
const results = [];
|
|
41
|
+
for (const s of stmts) {
|
|
42
|
+
results.push(await s.run());
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_exec(sql, params) {
|
|
48
|
+
const trimmed = sql.trim().replace(/\s+/g, ' ');
|
|
49
|
+
|
|
50
|
+
// INSERT INTO events
|
|
51
|
+
if (/^INSERT INTO events/i.test(trimmed)) {
|
|
52
|
+
const row = {
|
|
53
|
+
id: params[0], project_id: params[1], event: params[2],
|
|
54
|
+
properties: params[3], user_id: params[4], session_id: params[5],
|
|
55
|
+
timestamp: params[6], date: params[7],
|
|
56
|
+
};
|
|
57
|
+
this.tables.events.push(row);
|
|
58
|
+
return { success: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// INSERT INTO sessions ... ON CONFLICT
|
|
62
|
+
if (/^INSERT INTO sessions/i.test(trimmed)) {
|
|
63
|
+
return this._upsertSession(params);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// DELETE FROM sessions
|
|
67
|
+
if (/^DELETE FROM sessions/i.test(trimmed)) {
|
|
68
|
+
const project = params[0];
|
|
69
|
+
const before = params[1];
|
|
70
|
+
const before_count = this.tables.sessions.length;
|
|
71
|
+
this.tables.sessions = this.tables.sessions.filter(
|
|
72
|
+
s => !(s.project_id === project && s.date < before)
|
|
73
|
+
);
|
|
74
|
+
return { success: true, changes: before_count - this.tables.sessions.length };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// SELECT queries on sessions
|
|
78
|
+
if (/FROM sessions/i.test(trimmed)) {
|
|
79
|
+
return this._querySessionsTable(trimmed, params);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// SELECT queries on events
|
|
83
|
+
if (/FROM events/i.test(trimmed)) {
|
|
84
|
+
return this._queryEventsTable(trimmed, params);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { results: [], success: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_upsertSession(params) {
|
|
91
|
+
// SQL: VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, 1, ?)
|
|
92
|
+
// 9 params: session_id, user_id, project_id, start_time, end_time, entry_page, exit_page, event_count, date
|
|
93
|
+
const data = {
|
|
94
|
+
session_id: params[0], user_id: params[1], project_id: params[2],
|
|
95
|
+
start_time: params[3], end_time: params[4], duration: 0,
|
|
96
|
+
entry_page: params[5], exit_page: params[6],
|
|
97
|
+
event_count: params[7], is_bounce: 1, date: params[8],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const idx = this.tables.sessions.findIndex(s => s.session_id === data.session_id);
|
|
101
|
+
if (idx === -1) {
|
|
102
|
+
this.tables.sessions.push({ ...data });
|
|
103
|
+
} else {
|
|
104
|
+
const existing = this.tables.sessions[idx];
|
|
105
|
+
const oldStart = existing.start_time;
|
|
106
|
+
const oldEnd = existing.end_time;
|
|
107
|
+
const newStart = Math.min(oldStart, data.start_time);
|
|
108
|
+
const newEnd = Math.max(oldEnd, data.end_time);
|
|
109
|
+
const newCount = existing.event_count + data.event_count;
|
|
110
|
+
existing.start_time = newStart;
|
|
111
|
+
existing.end_time = newEnd;
|
|
112
|
+
existing.duration = newEnd - newStart;
|
|
113
|
+
existing.event_count = newCount;
|
|
114
|
+
existing.is_bounce = newCount > 1 ? 0 : 1;
|
|
115
|
+
// entry_page: update if new event is earlier
|
|
116
|
+
if (data.start_time < oldStart) {
|
|
117
|
+
existing.entry_page = data.entry_page;
|
|
118
|
+
}
|
|
119
|
+
// exit_page: update if new event is later or equal
|
|
120
|
+
if (data.end_time >= oldEnd) {
|
|
121
|
+
existing.exit_page = data.exit_page;
|
|
122
|
+
}
|
|
123
|
+
if (data.date < existing.date) existing.date = data.date;
|
|
124
|
+
}
|
|
125
|
+
return { success: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_querySessionsTable(sql, params) {
|
|
129
|
+
let rows = [...this.tables.sessions];
|
|
130
|
+
|
|
131
|
+
// Aggregate queries
|
|
132
|
+
if (/COUNT\(\*\) as total_sessions/i.test(sql)) {
|
|
133
|
+
const project = params[0];
|
|
134
|
+
const fromDate = params[1];
|
|
135
|
+
const filtered = rows.filter(s => s.project_id === project && s.date >= fromDate);
|
|
136
|
+
const total = filtered.length;
|
|
137
|
+
const bounced = filtered.filter(s => s.is_bounce === 1).length;
|
|
138
|
+
const totalDuration = filtered.reduce((sum, s) => sum + s.duration, 0);
|
|
139
|
+
const totalEvents = filtered.reduce((sum, s) => sum + s.event_count, 0);
|
|
140
|
+
const uniqueUsers = new Set(filtered.map(s => s.user_id).filter(Boolean)).size;
|
|
141
|
+
return {
|
|
142
|
+
results: [{
|
|
143
|
+
total_sessions: total,
|
|
144
|
+
bounced_sessions: bounced,
|
|
145
|
+
total_duration: totalDuration,
|
|
146
|
+
total_events: totalEvents,
|
|
147
|
+
unique_users: uniqueUsers || 1,
|
|
148
|
+
}],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// List sessions
|
|
153
|
+
const project = params[0];
|
|
154
|
+
const fromDate = params[1];
|
|
155
|
+
rows = rows.filter(s => s.project_id === project && s.date >= fromDate);
|
|
156
|
+
let paramIdx = 2;
|
|
157
|
+
|
|
158
|
+
// user_id filter
|
|
159
|
+
if (/user_id = \?/i.test(sql)) {
|
|
160
|
+
rows = rows.filter(s => s.user_id === params[paramIdx++]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// is_bounce filter
|
|
164
|
+
if (/is_bounce = \?/i.test(sql)) {
|
|
165
|
+
rows = rows.filter(s => s.is_bounce === params[paramIdx++]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
rows.sort((a, b) => b.start_time - a.start_time);
|
|
169
|
+
|
|
170
|
+
// LIMIT
|
|
171
|
+
if (/LIMIT \?/i.test(sql)) {
|
|
172
|
+
rows = rows.slice(0, params[paramIdx]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { results: rows };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_queryEventsTable(sql, params) {
|
|
179
|
+
let rows = [...this.tables.events];
|
|
180
|
+
let paramIdx = 0;
|
|
181
|
+
|
|
182
|
+
// project_id filter
|
|
183
|
+
if (/project_id = \?/i.test(sql)) {
|
|
184
|
+
rows = rows.filter(r => r.project_id === params[paramIdx++]);
|
|
185
|
+
}
|
|
186
|
+
// date filter
|
|
187
|
+
if (/date >= \?/i.test(sql)) {
|
|
188
|
+
const d = params[paramIdx++];
|
|
189
|
+
rows = rows.filter(r => r.date >= d);
|
|
190
|
+
}
|
|
191
|
+
if (/date <= \?/i.test(sql)) {
|
|
192
|
+
const d = params[paramIdx++];
|
|
193
|
+
rows = rows.filter(r => r.date <= d);
|
|
194
|
+
}
|
|
195
|
+
// event filter
|
|
196
|
+
if (/AND event = \?/i.test(sql)) {
|
|
197
|
+
rows = rows.filter(r => r.event === params[paramIdx++]);
|
|
198
|
+
}
|
|
199
|
+
// session_id filter
|
|
200
|
+
if (/AND session_id = \?/i.test(sql)) {
|
|
201
|
+
const sid = params[paramIdx++];
|
|
202
|
+
rows = rows.filter(r => r.session_id === sid);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// COUNT queries
|
|
206
|
+
if (/COUNT\(\*\) as event_count/i.test(sql)) {
|
|
207
|
+
return { results: [{ event_count: rows.length }] };
|
|
208
|
+
}
|
|
209
|
+
if (/COUNT\(DISTINCT user_id\) as unique_users.*COUNT\(\*\) as total_events/i.test(sql)) {
|
|
210
|
+
const uu = new Set(rows.map(r => r.user_id).filter(Boolean)).size;
|
|
211
|
+
return { results: [{ unique_users: uu, total_events: rows.length }] };
|
|
212
|
+
}
|
|
213
|
+
if (/COUNT\(\*\) as total_events/i.test(sql) && /COUNT\(DISTINCT user_id\)/i.test(sql)) {
|
|
214
|
+
if (/GROUP BY date/i.test(sql)) {
|
|
215
|
+
const byDate = {};
|
|
216
|
+
for (const r of rows) {
|
|
217
|
+
if (!byDate[r.date]) byDate[r.date] = { date: r.date, users: new Set(), count: 0 };
|
|
218
|
+
byDate[r.date].count++;
|
|
219
|
+
if (r.user_id) byDate[r.date].users.add(r.user_id);
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
results: Object.values(byDate).map(d => ({
|
|
223
|
+
date: d.date, unique_users: d.users.size, total_events: d.count,
|
|
224
|
+
})),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const uu = new Set(rows.map(r => r.user_id).filter(Boolean)).size;
|
|
228
|
+
return { unique_users: uu, total_events: rows.length };
|
|
229
|
+
}
|
|
230
|
+
if (/GROUP BY event/i.test(sql)) {
|
|
231
|
+
const byEvent = {};
|
|
232
|
+
for (const r of rows) {
|
|
233
|
+
if (!byEvent[r.event]) byEvent[r.event] = { event: r.event, count: 0, users: new Set() };
|
|
234
|
+
byEvent[r.event].count++;
|
|
235
|
+
if (r.user_id) byEvent[r.event].users.add(r.user_id);
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
results: Object.values(byEvent)
|
|
239
|
+
.map(e => ({ event: e.event, count: e.count, unique_users: e.users.size }))
|
|
240
|
+
.sort((a, b) => b.count - a.count),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// SELECT * (raw events)
|
|
245
|
+
rows.sort((a, b) => b.timestamp - a.timestamp);
|
|
246
|
+
if (/LIMIT \?/i.test(sql)) {
|
|
247
|
+
rows = rows.slice(0, params[params.length - 1]);
|
|
248
|
+
}
|
|
249
|
+
return { results: rows };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Helpers
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
function makeAdapter() {
|
|
258
|
+
const mock = new MockD1();
|
|
259
|
+
return { adapter: new D1Adapter(mock), mock };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function makeHandler(adapter) {
|
|
263
|
+
return createAnalyticsHandler({
|
|
264
|
+
db: adapter,
|
|
265
|
+
validateWrite: () => ({ valid: true }),
|
|
266
|
+
validateRead: () => ({ valid: true }),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function makeRequest(method, path, body) {
|
|
271
|
+
const url = `http://test${path}`;
|
|
272
|
+
const opts = { method };
|
|
273
|
+
if (body) {
|
|
274
|
+
opts.body = JSON.stringify(body);
|
|
275
|
+
opts.headers = { 'Content-Type': 'application/json' };
|
|
276
|
+
}
|
|
277
|
+
return new Request(url, opts);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Tests
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
describe('Session tracking - D1Adapter', () => {
|
|
285
|
+
let adapter, mock;
|
|
286
|
+
|
|
287
|
+
beforeEach(() => {
|
|
288
|
+
({ adapter, mock } = makeAdapter());
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('1. Session created on first event with session_id', async () => {
|
|
292
|
+
await adapter.trackEvent({
|
|
293
|
+
project: 'test', event: 'page_view', session_id: 'sess1',
|
|
294
|
+
user_id: 'u1', timestamp: 1000000, properties: { path: '/home' },
|
|
295
|
+
});
|
|
296
|
+
assert.equal(mock.tables.sessions.length, 1);
|
|
297
|
+
const s = mock.tables.sessions[0];
|
|
298
|
+
assert.equal(s.session_id, 'sess1');
|
|
299
|
+
assert.equal(s.project_id, 'test');
|
|
300
|
+
assert.equal(s.user_id, 'u1');
|
|
301
|
+
assert.equal(s.start_time, 1000000);
|
|
302
|
+
assert.equal(s.end_time, 1000000);
|
|
303
|
+
assert.equal(s.duration, 0);
|
|
304
|
+
assert.equal(s.entry_page, '/home');
|
|
305
|
+
assert.equal(s.exit_page, '/home');
|
|
306
|
+
assert.equal(s.event_count, 1);
|
|
307
|
+
assert.equal(s.is_bounce, 1);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('2. Session updated on second event same session', async () => {
|
|
311
|
+
await adapter.trackEvent({
|
|
312
|
+
project: 'test', event: 'page_view', session_id: 'sess1',
|
|
313
|
+
user_id: 'u1', timestamp: 1000000, properties: { path: '/home' },
|
|
314
|
+
});
|
|
315
|
+
await adapter.trackEvent({
|
|
316
|
+
project: 'test', event: 'click', session_id: 'sess1',
|
|
317
|
+
user_id: 'u1', timestamp: 1060000, properties: { path: '/about' },
|
|
318
|
+
});
|
|
319
|
+
assert.equal(mock.tables.sessions.length, 1);
|
|
320
|
+
const s = mock.tables.sessions[0];
|
|
321
|
+
assert.equal(s.end_time, 1060000);
|
|
322
|
+
assert.equal(s.exit_page, '/about');
|
|
323
|
+
assert.equal(s.event_count, 2);
|
|
324
|
+
assert.equal(s.duration, 60000);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('3. Bounce detection: single=1, multi=0', async () => {
|
|
328
|
+
await adapter.trackEvent({
|
|
329
|
+
project: 'test', event: 'page_view', session_id: 'sess1',
|
|
330
|
+
user_id: 'u1', timestamp: 1000000, properties: { path: '/' },
|
|
331
|
+
});
|
|
332
|
+
assert.equal(mock.tables.sessions[0].is_bounce, 1);
|
|
333
|
+
|
|
334
|
+
await adapter.trackEvent({
|
|
335
|
+
project: 'test', event: 'click', session_id: 'sess1',
|
|
336
|
+
user_id: 'u1', timestamp: 1001000, properties: { path: '/' },
|
|
337
|
+
});
|
|
338
|
+
assert.equal(mock.tables.sessions[0].is_bounce, 0);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('4. Out-of-order events: start_time=MIN, end_time=MAX', async () => {
|
|
342
|
+
await adapter.trackEvent({
|
|
343
|
+
project: 'test', event: 'page_view', session_id: 'sess1',
|
|
344
|
+
user_id: 'u1', timestamp: 2000000, properties: { path: '/second' },
|
|
345
|
+
});
|
|
346
|
+
await adapter.trackEvent({
|
|
347
|
+
project: 'test', event: 'page_view', session_id: 'sess1',
|
|
348
|
+
user_id: 'u1', timestamp: 1000000, properties: { path: '/first' },
|
|
349
|
+
});
|
|
350
|
+
const s = mock.tables.sessions[0];
|
|
351
|
+
assert.equal(s.start_time, 1000000);
|
|
352
|
+
assert.equal(s.end_time, 2000000);
|
|
353
|
+
assert.equal(s.duration, 1000000);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('5. Events without session_id skip session upsert', async () => {
|
|
357
|
+
await adapter.trackEvent({
|
|
358
|
+
project: 'test', event: 'page_view', user_id: 'u1', timestamp: 1000000,
|
|
359
|
+
});
|
|
360
|
+
assert.equal(mock.tables.sessions.length, 0);
|
|
361
|
+
assert.equal(mock.tables.events.length, 1);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('6. Batch events with multiple session_ids upsert correctly', async () => {
|
|
365
|
+
await adapter.trackBatch([
|
|
366
|
+
{ project: 'test', event: 'page_view', session_id: 'sA', user_id: 'u1', timestamp: 1000000, properties: { path: '/a' } },
|
|
367
|
+
{ project: 'test', event: 'click', session_id: 'sA', user_id: 'u1', timestamp: 1010000, properties: { path: '/b' } },
|
|
368
|
+
{ project: 'test', event: 'page_view', session_id: 'sB', user_id: 'u2', timestamp: 2000000, properties: { path: '/x' } },
|
|
369
|
+
]);
|
|
370
|
+
assert.equal(mock.tables.events.length, 3);
|
|
371
|
+
assert.equal(mock.tables.sessions.length, 2);
|
|
372
|
+
const sA = mock.tables.sessions.find(s => s.session_id === 'sA');
|
|
373
|
+
assert.equal(sA.event_count, 2);
|
|
374
|
+
assert.equal(sA.is_bounce, 0);
|
|
375
|
+
const sB = mock.tables.sessions.find(s => s.session_id === 'sB');
|
|
376
|
+
assert.equal(sB.event_count, 1);
|
|
377
|
+
assert.equal(sB.is_bounce, 1);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('7. getSessions returns filtered results', async () => {
|
|
381
|
+
await adapter.trackEvent({ project: 'test', event: 'pv', session_id: 's1', user_id: 'u1', timestamp: Date.now(), properties: { path: '/' } });
|
|
382
|
+
await adapter.trackEvent({ project: 'test', event: 'pv', session_id: 's2', user_id: 'u2', timestamp: Date.now(), properties: { path: '/' } });
|
|
383
|
+
const all = await adapter.getSessions({ project: 'test', days: 7 });
|
|
384
|
+
assert.equal(all.length, 2);
|
|
385
|
+
const filtered = await adapter.getSessions({ project: 'test', days: 7, user_id: 'u1' });
|
|
386
|
+
assert.equal(filtered.length, 1);
|
|
387
|
+
assert.equal(filtered[0].user_id, 'u1');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('8. getSessionStats computes metrics correctly', async () => {
|
|
391
|
+
const now = Date.now();
|
|
392
|
+
// Session 1: 2 events, 60s duration, user u1
|
|
393
|
+
await adapter.trackEvent({ project: 'p', event: 'pv', session_id: 's1', user_id: 'u1', timestamp: now, properties: { path: '/a' } });
|
|
394
|
+
await adapter.trackEvent({ project: 'p', event: 'click', session_id: 's1', user_id: 'u1', timestamp: now + 60000, properties: { path: '/b' } });
|
|
395
|
+
// Session 2: 1 event (bounce), user u2
|
|
396
|
+
await adapter.trackEvent({ project: 'p', event: 'pv', session_id: 's2', user_id: 'u2', timestamp: now, properties: { path: '/c' } });
|
|
397
|
+
// Session 3: 3 events, 120s duration, user u1
|
|
398
|
+
await adapter.trackEvent({ project: 'p', event: 'pv', session_id: 's3', user_id: 'u1', timestamp: now, properties: { path: '/d' } });
|
|
399
|
+
await adapter.trackEvent({ project: 'p', event: 'click', session_id: 's3', user_id: 'u1', timestamp: now + 60000, properties: { path: '/e' } });
|
|
400
|
+
await adapter.trackEvent({ project: 'p', event: 'click', session_id: 's3', user_id: 'u1', timestamp: now + 120000, properties: { path: '/f' } });
|
|
401
|
+
|
|
402
|
+
const stats = await adapter.getSessionStats({ project: 'p', days: 7 });
|
|
403
|
+
assert.equal(stats.total_sessions, 3);
|
|
404
|
+
// 1 bounce out of 3
|
|
405
|
+
assert.ok(Math.abs(stats.bounce_rate - 1 / 3) < 0.01);
|
|
406
|
+
// avg duration: (60000 + 0 + 120000) / 3 = 60000
|
|
407
|
+
assert.equal(stats.avg_duration, 60000);
|
|
408
|
+
// pages per session: (2 + 1 + 3) / 3 = 2
|
|
409
|
+
assert.equal(stats.pages_per_session, 2);
|
|
410
|
+
// sessions per user: 3 sessions / 2 users = 1.5
|
|
411
|
+
assert.equal(stats.sessions_per_user, 1.5);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('9. cleanupSessions deletes old sessions', async () => {
|
|
415
|
+
const old = new Date('2024-01-01').getTime();
|
|
416
|
+
const recent = Date.now();
|
|
417
|
+
await adapter.trackEvent({ project: 'p', event: 'pv', session_id: 's_old', user_id: 'u1', timestamp: old, properties: { path: '/' } });
|
|
418
|
+
await adapter.trackEvent({ project: 'p', event: 'pv', session_id: 's_new', user_id: 'u1', timestamp: recent, properties: { path: '/' } });
|
|
419
|
+
assert.equal(mock.tables.sessions.length, 2);
|
|
420
|
+
await adapter.cleanupSessions({ project: 'p', before_date: '2025-01-01' });
|
|
421
|
+
assert.equal(mock.tables.sessions.length, 1);
|
|
422
|
+
assert.equal(mock.tables.sessions[0].session_id, 's_new');
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('Session tracking - Handler endpoints', () => {
|
|
427
|
+
let handler, adapter, mock;
|
|
428
|
+
|
|
429
|
+
beforeEach(() => {
|
|
430
|
+
({ adapter, mock } = makeAdapter());
|
|
431
|
+
handler = makeHandler(adapter);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('10. GET /sessions returns correct response', async () => {
|
|
435
|
+
await adapter.trackEvent({ project: 'p', event: 'pv', session_id: 's1', user_id: 'u1', timestamp: Date.now(), properties: { path: '/' } });
|
|
436
|
+
const { response } = await handler(makeRequest('GET', '/sessions?project=p&days=7'));
|
|
437
|
+
assert.equal(response.status, 200);
|
|
438
|
+
const body = await response.json();
|
|
439
|
+
assert.equal(body.project, 'p');
|
|
440
|
+
assert.ok(Array.isArray(body.sessions));
|
|
441
|
+
assert.equal(body.sessions.length, 1);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('11. GET /sessions requires project', async () => {
|
|
445
|
+
const { response } = await handler(makeRequest('GET', '/sessions'));
|
|
446
|
+
assert.equal(response.status, 400);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('12. GET /stats includes session metrics', async () => {
|
|
450
|
+
await adapter.trackEvent({ project: 'p', event: 'pv', session_id: 's1', user_id: 'u1', timestamp: Date.now(), properties: { path: '/' } });
|
|
451
|
+
const { response } = await handler(makeRequest('GET', '/stats?project=p&days=7'));
|
|
452
|
+
const body = await response.json();
|
|
453
|
+
assert.ok(body.sessions, 'stats should include sessions');
|
|
454
|
+
assert.ok('total_sessions' in body.sessions);
|
|
455
|
+
assert.ok('bounce_rate' in body.sessions);
|
|
456
|
+
assert.ok('avg_duration' in body.sessions);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('13. GET /events accepts session_id filter', async () => {
|
|
460
|
+
const { adapter: a } = makeAdapter();
|
|
461
|
+
await a.trackEvent({ project: 'p', event: 'pv', session_id: 's1', user_id: 'u1', timestamp: Date.now(), properties: { path: '/' } });
|
|
462
|
+
await a.trackEvent({ project: 'p', event: 'pv', session_id: 's2', user_id: 'u1', timestamp: Date.now(), properties: { path: '/' } });
|
|
463
|
+
// Test adapter directly
|
|
464
|
+
const filtered = await a.getEvents({ project: 'p', session_id: 's1', days: 7 });
|
|
465
|
+
assert.equal(filtered.length, 1, 'adapter should filter by session_id');
|
|
466
|
+
assert.equal(filtered[0].session_id, 's1');
|
|
467
|
+
// Test handler endpoint
|
|
468
|
+
const h = makeHandler(a);
|
|
469
|
+
const { response } = await h(makeRequest('GET', '/events?project=p&session_id=s1&days=7'));
|
|
470
|
+
const body = await response.json();
|
|
471
|
+
assert.equal(body.events.length, 1, 'handler should filter by session_id');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('14. POST /query supports session_id group_by and session metrics', async () => {
|
|
475
|
+
await adapter.trackEvent({ project: 'p', event: 'pv', session_id: 's1', user_id: 'u1', timestamp: Date.now(), properties: { path: '/' } });
|
|
476
|
+
// session_id in group_by should not throw
|
|
477
|
+
const { response } = await handler(makeRequest('POST', '/query', {
|
|
478
|
+
project: 'p',
|
|
479
|
+
metrics: ['event_count'],
|
|
480
|
+
group_by: ['session_id'],
|
|
481
|
+
}));
|
|
482
|
+
assert.equal(response.status, 200);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
describe('Tracker.js session support', () => {
|
|
487
|
+
test('15. tracker.js contains session_id logic', async () => {
|
|
488
|
+
const { TRACKER_JS } = await import('../src/tracker.js');
|
|
489
|
+
assert.ok(TRACKER_JS.includes('session_id'), 'tracker should reference session_id');
|
|
490
|
+
assert.ok(TRACKER_JS.includes('sessionStorage'), 'tracker should use sessionStorage');
|
|
491
|
+
assert.ok(TRACKER_JS.includes('aa_sid'), 'tracker should use aa_sid key');
|
|
492
|
+
assert.ok(TRACKER_JS.includes('aa_last_activity'), 'tracker should track last activity');
|
|
493
|
+
});
|
|
494
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
// Extract the validation function (will be exported after fix)
|
|
5
|
+
let validatePropertyKey;
|
|
6
|
+
try {
|
|
7
|
+
const mod = await import('../src/db/d1.js');
|
|
8
|
+
validatePropertyKey = mod.validatePropertyKey;
|
|
9
|
+
} catch (e) {
|
|
10
|
+
// Before fix, function won't exist
|
|
11
|
+
validatePropertyKey = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('validatePropertyKey exists', () => {
|
|
15
|
+
assert.ok(validatePropertyKey, 'validatePropertyKey should be exported');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('valid keys pass', () => {
|
|
19
|
+
if (!validatePropertyKey) return;
|
|
20
|
+
for (const key of ['foo', 'user_name', 'page123', 'A', 'x_1_y']) {
|
|
21
|
+
assert.doesNotThrow(() => validatePropertyKey(key), `"${key}" should be valid`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('malicious keys are rejected', () => {
|
|
26
|
+
if (!validatePropertyKey) return;
|
|
27
|
+
for (const key of ["') OR 1=1 --", "key'; DROP TABLE", "a.b", 'key"value', "key'value"]) {
|
|
28
|
+
assert.throws(() => validatePropertyKey(key), /Invalid property filter key/, `"${key}" should be rejected`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('edge cases rejected', () => {
|
|
33
|
+
if (!validatePropertyKey) return;
|
|
34
|
+
assert.throws(() => validatePropertyKey(''), /Invalid property filter key/);
|
|
35
|
+
assert.throws(() => validatePropertyKey('a'.repeat(200)), /Invalid property filter key/);
|
|
36
|
+
assert.throws(() => validatePropertyKey('café'), /Invalid property filter key/);
|
|
37
|
+
assert.throws(() => validatePropertyKey('key value'), /Invalid property filter key/);
|
|
38
|
+
});
|