@agentlensai/server 0.2.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/LICENSE +21 -0
- package/dist/__tests__/agents-stats.test.d.ts +5 -0
- package/dist/__tests__/agents-stats.test.d.ts.map +1 -0
- package/dist/__tests__/agents-stats.test.js +134 -0
- package/dist/__tests__/agents-stats.test.js.map +1 -0
- package/dist/__tests__/api-keys.test.d.ts +5 -0
- package/dist/__tests__/api-keys.test.d.ts.map +1 -0
- package/dist/__tests__/api-keys.test.js +118 -0
- package/dist/__tests__/api-keys.test.js.map +1 -0
- package/dist/__tests__/auth-no-db.test.d.ts +2 -0
- package/dist/__tests__/auth-no-db.test.d.ts.map +1 -0
- package/dist/__tests__/auth-no-db.test.js +43 -0
- package/dist/__tests__/auth-no-db.test.js.map +1 -0
- package/dist/__tests__/auth.test.d.ts +5 -0
- package/dist/__tests__/auth.test.d.ts.map +1 -0
- package/dist/__tests__/auth.test.js +86 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/__tests__/config.test.d.ts +2 -0
- package/dist/__tests__/config.test.d.ts.map +1 -0
- package/dist/__tests__/config.test.js +37 -0
- package/dist/__tests__/config.test.js.map +1 -0
- package/dist/__tests__/events-ingest.test.d.ts +5 -0
- package/dist/__tests__/events-ingest.test.d.ts.map +1 -0
- package/dist/__tests__/events-ingest.test.js +248 -0
- package/dist/__tests__/events-ingest.test.js.map +1 -0
- package/dist/__tests__/events-query.test.d.ts +5 -0
- package/dist/__tests__/events-query.test.d.ts.map +1 -0
- package/dist/__tests__/events-query.test.js +205 -0
- package/dist/__tests__/events-query.test.js.map +1 -0
- package/dist/__tests__/health.test.d.ts +5 -0
- package/dist/__tests__/health.test.d.ts.map +1 -0
- package/dist/__tests__/health.test.js +40 -0
- package/dist/__tests__/health.test.js.map +1 -0
- package/dist/__tests__/sessions.test.d.ts +5 -0
- package/dist/__tests__/sessions.test.d.ts.map +1 -0
- package/dist/__tests__/sessions.test.js +176 -0
- package/dist/__tests__/sessions.test.js.map +1 -0
- package/dist/__tests__/test-helpers.d.ts +24 -0
- package/dist/__tests__/test-helpers.d.ts.map +1 -0
- package/dist/__tests__/test-helpers.js +45 -0
- package/dist/__tests__/test-helpers.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +20 -0
- package/dist/config.js.map +1 -0
- package/dist/db/__tests__/init.test.d.ts +2 -0
- package/dist/db/__tests__/init.test.d.ts.map +1 -0
- package/dist/db/__tests__/init.test.js +73 -0
- package/dist/db/__tests__/init.test.js.map +1 -0
- package/dist/db/__tests__/sqlite-store-read.test.d.ts +2 -0
- package/dist/db/__tests__/sqlite-store-read.test.d.ts.map +1 -0
- package/dist/db/__tests__/sqlite-store-read.test.js +749 -0
- package/dist/db/__tests__/sqlite-store-read.test.js.map +1 -0
- package/dist/db/__tests__/sqlite-store-write.test.d.ts +2 -0
- package/dist/db/__tests__/sqlite-store-write.test.d.ts.map +1 -0
- package/dist/db/__tests__/sqlite-store-write.test.js +418 -0
- package/dist/db/__tests__/sqlite-store-write.test.js.map +1 -0
- package/dist/db/errors.d.ts +16 -0
- package/dist/db/errors.d.ts.map +1 -0
- package/dist/db/errors.js +22 -0
- package/dist/db/errors.js.map +1 -0
- package/dist/db/index.d.ts +33 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +44 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate.d.ts +26 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +128 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema.sqlite.d.ts +1009 -0
- package/dist/db/schema.sqlite.d.ts.map +1 -0
- package/dist/db/schema.sqlite.js +96 -0
- package/dist/db/schema.sqlite.js.map +1 -0
- package/dist/db/sqlite-store.d.ts +68 -0
- package/dist/db/sqlite-store.d.ts.map +1 -0
- package/dist/db/sqlite-store.js +753 -0
- package/dist/db/sqlite-store.js.map +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +182 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/__tests__/retention.test.d.ts +2 -0
- package/dist/lib/__tests__/retention.test.d.ts.map +1 -0
- package/dist/lib/__tests__/retention.test.js +238 -0
- package/dist/lib/__tests__/retention.test.js.map +1 -0
- package/dist/lib/retention.d.ts +31 -0
- package/dist/lib/retention.d.ts.map +1 -0
- package/dist/lib/retention.js +47 -0
- package/dist/lib/retention.js.map +1 -0
- package/dist/middleware/auth.d.ts +37 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +78 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/routes/agents.d.ts +13 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +34 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/api-keys.d.ts +14 -0
- package/dist/routes/api-keys.d.ts.map +1 -0
- package/dist/routes/api-keys.js +81 -0
- package/dist/routes/api-keys.js.map +1 -0
- package/dist/routes/config.d.ts +39 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +97 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/events.d.ts +14 -0
- package/dist/routes/events.d.ts.map +1 -0
- package/dist/routes/events.js +164 -0
- package/dist/routes/events.js.map +1 -0
- package/dist/routes/sessions.d.ts +14 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +72 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/stats.d.ts +12 -0
- package/dist/routes/stats.d.ts.map +1 -0
- package/dist/routes/stats.js +16 -0
- package/dist/routes/stats.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { computeEventHash } from '@agentlens/core';
|
|
3
|
+
import { sql } from 'drizzle-orm';
|
|
4
|
+
import { createTestDb } from '../index.js';
|
|
5
|
+
import { runMigrations } from '../migrate.js';
|
|
6
|
+
import { SqliteEventStore } from '../sqlite-store.js';
|
|
7
|
+
import { NotFoundError } from '../errors.js';
|
|
8
|
+
import { safeJsonParse } from '../sqlite-store.js';
|
|
9
|
+
let counter = 0;
|
|
10
|
+
/**
|
|
11
|
+
* Create a valid event with correct hash. For chains within a batch, use makeChain().
|
|
12
|
+
*/
|
|
13
|
+
function makeEvent(overrides = {}) {
|
|
14
|
+
counter++;
|
|
15
|
+
const id = overrides.id ?? `evt_${String(counter).padStart(6, '0')}`;
|
|
16
|
+
const base = {
|
|
17
|
+
id,
|
|
18
|
+
timestamp: overrides.timestamp ?? new Date(Date.UTC(2026, 0, 15, 10, 0, counter)).toISOString(),
|
|
19
|
+
sessionId: overrides.sessionId ?? 'sess_001',
|
|
20
|
+
agentId: overrides.agentId ?? 'agent_001',
|
|
21
|
+
eventType: overrides.eventType ?? 'custom',
|
|
22
|
+
severity: overrides.severity ?? 'info',
|
|
23
|
+
payload: overrides.payload ?? { type: 'test', data: {} },
|
|
24
|
+
metadata: overrides.metadata ?? {},
|
|
25
|
+
prevHash: overrides.prevHash ?? null,
|
|
26
|
+
};
|
|
27
|
+
const hash = computeEventHash({
|
|
28
|
+
id: base.id,
|
|
29
|
+
timestamp: base.timestamp,
|
|
30
|
+
sessionId: base.sessionId,
|
|
31
|
+
agentId: base.agentId,
|
|
32
|
+
eventType: base.eventType,
|
|
33
|
+
severity: base.severity,
|
|
34
|
+
payload: base.payload,
|
|
35
|
+
metadata: base.metadata,
|
|
36
|
+
prevHash: base.prevHash,
|
|
37
|
+
});
|
|
38
|
+
return { ...base, hash };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build a chain of events for a session, correctly linking hashes.
|
|
42
|
+
*/
|
|
43
|
+
function makeChain(overridesList, startPrevHash = null) {
|
|
44
|
+
const chain = [];
|
|
45
|
+
let prevHash = startPrevHash;
|
|
46
|
+
for (const overrides of overridesList) {
|
|
47
|
+
counter++;
|
|
48
|
+
const id = overrides.id ?? `evt_${String(counter).padStart(6, '0')}`;
|
|
49
|
+
const base = {
|
|
50
|
+
id,
|
|
51
|
+
timestamp: overrides.timestamp ?? new Date(Date.UTC(2026, 0, 15, 10, 0, counter)).toISOString(),
|
|
52
|
+
sessionId: overrides.sessionId ?? 'sess_001',
|
|
53
|
+
agentId: overrides.agentId ?? 'agent_001',
|
|
54
|
+
eventType: overrides.eventType ?? 'custom',
|
|
55
|
+
severity: overrides.severity ?? 'info',
|
|
56
|
+
payload: overrides.payload ?? { type: 'test', data: {} },
|
|
57
|
+
metadata: overrides.metadata ?? {},
|
|
58
|
+
prevHash,
|
|
59
|
+
};
|
|
60
|
+
const hash = computeEventHash({
|
|
61
|
+
id: base.id,
|
|
62
|
+
timestamp: base.timestamp,
|
|
63
|
+
sessionId: base.sessionId,
|
|
64
|
+
agentId: base.agentId,
|
|
65
|
+
eventType: base.eventType,
|
|
66
|
+
severity: base.severity,
|
|
67
|
+
payload: base.payload,
|
|
68
|
+
metadata: base.metadata,
|
|
69
|
+
prevHash: base.prevHash,
|
|
70
|
+
});
|
|
71
|
+
const event = { ...base, hash };
|
|
72
|
+
chain.push(event);
|
|
73
|
+
prevHash = hash;
|
|
74
|
+
}
|
|
75
|
+
return chain;
|
|
76
|
+
}
|
|
77
|
+
describe('SqliteEventStore — Read Operations (Story 3.5)', () => {
|
|
78
|
+
let db;
|
|
79
|
+
let store;
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
counter = 0;
|
|
82
|
+
db = createTestDb();
|
|
83
|
+
runMigrations(db);
|
|
84
|
+
store = new SqliteEventStore(db);
|
|
85
|
+
});
|
|
86
|
+
// ─── queryEvents ─────────────────────────────────────────
|
|
87
|
+
describe('queryEvents()', () => {
|
|
88
|
+
it('should return events filtered by sessionId', async () => {
|
|
89
|
+
// Two separate sessions — each starts its own chain
|
|
90
|
+
const chainA = makeChain([
|
|
91
|
+
{ sessionId: 'sess_A', eventType: 'session_started', payload: { tags: [] } },
|
|
92
|
+
{ sessionId: 'sess_A' },
|
|
93
|
+
]);
|
|
94
|
+
const chainB = makeChain([
|
|
95
|
+
{ sessionId: 'sess_B', eventType: 'session_started', payload: { tags: [] } },
|
|
96
|
+
{ sessionId: 'sess_B' },
|
|
97
|
+
]);
|
|
98
|
+
await store.insertEvents(chainA);
|
|
99
|
+
await store.insertEvents(chainB);
|
|
100
|
+
const result = await store.queryEvents({ sessionId: 'sess_A' });
|
|
101
|
+
expect(result.events).toHaveLength(2);
|
|
102
|
+
expect(result.events.every((e) => e.sessionId === 'sess_A')).toBe(true);
|
|
103
|
+
expect(result.total).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
it('should return events filtered by eventType', async () => {
|
|
106
|
+
const chain = makeChain([
|
|
107
|
+
{ eventType: 'session_started', payload: { tags: [] } },
|
|
108
|
+
{ eventType: 'tool_call', payload: { toolName: 'search', arguments: {}, callId: 'c1' } },
|
|
109
|
+
{ eventType: 'tool_call', payload: { toolName: 'read', arguments: {}, callId: 'c2' } },
|
|
110
|
+
{ eventType: 'tool_response', payload: { callId: 'c1', toolName: 'search', result: {}, durationMs: 100 } },
|
|
111
|
+
]);
|
|
112
|
+
await store.insertEvents(chain);
|
|
113
|
+
const result = await store.queryEvents({ eventType: 'tool_call' });
|
|
114
|
+
expect(result.events).toHaveLength(2);
|
|
115
|
+
expect(result.events.every((e) => e.eventType === 'tool_call')).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
it('should return events filtered by multiple eventTypes (array)', async () => {
|
|
118
|
+
const chain = makeChain([
|
|
119
|
+
{ eventType: 'session_started', payload: { tags: [] } },
|
|
120
|
+
{ eventType: 'tool_call', payload: { toolName: 'a', arguments: {}, callId: 'c1' } },
|
|
121
|
+
{ eventType: 'tool_error', severity: 'error', payload: { callId: 'c1', toolName: 'a', error: 'fail', durationMs: 100 } },
|
|
122
|
+
{ eventType: 'custom' },
|
|
123
|
+
]);
|
|
124
|
+
await store.insertEvents(chain);
|
|
125
|
+
const result = await store.queryEvents({ eventType: ['tool_call', 'tool_error'] });
|
|
126
|
+
expect(result.events).toHaveLength(2);
|
|
127
|
+
});
|
|
128
|
+
it('should return events filtered by severity', async () => {
|
|
129
|
+
const chain = makeChain([
|
|
130
|
+
{ eventType: 'session_started', payload: { tags: [] } },
|
|
131
|
+
{ severity: 'info' },
|
|
132
|
+
{ severity: 'error' },
|
|
133
|
+
{ severity: 'warn' },
|
|
134
|
+
{ severity: 'error' },
|
|
135
|
+
]);
|
|
136
|
+
await store.insertEvents(chain);
|
|
137
|
+
const result = await store.queryEvents({ severity: 'error' });
|
|
138
|
+
expect(result.events).toHaveLength(2);
|
|
139
|
+
expect(result.events.every((e) => e.severity === 'error')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
it('should return events filtered by multiple severities (array)', async () => {
|
|
142
|
+
const chain = makeChain([
|
|
143
|
+
{ eventType: 'session_started', payload: { tags: [] } },
|
|
144
|
+
{ severity: 'info' },
|
|
145
|
+
{ severity: 'error' },
|
|
146
|
+
{ severity: 'critical' },
|
|
147
|
+
{ severity: 'warn' },
|
|
148
|
+
]);
|
|
149
|
+
await store.insertEvents(chain);
|
|
150
|
+
const result = await store.queryEvents({ severity: ['error', 'critical'] });
|
|
151
|
+
expect(result.events).toHaveLength(2);
|
|
152
|
+
});
|
|
153
|
+
it('should filter events by eventType AND severity combined', async () => {
|
|
154
|
+
const chain = makeChain([
|
|
155
|
+
{ eventType: 'session_started', payload: { tags: [] } },
|
|
156
|
+
{ eventType: 'tool_call', severity: 'info', payload: { toolName: 'a', arguments: {}, callId: 'c1' } },
|
|
157
|
+
{ eventType: 'tool_call', severity: 'error', payload: { toolName: 'b', arguments: {}, callId: 'c2' } },
|
|
158
|
+
{ eventType: 'custom', severity: 'error' },
|
|
159
|
+
]);
|
|
160
|
+
await store.insertEvents(chain);
|
|
161
|
+
const result = await store.queryEvents({ eventType: 'tool_call', severity: 'error' });
|
|
162
|
+
expect(result.events).toHaveLength(1);
|
|
163
|
+
expect(result.events[0].eventType).toBe('tool_call');
|
|
164
|
+
expect(result.events[0].severity).toBe('error');
|
|
165
|
+
});
|
|
166
|
+
it('should filter events by time range (from/to)', async () => {
|
|
167
|
+
const chain = makeChain([
|
|
168
|
+
{ id: 'e1', timestamp: '2026-01-01T00:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
169
|
+
{ id: 'e2', timestamp: '2026-01-02T00:00:00Z' },
|
|
170
|
+
{ id: 'e3', timestamp: '2026-01-03T00:00:00Z' },
|
|
171
|
+
{ id: 'e4', timestamp: '2026-01-04T00:00:00Z' },
|
|
172
|
+
{ id: 'e5', timestamp: '2026-01-05T00:00:00Z' },
|
|
173
|
+
]);
|
|
174
|
+
await store.insertEvents(chain);
|
|
175
|
+
const result = await store.queryEvents({
|
|
176
|
+
from: '2026-01-02T00:00:00Z',
|
|
177
|
+
to: '2026-01-04T00:00:00Z',
|
|
178
|
+
});
|
|
179
|
+
expect(result.events).toHaveLength(3);
|
|
180
|
+
expect(result.events.map((e) => e.id).sort()).toEqual(['e2', 'e3', 'e4']);
|
|
181
|
+
});
|
|
182
|
+
it('should return events ordered by timestamp descending by default', async () => {
|
|
183
|
+
const chain = makeChain([
|
|
184
|
+
{ id: 'e1', timestamp: '2026-01-01T00:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
185
|
+
{ id: 'e2', timestamp: '2026-01-02T00:00:00Z' },
|
|
186
|
+
{ id: 'e3', timestamp: '2026-01-03T00:00:00Z' },
|
|
187
|
+
]);
|
|
188
|
+
await store.insertEvents(chain);
|
|
189
|
+
const result = await store.queryEvents({});
|
|
190
|
+
expect(result.events[0].id).toBe('e3');
|
|
191
|
+
expect(result.events[2].id).toBe('e1');
|
|
192
|
+
});
|
|
193
|
+
it('should return events ordered ascending when requested', async () => {
|
|
194
|
+
const chain = makeChain([
|
|
195
|
+
{ id: 'e1', timestamp: '2026-01-01T00:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
196
|
+
{ id: 'e2', timestamp: '2026-01-02T00:00:00Z' },
|
|
197
|
+
{ id: 'e3', timestamp: '2026-01-03T00:00:00Z' },
|
|
198
|
+
]);
|
|
199
|
+
await store.insertEvents(chain);
|
|
200
|
+
const result = await store.queryEvents({ order: 'asc' });
|
|
201
|
+
expect(result.events[0].id).toBe('e1');
|
|
202
|
+
expect(result.events[2].id).toBe('e3');
|
|
203
|
+
});
|
|
204
|
+
it('should respect limit and offset for pagination', async () => {
|
|
205
|
+
const chain = makeChain(Array.from({ length: 10 }, (_, i) => ({
|
|
206
|
+
id: `e${String(i + 1).padStart(2, '0')}`,
|
|
207
|
+
timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString(),
|
|
208
|
+
...(i === 0
|
|
209
|
+
? { eventType: 'session_started', payload: { tags: [] } }
|
|
210
|
+
: {}),
|
|
211
|
+
})));
|
|
212
|
+
await store.insertEvents(chain);
|
|
213
|
+
const page1 = await store.queryEvents({ limit: 3, offset: 0, order: 'asc' });
|
|
214
|
+
expect(page1.events).toHaveLength(3);
|
|
215
|
+
expect(page1.total).toBe(10);
|
|
216
|
+
expect(page1.hasMore).toBe(true);
|
|
217
|
+
const page2 = await store.queryEvents({ limit: 3, offset: 3, order: 'asc' });
|
|
218
|
+
expect(page2.events).toHaveLength(3);
|
|
219
|
+
expect(page2.hasMore).toBe(true);
|
|
220
|
+
const lastPage = await store.queryEvents({ limit: 3, offset: 9, order: 'asc' });
|
|
221
|
+
expect(lastPage.events).toHaveLength(1);
|
|
222
|
+
expect(lastPage.hasMore).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
it('should enforce max limit of 500', async () => {
|
|
225
|
+
await store.insertEvents([
|
|
226
|
+
makeEvent({ eventType: 'session_started', payload: { tags: [] } }),
|
|
227
|
+
]);
|
|
228
|
+
const result = await store.queryEvents({ limit: 9999 });
|
|
229
|
+
expect(result.events.length).toBeLessThanOrEqual(500);
|
|
230
|
+
});
|
|
231
|
+
it('should default limit to 50', async () => {
|
|
232
|
+
const chain = makeChain(Array.from({ length: 60 }, (_, i) => ({
|
|
233
|
+
id: `e${String(i + 1).padStart(3, '0')}`,
|
|
234
|
+
timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString(),
|
|
235
|
+
...(i === 0
|
|
236
|
+
? { eventType: 'session_started', payload: { tags: [] } }
|
|
237
|
+
: {}),
|
|
238
|
+
})));
|
|
239
|
+
await store.insertEvents(chain);
|
|
240
|
+
const result = await store.queryEvents({});
|
|
241
|
+
expect(result.events).toHaveLength(50);
|
|
242
|
+
expect(result.total).toBe(60);
|
|
243
|
+
});
|
|
244
|
+
it('should search payload text', async () => {
|
|
245
|
+
const chain = makeChain([
|
|
246
|
+
{ eventType: 'session_started', payload: { tags: [] } },
|
|
247
|
+
{ id: 'e_search1', eventType: 'tool_call', payload: { toolName: 'web_search', arguments: { query: 'weather' }, callId: 'c1' } },
|
|
248
|
+
{ id: 'e_search2', eventType: 'tool_call', payload: { toolName: 'file_read', arguments: {}, callId: 'c2' } },
|
|
249
|
+
]);
|
|
250
|
+
await store.insertEvents(chain);
|
|
251
|
+
const result = await store.queryEvents({ search: 'web_search' });
|
|
252
|
+
expect(result.events).toHaveLength(1);
|
|
253
|
+
expect(result.events[0].id).toBe('e_search1');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
// ─── getEvent ────────────────────────────────────────────
|
|
257
|
+
describe('getEvent()', () => {
|
|
258
|
+
it('should return a single event by ID', async () => {
|
|
259
|
+
await store.insertEvents([
|
|
260
|
+
makeEvent({ id: 'evt_target', eventType: 'session_started', payload: { agentName: 'Test', tags: [] } }),
|
|
261
|
+
]);
|
|
262
|
+
const event = await store.getEvent('evt_target');
|
|
263
|
+
expect(event).not.toBeNull();
|
|
264
|
+
expect(event.id).toBe('evt_target');
|
|
265
|
+
expect(event.payload).toEqual({ agentName: 'Test', tags: [] });
|
|
266
|
+
});
|
|
267
|
+
it('should return null for non-existent event', async () => {
|
|
268
|
+
const event = await store.getEvent('nonexistent');
|
|
269
|
+
expect(event).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
// ─── getSessionTimeline ──────────────────────────────────
|
|
273
|
+
describe('getSessionTimeline()', () => {
|
|
274
|
+
it('should return all events for a session in ascending timestamp order', async () => {
|
|
275
|
+
const chainTimeline = makeChain([
|
|
276
|
+
{ id: 'e1', sessionId: 'sess_timeline', timestamp: '2026-01-01T10:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
277
|
+
{ id: 'e2', sessionId: 'sess_timeline', timestamp: '2026-01-01T10:01:00Z', eventType: 'tool_call', payload: { toolName: 'a', arguments: {}, callId: 'c1' } },
|
|
278
|
+
{ id: 'e3', sessionId: 'sess_timeline', timestamp: '2026-01-01T10:02:00Z', eventType: 'tool_response', payload: { callId: 'c1', toolName: 'a', result: {}, durationMs: 50 } },
|
|
279
|
+
{ id: 'e4', sessionId: 'sess_timeline', timestamp: '2026-01-01T10:03:00Z', eventType: 'session_ended', payload: { reason: 'completed' } },
|
|
280
|
+
]);
|
|
281
|
+
const chainOther = makeChain([
|
|
282
|
+
{ id: 'e5', sessionId: 'sess_other', timestamp: '2026-01-01T10:00:30Z', eventType: 'session_started', payload: { tags: [] } },
|
|
283
|
+
]);
|
|
284
|
+
await store.insertEvents(chainTimeline);
|
|
285
|
+
await store.insertEvents(chainOther);
|
|
286
|
+
const timeline = await store.getSessionTimeline('sess_timeline');
|
|
287
|
+
expect(timeline).toHaveLength(4);
|
|
288
|
+
expect(timeline[0].id).toBe('e1');
|
|
289
|
+
expect(timeline[1].id).toBe('e2');
|
|
290
|
+
expect(timeline[2].id).toBe('e3');
|
|
291
|
+
expect(timeline[3].id).toBe('e4');
|
|
292
|
+
for (let i = 1; i < timeline.length; i++) {
|
|
293
|
+
expect(timeline[i].timestamp >= timeline[i - 1].timestamp).toBe(true);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
it('should return empty array for non-existent session', async () => {
|
|
297
|
+
const timeline = await store.getSessionTimeline('nonexistent');
|
|
298
|
+
expect(timeline).toEqual([]);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
// ─── countEvents ─────────────────────────────────────────
|
|
302
|
+
describe('countEvents()', () => {
|
|
303
|
+
it('should count all events without filters', async () => {
|
|
304
|
+
const chain = makeChain([
|
|
305
|
+
{ eventType: 'session_started', payload: { tags: [] } },
|
|
306
|
+
{},
|
|
307
|
+
{},
|
|
308
|
+
]);
|
|
309
|
+
await store.insertEvents(chain);
|
|
310
|
+
const count = await store.countEvents({});
|
|
311
|
+
expect(count).toBe(3);
|
|
312
|
+
});
|
|
313
|
+
it('should count events matching filters', async () => {
|
|
314
|
+
const chain = makeChain([
|
|
315
|
+
{ eventType: 'session_started', payload: { tags: [] } },
|
|
316
|
+
{ severity: 'error' },
|
|
317
|
+
{ severity: 'error' },
|
|
318
|
+
{ severity: 'info' },
|
|
319
|
+
]);
|
|
320
|
+
await store.insertEvents(chain);
|
|
321
|
+
const count = await store.countEvents({ severity: 'error' });
|
|
322
|
+
expect(count).toBe(2);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
// ─── querySessions ───────────────────────────────────────
|
|
326
|
+
describe('querySessions()', () => {
|
|
327
|
+
beforeEach(async () => {
|
|
328
|
+
// Create sessions across two agents — each session is its own chain
|
|
329
|
+
const chainA1 = makeChain([
|
|
330
|
+
{ id: 'e1', sessionId: 'sess_A1', agentId: 'agent_A', timestamp: '2026-01-01T10:00:00Z', eventType: 'session_started', payload: { agentName: 'Agent A', tags: ['prod'] } },
|
|
331
|
+
{ id: 'e2', sessionId: 'sess_A1', agentId: 'agent_A', timestamp: '2026-01-01T10:05:00Z', eventType: 'session_ended', payload: { reason: 'completed' } },
|
|
332
|
+
]);
|
|
333
|
+
const chainA2 = makeChain([
|
|
334
|
+
{ id: 'e3', sessionId: 'sess_A2', agentId: 'agent_A', timestamp: '2026-01-02T10:00:00Z', eventType: 'session_started', payload: { agentName: 'Agent A', tags: ['dev'] } },
|
|
335
|
+
]);
|
|
336
|
+
const chainB1 = makeChain([
|
|
337
|
+
{ id: 'e4', sessionId: 'sess_B1', agentId: 'agent_B', timestamp: '2026-01-03T10:00:00Z', eventType: 'session_started', payload: { agentName: 'Agent B', tags: ['prod'] } },
|
|
338
|
+
{ id: 'e5', sessionId: 'sess_B1', agentId: 'agent_B', timestamp: '2026-01-03T10:10:00Z', eventType: 'session_ended', payload: { reason: 'error' } },
|
|
339
|
+
]);
|
|
340
|
+
await store.insertEvents(chainA1);
|
|
341
|
+
await store.insertEvents(chainA2);
|
|
342
|
+
await store.insertEvents(chainB1);
|
|
343
|
+
});
|
|
344
|
+
it('should return all sessions ordered by startedAt desc', async () => {
|
|
345
|
+
const { sessions, total } = await store.querySessions({});
|
|
346
|
+
expect(total).toBe(3);
|
|
347
|
+
expect(sessions).toHaveLength(3);
|
|
348
|
+
expect(sessions[0].id).toBe('sess_B1');
|
|
349
|
+
expect(sessions[1].id).toBe('sess_A2');
|
|
350
|
+
expect(sessions[2].id).toBe('sess_A1');
|
|
351
|
+
});
|
|
352
|
+
it('should filter sessions by agentId', async () => {
|
|
353
|
+
const { sessions, total } = await store.querySessions({ agentId: 'agent_A' });
|
|
354
|
+
expect(total).toBe(2);
|
|
355
|
+
expect(sessions.every((s) => s.agentId === 'agent_A')).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
it('should filter sessions by status', async () => {
|
|
358
|
+
const { sessions } = await store.querySessions({ status: 'completed' });
|
|
359
|
+
expect(sessions).toHaveLength(1);
|
|
360
|
+
expect(sessions[0].id).toBe('sess_A1');
|
|
361
|
+
const { sessions: errorSessions } = await store.querySessions({ status: 'error' });
|
|
362
|
+
expect(errorSessions).toHaveLength(1);
|
|
363
|
+
expect(errorSessions[0].id).toBe('sess_B1');
|
|
364
|
+
const { sessions: activeSessions } = await store.querySessions({ status: 'active' });
|
|
365
|
+
expect(activeSessions).toHaveLength(1);
|
|
366
|
+
expect(activeSessions[0].id).toBe('sess_A2');
|
|
367
|
+
});
|
|
368
|
+
it('should filter sessions by time range', async () => {
|
|
369
|
+
const { sessions } = await store.querySessions({
|
|
370
|
+
from: '2026-01-02T00:00:00Z',
|
|
371
|
+
to: '2026-01-03T23:59:59Z',
|
|
372
|
+
});
|
|
373
|
+
expect(sessions).toHaveLength(2);
|
|
374
|
+
});
|
|
375
|
+
it('should filter sessions by tags', async () => {
|
|
376
|
+
const { sessions } = await store.querySessions({ tags: ['prod'] });
|
|
377
|
+
expect(sessions).toHaveLength(2);
|
|
378
|
+
const ids = sessions.map((s) => s.id).sort();
|
|
379
|
+
expect(ids).toEqual(['sess_A1', 'sess_B1']);
|
|
380
|
+
});
|
|
381
|
+
it('should paginate sessions', async () => {
|
|
382
|
+
const page1 = await store.querySessions({ limit: 2, offset: 0 });
|
|
383
|
+
expect(page1.sessions).toHaveLength(2);
|
|
384
|
+
expect(page1.total).toBe(3);
|
|
385
|
+
const page2 = await store.querySessions({ limit: 2, offset: 2 });
|
|
386
|
+
expect(page2.sessions).toHaveLength(1);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
// ─── getSession ──────────────────────────────────────────
|
|
390
|
+
describe('getSession()', () => {
|
|
391
|
+
it('should return a session with correct fields', async () => {
|
|
392
|
+
const chain = makeChain([
|
|
393
|
+
{ sessionId: 'sess_detail', eventType: 'session_started', payload: { agentName: 'DetailAgent', tags: ['test'] } },
|
|
394
|
+
{ sessionId: 'sess_detail', eventType: 'tool_call', payload: { toolName: 'x', arguments: {}, callId: 'c1' } },
|
|
395
|
+
{ sessionId: 'sess_detail', severity: 'error' },
|
|
396
|
+
]);
|
|
397
|
+
await store.insertEvents(chain);
|
|
398
|
+
const session = await store.getSession('sess_detail');
|
|
399
|
+
expect(session).not.toBeNull();
|
|
400
|
+
expect(session.agentName).toBe('DetailAgent');
|
|
401
|
+
expect(session.status).toBe('active');
|
|
402
|
+
expect(session.eventCount).toBe(3);
|
|
403
|
+
expect(session.toolCallCount).toBe(1);
|
|
404
|
+
expect(session.errorCount).toBe(1);
|
|
405
|
+
expect(session.tags).toEqual(['test']);
|
|
406
|
+
});
|
|
407
|
+
it('should return null for non-existent session', async () => {
|
|
408
|
+
const session = await store.getSession('nonexistent');
|
|
409
|
+
expect(session).toBeNull();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
// ─── listAgents / getAgent ───────────────────────────────
|
|
413
|
+
describe('listAgents()', () => {
|
|
414
|
+
it('should list all agents ordered by lastSeenAt desc', async () => {
|
|
415
|
+
const chainOld = makeChain([
|
|
416
|
+
{ agentId: 'agent_old', sessionId: 'sess_old', timestamp: '2026-01-01T00:00:00Z', eventType: 'session_started', payload: { agentName: 'Old Agent', tags: [] } },
|
|
417
|
+
]);
|
|
418
|
+
const chainNew = makeChain([
|
|
419
|
+
{ agentId: 'agent_new', sessionId: 'sess_new', timestamp: '2026-01-10T00:00:00Z', eventType: 'session_started', payload: { agentName: 'New Agent', tags: [] } },
|
|
420
|
+
]);
|
|
421
|
+
// Different agents, different sessions — separate chains
|
|
422
|
+
await store.insertEvents(chainOld);
|
|
423
|
+
await store.insertEvents(chainNew);
|
|
424
|
+
const agentList = await store.listAgents();
|
|
425
|
+
expect(agentList).toHaveLength(2);
|
|
426
|
+
expect(agentList[0].id).toBe('agent_new');
|
|
427
|
+
expect(agentList[1].id).toBe('agent_old');
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
describe('getAgent()', () => {
|
|
431
|
+
it('should return agent with correct fields', async () => {
|
|
432
|
+
const chain1 = makeChain([
|
|
433
|
+
{ agentId: 'agent_x', sessionId: 's1', timestamp: '2026-01-01T00:00:00Z', eventType: 'session_started', payload: { agentName: 'Agent X', tags: [] } },
|
|
434
|
+
]);
|
|
435
|
+
const chain2 = makeChain([
|
|
436
|
+
{ agentId: 'agent_x', sessionId: 's2', timestamp: '2026-01-05T00:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
437
|
+
]);
|
|
438
|
+
await store.insertEvents(chain1);
|
|
439
|
+
await store.insertEvents(chain2);
|
|
440
|
+
const agent = await store.getAgent('agent_x');
|
|
441
|
+
expect(agent).not.toBeNull();
|
|
442
|
+
expect(agent.name).toBe('Agent X');
|
|
443
|
+
expect(agent.firstSeenAt).toBe('2026-01-01T00:00:00Z');
|
|
444
|
+
expect(agent.lastSeenAt).toBe('2026-01-05T00:00:00Z');
|
|
445
|
+
expect(agent.sessionCount).toBe(2);
|
|
446
|
+
});
|
|
447
|
+
it('should return null for non-existent agent', async () => {
|
|
448
|
+
const agent = await store.getAgent('nonexistent');
|
|
449
|
+
expect(agent).toBeNull();
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
// ─── getAnalytics ────────────────────────────────────────
|
|
453
|
+
describe('getAnalytics()', () => {
|
|
454
|
+
beforeEach(async () => {
|
|
455
|
+
// Insert events spanning 3 hours — each session has its own chain
|
|
456
|
+
const chainA = makeChain([
|
|
457
|
+
// Hour 1: 10:00–10:59
|
|
458
|
+
{ id: 'a1', timestamp: '2026-01-15T10:00:00Z', eventType: 'session_started', sessionId: 'sA', payload: { tags: [] } },
|
|
459
|
+
{ id: 'a2', timestamp: '2026-01-15T10:10:00Z', eventType: 'tool_call', sessionId: 'sA', payload: { toolName: 'a', arguments: {}, callId: 'c1' } },
|
|
460
|
+
{ id: 'a3', timestamp: '2026-01-15T10:20:00Z', eventType: 'tool_error', sessionId: 'sA', severity: 'error', payload: { callId: 'c1', toolName: 'a', error: 'fail', durationMs: 100 } },
|
|
461
|
+
// Hour 3: 12:00–12:59 (same session sA)
|
|
462
|
+
{ id: 'a6', timestamp: '2026-01-15T12:00:00Z', eventType: 'tool_call', sessionId: 'sA', payload: { toolName: 'c', arguments: {}, callId: 'c3' } },
|
|
463
|
+
]);
|
|
464
|
+
const chainB = makeChain([
|
|
465
|
+
// Hour 2: 11:00–11:59
|
|
466
|
+
{ id: 'a4', timestamp: '2026-01-15T11:00:00Z', eventType: 'tool_call', sessionId: 'sB', agentId: 'agent_002', payload: { toolName: 'b', arguments: {}, callId: 'c2' } },
|
|
467
|
+
{ id: 'a5', timestamp: '2026-01-15T11:30:00Z', eventType: 'custom', sessionId: 'sB', agentId: 'agent_002', severity: 'info' },
|
|
468
|
+
]);
|
|
469
|
+
await store.insertEvents(chainA);
|
|
470
|
+
await store.insertEvents(chainB);
|
|
471
|
+
});
|
|
472
|
+
it('should return bucketed counts with hourly granularity', async () => {
|
|
473
|
+
const result = await store.getAnalytics({
|
|
474
|
+
from: '2026-01-15T10:00:00Z',
|
|
475
|
+
to: '2026-01-15T12:59:59Z',
|
|
476
|
+
granularity: 'hour',
|
|
477
|
+
});
|
|
478
|
+
expect(result.buckets).toHaveLength(3);
|
|
479
|
+
const h10 = result.buckets.find((b) => b.timestamp.includes('10:'));
|
|
480
|
+
expect(h10).toBeDefined();
|
|
481
|
+
expect(h10.eventCount).toBe(3);
|
|
482
|
+
expect(h10.toolCallCount).toBe(1);
|
|
483
|
+
expect(h10.errorCount).toBe(1);
|
|
484
|
+
const h11 = result.buckets.find((b) => b.timestamp.includes('11:'));
|
|
485
|
+
expect(h11).toBeDefined();
|
|
486
|
+
expect(h11.eventCount).toBe(2);
|
|
487
|
+
expect(h11.toolCallCount).toBe(1);
|
|
488
|
+
expect(h11.errorCount).toBe(0);
|
|
489
|
+
const h12 = result.buckets.find((b) => b.timestamp.includes('12:'));
|
|
490
|
+
expect(h12).toBeDefined();
|
|
491
|
+
expect(h12.eventCount).toBe(1);
|
|
492
|
+
expect(h12.toolCallCount).toBe(1);
|
|
493
|
+
});
|
|
494
|
+
it('should return correct totals', async () => {
|
|
495
|
+
const result = await store.getAnalytics({
|
|
496
|
+
from: '2026-01-15T10:00:00Z',
|
|
497
|
+
to: '2026-01-15T12:59:59Z',
|
|
498
|
+
granularity: 'hour',
|
|
499
|
+
});
|
|
500
|
+
expect(result.totals.eventCount).toBe(6);
|
|
501
|
+
expect(result.totals.toolCallCount).toBe(3);
|
|
502
|
+
expect(result.totals.errorCount).toBe(1);
|
|
503
|
+
expect(result.totals.uniqueSessions).toBe(2);
|
|
504
|
+
expect(result.totals.uniqueAgents).toBe(2);
|
|
505
|
+
});
|
|
506
|
+
it('should filter analytics by agentId', async () => {
|
|
507
|
+
const result = await store.getAnalytics({
|
|
508
|
+
from: '2026-01-15T10:00:00Z',
|
|
509
|
+
to: '2026-01-15T12:59:59Z',
|
|
510
|
+
agentId: 'agent_002',
|
|
511
|
+
granularity: 'hour',
|
|
512
|
+
});
|
|
513
|
+
expect(result.totals.eventCount).toBe(2);
|
|
514
|
+
expect(result.totals.uniqueAgents).toBe(1);
|
|
515
|
+
});
|
|
516
|
+
it('should return daily granularity', async () => {
|
|
517
|
+
const result = await store.getAnalytics({
|
|
518
|
+
from: '2026-01-15T00:00:00Z',
|
|
519
|
+
to: '2026-01-15T23:59:59Z',
|
|
520
|
+
granularity: 'day',
|
|
521
|
+
});
|
|
522
|
+
expect(result.buckets).toHaveLength(1);
|
|
523
|
+
expect(result.buckets[0].eventCount).toBe(6);
|
|
524
|
+
});
|
|
525
|
+
it('should return empty result for time range with no events', async () => {
|
|
526
|
+
const result = await store.getAnalytics({
|
|
527
|
+
from: '2025-01-01T00:00:00Z',
|
|
528
|
+
to: '2025-01-02T00:00:00Z',
|
|
529
|
+
granularity: 'hour',
|
|
530
|
+
});
|
|
531
|
+
expect(result.buckets).toHaveLength(0);
|
|
532
|
+
expect(result.totals.eventCount).toBe(0);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
// ─── getStats ────────────────────────────────────────────
|
|
536
|
+
describe('getStats()', () => {
|
|
537
|
+
it('should return correct counts', async () => {
|
|
538
|
+
// Two separate sessions with different agents
|
|
539
|
+
const chain1 = makeChain([
|
|
540
|
+
{ sessionId: 's1', agentId: 'a1', timestamp: '2026-01-01T00:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
541
|
+
{ sessionId: 's1', agentId: 'a1', timestamp: '2026-01-02T00:00:00Z' },
|
|
542
|
+
]);
|
|
543
|
+
const chain2 = makeChain([
|
|
544
|
+
{ sessionId: 's2', agentId: 'a2', timestamp: '2026-01-03T00:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
545
|
+
]);
|
|
546
|
+
await store.insertEvents(chain1);
|
|
547
|
+
await store.insertEvents(chain2);
|
|
548
|
+
const stats = await store.getStats();
|
|
549
|
+
expect(stats.totalEvents).toBe(3);
|
|
550
|
+
expect(stats.totalSessions).toBe(2);
|
|
551
|
+
expect(stats.totalAgents).toBe(2);
|
|
552
|
+
expect(stats.oldestEvent).toBe('2026-01-01T00:00:00Z');
|
|
553
|
+
expect(stats.newestEvent).toBe('2026-01-03T00:00:00Z');
|
|
554
|
+
expect(stats.storageSizeBytes).toBeGreaterThan(0);
|
|
555
|
+
});
|
|
556
|
+
it('should return zeros for empty database', async () => {
|
|
557
|
+
const stats = await store.getStats();
|
|
558
|
+
expect(stats.totalEvents).toBe(0);
|
|
559
|
+
expect(stats.totalSessions).toBe(0);
|
|
560
|
+
expect(stats.totalAgents).toBe(0);
|
|
561
|
+
expect(stats.oldestEvent).toBeUndefined();
|
|
562
|
+
expect(stats.newestEvent).toBeUndefined();
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
// ─── Alert Rules CRUD ────────────────────────────────────
|
|
566
|
+
describe('Alert Rules', () => {
|
|
567
|
+
const sampleRule = {
|
|
568
|
+
id: 'rule_001',
|
|
569
|
+
name: 'High Error Rate',
|
|
570
|
+
enabled: true,
|
|
571
|
+
condition: 'error_rate_exceeds',
|
|
572
|
+
threshold: 0.1,
|
|
573
|
+
windowMinutes: 60,
|
|
574
|
+
scope: { agentId: 'agent_001' },
|
|
575
|
+
notifyChannels: ['https://webhook.example.com/alert'],
|
|
576
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
577
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
578
|
+
};
|
|
579
|
+
it('should create and retrieve an alert rule', async () => {
|
|
580
|
+
await store.createAlertRule(sampleRule);
|
|
581
|
+
const rule = await store.getAlertRule('rule_001');
|
|
582
|
+
expect(rule).not.toBeNull();
|
|
583
|
+
expect(rule.name).toBe('High Error Rate');
|
|
584
|
+
expect(rule.enabled).toBe(true);
|
|
585
|
+
expect(rule.threshold).toBe(0.1);
|
|
586
|
+
expect(rule.scope).toEqual({ agentId: 'agent_001' });
|
|
587
|
+
expect(rule.notifyChannels).toEqual(['https://webhook.example.com/alert']);
|
|
588
|
+
});
|
|
589
|
+
it('should list all alert rules', async () => {
|
|
590
|
+
await store.createAlertRule(sampleRule);
|
|
591
|
+
await store.createAlertRule({ ...sampleRule, id: 'rule_002', name: 'Cost Exceeded' });
|
|
592
|
+
const rules = await store.listAlertRules();
|
|
593
|
+
expect(rules).toHaveLength(2);
|
|
594
|
+
});
|
|
595
|
+
it('should update an alert rule', async () => {
|
|
596
|
+
await store.createAlertRule(sampleRule);
|
|
597
|
+
await store.updateAlertRule('rule_001', {
|
|
598
|
+
name: 'Updated Rule',
|
|
599
|
+
enabled: false,
|
|
600
|
+
threshold: 0.5,
|
|
601
|
+
updatedAt: '2026-02-01T00:00:00Z',
|
|
602
|
+
});
|
|
603
|
+
const rule = await store.getAlertRule('rule_001');
|
|
604
|
+
expect(rule.name).toBe('Updated Rule');
|
|
605
|
+
expect(rule.enabled).toBe(false);
|
|
606
|
+
expect(rule.threshold).toBe(0.5);
|
|
607
|
+
});
|
|
608
|
+
it('should delete an alert rule', async () => {
|
|
609
|
+
await store.createAlertRule(sampleRule);
|
|
610
|
+
await store.deleteAlertRule('rule_001');
|
|
611
|
+
const rule = await store.getAlertRule('rule_001');
|
|
612
|
+
expect(rule).toBeNull();
|
|
613
|
+
});
|
|
614
|
+
it('should return null for non-existent alert rule', async () => {
|
|
615
|
+
const rule = await store.getAlertRule('nonexistent');
|
|
616
|
+
expect(rule).toBeNull();
|
|
617
|
+
});
|
|
618
|
+
// HIGH 8: Alert rule update/delete on missing IDs
|
|
619
|
+
it('should throw NotFoundError when updating a non-existent alert rule', async () => {
|
|
620
|
+
await expect(store.updateAlertRule('nonexistent', { name: 'foo' })).rejects.toThrow(NotFoundError);
|
|
621
|
+
});
|
|
622
|
+
it('should throw NotFoundError when deleting a non-existent alert rule', async () => {
|
|
623
|
+
await expect(store.deleteAlertRule('nonexistent')).rejects.toThrow(NotFoundError);
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
// ─── HIGH 4: Tag filtering exact matching ─────────────────
|
|
627
|
+
describe('Tag filtering (HIGH 4)', () => {
|
|
628
|
+
beforeEach(async () => {
|
|
629
|
+
// Create sessions with various tags
|
|
630
|
+
await store.upsertSession({
|
|
631
|
+
id: 'sess_prod',
|
|
632
|
+
agentId: 'a1',
|
|
633
|
+
startedAt: '2026-01-01T00:00:00Z',
|
|
634
|
+
status: 'active',
|
|
635
|
+
tags: ['production', 'critical'],
|
|
636
|
+
});
|
|
637
|
+
await store.upsertSession({
|
|
638
|
+
id: 'sess_dev',
|
|
639
|
+
agentId: 'a1',
|
|
640
|
+
startedAt: '2026-01-02T00:00:00Z',
|
|
641
|
+
status: 'active',
|
|
642
|
+
tags: ['dev', 'test'],
|
|
643
|
+
});
|
|
644
|
+
await store.upsertSession({
|
|
645
|
+
id: 'sess_prod2',
|
|
646
|
+
agentId: 'a2',
|
|
647
|
+
startedAt: '2026-01-03T00:00:00Z',
|
|
648
|
+
status: 'active',
|
|
649
|
+
tags: ['production'],
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
it('should use OR semantics — match sessions with ANY of the tags', async () => {
|
|
653
|
+
const { sessions } = await store.querySessions({ tags: ['production', 'dev'] });
|
|
654
|
+
expect(sessions).toHaveLength(3); // all three match
|
|
655
|
+
});
|
|
656
|
+
it('should not produce false positives with LIKE-style partial matching', async () => {
|
|
657
|
+
// "prod" should NOT match "production" — must be exact
|
|
658
|
+
const { sessions } = await store.querySessions({ tags: ['prod'] });
|
|
659
|
+
expect(sessions).toHaveLength(0);
|
|
660
|
+
});
|
|
661
|
+
it('should match exact tag values only', async () => {
|
|
662
|
+
const { sessions } = await store.querySessions({ tags: ['critical'] });
|
|
663
|
+
expect(sessions).toHaveLength(1);
|
|
664
|
+
expect(sessions[0].id).toBe('sess_prod');
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
// ─── HIGH 5: Week granularity in analytics ─────────────────
|
|
668
|
+
describe('Analytics week granularity (HIGH 5)', () => {
|
|
669
|
+
it('should bucket events by ISO week', async () => {
|
|
670
|
+
// Week 2 of 2026: Jan 5-11; Week 3: Jan 12-18
|
|
671
|
+
const chainW2 = makeChain([
|
|
672
|
+
{ id: 'w2_1', sessionId: 'sw2', timestamp: '2026-01-06T10:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
673
|
+
{ id: 'w2_2', sessionId: 'sw2', timestamp: '2026-01-07T10:00:00Z' },
|
|
674
|
+
]);
|
|
675
|
+
const chainW3 = makeChain([
|
|
676
|
+
{ id: 'w3_1', sessionId: 'sw3', timestamp: '2026-01-13T10:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
677
|
+
]);
|
|
678
|
+
await store.insertEvents(chainW2);
|
|
679
|
+
await store.insertEvents(chainW3);
|
|
680
|
+
const result = await store.getAnalytics({
|
|
681
|
+
from: '2026-01-01T00:00:00Z',
|
|
682
|
+
to: '2026-01-20T00:00:00Z',
|
|
683
|
+
granularity: 'week',
|
|
684
|
+
});
|
|
685
|
+
expect(result.buckets.length).toBeGreaterThanOrEqual(2);
|
|
686
|
+
// Different weeks should be in different buckets
|
|
687
|
+
const uniqueBuckets = new Set(result.buckets.map(b => b.timestamp));
|
|
688
|
+
expect(uniqueBuckets.size).toBeGreaterThanOrEqual(2);
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
// ─── HIGH 6: Analytics avgLatencyMs and totalCostUsd ───────
|
|
692
|
+
describe('Analytics avgLatencyMs and totalCostUsd (HIGH 6)', () => {
|
|
693
|
+
it('should compute avgLatencyMs from tool_response events', async () => {
|
|
694
|
+
const chain = makeChain([
|
|
695
|
+
{ id: 'lat1', sessionId: 'slat', timestamp: '2026-01-15T10:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
696
|
+
{ id: 'lat2', sessionId: 'slat', timestamp: '2026-01-15T10:01:00Z', eventType: 'tool_response', payload: { callId: 'c1', toolName: 'a', result: {}, durationMs: 100 } },
|
|
697
|
+
{ id: 'lat3', sessionId: 'slat', timestamp: '2026-01-15T10:02:00Z', eventType: 'tool_response', payload: { callId: 'c2', toolName: 'b', result: {}, durationMs: 300 } },
|
|
698
|
+
]);
|
|
699
|
+
await store.insertEvents(chain);
|
|
700
|
+
const result = await store.getAnalytics({
|
|
701
|
+
from: '2026-01-15T00:00:00Z',
|
|
702
|
+
to: '2026-01-15T23:59:59Z',
|
|
703
|
+
granularity: 'day',
|
|
704
|
+
});
|
|
705
|
+
// Average of 100 and 300 = 200
|
|
706
|
+
expect(result.totals.avgLatencyMs).toBe(200);
|
|
707
|
+
expect(result.buckets[0].avgLatencyMs).toBe(200);
|
|
708
|
+
});
|
|
709
|
+
it('should compute totalCostUsd from cost_tracked events', async () => {
|
|
710
|
+
const chain = makeChain([
|
|
711
|
+
{ id: 'cost1', sessionId: 'scost', timestamp: '2026-01-15T10:00:00Z', eventType: 'session_started', payload: { tags: [] } },
|
|
712
|
+
{ id: 'cost2', sessionId: 'scost', timestamp: '2026-01-15T10:01:00Z', eventType: 'cost_tracked', payload: { provider: 'openai', model: 'gpt-4', inputTokens: 100, outputTokens: 50, totalTokens: 150, costUsd: 0.05 } },
|
|
713
|
+
{ id: 'cost3', sessionId: 'scost', timestamp: '2026-01-15T10:02:00Z', eventType: 'cost_tracked', payload: { provider: 'openai', model: 'gpt-4', inputTokens: 200, outputTokens: 100, totalTokens: 300, costUsd: 0.10 } },
|
|
714
|
+
]);
|
|
715
|
+
await store.insertEvents(chain);
|
|
716
|
+
const result = await store.getAnalytics({
|
|
717
|
+
from: '2026-01-15T00:00:00Z',
|
|
718
|
+
to: '2026-01-15T23:59:59Z',
|
|
719
|
+
granularity: 'day',
|
|
720
|
+
});
|
|
721
|
+
expect(result.totals.totalCostUsd).toBeCloseTo(0.15, 2);
|
|
722
|
+
expect(result.buckets[0].totalCostUsd).toBeCloseTo(0.15, 2);
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
// ─── HIGH 7: safeJsonParse ─────────────────────────────────
|
|
726
|
+
describe('safeJsonParse (HIGH 7)', () => {
|
|
727
|
+
it('should parse valid JSON', () => {
|
|
728
|
+
expect(safeJsonParse('{"a":1}', {})).toEqual({ a: 1 });
|
|
729
|
+
expect(safeJsonParse('[]', [])).toEqual([]);
|
|
730
|
+
expect(safeJsonParse('"hello"', '')).toBe('hello');
|
|
731
|
+
});
|
|
732
|
+
it('should return fallback for malformed JSON', () => {
|
|
733
|
+
expect(safeJsonParse('not json', {})).toEqual({});
|
|
734
|
+
expect(safeJsonParse('{broken', [])).toEqual([]);
|
|
735
|
+
expect(safeJsonParse('', 'fallback')).toBe('fallback');
|
|
736
|
+
});
|
|
737
|
+
it('should be resilient when reading events with corrupted payload', async () => {
|
|
738
|
+
// Directly insert a row with malformed JSON via raw SQL
|
|
739
|
+
db.run(sql `INSERT INTO events (id, timestamp, session_id, agent_id, event_type, severity, payload, metadata, prev_hash, hash)
|
|
740
|
+
VALUES ('corrupt_1', '2026-01-01T00:00:00Z', 'sess_c', 'agent_c', 'custom', 'info', 'NOT_JSON', '{bad}', NULL, 'hash_c')`);
|
|
741
|
+
// Reading should not throw
|
|
742
|
+
const event = await store.getEvent('corrupt_1');
|
|
743
|
+
expect(event).not.toBeNull();
|
|
744
|
+
expect(event.payload).toEqual({});
|
|
745
|
+
expect(event.metadata).toEqual({});
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
//# sourceMappingURL=sqlite-store-read.test.js.map
|