Skip to main content

mcpr_integrations/store/query/
mod.rs

1//! Query engine — read-only access to the storage database.
2//!
3//! All CLI observability commands (`mcpr proxy logs`, `mcpr proxy slow`, etc.)
4//! are thin wrappers around [`QueryEngine`] methods. Each method executes a
5//! parameterized SQL query and maps rows to typed result structs.
6//!
7//! The query engine opens its own read-only connection to the database.
8//! WAL mode ensures this never blocks the background writer.
9
10pub 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
24/// Read-only query interface to the storage database.
25pub struct QueryEngine {
26    conn: Connection,
27}
28
29impl QueryEngine {
30    /// Open a query connection to the database at the given path.
31    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    /// Get a reference to the underlying connection (for query methods).
37    pub(crate) fn conn(&self) -> &Connection {
38        &self.conn
39    }
40
41    /// Create a query engine from an in-memory connection (for testing).
42    #[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    /// Create a test QueryEngine with schema applied and seed data inserted.
56    pub(crate) fn seeded_engine() -> QueryEngine {
57        let conn = Connection::open_in_memory().unwrap();
58        db::run_migrations(&conn, "test").unwrap();
59
60        // Seed sessions
61        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        // Seed requests
73        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    // ── logs ────────────────────────────────────────────────────────────
172
173    #[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(&params, 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    // ── slow ────────────────────────────────────────────────────────────
403
404    #[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    // ── slow_since (--follow) ──────────────────────────────────────────
454
455    #[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(&params, 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(&params, 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(&params, 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(&params, 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(&params, 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    // ── stats ───────────────────────────────────────────────────────────
532
533    #[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    // ── clients ─────────────────────────────────────────────────────────
671
672    #[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    // ── sessions ────────────────────────────────────────────────────────
689
690    #[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    // ── session_detail ──────────────────────────────────────────────────
722
723    #[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    // ── store_ops ───────────────────────────────────────────────────────
779
780    #[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    // ── serialization ───────────────────────────────────────────────────
823
824    #[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    // ── schema ─────────────────────────────────────────────────────────
873
874    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    /// Insert one `tools/list` schema row + its "initial" change row under
911    /// the given proxy name and URL.
912    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 schema_changes__returns_history() {
1007        let engine = seeded_engine();
1008        seed_schema(&engine);
1009        let rows = engine
1010            .schema_changes(&super::schema::SchemaChangesParams {
1011                proxy: None,
1012                method: None,
1013                limit: 50,
1014            })
1015            .unwrap();
1016        assert_eq!(rows.len(), 1);
1017        assert_eq!(rows[0].change_type, "initial");
1018    }
1019
1020    #[test]
1021    fn schema_changes__filter_by_proxy() {
1022        let engine = seeded_engine();
1023        seed_schema_for_proxy(&engine, "alpha", "http://a:9000");
1024        seed_schema_for_proxy(&engine, "beta", "http://b:9000");
1025
1026        let alpha = engine
1027            .schema_changes(&super::schema::SchemaChangesParams {
1028                proxy: Some("alpha".into()),
1029                method: None,
1030                limit: 50,
1031            })
1032            .unwrap();
1033        assert_eq!(alpha.len(), 1);
1034        assert_eq!(alpha[0].upstream_url, "http://a:9000");
1035
1036        let all = engine
1037            .schema_changes(&super::schema::SchemaChangesParams {
1038                proxy: None,
1039                method: None,
1040                limit: 50,
1041            })
1042            .unwrap();
1043        assert_eq!(all.len(), 2);
1044    }
1045
1046    #[test]
1047    fn schema_status__complete() {
1048        let engine = seeded_engine();
1049        seed_schema(&engine);
1050        let status = engine.schema_status("http://localhost:9000").unwrap();
1051        assert_eq!(status.status, "complete");
1052        assert_eq!(status.server_name.as_deref(), Some("test-server"));
1053        assert_eq!(status.server_version.as_deref(), Some("1.0"));
1054        assert_eq!(status.protocol_version.as_deref(), Some("2025-03-26"));
1055        assert!(status.capabilities.contains(&"tools".to_string()));
1056        assert_eq!(status.methods_captured.len(), 2);
1057    }
1058
1059    #[test]
1060    fn schema_status__unknown() {
1061        let engine = seeded_engine();
1062        let status = engine.schema_status("http://nonexistent").unwrap();
1063        assert_eq!(status.status, "unknown");
1064        assert!(status.methods_captured.is_empty());
1065    }
1066
1067    #[test]
1068    fn schema_status__partial() {
1069        let engine = seeded_engine();
1070        engine
1071            .conn()
1072            .execute(
1073                "INSERT INTO server_schema (upstream_url, method, payload, captured_at, schema_hash) VALUES (?1, ?2, ?3, ?4, ?5)",
1074                params!["http://partial", "tools/list", "{}", 1000i64, "h1"],
1075            )
1076            .unwrap();
1077        let status = engine.schema_status("http://partial").unwrap();
1078        assert_eq!(status.status, "partial");
1079    }
1080
1081    // ── schema unused ──────────────────────────────────────────────────
1082
1083    #[test]
1084    fn schema_unused__finds_uncalled_tools() {
1085        let engine = seeded_engine();
1086        seed_schema(&engine);
1087
1088        engine
1089            .conn()
1090            .execute(
1091                "UPDATE server_schema SET payload = ?1 WHERE method = 'tools/list'",
1092                params![r#"{"tools":[{"name":"search","description":"search things"},{"name":"never_used","description":"does nothing"}]}"#],
1093            )
1094            .unwrap();
1095
1096        let rows = engine
1097            .schema_unused(&super::schema::SchemaUnusedParams {
1098                proxy: Some("api".into()),
1099                since_ts: 0,
1100            })
1101            .unwrap();
1102
1103        assert_eq!(rows.len(), 2);
1104        assert_eq!(rows[0].tool_name, "never_used");
1105        assert_eq!(rows[0].calls, 0);
1106        assert_eq!(rows[1].tool_name, "search");
1107        assert!(rows[1].calls > 0);
1108    }
1109
1110    #[test]
1111    fn schema_unused__empty_when_no_schema() {
1112        let engine = seeded_engine();
1113        let rows = engine
1114            .schema_unused(&super::schema::SchemaUnusedParams {
1115                proxy: Some("api".into()),
1116                since_ts: 0,
1117            })
1118            .unwrap();
1119        assert!(rows.is_empty());
1120    }
1121
1122    // ── multi-proxy: proxy: None shows all ──────────────────────────────
1123
1124    /// Seed a second proxy ("email") alongside the default "api" proxy.
1125    fn seeded_multi_proxy_engine() -> QueryEngine {
1126        let engine = seeded_engine();
1127
1128        // Add a second proxy's session and requests.
1129        engine.conn().execute(
1130            "INSERT INTO sessions (session_id, proxy, started_at, last_seen_at, client_name, client_version, client_platform, total_calls, total_errors)
1131             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1132            params!["s-email-1", "email", 6000, 7000, "claude-desktop", "1.2.0", "claude", 1, 0],
1133        ).unwrap();
1134
1135        engine.conn().execute(
1136            "INSERT INTO requests (request_id, ts, proxy, session_id, method, tool, latency_us, status, error_code, error_msg, bytes_in, bytes_out)
1137             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1138            params!["r-email-1", 6000i64, "email", "s-email-1", "tools/call", "send_email", 320i64, "ok", None::<&str>, None::<&str>, Some(512i64), Some(128i64)],
1139        ).unwrap();
1140
1141        engine.conn().execute(
1142            "INSERT INTO requests (request_id, ts, proxy, session_id, method, tool, latency_us, status, error_code, error_msg, bytes_in, bytes_out)
1143             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1144            params!["r-email-2", 7000i64, "email", "s-email-1", "tools/call", "send_email", 250i64, "ok", None::<&str>, None::<&str>, Some(512i64), Some(128i64)],
1145        ).unwrap();
1146
1147        engine
1148    }
1149
1150    #[test]
1151    fn logs__proxy_none_returns_all() {
1152        let engine = seeded_multi_proxy_engine();
1153        let rows = engine
1154            .logs(&super::logs::LogsParams {
1155                proxy: None,
1156                since_ts: 0,
1157                limit: 100,
1158                tool: None,
1159                method: None,
1160                session: None,
1161                status: None,
1162                error_code: None,
1163            })
1164            .unwrap();
1165        assert_eq!(rows.len(), 7);
1166    }
1167
1168    #[test]
1169    fn logs__proxy_filter_excludes_other() {
1170        let engine = seeded_multi_proxy_engine();
1171        let rows = engine
1172            .logs(&super::logs::LogsParams {
1173                proxy: Some("email".into()),
1174                since_ts: 0,
1175                limit: 100,
1176                tool: None,
1177                method: None,
1178                session: None,
1179                status: None,
1180                error_code: None,
1181            })
1182            .unwrap();
1183        assert_eq!(rows.len(), 2);
1184    }
1185
1186    #[test]
1187    fn stats__proxy_none_aggregates_all() {
1188        let engine = seeded_multi_proxy_engine();
1189        let result = engine
1190            .stats(&super::stats::StatsParams {
1191                proxy: None,
1192                since_ts: 0,
1193            })
1194            .unwrap();
1195        assert_eq!(result.total_calls, 7);
1196    }
1197
1198    #[test]
1199    fn stats__proxy_filter_scopes_to_one() {
1200        let engine = seeded_multi_proxy_engine();
1201        let result = engine
1202            .stats(&super::stats::StatsParams {
1203                proxy: Some("email".into()),
1204                since_ts: 0,
1205            })
1206            .unwrap();
1207        assert_eq!(result.total_calls, 2);
1208    }
1209
1210    #[test]
1211    fn slow__proxy_none_returns_all() {
1212        let engine = seeded_multi_proxy_engine();
1213        let rows = engine
1214            .slow(&super::slow::SlowParams {
1215                proxy: None,
1216                threshold_us: 100,
1217                since_ts: 0,
1218                tool: None,
1219                limit: 100,
1220            })
1221            .unwrap();
1222        assert_eq!(rows.len(), 6);
1223    }
1224
1225    #[test]
1226    fn sessions__proxy_none_returns_all() {
1227        let engine = seeded_multi_proxy_engine();
1228        let rows = engine
1229            .sessions(&super::sessions::SessionsParams {
1230                proxy: None,
1231                since_ts: 0,
1232                limit: 100,
1233                active_only: false,
1234                client: None,
1235            })
1236            .unwrap();
1237        assert_eq!(rows.len(), 3);
1238    }
1239
1240    #[test]
1241    fn clients__proxy_none_returns_all() {
1242        let engine = seeded_multi_proxy_engine();
1243        let rows = engine
1244            .clients(&super::clients::ClientsParams {
1245                proxy: None,
1246                since_ts: 0,
1247            })
1248            .unwrap();
1249        assert!(rows.len() >= 2);
1250    }
1251}