@operor/analytics 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,554 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { SQLiteAnalyticsStore } from './SQLiteAnalyticsStore.js';
3
+ import type { AnalyticsEvent, TimeRange } from './types.js';
4
+ import { mkdtempSync, rmSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { tmpdir } from 'os';
7
+
8
+ function makeEvent(overrides: Partial<AnalyticsEvent> = {}): AnalyticsEvent {
9
+ return {
10
+ timestamp: Date.now(),
11
+ channel: 'whatsapp',
12
+ customerPhone: '+1234567890',
13
+ customerId: 'cust_1',
14
+ agentName: 'support',
15
+ intent: 'order_status',
16
+ intentConfidence: 0.95,
17
+ responseTimeMs: 450,
18
+ llmCost: 0.002,
19
+ kbTopScore: 0.85,
20
+ kbIsFaqMatch: true,
21
+ kbResultCount: 3,
22
+ toolCallsCount: 0,
23
+ toolCallsJson: null,
24
+ messageLength: 30,
25
+ responseLength: 120,
26
+ isNewCustomer: false,
27
+ wasEscalated: false,
28
+ conversationId: null,
29
+ promptTokens: 100,
30
+ completionTokens: 50,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ describe('SQLiteAnalyticsStore', () => {
36
+ let store: SQLiteAnalyticsStore;
37
+ let tmpDir: string;
38
+
39
+ beforeEach(async () => {
40
+ tmpDir = mkdtempSync(join(tmpdir(), 'analytics-test-'));
41
+ store = new SQLiteAnalyticsStore(join(tmpDir, 'test.db'));
42
+ await store.initialize();
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await store.close();
47
+ rmSync(tmpDir, { recursive: true, force: true });
48
+ });
49
+
50
+ // --- Schema / initialize ---
51
+
52
+ describe('initialize', () => {
53
+ it('creates tables without error', async () => {
54
+ // Already initialized in beforeEach — just verify it works
55
+ const summary = await store.getSummary();
56
+ expect(summary.totalMessages).toBe(0);
57
+ });
58
+
59
+ it('is idempotent (can be called twice)', async () => {
60
+ await store.initialize();
61
+ const summary = await store.getSummary();
62
+ expect(summary.totalMessages).toBe(0);
63
+ });
64
+ });
65
+
66
+ // --- recordMessageEvent ---
67
+
68
+ describe('recordMessageEvent', () => {
69
+ it('inserts a single event', async () => {
70
+ await store.recordMessageEvent(makeEvent());
71
+ const summary = await store.getSummary();
72
+ expect(summary.totalMessages).toBe(1);
73
+ });
74
+
75
+ it('inserts multiple events', async () => {
76
+ await store.recordMessageEvent(makeEvent());
77
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+999' }));
78
+ await store.recordMessageEvent(makeEvent({ agentName: 'billing' }));
79
+ const summary = await store.getSummary();
80
+ expect(summary.totalMessages).toBe(3);
81
+ });
82
+
83
+ it('stores boolean fields as integers', async () => {
84
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, isNewCustomer: true, wasEscalated: true }));
85
+ const summary = await store.getSummary();
86
+ expect(summary.faqHitRate).toBe(100);
87
+ expect(summary.newCustomers).toBe(1);
88
+ });
89
+
90
+ it('handles null toolCallsJson', async () => {
91
+ await store.recordMessageEvent(makeEvent({ toolCallsJson: null }));
92
+ const summary = await store.getSummary();
93
+ expect(summary.totalMessages).toBe(1);
94
+ });
95
+
96
+ it('handles non-null toolCallsJson', async () => {
97
+ await store.recordMessageEvent(makeEvent({
98
+ toolCallsCount: 2,
99
+ toolCallsJson: JSON.stringify([{ name: 'get_order', params: {} }]),
100
+ }));
101
+ const summary = await store.getSummary();
102
+ expect(summary.totalMessages).toBe(1);
103
+ });
104
+ });
105
+
106
+ // --- Conversation sessions (30-min gap) ---
107
+
108
+ describe('getOrCreateConversation', () => {
109
+ it('creates a new conversation', async () => {
110
+ const id = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
111
+ expect(id).toBeTruthy();
112
+ expect(typeof id).toBe('string');
113
+ expect(id.length).toBe(36); // UUID format
114
+ });
115
+
116
+ it('returns same conversation within session gap', async () => {
117
+ const id1 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
118
+ const id2 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
119
+ expect(id2).toBe(id1);
120
+ });
121
+
122
+ it('creates new conversation for different customer', async () => {
123
+ const id1 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
124
+ const id2 = await store.getOrCreateConversation('+9999999999', 'whatsapp', 'support');
125
+ expect(id2).not.toBe(id1);
126
+ });
127
+
128
+ it('creates new conversation for different channel', async () => {
129
+ const id1 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
130
+ const id2 = await store.getOrCreateConversation('+1234567890', 'telegram', 'support');
131
+ expect(id2).not.toBe(id1);
132
+ });
133
+
134
+ it('creates new conversation for different agent', async () => {
135
+ const id1 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
136
+ const id2 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'billing');
137
+ expect(id2).not.toBe(id1);
138
+ });
139
+ });
140
+
141
+ describe('updateConversationStats', () => {
142
+ it('updates message count and cost', async () => {
143
+ const convId = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
144
+ const event = makeEvent({ conversationId: convId, toolCallsCount: 2, llmCost: 0.01 });
145
+ await store.updateConversationStats(convId, event);
146
+ // Verify by creating another event — conversation should still be active
147
+ const convId2 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
148
+ expect(convId2).toBe(convId);
149
+ });
150
+
151
+ it('sets customer_id on first update', async () => {
152
+ const convId = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
153
+ const event = makeEvent({ customerId: 'cust_42', conversationId: convId });
154
+ await store.updateConversationStats(convId, event);
155
+ // No crash — customer_id set internally
156
+ });
157
+ });
158
+
159
+ // --- getSummary ---
160
+
161
+ describe('getSummary', () => {
162
+ it('returns zeros for empty database', async () => {
163
+ const summary = await store.getSummary();
164
+ expect(summary.totalMessages).toBe(0);
165
+ expect(summary.avgPerDay).toBe(0);
166
+ expect(summary.peakDay).toBeNull();
167
+ expect(summary.uniqueCustomers).toBe(0);
168
+ expect(summary.newCustomers).toBe(0);
169
+ expect(summary.kbAnsweredPct).toBe(0);
170
+ expect(summary.faqHitRate).toBe(0);
171
+ expect(summary.avgResponseTime).toBe(0);
172
+ expect(summary.pendingReviews).toBe(0);
173
+ });
174
+
175
+ it('counts unique customers', async () => {
176
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+1' }));
177
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+1' }));
178
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+2' }));
179
+ const summary = await store.getSummary();
180
+ expect(summary.uniqueCustomers).toBe(2);
181
+ });
182
+
183
+ it('counts new customers', async () => {
184
+ await store.recordMessageEvent(makeEvent({ isNewCustomer: true }));
185
+ await store.recordMessageEvent(makeEvent({ isNewCustomer: false }));
186
+ const summary = await store.getSummary();
187
+ expect(summary.newCustomers).toBe(1);
188
+ });
189
+
190
+ it('calculates KB answered percentage', async () => {
191
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.9 }));
192
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.8 }));
193
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.2 }));
194
+ const summary = await store.getSummary();
195
+ expect(summary.kbAnsweredPct).toBeCloseTo(66.67, 0);
196
+ });
197
+
198
+ it('calculates no-answer percentage', async () => {
199
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.1 }));
200
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.9 }));
201
+ const summary = await store.getSummary();
202
+ expect(summary.noAnswerPct).toBe(50);
203
+ });
204
+
205
+ it('calculates FAQ hit rate', async () => {
206
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true }));
207
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: false }));
208
+ const summary = await store.getSummary();
209
+ expect(summary.faqHitRate).toBe(50);
210
+ });
211
+
212
+ it('calculates average response time', async () => {
213
+ await store.recordMessageEvent(makeEvent({ responseTimeMs: 200 }));
214
+ await store.recordMessageEvent(makeEvent({ responseTimeMs: 400 }));
215
+ const summary = await store.getSummary();
216
+ expect(summary.avgResponseTime).toBe(300);
217
+ });
218
+
219
+ it('builds agent breakdown', async () => {
220
+ await store.recordMessageEvent(makeEvent({ agentName: 'support' }));
221
+ await store.recordMessageEvent(makeEvent({ agentName: 'support' }));
222
+ await store.recordMessageEvent(makeEvent({ agentName: 'billing' }));
223
+ const summary = await store.getSummary();
224
+ expect(summary.agentBreakdown).toEqual({ support: 2, billing: 1 });
225
+ });
226
+
227
+ it('filters by time range', async () => {
228
+ const t1 = new Date('2026-01-10T10:00:00Z').getTime();
229
+ const t2 = new Date('2026-01-15T10:00:00Z').getTime();
230
+ const t3 = new Date('2026-01-20T10:00:00Z').getTime();
231
+ await store.recordMessageEvent(makeEvent({ timestamp: t1 }));
232
+ await store.recordMessageEvent(makeEvent({ timestamp: t2 }));
233
+ await store.recordMessageEvent(makeEvent({ timestamp: t3 }));
234
+
235
+ const range: TimeRange = { from: t2 - 1000, to: t2 + 1000 };
236
+ const summary = await store.getSummary(range);
237
+ expect(summary.totalMessages).toBe(1);
238
+ });
239
+ });
240
+
241
+ // --- getMessageVolume ---
242
+
243
+ describe('getMessageVolume', () => {
244
+ it('returns empty array for empty database', async () => {
245
+ const volume = await store.getMessageVolume();
246
+ expect(volume).toEqual([]);
247
+ });
248
+
249
+ it('groups by day', async () => {
250
+ const day1 = new Date('2026-01-15T10:00:00Z').getTime();
251
+ const day1b = new Date('2026-01-15T14:00:00Z').getTime();
252
+ const day2 = new Date('2026-01-16T10:00:00Z').getTime();
253
+ await store.recordMessageEvent(makeEvent({ timestamp: day1 }));
254
+ await store.recordMessageEvent(makeEvent({ timestamp: day1b }));
255
+ await store.recordMessageEvent(makeEvent({ timestamp: day2 }));
256
+
257
+ const volume = await store.getMessageVolume(undefined, 'day');
258
+ expect(volume).toHaveLength(2);
259
+ // First day has 2 messages
260
+ const totalCount = volume.reduce((s, v) => s + v.count, 0);
261
+ expect(totalCount).toBe(3);
262
+ });
263
+
264
+ it('groups by hour', async () => {
265
+ const h1 = new Date('2026-01-15T10:15:00Z').getTime();
266
+ const h1b = new Date('2026-01-15T10:45:00Z').getTime();
267
+ const h2 = new Date('2026-01-15T11:15:00Z').getTime();
268
+ await store.recordMessageEvent(makeEvent({ timestamp: h1 }));
269
+ await store.recordMessageEvent(makeEvent({ timestamp: h1b }));
270
+ await store.recordMessageEvent(makeEvent({ timestamp: h2 }));
271
+
272
+ const volume = await store.getMessageVolume(undefined, 'hour');
273
+ expect(volume).toHaveLength(2);
274
+ });
275
+
276
+ it('returns sorted by date', async () => {
277
+ const t2 = new Date('2026-01-16T10:00:00Z').getTime();
278
+ const t1 = new Date('2026-01-15T10:00:00Z').getTime();
279
+ await store.recordMessageEvent(makeEvent({ timestamp: t2 }));
280
+ await store.recordMessageEvent(makeEvent({ timestamp: t1 }));
281
+
282
+ const volume = await store.getMessageVolume();
283
+ expect(volume[0].date < volume[1].date).toBe(true);
284
+ });
285
+
286
+ it('respects time range', async () => {
287
+ const t1 = new Date('2026-01-10T10:00:00Z').getTime();
288
+ const t2 = new Date('2026-01-20T10:00:00Z').getTime();
289
+ await store.recordMessageEvent(makeEvent({ timestamp: t1 }));
290
+ await store.recordMessageEvent(makeEvent({ timestamp: t2 }));
291
+
292
+ const range: TimeRange = { from: t2 - 1000, to: t2 + 1000 };
293
+ const volume = await store.getMessageVolume(range);
294
+ expect(volume).toHaveLength(1);
295
+ });
296
+ });
297
+
298
+ // --- getAgentStats ---
299
+
300
+ describe('getAgentStats', () => {
301
+ it('returns empty array for empty database', async () => {
302
+ const stats = await store.getAgentStats();
303
+ expect(stats).toEqual([]);
304
+ });
305
+
306
+ it('aggregates per agent', async () => {
307
+ await store.recordMessageEvent(makeEvent({ agentName: 'support', responseTimeMs: 200, intentConfidence: 0.9 }));
308
+ await store.recordMessageEvent(makeEvent({ agentName: 'support', responseTimeMs: 400, intentConfidence: 0.8 }));
309
+ await store.recordMessageEvent(makeEvent({ agentName: 'billing', responseTimeMs: 300, intentConfidence: 0.7 }));
310
+
311
+ const stats = await store.getAgentStats();
312
+ expect(stats).toHaveLength(2);
313
+
314
+ const support = stats.find(s => s.agentName === 'support')!;
315
+ expect(support.messageCount).toBe(2);
316
+ expect(support.avgResponseTimeMs).toBe(300);
317
+ expect(support.avgConfidence).toBeCloseTo(0.85);
318
+ });
319
+
320
+ it('counts escalations', async () => {
321
+ await store.recordMessageEvent(makeEvent({ agentName: 'support', wasEscalated: true }));
322
+ await store.recordMessageEvent(makeEvent({ agentName: 'support', wasEscalated: false }));
323
+ const stats = await store.getAgentStats();
324
+ const support = stats.find(s => s.agentName === 'support')!;
325
+ expect(support.escalationCount).toBe(1);
326
+ });
327
+
328
+ it('sums tool calls', async () => {
329
+ await store.recordMessageEvent(makeEvent({ agentName: 'support', toolCallsCount: 3 }));
330
+ await store.recordMessageEvent(makeEvent({ agentName: 'support', toolCallsCount: 2 }));
331
+ const stats = await store.getAgentStats();
332
+ const support = stats.find(s => s.agentName === 'support')!;
333
+ expect(support.toolCallCount).toBe(5);
334
+ });
335
+
336
+ it('respects time range', async () => {
337
+ const t1 = new Date('2026-01-10T10:00:00Z').getTime();
338
+ const t2 = new Date('2026-01-20T10:00:00Z').getTime();
339
+ await store.recordMessageEvent(makeEvent({ timestamp: t1, agentName: 'support' }));
340
+ await store.recordMessageEvent(makeEvent({ timestamp: t2, agentName: 'billing' }));
341
+
342
+ const range: TimeRange = { from: t2 - 1000, to: t2 + 1000 };
343
+ const stats = await store.getAgentStats(range);
344
+ expect(stats).toHaveLength(1);
345
+ expect(stats[0].agentName).toBe('billing');
346
+ });
347
+ });
348
+
349
+ // --- getCustomerStats ---
350
+
351
+ describe('getCustomerStats', () => {
352
+ it('returns empty array for empty database', async () => {
353
+ const stats = await store.getCustomerStats();
354
+ expect(stats).toEqual([]);
355
+ });
356
+
357
+ it('aggregates per customer', async () => {
358
+ const t1 = new Date('2026-01-15T10:00:00Z').getTime();
359
+ const t2 = new Date('2026-01-15T14:00:00Z').getTime();
360
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+1', customerId: 'c1', timestamp: t1 }));
361
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+1', customerId: 'c1', timestamp: t2 }));
362
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+2', customerId: 'c2', timestamp: t1 }));
363
+
364
+ const stats = await store.getCustomerStats();
365
+ expect(stats).toHaveLength(2);
366
+ // Sorted by message count desc
367
+ expect(stats[0].customerPhone).toBe('+1');
368
+ expect(stats[0].messageCount).toBe(2);
369
+ expect(stats[0].firstSeen).toBe(t1);
370
+ expect(stats[0].lastSeen).toBe(t2);
371
+ });
372
+
373
+ it('respects limit', async () => {
374
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+1' }));
375
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+2' }));
376
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+3' }));
377
+
378
+ const stats = await store.getCustomerStats(undefined, 2);
379
+ expect(stats).toHaveLength(2);
380
+ });
381
+
382
+ it('tracks isNew flag', async () => {
383
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+1', isNewCustomer: true }));
384
+ await store.recordMessageEvent(makeEvent({ customerPhone: '+1', isNewCustomer: false }));
385
+ const stats = await store.getCustomerStats();
386
+ expect(stats[0].isNew).toBe(true);
387
+ });
388
+ });
389
+
390
+ // --- getKbStats ---
391
+
392
+ describe('getKbStats', () => {
393
+ it('returns zeros for empty database', async () => {
394
+ const kb = await store.getKbStats();
395
+ expect(kb.totalQueries).toBe(0);
396
+ expect(kb.faqHits).toBe(0);
397
+ expect(kb.faqHitRate).toBe(0);
398
+ expect(kb.avgTopScore).toBe(0);
399
+ expect(kb.topFaqHits).toEqual([]);
400
+ expect(kb.topMisses).toEqual([]);
401
+ });
402
+
403
+ it('calculates FAQ stats', async () => {
404
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, kbTopScore: 0.9, intent: 'hours' }));
405
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, kbTopScore: 0.85, intent: 'hours' }));
406
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: false, kbTopScore: 0.4, intent: 'unknown' }));
407
+
408
+ const kb = await store.getKbStats();
409
+ expect(kb.totalQueries).toBe(3);
410
+ expect(kb.faqHits).toBe(2);
411
+ expect(kb.faqHitRate).toBeCloseTo(66.67, 0);
412
+ });
413
+
414
+ it('calculates average top score', async () => {
415
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.8 }));
416
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.6 }));
417
+ const kb = await store.getKbStats();
418
+ expect(kb.avgTopScore).toBeCloseTo(0.7);
419
+ });
420
+ });
421
+
422
+ // --- getTopFaqHits ---
423
+
424
+ describe('getTopFaqHits', () => {
425
+ it('returns empty for no FAQ matches', async () => {
426
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: false }));
427
+ const hits = await store.getTopFaqHits();
428
+ expect(hits).toEqual([]);
429
+ });
430
+
431
+ it('groups by intent and sorts by count', async () => {
432
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'hours' }));
433
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'hours' }));
434
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'returns' }));
435
+
436
+ const hits = await store.getTopFaqHits();
437
+ expect(hits[0]).toEqual({ intent: 'hours', count: 2 });
438
+ expect(hits[1]).toEqual({ intent: 'returns', count: 1 });
439
+ });
440
+
441
+ it('respects limit', async () => {
442
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'a' }));
443
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'b' }));
444
+ await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'c' }));
445
+
446
+ const hits = await store.getTopFaqHits(2);
447
+ expect(hits).toHaveLength(2);
448
+ });
449
+
450
+ it('respects time range', async () => {
451
+ const t1 = new Date('2026-01-10T10:00:00Z').getTime();
452
+ const t2 = new Date('2026-01-20T10:00:00Z').getTime();
453
+ await store.recordMessageEvent(makeEvent({ timestamp: t1, kbIsFaqMatch: true, intent: 'old' }));
454
+ await store.recordMessageEvent(makeEvent({ timestamp: t2, kbIsFaqMatch: true, intent: 'new' }));
455
+
456
+ const range: TimeRange = { from: t2 - 1000, to: t2 + 1000 };
457
+ const hits = await store.getTopFaqHits(10, range);
458
+ expect(hits).toHaveLength(1);
459
+ expect(hits[0].intent).toBe('new');
460
+ });
461
+ });
462
+
463
+ // --- getTopMisses ---
464
+
465
+ describe('getTopMisses', () => {
466
+ it('returns empty when no low-score events', async () => {
467
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.9 }));
468
+ const misses = await store.getTopMisses();
469
+ expect(misses).toEqual([]);
470
+ });
471
+
472
+ it('captures low-score events', async () => {
473
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.3, intent: 'unknown_q' }));
474
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.2, intent: 'unknown_q' }));
475
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.4, intent: 'other_q' }));
476
+
477
+ const misses = await store.getTopMisses();
478
+ expect(misses).toHaveLength(2);
479
+ expect(misses[0].intent).toBe('unknown_q');
480
+ expect(misses[0].count).toBe(2);
481
+ expect(misses[0].avgScore).toBeCloseTo(0.25);
482
+ });
483
+
484
+ it('excludes zero-score events', async () => {
485
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0, intent: 'no_kb' }));
486
+ const misses = await store.getTopMisses();
487
+ expect(misses).toEqual([]);
488
+ });
489
+
490
+ it('excludes high-score events (>= 0.5)', async () => {
491
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.5, intent: 'borderline' }));
492
+ await store.recordMessageEvent(makeEvent({ kbTopScore: 0.8, intent: 'good' }));
493
+ const misses = await store.getTopMisses();
494
+ expect(misses).toEqual([]);
495
+ });
496
+
497
+ it('respects time range', async () => {
498
+ const t1 = new Date('2026-01-10T10:00:00Z').getTime();
499
+ const t2 = new Date('2026-01-20T10:00:00Z').getTime();
500
+ await store.recordMessageEvent(makeEvent({ timestamp: t1, kbTopScore: 0.3, intent: 'old_miss' }));
501
+ await store.recordMessageEvent(makeEvent({ timestamp: t2, kbTopScore: 0.3, intent: 'new_miss' }));
502
+
503
+ const range: TimeRange = { from: t2 - 1000, to: t2 + 1000 };
504
+ const misses = await store.getTopMisses(10, range);
505
+ expect(misses).toHaveLength(1);
506
+ expect(misses[0].intent).toBe('new_miss');
507
+ });
508
+ });
509
+
510
+ // --- Edge cases ---
511
+
512
+ describe('edge cases', () => {
513
+ it('handles single message correctly', async () => {
514
+ await store.recordMessageEvent(makeEvent());
515
+ const summary = await store.getSummary();
516
+ expect(summary.totalMessages).toBe(1);
517
+ expect(summary.avgPerDay).toBe(1);
518
+ });
519
+
520
+ it('handles multiple agents with same customer', async () => {
521
+ await store.recordMessageEvent(makeEvent({ agentName: 'support', customerPhone: '+1' }));
522
+ await store.recordMessageEvent(makeEvent({ agentName: 'billing', customerPhone: '+1' }));
523
+ const summary = await store.getSummary();
524
+ expect(summary.uniqueCustomers).toBe(1);
525
+ expect(summary.agentBreakdown).toEqual({ support: 1, billing: 1 });
526
+ });
527
+
528
+ it('handles large batch of events', async () => {
529
+ for (let i = 0; i < 100; i++) {
530
+ await store.recordMessageEvent(makeEvent({
531
+ customerPhone: `+${i % 10}`,
532
+ agentName: i % 3 === 0 ? 'support' : 'billing',
533
+ kbTopScore: Math.random(),
534
+ kbIsFaqMatch: Math.random() > 0.5,
535
+ }));
536
+ }
537
+ const summary = await store.getSummary();
538
+ expect(summary.totalMessages).toBe(100);
539
+ expect(summary.uniqueCustomers).toBe(10);
540
+ });
541
+
542
+ it('handles events with zero response time', async () => {
543
+ await store.recordMessageEvent(makeEvent({ responseTimeMs: 0 }));
544
+ const summary = await store.getSummary();
545
+ expect(summary.avgResponseTime).toBe(0);
546
+ });
547
+
548
+ it('handles events with zero cost', async () => {
549
+ await store.recordMessageEvent(makeEvent({ llmCost: 0 }));
550
+ const summary = await store.getSummary();
551
+ expect(summary.totalMessages).toBe(1);
552
+ });
553
+ });
554
+ });