mockforge_http/
coverage.rs

1//! Mock Coverage Tracking
2//!
3//! This module provides API coverage tracking functionality, allowing users to see
4//! which endpoints from their OpenAPI spec have been exercised during testing.
5//! This is analogous to code coverage but for API surface area.
6use axum::{
7    extract::{Query, State},
8    response::Json,
9};
10use mockforge_observability::prometheus::{get_global_registry, MetricFamily};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14use crate::{HttpServerState, RouteInfo};
15
16/// Coverage information for a single route
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RouteCoverage {
19    /// HTTP method (GET, POST, etc.)
20    pub method: String,
21    /// Route path template
22    pub path: String,
23    /// Operation ID from OpenAPI spec
24    pub operation_id: Option<String>,
25    /// Operation summary
26    pub summary: Option<String>,
27    /// Whether this route has been called
28    pub covered: bool,
29    /// Number of times this route has been called
30    pub hit_count: u64,
31    /// Breakdown by status code
32    pub status_breakdown: HashMap<u16, u64>,
33    /// Average latency in seconds (if called)
34    pub avg_latency_seconds: Option<f64>,
35}
36
37/// Overall coverage report
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CoverageReport {
40    /// Total number of routes defined in the spec
41    pub total_routes: usize,
42    /// Number of routes that have been called
43    pub covered_routes: usize,
44    /// Coverage percentage (0.0 to 100.0)
45    pub coverage_percentage: f64,
46    /// Individual route coverage details
47    pub routes: Vec<RouteCoverage>,
48    /// Coverage breakdown by HTTP method
49    pub method_coverage: HashMap<String, MethodCoverage>,
50    /// Timestamp of the report
51    pub timestamp: String,
52}
53
54/// Coverage statistics for a specific HTTP method
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct MethodCoverage {
57    /// Total number of routes for this HTTP method
58    pub total: usize,
59    /// Number of routes that have been covered (called at least once)
60    pub covered: usize,
61    /// Coverage percentage (0.0 to 100.0)
62    pub percentage: f64,
63}
64
65/// Query parameters for coverage endpoint
66#[derive(Debug, Deserialize)]
67pub struct CoverageQuery {
68    /// Filter by HTTP method (e.g., "GET", "POST")
69    pub method: Option<String>,
70    /// Filter by path pattern (e.g., "/users")
71    pub path: Option<String>,
72    /// Only show uncovered routes
73    pub uncovered_only: Option<bool>,
74}
75
76/// Calculate coverage for all routes
77pub async fn calculate_coverage(routes: &[RouteInfo]) -> CoverageReport {
78    let metrics_registry = get_global_registry();
79
80    // Gather metrics from Prometheus
81    let metric_families = metrics_registry.registry().gather();
82    let path_metrics = extract_path_metrics(&metric_families);
83
84    let mut route_coverages = Vec::new();
85    let mut covered_count = 0;
86    let mut method_stats: HashMap<String, (usize, usize)> = HashMap::new();
87
88    for route in routes {
89        let normalized_path = normalize_path(&route.path);
90        let key = format!("{} {}", route.method, normalized_path);
91
92        // Check if this route has been hit
93        let (covered, hit_count, status_breakdown) = if let Some(metrics) = path_metrics.get(&key) {
94            let total_hits: u64 = metrics.values().sum();
95            (total_hits > 0, total_hits, metrics.clone())
96        } else {
97            (false, 0, HashMap::new())
98        };
99
100        // Get average latency if available
101        let avg_latency = if covered {
102            get_average_latency(&metric_families, &normalized_path, &route.method)
103        } else {
104            None
105        };
106
107        if covered {
108            covered_count += 1;
109        }
110
111        // Update method stats
112        let method_entry = method_stats.entry(route.method.clone()).or_insert((0, 0));
113        method_entry.0 += 1; // total
114        if covered {
115            method_entry.1 += 1; // covered
116        }
117
118        route_coverages.push(RouteCoverage {
119            method: route.method.clone(),
120            path: route.path.clone(),
121            operation_id: route.operation_id.clone(),
122            summary: route.summary.clone(),
123            covered,
124            hit_count,
125            status_breakdown,
126            avg_latency_seconds: avg_latency,
127        });
128    }
129
130    let total_routes = routes.len();
131    let coverage_percentage = if total_routes > 0 {
132        (covered_count as f64 / total_routes as f64) * 100.0
133    } else {
134        0.0
135    };
136
137    // Build method coverage breakdown
138    let method_coverage = method_stats
139        .into_iter()
140        .map(|(method, (total, covered))| {
141            let percentage = if total > 0 {
142                (covered as f64 / total as f64) * 100.0
143            } else {
144                0.0
145            };
146            (
147                method,
148                MethodCoverage {
149                    total,
150                    covered,
151                    percentage,
152                },
153            )
154        })
155        .collect();
156
157    CoverageReport {
158        total_routes,
159        covered_routes: covered_count,
160        coverage_percentage,
161        routes: route_coverages,
162        method_coverage,
163        timestamp: chrono::Utc::now().to_rfc3339(),
164    }
165}
166
167/// Extract path-based metrics from Prometheus metric families
168fn extract_path_metrics(metric_families: &[MetricFamily]) -> HashMap<String, HashMap<u16, u64>> {
169    let mut path_metrics: HashMap<String, HashMap<u16, u64>> = HashMap::new();
170
171    // Find the requests_by_path_total metric
172    for mf in metric_families {
173        if mf.name() == "mockforge_requests_by_path_total" {
174            for metric in mf.get_metric() {
175                let mut path = String::new();
176                let mut method = String::new();
177                let mut status = 0u16;
178
179                // Extract labels
180                for label_pair in metric.get_label() {
181                    match label_pair.name() {
182                        "path" => path = label_pair.value().to_string(),
183                        "method" => method = label_pair.value().to_string(),
184                        "status" => {
185                            status = label_pair.value().parse().unwrap_or(0);
186                        }
187                        _ => {}
188                    }
189                }
190
191                let key = format!("{} {}", method, path);
192                let count = metric.get_counter().value.unwrap_or(0.0) as u64;
193
194                path_metrics.entry(key).or_default().insert(status, count);
195            }
196        }
197    }
198
199    path_metrics
200}
201
202/// Get average latency for a specific route
203fn get_average_latency(metric_families: &[MetricFamily], path: &str, method: &str) -> Option<f64> {
204    for mf in metric_families {
205        if mf.name() == "mockforge_average_latency_by_path_seconds" {
206            for metric in mf.get_metric() {
207                let mut metric_path = String::new();
208                let mut metric_method = String::new();
209
210                for label_pair in metric.get_label() {
211                    match label_pair.name() {
212                        "path" => metric_path = label_pair.value().to_string(),
213                        "method" => metric_method = label_pair.value().to_string(),
214                        _ => {}
215                    }
216                }
217
218                if metric_path == path && metric_method == method {
219                    if let Some(value) = metric.get_gauge().value {
220                        return if value > 0.0 { Some(value) } else { None };
221                    }
222                }
223            }
224        }
225    }
226
227    None
228}
229
230/// Normalize path to match metrics normalization
231/// This must match the logic in mockforge-observability/src/prometheus/metrics.rs
232fn normalize_path(path: &str) -> String {
233    let mut segments: Vec<&str> = path.split('/').collect();
234
235    for segment in &mut segments {
236        // Replace path parameters like {id} with :id
237        if segment.starts_with('{') && segment.ends_with('}')
238            || is_uuid(segment)
239            || segment.parse::<i64>().is_ok()
240            || (segment.len() > 8 && segment.chars().all(|c| c.is_ascii_hexdigit()))
241        {
242            *segment = ":id";
243        }
244    }
245
246    segments.join("/")
247}
248
249/// Check if a string is a UUID
250fn is_uuid(s: &str) -> bool {
251    s.len() == 36 && s.chars().filter(|&c| c == '-').count() == 4
252}
253
254/// Handler for the coverage endpoint
255pub async fn get_coverage_handler(
256    State(state): State<HttpServerState>,
257    Query(params): Query<CoverageQuery>,
258) -> Json<CoverageReport> {
259    let mut report = calculate_coverage(&state.routes).await;
260
261    // Apply filters
262    if let Some(method_filter) = params.method {
263        report.routes.retain(|r| r.method == method_filter);
264        report.total_routes = report.routes.len();
265        report.covered_routes = report.routes.iter().filter(|r| r.covered).count();
266        report.coverage_percentage = if report.total_routes > 0 {
267            (report.covered_routes as f64 / report.total_routes as f64) * 100.0
268        } else {
269            0.0
270        };
271    }
272
273    if let Some(path_filter) = params.path {
274        report.routes.retain(|r| r.path.contains(&path_filter));
275        report.total_routes = report.routes.len();
276        report.covered_routes = report.routes.iter().filter(|r| r.covered).count();
277        report.coverage_percentage = if report.total_routes > 0 {
278            (report.covered_routes as f64 / report.total_routes as f64) * 100.0
279        } else {
280            0.0
281        };
282    }
283
284    if params.uncovered_only.unwrap_or(false) {
285        report.routes.retain(|r| !r.covered);
286        report.total_routes = report.routes.len();
287        report.covered_routes = 0;
288        report.coverage_percentage = 0.0;
289    }
290
291    Json(report)
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    // ==================== Normalize Path Tests ====================
299
300    #[test]
301    fn test_normalize_path() {
302        assert_eq!(normalize_path("/users/{id}"), "/users/:id");
303        assert_eq!(normalize_path("/users/123"), "/users/:id");
304        assert_eq!(normalize_path("/users/550e8400-e29b-41d4-a716-446655440000"), "/users/:id");
305        assert_eq!(normalize_path("/users/list"), "/users/list");
306        assert_eq!(
307            normalize_path("/api/v1/users/{id}/posts/{postId}"),
308            "/api/v1/users/:id/posts/:id"
309        );
310    }
311
312    #[test]
313    fn test_normalize_path_root() {
314        assert_eq!(normalize_path("/"), "/");
315    }
316
317    #[test]
318    fn test_normalize_path_deep_nested() {
319        assert_eq!(
320            normalize_path("/api/v1/org/{orgId}/team/{teamId}/member/{memberId}"),
321            "/api/v1/org/:id/team/:id/member/:id"
322        );
323    }
324
325    #[test]
326    fn test_normalize_path_no_params() {
327        assert_eq!(normalize_path("/health/check"), "/health/check");
328        assert_eq!(normalize_path("/api/v1/status"), "/api/v1/status");
329    }
330
331    #[test]
332    fn test_normalize_path_hex_ids() {
333        // Long hex strings should be normalized
334        assert_eq!(normalize_path("/objects/abcdef1234567890"), "/objects/:id");
335    }
336
337    // ==================== Is UUID Tests ====================
338
339    #[test]
340    fn test_is_uuid() {
341        assert!(is_uuid("550e8400-e29b-41d4-a716-446655440000"));
342        assert!(!is_uuid("not-a-uuid"));
343        assert!(!is_uuid("123"));
344    }
345
346    #[test]
347    fn test_is_uuid_various_formats() {
348        // Valid UUIDs
349        assert!(is_uuid("00000000-0000-0000-0000-000000000000"));
350        assert!(is_uuid("ffffffff-ffff-ffff-ffff-ffffffffffff"));
351        assert!(is_uuid("12345678-1234-1234-1234-123456789abc"));
352
353        // Invalid
354        assert!(!is_uuid("12345678-1234-1234-1234-123456789")); // too short
355        assert!(!is_uuid("12345678123412341234123456789abc")); // no dashes
356        assert!(!is_uuid("")); // empty
357    }
358
359    // ==================== RouteCoverage Tests ====================
360
361    #[test]
362    fn test_route_coverage_creation() {
363        let coverage = RouteCoverage {
364            method: "GET".to_string(),
365            path: "/users".to_string(),
366            operation_id: Some("getUsers".to_string()),
367            summary: Some("List users".to_string()),
368            covered: true,
369            hit_count: 100,
370            status_breakdown: HashMap::new(),
371            avg_latency_seconds: Some(0.05),
372        };
373
374        assert_eq!(coverage.method, "GET");
375        assert!(coverage.covered);
376        assert_eq!(coverage.hit_count, 100);
377    }
378
379    #[test]
380    fn test_route_coverage_serialization() {
381        let coverage = RouteCoverage {
382            method: "POST".to_string(),
383            path: "/orders".to_string(),
384            operation_id: None,
385            summary: None,
386            covered: false,
387            hit_count: 0,
388            status_breakdown: HashMap::new(),
389            avg_latency_seconds: None,
390        };
391
392        let json = serde_json::to_string(&coverage).unwrap();
393        assert!(json.contains("POST"));
394        assert!(json.contains("/orders"));
395        assert!(json.contains("false"));
396    }
397
398    #[test]
399    fn test_route_coverage_with_status_breakdown() {
400        let mut status_breakdown = HashMap::new();
401        status_breakdown.insert(200, 50);
402        status_breakdown.insert(201, 30);
403        status_breakdown.insert(500, 5);
404
405        let coverage = RouteCoverage {
406            method: "POST".to_string(),
407            path: "/api/data".to_string(),
408            operation_id: Some("createData".to_string()),
409            summary: None,
410            covered: true,
411            hit_count: 85,
412            status_breakdown,
413            avg_latency_seconds: Some(0.1),
414        };
415
416        assert_eq!(coverage.status_breakdown.len(), 3);
417        assert_eq!(coverage.status_breakdown.get(&200), Some(&50));
418    }
419
420    #[test]
421    fn test_route_coverage_clone() {
422        let coverage = RouteCoverage {
423            method: "DELETE".to_string(),
424            path: "/items/{id}".to_string(),
425            operation_id: None,
426            summary: None,
427            covered: true,
428            hit_count: 10,
429            status_breakdown: HashMap::new(),
430            avg_latency_seconds: None,
431        };
432
433        let cloned = coverage.clone();
434        assert_eq!(cloned.method, coverage.method);
435        assert_eq!(cloned.hit_count, coverage.hit_count);
436    }
437
438    // ==================== CoverageReport Tests ====================
439
440    #[test]
441    fn test_coverage_report_creation() {
442        let report = CoverageReport {
443            total_routes: 10,
444            covered_routes: 7,
445            coverage_percentage: 70.0,
446            routes: vec![],
447            method_coverage: HashMap::new(),
448            timestamp: "2024-01-15T10:00:00Z".to_string(),
449        };
450
451        assert_eq!(report.total_routes, 10);
452        assert_eq!(report.covered_routes, 7);
453        assert_eq!(report.coverage_percentage, 70.0);
454    }
455
456    #[test]
457    fn test_coverage_report_serialization() {
458        let report = CoverageReport {
459            total_routes: 5,
460            covered_routes: 3,
461            coverage_percentage: 60.0,
462            routes: vec![],
463            method_coverage: HashMap::new(),
464            timestamp: "2024-01-15T10:00:00Z".to_string(),
465        };
466
467        let json = serde_json::to_string(&report).unwrap();
468        assert!(json.contains("60.0"));
469        assert!(json.contains("total_routes"));
470    }
471
472    #[test]
473    fn test_coverage_report_clone() {
474        let report = CoverageReport {
475            total_routes: 20,
476            covered_routes: 15,
477            coverage_percentage: 75.0,
478            routes: vec![],
479            method_coverage: HashMap::new(),
480            timestamp: "2024-01-15T10:00:00Z".to_string(),
481        };
482
483        let cloned = report.clone();
484        assert_eq!(cloned.total_routes, report.total_routes);
485        assert_eq!(cloned.coverage_percentage, report.coverage_percentage);
486    }
487
488    // ==================== MethodCoverage Tests ====================
489
490    #[test]
491    fn test_method_coverage_creation() {
492        let coverage = MethodCoverage {
493            total: 10,
494            covered: 8,
495            percentage: 80.0,
496        };
497
498        assert_eq!(coverage.total, 10);
499        assert_eq!(coverage.covered, 8);
500        assert_eq!(coverage.percentage, 80.0);
501    }
502
503    #[test]
504    fn test_method_coverage_serialization() {
505        let coverage = MethodCoverage {
506            total: 5,
507            covered: 5,
508            percentage: 100.0,
509        };
510
511        let json = serde_json::to_string(&coverage).unwrap();
512        assert!(json.contains("100.0"));
513    }
514
515    #[test]
516    fn test_method_coverage_clone() {
517        let coverage = MethodCoverage {
518            total: 3,
519            covered: 2,
520            percentage: 66.67,
521        };
522
523        let cloned = coverage.clone();
524        assert_eq!(cloned.total, coverage.total);
525    }
526
527    // ==================== CoverageQuery Tests ====================
528
529    #[test]
530    fn test_coverage_query_empty() {
531        let query = CoverageQuery {
532            method: None,
533            path: None,
534            uncovered_only: None,
535        };
536
537        assert!(query.method.is_none());
538        assert!(query.path.is_none());
539        assert!(query.uncovered_only.is_none());
540    }
541
542    #[test]
543    fn test_coverage_query_with_method_filter() {
544        let query = CoverageQuery {
545            method: Some("GET".to_string()),
546            path: None,
547            uncovered_only: None,
548        };
549
550        assert_eq!(query.method, Some("GET".to_string()));
551    }
552
553    #[test]
554    fn test_coverage_query_with_path_filter() {
555        let query = CoverageQuery {
556            method: None,
557            path: Some("/users".to_string()),
558            uncovered_only: None,
559        };
560
561        assert_eq!(query.path, Some("/users".to_string()));
562    }
563
564    #[test]
565    fn test_coverage_query_uncovered_only() {
566        let query = CoverageQuery {
567            method: None,
568            path: None,
569            uncovered_only: Some(true),
570        };
571
572        assert_eq!(query.uncovered_only, Some(true));
573    }
574
575    // ==================== Calculate Coverage Tests ====================
576
577    #[tokio::test]
578    async fn test_calculate_coverage_empty() {
579        let routes = vec![];
580        let report = calculate_coverage(&routes).await;
581
582        assert_eq!(report.total_routes, 0);
583        assert_eq!(report.covered_routes, 0);
584        assert_eq!(report.coverage_percentage, 0.0);
585    }
586
587    #[tokio::test]
588    async fn test_calculate_coverage_with_routes() {
589        let routes = vec![
590            RouteInfo {
591                method: "GET".to_string(),
592                path: "/users".to_string(),
593                operation_id: Some("getUsers".to_string()),
594                summary: Some("Get all users".to_string()),
595                description: None,
596                parameters: vec![],
597            },
598            RouteInfo {
599                method: "POST".to_string(),
600                path: "/users".to_string(),
601                operation_id: Some("createUser".to_string()),
602                summary: Some("Create a user".to_string()),
603                description: None,
604                parameters: vec![],
605            },
606        ];
607
608        let report = calculate_coverage(&routes).await;
609
610        assert_eq!(report.total_routes, 2);
611        assert_eq!(report.routes.len(), 2);
612        // Coverage will be 0% since no metrics have been recorded in this test
613        assert!(report.coverage_percentage >= 0.0 && report.coverage_percentage <= 100.0);
614    }
615
616    #[tokio::test]
617    async fn test_calculate_coverage_single_route() {
618        let routes = vec![RouteInfo {
619            method: "GET".to_string(),
620            path: "/health".to_string(),
621            operation_id: Some("healthCheck".to_string()),
622            summary: None,
623            description: None,
624            parameters: vec![],
625        }];
626
627        let report = calculate_coverage(&routes).await;
628
629        assert_eq!(report.total_routes, 1);
630        assert_eq!(report.routes.len(), 1);
631        assert_eq!(report.routes[0].method, "GET");
632        assert_eq!(report.routes[0].path, "/health");
633    }
634
635    #[tokio::test]
636    async fn test_calculate_coverage_method_breakdown() {
637        let routes = vec![
638            RouteInfo {
639                method: "GET".to_string(),
640                path: "/users".to_string(),
641                operation_id: None,
642                summary: None,
643                description: None,
644                parameters: vec![],
645            },
646            RouteInfo {
647                method: "GET".to_string(),
648                path: "/users/{id}".to_string(),
649                operation_id: None,
650                summary: None,
651                description: None,
652                parameters: vec![],
653            },
654            RouteInfo {
655                method: "POST".to_string(),
656                path: "/users".to_string(),
657                operation_id: None,
658                summary: None,
659                description: None,
660                parameters: vec![],
661            },
662        ];
663
664        let report = calculate_coverage(&routes).await;
665
666        assert_eq!(report.total_routes, 3);
667        assert!(report.method_coverage.contains_key("GET"));
668        assert!(report.method_coverage.contains_key("POST"));
669        assert_eq!(report.method_coverage.get("GET").unwrap().total, 2);
670        assert_eq!(report.method_coverage.get("POST").unwrap().total, 1);
671    }
672
673    // ==================== Debug Tests ====================
674
675    #[test]
676    fn test_route_coverage_debug() {
677        let coverage = RouteCoverage {
678            method: "GET".to_string(),
679            path: "/test".to_string(),
680            operation_id: None,
681            summary: None,
682            covered: false,
683            hit_count: 0,
684            status_breakdown: HashMap::new(),
685            avg_latency_seconds: None,
686        };
687
688        let debug = format!("{:?}", coverage);
689        assert!(debug.contains("RouteCoverage"));
690    }
691
692    #[test]
693    fn test_coverage_report_debug() {
694        let report = CoverageReport {
695            total_routes: 0,
696            covered_routes: 0,
697            coverage_percentage: 0.0,
698            routes: vec![],
699            method_coverage: HashMap::new(),
700            timestamp: "test".to_string(),
701        };
702
703        let debug = format!("{:?}", report);
704        assert!(debug.contains("CoverageReport"));
705    }
706
707    #[test]
708    fn test_method_coverage_debug() {
709        let coverage = MethodCoverage {
710            total: 5,
711            covered: 3,
712            percentage: 60.0,
713        };
714
715        let debug = format!("{:?}", coverage);
716        assert!(debug.contains("MethodCoverage"));
717    }
718
719    #[test]
720    fn test_coverage_query_debug() {
721        let query = CoverageQuery {
722            method: Some("GET".to_string()),
723            path: None,
724            uncovered_only: None,
725        };
726
727        let debug = format!("{:?}", query);
728        assert!(debug.contains("CoverageQuery"));
729    }
730}