1pub mod clients;
11pub mod logs;
12pub mod schema;
13pub mod session_detail;
14pub mod sessions;
15pub mod slow;
16pub mod stats;
17pub mod store_ops;
18
19use rusqlite::Connection;
20use std::path::Path;
21
22use super::db;
23
24pub struct QueryEngine {
26 conn: Connection,
27}
28
29impl QueryEngine {
30 pub fn open(db_path: &Path) -> Result<Self, rusqlite::Error> {
32 let conn = db::open_connection(db_path)?;
33 Ok(QueryEngine { conn })
34 }
35
36 pub(crate) fn conn(&self) -> &Connection {
38 &self.conn
39 }
40
41 #[cfg(test)]
43 pub(crate) fn from_conn(conn: Connection) -> Self {
44 QueryEngine { conn }
45 }
46}
47
48#[cfg(test)]
49#[allow(non_snake_case)]
50mod tests {
51 use super::*;
52 use crate::store::db;
53 use rusqlite::params;
54
55 pub(crate) fn seeded_engine() -> QueryEngine {
57 let conn = Connection::open_in_memory().unwrap();
58 db::run_migrations(&conn, "test").unwrap();
59
60 conn.execute(
62 "INSERT INTO sessions (session_id, proxy, started_at, last_seen_at, client_name, client_version, client_platform, total_calls, total_errors)
63 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
64 params!["s1", "api", 1000, 5000, "claude-desktop", "1.2.0", "claude", 3, 1],
65 ).unwrap();
66 conn.execute(
67 "INSERT INTO sessions (session_id, proxy, started_at, last_seen_at, ended_at, client_name, client_version, client_platform, total_calls, total_errors)
68 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
69 params!["s2", "api", 2000, 3000, 3500, "cursor", "0.44", "cursor", 2, 0],
70 ).unwrap();
71
72 let requests = vec![
74 (
75 "r1",
76 1000i64,
77 "api",
78 Some("s1"),
79 "tools/call",
80 Some("search"),
81 142i64,
82 "ok",
83 None::<&str>,
84 None::<&str>,
85 Some(256i64),
86 Some(1024i64),
87 ),
88 (
89 "r2",
90 2000,
91 "api",
92 Some("s1"),
93 "tools/call",
94 Some("search"),
95 891,
96 "ok",
97 None,
98 None,
99 Some(256),
100 Some(4096),
101 ),
102 (
103 "r3",
104 3000,
105 "api",
106 Some("s1"),
107 "tools/call",
108 Some("create_order"),
109 4201,
110 "error",
111 Some("-32600"),
112 Some("timeout"),
113 Some(512),
114 None,
115 ),
116 (
117 "r4",
118 4000,
119 "api",
120 Some("s2"),
121 "resources/read",
122 None,
123 23,
124 "ok",
125 None,
126 None,
127 Some(64),
128 Some(2048),
129 ),
130 (
131 "r5",
132 5000,
133 "api",
134 Some("s2"),
135 "tools/call",
136 Some("search"),
137 156,
138 "ok",
139 None,
140 None,
141 Some(256),
142 Some(1024),
143 ),
144 ];
145
146 for (
147 id,
148 ts,
149 proxy,
150 sid,
151 method,
152 tool,
153 latency,
154 status,
155 err_code,
156 err_msg,
157 bytes_in,
158 bytes_out,
159 ) in requests
160 {
161 conn.execute(
162 "INSERT INTO requests (request_id, ts, proxy, session_id, method, tool, latency_us, status, error_code, error_msg, bytes_in, bytes_out)
163 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
164 params![id, ts, proxy, sid, method, tool, latency, status, err_code, err_msg, bytes_in, bytes_out],
165 ).unwrap();
166 }
167
168 QueryEngine::from_conn(conn)
169 }
170
171 #[test]
174 fn logs__returns_all_rows() {
175 let engine = seeded_engine();
176 let rows = engine
177 .logs(&super::logs::LogsParams {
178 proxy: Some("api".into()),
179 since_ts: 0,
180 limit: 100,
181 tool: None,
182 method: None,
183 session: None,
184 status: None,
185 error_code: None,
186 })
187 .unwrap();
188 assert_eq!(rows.len(), 5);
189 assert_eq!(rows[0].request_id, "r5");
190 assert_eq!(rows[4].request_id, "r1");
191 }
192
193 #[test]
194 fn logs__filter_by_tool() {
195 let engine = seeded_engine();
196 let rows = engine
197 .logs(&super::logs::LogsParams {
198 proxy: Some("api".into()),
199 since_ts: 0,
200 limit: 100,
201 tool: Some("search".into()),
202 method: None,
203 session: None,
204 status: None,
205 error_code: None,
206 })
207 .unwrap();
208 assert_eq!(rows.len(), 3);
209 assert!(rows.iter().all(|r| r.tool.as_deref() == Some("search")));
210 }
211
212 #[test]
213 fn logs__filter_by_status() {
214 let engine = seeded_engine();
215 let rows = engine
216 .logs(&super::logs::LogsParams {
217 proxy: Some("api".into()),
218 since_ts: 0,
219 limit: 100,
220 tool: None,
221 method: None,
222 session: None,
223 status: Some("error".into()),
224 error_code: None,
225 })
226 .unwrap();
227 assert_eq!(rows.len(), 1);
228 assert_eq!(rows[0].request_id, "r3");
229 assert_eq!(rows[0].error_msg.as_deref(), Some("timeout"));
230 }
231
232 #[test]
233 fn logs__since_returns_newer() {
234 let engine = seeded_engine();
235 let params = super::logs::LogsParams {
236 proxy: Some("api".into()),
237 since_ts: 0,
238 limit: 100,
239 tool: None,
240 method: None,
241 session: None,
242 status: None,
243 error_code: None,
244 };
245 let rows = engine.logs_since(¶ms, 3000).unwrap();
246 assert_eq!(rows.len(), 2);
247 assert_eq!(rows[0].request_id, "r4");
248 assert_eq!(rows[1].request_id, "r5");
249 }
250
251 #[test]
252 fn logs__empty_proxy() {
253 let engine = seeded_engine();
254 let rows = engine
255 .logs(&super::logs::LogsParams {
256 proxy: Some("nonexistent".into()),
257 since_ts: 0,
258 limit: 100,
259 tool: None,
260 method: None,
261 session: None,
262 status: None,
263 error_code: None,
264 })
265 .unwrap();
266 assert!(rows.is_empty());
267 }
268
269 #[test]
270 fn logs__filter_by_session() {
271 let engine = seeded_engine();
272 let rows = engine
273 .logs(&super::logs::LogsParams {
274 proxy: Some("api".into()),
275 since_ts: 0,
276 limit: 100,
277 tool: None,
278 method: None,
279 session: Some("s1".into()),
280 status: None,
281 error_code: None,
282 })
283 .unwrap();
284 assert_eq!(rows.len(), 3);
285 assert!(rows.iter().all(|r| r.session_id.as_deref() == Some("s1")));
286 }
287
288 #[test]
289 fn logs__filter_by_session_prefix() {
290 let engine = seeded_engine();
291 let rows = engine
292 .logs(&super::logs::LogsParams {
293 proxy: Some("api".into()),
294 since_ts: 0,
295 limit: 100,
296 tool: None,
297 method: None,
298 session: Some("s".into()),
299 status: None,
300 error_code: None,
301 })
302 .unwrap();
303 assert_eq!(rows.len(), 5);
304 }
305
306 #[test]
307 fn logs__filter_by_method() {
308 let engine = seeded_engine();
309 let rows = engine
310 .logs(&super::logs::LogsParams {
311 proxy: Some("api".into()),
312 since_ts: 0,
313 limit: 100,
314 tool: None,
315 method: Some("resources/read".into()),
316 session: None,
317 status: None,
318 error_code: None,
319 })
320 .unwrap();
321 assert_eq!(rows.len(), 1);
322 assert_eq!(rows[0].request_id, "r4");
323 }
324
325 #[test]
326 fn logs__filter_combined_session_and_method() {
327 let engine = seeded_engine();
328 let rows = engine
329 .logs(&super::logs::LogsParams {
330 proxy: Some("api".into()),
331 since_ts: 0,
332 limit: 100,
333 tool: None,
334 method: Some("tools/call".into()),
335 session: Some("s1".into()),
336 status: None,
337 error_code: None,
338 })
339 .unwrap();
340 assert_eq!(rows.len(), 3);
341 }
342
343 #[test]
344 fn logs__filter_by_error_code() {
345 let engine = seeded_engine();
346 let rows = engine
347 .logs(&super::logs::LogsParams {
348 proxy: Some("api".into()),
349 since_ts: 0,
350 limit: 100,
351 tool: None,
352 method: None,
353 session: None,
354 status: None,
355 error_code: Some("-32600".into()),
356 })
357 .unwrap();
358 assert_eq!(rows.len(), 1);
359 assert_eq!(rows[0].request_id, "r3");
360 assert_eq!(rows[0].error_code.as_deref(), Some("-32600"));
361 }
362
363 #[test]
364 fn logs__filter_by_error_code_no_match() {
365 let engine = seeded_engine();
366 let rows = engine
367 .logs(&super::logs::LogsParams {
368 proxy: Some("api".into()),
369 since_ts: 0,
370 limit: 100,
371 tool: None,
372 method: None,
373 session: None,
374 status: None,
375 error_code: Some("-32601".into()),
376 })
377 .unwrap();
378 assert!(rows.is_empty());
379 }
380
381 #[test]
382 fn logs__error_code_present_in_row() {
383 let engine = seeded_engine();
384 let rows = engine
385 .logs(&super::logs::LogsParams {
386 proxy: Some("api".into()),
387 since_ts: 0,
388 limit: 100,
389 tool: None,
390 method: None,
391 session: None,
392 status: None,
393 error_code: None,
394 })
395 .unwrap();
396 let r3 = rows.iter().find(|r| r.request_id == "r3").unwrap();
397 assert_eq!(r3.error_code.as_deref(), Some("-32600"));
398 let r1 = rows.iter().find(|r| r.request_id == "r1").unwrap();
399 assert!(r1.error_code.is_none());
400 }
401
402 #[test]
405 fn slow__filter_by_tool() {
406 let engine = seeded_engine();
407 let rows = engine
408 .slow(&super::slow::SlowParams {
409 proxy: Some("api".into()),
410 tool: Some("search".into()),
411 threshold_us: 500,
412 since_ts: 0,
413 limit: 100,
414 })
415 .unwrap();
416 assert_eq!(rows.len(), 1);
417 assert_eq!(rows[0].tool.as_deref(), Some("search"));
418 assert_eq!(rows[0].latency_us, 891);
419 }
420
421 #[test]
422 fn slow__returns_above_threshold() {
423 let engine = seeded_engine();
424 let rows = engine
425 .slow(&super::slow::SlowParams {
426 proxy: Some("api".into()),
427 tool: None,
428 threshold_us: 500,
429 since_ts: 0,
430 limit: 100,
431 })
432 .unwrap();
433 assert_eq!(rows.len(), 2);
434 assert_eq!(rows[0].latency_us, 4201);
435 assert_eq!(rows[1].latency_us, 891);
436 }
437
438 #[test]
439 fn slow__high_threshold_returns_empty() {
440 let engine = seeded_engine();
441 let rows = engine
442 .slow(&super::slow::SlowParams {
443 proxy: Some("api".into()),
444 tool: None,
445 threshold_us: 10000,
446 since_ts: 0,
447 limit: 100,
448 })
449 .unwrap();
450 assert!(rows.is_empty());
451 }
452
453 #[test]
456 fn slow_since__returns_newer_rows() {
457 let engine = seeded_engine();
458 let params = super::slow::SlowParams {
459 proxy: Some("api".into()),
460 threshold_us: 500,
461 since_ts: 0,
462 tool: None,
463 limit: 100,
464 };
465 let rows = engine.slow_since(¶ms, 1000).unwrap();
466 assert_eq!(rows.len(), 2);
467 assert_eq!(rows[0].request_id, "r2");
468 assert_eq!(rows[1].request_id, "r3");
469 }
470
471 #[test]
472 fn slow_since__excludes_at_boundary() {
473 let engine = seeded_engine();
474 let params = super::slow::SlowParams {
475 proxy: Some("api".into()),
476 threshold_us: 500,
477 since_ts: 0,
478 tool: None,
479 limit: 100,
480 };
481 let rows = engine.slow_since(¶ms, 2000).unwrap();
482 assert_eq!(rows.len(), 1);
483 assert_eq!(rows[0].request_id, "r3");
484 }
485
486 #[test]
487 fn slow_since__returns_empty_when_no_new() {
488 let engine = seeded_engine();
489 let params = super::slow::SlowParams {
490 proxy: Some("api".into()),
491 threshold_us: 500,
492 since_ts: 0,
493 tool: None,
494 limit: 100,
495 };
496 let rows = engine.slow_since(¶ms, 5000).unwrap();
497 assert!(rows.is_empty());
498 }
499
500 #[test]
501 fn slow_since__respects_threshold() {
502 let engine = seeded_engine();
503 let params = super::slow::SlowParams {
504 proxy: Some("api".into()),
505 threshold_us: 1000,
506 since_ts: 0,
507 tool: None,
508 limit: 100,
509 };
510 let rows = engine.slow_since(¶ms, 0).unwrap();
511 assert_eq!(rows.len(), 1);
512 assert_eq!(rows[0].latency_us, 4201);
513 }
514
515 #[test]
516 fn slow_since__respects_tool_filter() {
517 let engine = seeded_engine();
518 let params = super::slow::SlowParams {
519 proxy: Some("api".into()),
520 threshold_us: 500,
521 since_ts: 0,
522 tool: Some("search".into()),
523 limit: 100,
524 };
525 let rows = engine.slow_since(¶ms, 0).unwrap();
526 assert_eq!(rows.len(), 1);
527 assert_eq!(rows[0].tool.as_deref(), Some("search"));
528 assert_eq!(rows[0].latency_us, 891);
529 }
530
531 #[test]
534 fn stats__aggregates_correctly() {
535 let engine = seeded_engine();
536 let result = engine
537 .stats(&super::stats::StatsParams {
538 proxy: Some("api".into()),
539 since_ts: 0,
540 })
541 .unwrap();
542 assert_eq!(result.total_calls, 5);
543 assert!(result.error_pct > 0.0);
544 let search = result.tools.iter().find(|t| t.label == "search").unwrap();
545 assert_eq!(search.calls, 3);
546 }
547
548 #[test]
549 fn stats__empty_proxy() {
550 let engine = seeded_engine();
551 let result = engine
552 .stats(&super::stats::StatsParams {
553 proxy: Some("nonexistent".into()),
554 since_ts: 0,
555 })
556 .unwrap();
557 assert_eq!(result.total_calls, 0);
558 assert!(result.tools.is_empty());
559 }
560
561 #[test]
562 fn stats__latency_us_values() {
563 let engine = seeded_engine();
564 let result = engine
565 .stats(&super::stats::StatsParams {
566 proxy: Some("api".into()),
567 since_ts: 0,
568 })
569 .unwrap();
570
571 let search = result.tools.iter().find(|t| t.label == "search").unwrap();
572 assert_eq!(search.min_us, 142);
573 assert_eq!(search.max_us, 891);
574 assert!((search.avg_us - 396.33).abs() < 1.0);
575 assert_eq!(search.p95_us, 891);
576 }
577
578 #[test]
579 fn stats__serialization_uses_us_field_names() {
580 let engine = seeded_engine();
581 let result = engine
582 .stats(&super::stats::StatsParams {
583 proxy: Some("api".into()),
584 since_ts: 0,
585 })
586 .unwrap();
587 let json = serde_json::to_string(&result).unwrap();
588 assert!(json.contains("avg_us"));
589 assert!(json.contains("min_us"));
590 assert!(json.contains("max_us"));
591 assert!(json.contains("p95_us"));
592 assert!(!json.contains("avg_ms"));
593 }
594
595 #[test]
596 fn log_row__latency_us_field() {
597 let engine = seeded_engine();
598 let rows = engine
599 .logs(&super::logs::LogsParams {
600 proxy: Some("api".into()),
601 since_ts: 0,
602 limit: 100,
603 tool: Some("search".into()),
604 method: None,
605 session: None,
606 status: None,
607 error_code: None,
608 })
609 .unwrap();
610 assert_eq!(rows[0].latency_us, 156);
611 assert_eq!(rows[1].latency_us, 891);
612 assert_eq!(rows[2].latency_us, 142);
613 }
614
615 #[test]
616 fn log_row__serialization_uses_us_field() {
617 let engine = seeded_engine();
618 let rows = engine
619 .logs(&super::logs::LogsParams {
620 proxy: Some("api".into()),
621 since_ts: 0,
622 limit: 1,
623 tool: None,
624 method: None,
625 session: None,
626 status: None,
627 error_code: None,
628 })
629 .unwrap();
630 let json = serde_json::to_string(&rows[0]).unwrap();
631 assert!(json.contains("latency_us"));
632 assert!(!json.contains("latency_ms"));
633 }
634
635 #[test]
636 fn slow__threshold_us_precision() {
637 let engine = seeded_engine();
638 let rows = engine
639 .slow(&super::slow::SlowParams {
640 proxy: Some("api".into()),
641 tool: None,
642 threshold_us: 150,
643 since_ts: 0,
644 limit: 100,
645 })
646 .unwrap();
647 assert_eq!(rows.len(), 3);
648 assert_eq!(rows[0].latency_us, 4201);
649 assert_eq!(rows[1].latency_us, 891);
650 assert_eq!(rows[2].latency_us, 156);
651 }
652
653 #[test]
654 fn slow__exact_threshold_boundary() {
655 let engine = seeded_engine();
656 let rows = engine
657 .slow(&super::slow::SlowParams {
658 proxy: Some("api".into()),
659 tool: None,
660 threshold_us: 891,
661 since_ts: 0,
662 limit: 100,
663 })
664 .unwrap();
665 assert_eq!(rows.len(), 2);
666 assert_eq!(rows[0].latency_us, 4201);
667 assert_eq!(rows[1].latency_us, 891);
668 }
669
670 #[test]
673 fn clients__aggregates_by_client() {
674 let engine = seeded_engine();
675 let rows = engine
676 .clients(&super::clients::ClientsParams {
677 proxy: Some("api".into()),
678 since_ts: 0,
679 })
680 .unwrap();
681 assert_eq!(rows.len(), 2);
682 assert_eq!(rows[0].client_name.as_deref(), Some("claude-desktop"));
683 assert_eq!(rows[0].total_calls, 3);
684 assert_eq!(rows[1].client_name.as_deref(), Some("cursor"));
685 assert_eq!(rows[1].total_calls, 2);
686 }
687
688 #[test]
691 fn sessions__returns_all() {
692 let engine = seeded_engine();
693 let rows = engine
694 .sessions(&super::sessions::SessionsParams {
695 proxy: Some("api".into()),
696 since_ts: 0,
697 limit: 100,
698 active_only: false,
699 client: None,
700 })
701 .unwrap();
702 assert_eq!(rows.len(), 2);
703 }
704
705 #[test]
706 fn sessions__filter_by_client() {
707 let engine = seeded_engine();
708 let rows = engine
709 .sessions(&super::sessions::SessionsParams {
710 proxy: Some("api".into()),
711 since_ts: 0,
712 limit: 100,
713 active_only: false,
714 client: Some("cursor".into()),
715 })
716 .unwrap();
717 assert_eq!(rows.len(), 1);
718 assert_eq!(rows[0].client_name.as_deref(), Some("cursor"));
719 }
720
721 #[test]
724 fn session_detail__returns_with_requests() {
725 let engine = seeded_engine();
726 let detail = engine.session_detail("s1").unwrap().unwrap();
727 assert_eq!(detail.session_id, "s1");
728 assert_eq!(detail.client_name.as_deref(), Some("claude-desktop"));
729 assert_eq!(detail.client_version.as_deref(), Some("1.2.0"));
730 assert_eq!(detail.client_platform.as_deref(), Some("claude"));
731 assert_eq!(detail.total_calls, 3);
732 assert_eq!(detail.total_errors, 1);
733 assert_eq!(detail.requests.len(), 3);
734 assert_eq!(detail.requests[0].request_id, "r1");
735 assert_eq!(detail.requests[1].request_id, "r2");
736 assert_eq!(detail.requests[2].request_id, "r3");
737 }
738
739 #[test]
740 fn session_detail__closed_session() {
741 let engine = seeded_engine();
742 let detail = engine.session_detail("s2").unwrap().unwrap();
743 assert_eq!(detail.session_id, "s2");
744 assert_eq!(detail.client_name.as_deref(), Some("cursor"));
745 assert_eq!(detail.ended_at, Some(3500));
746 assert_eq!(detail.requests.len(), 2);
747 assert_eq!(detail.requests[0].request_id, "r4");
748 assert_eq!(detail.requests[1].request_id, "r5");
749 }
750
751 #[test]
752 fn session_detail__nonexistent_returns_none() {
753 let engine = seeded_engine();
754 let result = engine.session_detail("no-such-session").unwrap();
755 assert!(result.is_none());
756 }
757
758 #[test]
759 fn session_detail__requests_ordered_oldest_first() {
760 let engine = seeded_engine();
761 let detail = engine.session_detail("s1").unwrap().unwrap();
762 for pair in detail.requests.windows(2) {
763 assert!(pair[0].ts <= pair[1].ts);
764 }
765 }
766
767 #[test]
768 fn session_detail__serializes_to_json() {
769 let engine = seeded_engine();
770 let detail = engine.session_detail("s1").unwrap().unwrap();
771 let json = serde_json::to_string(&detail).unwrap();
772 assert!(json.contains("session_id"));
773 assert!(json.contains("client_name"));
774 assert!(json.contains("requests"));
775 assert!(json.contains("r1"));
776 }
777
778 #[test]
781 fn vacuum__dry_run_counts_correctly() {
782 let engine = seeded_engine();
783 let result = engine
784 .vacuum(&super::store_ops::VacuumParams {
785 before_ts: 3500,
786 proxy: None,
787 dry_run: true,
788 })
789 .unwrap();
790 assert_eq!(result.deleted_requests, 3);
791 assert!(result.dry_run);
792 }
793
794 #[test]
795 fn vacuum__actually_deletes() {
796 let engine = seeded_engine();
797 let result = engine
798 .vacuum(&super::store_ops::VacuumParams {
799 before_ts: 3500,
800 proxy: None,
801 dry_run: false,
802 })
803 .unwrap();
804 assert_eq!(result.deleted_requests, 3);
805 assert!(!result.dry_run);
806
807 let remaining = engine
808 .logs(&super::logs::LogsParams {
809 proxy: Some("api".into()),
810 since_ts: 0,
811 limit: 100,
812 tool: None,
813 method: None,
814 session: None,
815 status: None,
816 error_code: None,
817 })
818 .unwrap();
819 assert_eq!(remaining.len(), 2);
820 }
821
822 #[test]
825 fn log_row__serializes_to_json() {
826 let engine = seeded_engine();
827 let rows = engine
828 .logs(&super::logs::LogsParams {
829 proxy: Some("api".into()),
830 since_ts: 0,
831 limit: 1,
832 tool: None,
833 method: None,
834 session: None,
835 status: None,
836 error_code: None,
837 })
838 .unwrap();
839 let json = serde_json::to_string(&rows[0]).unwrap();
840 assert!(json.contains("request_id"));
841 assert!(json.contains("latency_us"));
842 }
843
844 #[test]
845 fn client_row__serializes_to_json() {
846 let engine = seeded_engine();
847 let rows = engine
848 .clients(&super::clients::ClientsParams {
849 proxy: Some("api".into()),
850 since_ts: 0,
851 })
852 .unwrap();
853 let json = serde_json::to_string(&rows[0]).unwrap();
854 assert!(json.contains("client_name"));
855 assert!(json.contains("total_calls"));
856 }
857
858 #[test]
859 fn stats__serializes_to_json() {
860 let engine = seeded_engine();
861 let result = engine
862 .stats(&super::stats::StatsParams {
863 proxy: Some("api".into()),
864 since_ts: 0,
865 })
866 .unwrap();
867 let json = serde_json::to_string(&result).unwrap();
868 assert!(json.contains("total_calls"));
869 assert!(json.contains("tools"));
870 }
871
872 fn seed_schema(engine: &QueryEngine) {
875 engine
876 .conn()
877 .execute(
878 "INSERT INTO server_schema (upstream_url, method, payload, captured_at, schema_hash) VALUES (?1, ?2, ?3, ?4, ?5)",
879 params![
880 "http://localhost:9000",
881 "initialize",
882 r#"{"serverInfo":{"name":"test-server","version":"1.0"},"protocolVersion":"2025-03-26","capabilities":{"tools":{}}}"#,
883 1000i64,
884 "hash_init"
885 ],
886 )
887 .unwrap();
888 engine
889 .conn()
890 .execute(
891 "INSERT INTO server_schema (upstream_url, method, payload, captured_at, schema_hash) VALUES (?1, ?2, ?3, ?4, ?5)",
892 params![
893 "http://localhost:9000",
894 "tools/list",
895 r#"{"tools":[{"name":"search","description":"search things"}]}"#,
896 2000i64,
897 "hash_tools"
898 ],
899 )
900 .unwrap();
901 engine
902 .conn()
903 .execute(
904 "INSERT INTO schema_changes (upstream_url, method, change_type, item_name, old_hash, new_hash, detected_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
905 params!["http://localhost:9000", "tools/list", "initial", Option::<String>::None, Option::<String>::None, "hash_tools", 2000i64],
906 )
907 .unwrap();
908 }
909
910 #[test]
911 fn schema__returns_all_snapshots() {
912 let engine = seeded_engine();
913 seed_schema(&engine);
914 let rows = engine
915 .schema(&super::schema::SchemaParams {
916 upstream_url: None,
917 method: None,
918 })
919 .unwrap();
920 assert_eq!(rows.len(), 2);
921 }
922
923 #[test]
924 fn schema__filter_by_method() {
925 let engine = seeded_engine();
926 seed_schema(&engine);
927 let rows = engine
928 .schema(&super::schema::SchemaParams {
929 upstream_url: None,
930 method: Some("tools/list".into()),
931 })
932 .unwrap();
933 assert_eq!(rows.len(), 1);
934 assert_eq!(rows[0].method, "tools/list");
935 }
936
937 #[test]
938 fn schema_changes__returns_history() {
939 let engine = seeded_engine();
940 seed_schema(&engine);
941 let rows = engine
942 .schema_changes(&super::schema::SchemaChangesParams {
943 upstream_url: None,
944 method: None,
945 limit: 50,
946 })
947 .unwrap();
948 assert_eq!(rows.len(), 1);
949 assert_eq!(rows[0].change_type, "initial");
950 }
951
952 #[test]
953 fn schema_status__complete() {
954 let engine = seeded_engine();
955 seed_schema(&engine);
956 let status = engine.schema_status("http://localhost:9000").unwrap();
957 assert_eq!(status.status, "complete");
958 assert_eq!(status.server_name.as_deref(), Some("test-server"));
959 assert_eq!(status.server_version.as_deref(), Some("1.0"));
960 assert_eq!(status.protocol_version.as_deref(), Some("2025-03-26"));
961 assert!(status.capabilities.contains(&"tools".to_string()));
962 assert_eq!(status.methods_captured.len(), 2);
963 }
964
965 #[test]
966 fn schema_status__unknown() {
967 let engine = seeded_engine();
968 let status = engine.schema_status("http://nonexistent").unwrap();
969 assert_eq!(status.status, "unknown");
970 assert!(status.methods_captured.is_empty());
971 }
972
973 #[test]
974 fn schema_status__partial() {
975 let engine = seeded_engine();
976 engine
977 .conn()
978 .execute(
979 "INSERT INTO server_schema (upstream_url, method, payload, captured_at, schema_hash) VALUES (?1, ?2, ?3, ?4, ?5)",
980 params!["http://partial", "tools/list", "{}", 1000i64, "h1"],
981 )
982 .unwrap();
983 let status = engine.schema_status("http://partial").unwrap();
984 assert_eq!(status.status, "partial");
985 }
986
987 #[test]
988 fn schema_status__stale() {
989 let engine = seeded_engine();
990 seed_schema(&engine);
991 engine
992 .conn()
993 .execute(
994 "INSERT INTO schema_changes (upstream_url, method, change_type, item_name, old_hash, new_hash, detected_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
995 params!["http://localhost:9000", "tools/list", "stale", Option::<String>::None, "hash_tools", Option::<String>::None, 9000i64],
996 )
997 .unwrap();
998 let status = engine.schema_status("http://localhost:9000").unwrap();
999 assert_eq!(status.status, "stale");
1000 }
1001
1002 #[test]
1005 fn schema_unused__finds_uncalled_tools() {
1006 let engine = seeded_engine();
1007 seed_schema(&engine);
1008
1009 engine
1010 .conn()
1011 .execute(
1012 "UPDATE server_schema SET payload = ?1 WHERE method = 'tools/list'",
1013 params![r#"{"tools":[{"name":"search","description":"search things"},{"name":"never_used","description":"does nothing"}]}"#],
1014 )
1015 .unwrap();
1016
1017 let rows = engine
1018 .schema_unused(&super::schema::SchemaUnusedParams {
1019 proxy: Some("api".into()),
1020 since_ts: 0,
1021 })
1022 .unwrap();
1023
1024 assert_eq!(rows.len(), 2);
1025 assert_eq!(rows[0].tool_name, "never_used");
1026 assert_eq!(rows[0].calls, 0);
1027 assert_eq!(rows[1].tool_name, "search");
1028 assert!(rows[1].calls > 0);
1029 }
1030
1031 #[test]
1032 fn schema_unused__empty_when_no_schema() {
1033 let engine = seeded_engine();
1034 let rows = engine
1035 .schema_unused(&super::schema::SchemaUnusedParams {
1036 proxy: Some("api".into()),
1037 since_ts: 0,
1038 })
1039 .unwrap();
1040 assert!(rows.is_empty());
1041 }
1042
1043 fn seeded_multi_proxy_engine() -> QueryEngine {
1047 let engine = seeded_engine();
1048
1049 engine.conn().execute(
1051 "INSERT INTO sessions (session_id, proxy, started_at, last_seen_at, client_name, client_version, client_platform, total_calls, total_errors)
1052 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1053 params!["s-email-1", "email", 6000, 7000, "claude-desktop", "1.2.0", "claude", 1, 0],
1054 ).unwrap();
1055
1056 engine.conn().execute(
1057 "INSERT INTO requests (request_id, ts, proxy, session_id, method, tool, latency_us, status, error_code, error_msg, bytes_in, bytes_out)
1058 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1059 params!["r-email-1", 6000i64, "email", "s-email-1", "tools/call", "send_email", 320i64, "ok", None::<&str>, None::<&str>, Some(512i64), Some(128i64)],
1060 ).unwrap();
1061
1062 engine.conn().execute(
1063 "INSERT INTO requests (request_id, ts, proxy, session_id, method, tool, latency_us, status, error_code, error_msg, bytes_in, bytes_out)
1064 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1065 params!["r-email-2", 7000i64, "email", "s-email-1", "tools/call", "send_email", 250i64, "ok", None::<&str>, None::<&str>, Some(512i64), Some(128i64)],
1066 ).unwrap();
1067
1068 engine
1069 }
1070
1071 #[test]
1072 fn logs__proxy_none_returns_all() {
1073 let engine = seeded_multi_proxy_engine();
1074 let rows = engine
1075 .logs(&super::logs::LogsParams {
1076 proxy: None,
1077 since_ts: 0,
1078 limit: 100,
1079 tool: None,
1080 method: None,
1081 session: None,
1082 status: None,
1083 error_code: None,
1084 })
1085 .unwrap();
1086 assert_eq!(rows.len(), 7);
1087 }
1088
1089 #[test]
1090 fn logs__proxy_filter_excludes_other() {
1091 let engine = seeded_multi_proxy_engine();
1092 let rows = engine
1093 .logs(&super::logs::LogsParams {
1094 proxy: Some("email".into()),
1095 since_ts: 0,
1096 limit: 100,
1097 tool: None,
1098 method: None,
1099 session: None,
1100 status: None,
1101 error_code: None,
1102 })
1103 .unwrap();
1104 assert_eq!(rows.len(), 2);
1105 }
1106
1107 #[test]
1108 fn stats__proxy_none_aggregates_all() {
1109 let engine = seeded_multi_proxy_engine();
1110 let result = engine
1111 .stats(&super::stats::StatsParams {
1112 proxy: None,
1113 since_ts: 0,
1114 })
1115 .unwrap();
1116 assert_eq!(result.total_calls, 7);
1117 }
1118
1119 #[test]
1120 fn stats__proxy_filter_scopes_to_one() {
1121 let engine = seeded_multi_proxy_engine();
1122 let result = engine
1123 .stats(&super::stats::StatsParams {
1124 proxy: Some("email".into()),
1125 since_ts: 0,
1126 })
1127 .unwrap();
1128 assert_eq!(result.total_calls, 2);
1129 }
1130
1131 #[test]
1132 fn slow__proxy_none_returns_all() {
1133 let engine = seeded_multi_proxy_engine();
1134 let rows = engine
1135 .slow(&super::slow::SlowParams {
1136 proxy: None,
1137 threshold_us: 100,
1138 since_ts: 0,
1139 tool: None,
1140 limit: 100,
1141 })
1142 .unwrap();
1143 assert_eq!(rows.len(), 6);
1144 }
1145
1146 #[test]
1147 fn sessions__proxy_none_returns_all() {
1148 let engine = seeded_multi_proxy_engine();
1149 let rows = engine
1150 .sessions(&super::sessions::SessionsParams {
1151 proxy: None,
1152 since_ts: 0,
1153 limit: 100,
1154 active_only: false,
1155 client: None,
1156 })
1157 .unwrap();
1158 assert_eq!(rows.len(), 3);
1159 }
1160
1161 #[test]
1162 fn clients__proxy_none_returns_all() {
1163 let engine = seeded_multi_proxy_engine();
1164 let rows = engine
1165 .clients(&super::clients::ClientsParams {
1166 proxy: None,
1167 since_ts: 0,
1168 })
1169 .unwrap();
1170 assert!(rows.len() >= 2);
1171 }
1172}