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    #[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    // ── schema unused ──────────────────────────────────────────────────
1003
1004    #[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    // ── multi-proxy: proxy: None shows all ──────────────────────────────
1044
1045    /// Seed a second proxy ("email") alongside the default "api" proxy.
1046    fn seeded_multi_proxy_engine() -> QueryEngine {
1047        let engine = seeded_engine();
1048
1049        // Add a second proxy's session and requests.
1050        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}