@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.
@@ -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
+ });