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 fn seed_schema_for_proxy(engine: &QueryEngine, proxy: &str, upstream: &str) {
913 engine
914 .conn()
915 .execute(
916 "INSERT INTO server_schema (proxy, upstream_url, method, payload, captured_at, schema_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
917 params![
918 proxy,
919 upstream,
920 "tools/list",
921 r#"{"tools":[{"name":"search","description":"search things"}]}"#,
922 1000i64,
923 format!("hash-{proxy}")
924 ],
925 )
926 .unwrap();
927 engine
928 .conn()
929 .execute(
930 "INSERT INTO schema_changes (proxy, upstream_url, method, change_type, item_name, old_hash, new_hash, detected_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
931 params![
932 proxy,
933 upstream,
934 "tools/list",
935 "initial",
936 Option::<String>::None,
937 Option::<String>::None,
938 format!("hash-{proxy}"),
939 1000i64,
940 ],
941 )
942 .unwrap();
943 }
944
945 #[test]
946 fn schema__returns_all_snapshots() {
947 let engine = seeded_engine();
948 seed_schema(&engine);
949 let rows = engine
950 .schema(&super::schema::SchemaParams {
951 proxy: None,
952 method: None,
953 })
954 .unwrap();
955 assert_eq!(rows.len(), 2);
956 }
957
958 #[test]
959 fn schema__filter_by_method() {
960 let engine = seeded_engine();
961 seed_schema(&engine);
962 let rows = engine
963 .schema(&super::schema::SchemaParams {
964 proxy: None,
965 method: Some("tools/list".into()),
966 })
967 .unwrap();
968 assert_eq!(rows.len(), 1);
969 assert_eq!(rows[0].method, "tools/list");
970 }
971
972 #[test]
973 fn schema__filter_by_proxy() {
974 let engine = seeded_engine();
975 seed_schema_for_proxy(&engine, "alpha", "http://a:9000");
976 seed_schema_for_proxy(&engine, "beta", "http://b:9000");
977
978 let alpha = engine
979 .schema(&super::schema::SchemaParams {
980 proxy: Some("alpha".into()),
981 method: None,
982 })
983 .unwrap();
984 assert_eq!(alpha.len(), 1);
985 assert_eq!(alpha[0].upstream_url, "http://a:9000");
986
987 let beta = engine
988 .schema(&super::schema::SchemaParams {
989 proxy: Some("beta".into()),
990 method: None,
991 })
992 .unwrap();
993 assert_eq!(beta.len(), 1);
994 assert_eq!(beta[0].upstream_url, "http://b:9000");
995
996 let missing = engine
997 .schema(&super::schema::SchemaParams {
998 proxy: Some("nonexistent".into()),
999 method: None,
1000 })
1001 .unwrap();
1002 assert!(missing.is_empty());
1003 }
1004
1005 #[test]
1006 fn latest_schema_row__returns_row_for_proxy_method() {
1007 let engine = seeded_engine();
1008 seed_schema_for_proxy(&engine, "alpha", "http://a:9000");
1009
1010 let row = engine
1011 .latest_schema_row("alpha", "tools/list")
1012 .unwrap()
1013 .expect("row must exist");
1014 assert_eq!(row.proxy, "alpha");
1015 assert_eq!(row.upstream_url, "http://a:9000");
1016 assert_eq!(row.method, "tools/list");
1017 assert_eq!(row.schema_hash, "hash-alpha");
1018 }
1019
1020 #[test]
1021 fn latest_schema_row__none_when_missing() {
1022 let engine = seeded_engine();
1023 assert!(
1024 engine
1025 .latest_schema_row("nonexistent", "tools/list")
1026 .unwrap()
1027 .is_none()
1028 );
1029 }
1030
1031 #[test]
1032 fn latest_schema_row__scoped_by_proxy() {
1033 let engine = seeded_engine();
1034 seed_schema_for_proxy(&engine, "alpha", "http://a:9000");
1035 seed_schema_for_proxy(&engine, "beta", "http://b:9000");
1036
1037 let a = engine
1038 .latest_schema_row("alpha", "tools/list")
1039 .unwrap()
1040 .unwrap();
1041 let b = engine
1042 .latest_schema_row("beta", "tools/list")
1043 .unwrap()
1044 .unwrap();
1045 assert_eq!(a.schema_hash, "hash-alpha");
1046 assert_eq!(b.schema_hash, "hash-beta");
1047 }
1048
1049 #[test]
1050 fn schema_changes__returns_history() {
1051 let engine = seeded_engine();
1052 seed_schema(&engine);
1053 let rows = engine
1054 .schema_changes(&super::schema::SchemaChangesParams {
1055 proxy: None,
1056 method: None,
1057 limit: 50,
1058 })
1059 .unwrap();
1060 assert_eq!(rows.len(), 1);
1061 assert_eq!(rows[0].change_type, "initial");
1062 }
1063
1064 #[test]
1065 fn schema_changes__filter_by_proxy() {
1066 let engine = seeded_engine();
1067 seed_schema_for_proxy(&engine, "alpha", "http://a:9000");
1068 seed_schema_for_proxy(&engine, "beta", "http://b:9000");
1069
1070 let alpha = engine
1071 .schema_changes(&super::schema::SchemaChangesParams {
1072 proxy: Some("alpha".into()),
1073 method: None,
1074 limit: 50,
1075 })
1076 .unwrap();
1077 assert_eq!(alpha.len(), 1);
1078 assert_eq!(alpha[0].upstream_url, "http://a:9000");
1079
1080 let all = engine
1081 .schema_changes(&super::schema::SchemaChangesParams {
1082 proxy: None,
1083 method: None,
1084 limit: 50,
1085 })
1086 .unwrap();
1087 assert_eq!(all.len(), 2);
1088 }
1089
1090 #[test]
1091 fn schema_status__complete() {
1092 let engine = seeded_engine();
1093 seed_schema(&engine);
1094 let status = engine.schema_status("http://localhost:9000").unwrap();
1095 assert_eq!(status.status, "complete");
1096 assert_eq!(status.server_name.as_deref(), Some("test-server"));
1097 assert_eq!(status.server_version.as_deref(), Some("1.0"));
1098 assert_eq!(status.protocol_version.as_deref(), Some("2025-03-26"));
1099 assert!(status.capabilities.contains(&"tools".to_string()));
1100 assert_eq!(status.methods_captured.len(), 2);
1101 }
1102
1103 #[test]
1104 fn schema_status__unknown() {
1105 let engine = seeded_engine();
1106 let status = engine.schema_status("http://nonexistent").unwrap();
1107 assert_eq!(status.status, "unknown");
1108 assert!(status.methods_captured.is_empty());
1109 }
1110
1111 #[test]
1112 fn schema_status__partial() {
1113 let engine = seeded_engine();
1114 engine
1115 .conn()
1116 .execute(
1117 "INSERT INTO server_schema (upstream_url, method, payload, captured_at, schema_hash) VALUES (?1, ?2, ?3, ?4, ?5)",
1118 params!["http://partial", "tools/list", "{}", 1000i64, "h1"],
1119 )
1120 .unwrap();
1121 let status = engine.schema_status("http://partial").unwrap();
1122 assert_eq!(status.status, "partial");
1123 }
1124
1125 #[test]
1128 fn schema_unused__finds_uncalled_tools() {
1129 let engine = seeded_engine();
1130 seed_schema(&engine);
1131
1132 engine
1133 .conn()
1134 .execute(
1135 "UPDATE server_schema SET payload = ?1 WHERE method = 'tools/list'",
1136 params![r#"{"tools":[{"name":"search","description":"search things"},{"name":"never_used","description":"does nothing"}]}"#],
1137 )
1138 .unwrap();
1139
1140 let rows = engine
1141 .schema_unused(&super::schema::SchemaUnusedParams {
1142 proxy: Some("api".into()),
1143 since_ts: 0,
1144 })
1145 .unwrap();
1146
1147 assert_eq!(rows.len(), 2);
1148 assert_eq!(rows[0].tool_name, "never_used");
1149 assert_eq!(rows[0].calls, 0);
1150 assert_eq!(rows[1].tool_name, "search");
1151 assert!(rows[1].calls > 0);
1152 }
1153
1154 #[test]
1155 fn schema_unused__empty_when_no_schema() {
1156 let engine = seeded_engine();
1157 let rows = engine
1158 .schema_unused(&super::schema::SchemaUnusedParams {
1159 proxy: Some("api".into()),
1160 since_ts: 0,
1161 })
1162 .unwrap();
1163 assert!(rows.is_empty());
1164 }
1165
1166 fn seeded_multi_proxy_engine() -> QueryEngine {
1170 let engine = seeded_engine();
1171
1172 engine.conn().execute(
1174 "INSERT INTO sessions (session_id, proxy, started_at, last_seen_at, client_name, client_version, client_platform, total_calls, total_errors)
1175 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1176 params!["s-email-1", "email", 6000, 7000, "claude-desktop", "1.2.0", "claude", 1, 0],
1177 ).unwrap();
1178
1179 engine.conn().execute(
1180 "INSERT INTO requests (request_id, ts, proxy, session_id, method, tool, latency_us, status, error_code, error_msg, bytes_in, bytes_out)
1181 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1182 params!["r-email-1", 6000i64, "email", "s-email-1", "tools/call", "send_email", 320i64, "ok", None::<&str>, None::<&str>, Some(512i64), Some(128i64)],
1183 ).unwrap();
1184
1185 engine.conn().execute(
1186 "INSERT INTO requests (request_id, ts, proxy, session_id, method, tool, latency_us, status, error_code, error_msg, bytes_in, bytes_out)
1187 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1188 params!["r-email-2", 7000i64, "email", "s-email-1", "tools/call", "send_email", 250i64, "ok", None::<&str>, None::<&str>, Some(512i64), Some(128i64)],
1189 ).unwrap();
1190
1191 engine
1192 }
1193
1194 #[test]
1195 fn logs__proxy_none_returns_all() {
1196 let engine = seeded_multi_proxy_engine();
1197 let rows = engine
1198 .logs(&super::logs::LogsParams {
1199 proxy: None,
1200 since_ts: 0,
1201 limit: 100,
1202 tool: None,
1203 method: None,
1204 session: None,
1205 status: None,
1206 error_code: None,
1207 })
1208 .unwrap();
1209 assert_eq!(rows.len(), 7);
1210 }
1211
1212 #[test]
1213 fn logs__proxy_filter_excludes_other() {
1214 let engine = seeded_multi_proxy_engine();
1215 let rows = engine
1216 .logs(&super::logs::LogsParams {
1217 proxy: Some("email".into()),
1218 since_ts: 0,
1219 limit: 100,
1220 tool: None,
1221 method: None,
1222 session: None,
1223 status: None,
1224 error_code: None,
1225 })
1226 .unwrap();
1227 assert_eq!(rows.len(), 2);
1228 }
1229
1230 #[test]
1231 fn stats__proxy_none_aggregates_all() {
1232 let engine = seeded_multi_proxy_engine();
1233 let result = engine
1234 .stats(&super::stats::StatsParams {
1235 proxy: None,
1236 since_ts: 0,
1237 })
1238 .unwrap();
1239 assert_eq!(result.total_calls, 7);
1240 }
1241
1242 #[test]
1243 fn stats__proxy_filter_scopes_to_one() {
1244 let engine = seeded_multi_proxy_engine();
1245 let result = engine
1246 .stats(&super::stats::StatsParams {
1247 proxy: Some("email".into()),
1248 since_ts: 0,
1249 })
1250 .unwrap();
1251 assert_eq!(result.total_calls, 2);
1252 }
1253
1254 #[test]
1255 fn slow__proxy_none_returns_all() {
1256 let engine = seeded_multi_proxy_engine();
1257 let rows = engine
1258 .slow(&super::slow::SlowParams {
1259 proxy: None,
1260 threshold_us: 100,
1261 since_ts: 0,
1262 tool: None,
1263 limit: 100,
1264 })
1265 .unwrap();
1266 assert_eq!(rows.len(), 6);
1267 }
1268
1269 #[test]
1270 fn sessions__proxy_none_returns_all() {
1271 let engine = seeded_multi_proxy_engine();
1272 let rows = engine
1273 .sessions(&super::sessions::SessionsParams {
1274 proxy: None,
1275 since_ts: 0,
1276 limit: 100,
1277 active_only: false,
1278 client: None,
1279 })
1280 .unwrap();
1281 assert_eq!(rows.len(), 3);
1282 }
1283
1284 #[test]
1285 fn clients__proxy_none_returns_all() {
1286 let engine = seeded_multi_proxy_engine();
1287 let rows = engine
1288 .clients(&super::clients::ClientsParams {
1289 proxy: None,
1290 since_ts: 0,
1291 })
1292 .unwrap();
1293 assert!(rows.len() >= 2);
1294 }
1295}