mockforge_ui/handlers/
coverage_metrics.rs

1//! Coverage Metrics API handlers (MockOps)
2//!
3//! Provides endpoints for scenario usage, persona CI hits, endpoint coverage,
4//! reality level staleness, and drift percentage metrics.
5
6use axum::{extract::Query, http::StatusCode, Json};
7use mockforge_analytics::{
8    AnalyticsDatabase, DriftPercentageMetrics, EndpointCoverage, PersonaCIHit,
9    RealityLevelStaleness, ScenarioUsageMetrics,
10};
11use serde::Deserialize;
12use std::sync::Arc;
13use tracing::{debug, error};
14
15use crate::models::ApiResponse;
16
17/// Coverage metrics state
18#[derive(Clone)]
19pub struct CoverageMetricsState {
20    pub db: Arc<tokio::sync::OnceCell<AnalyticsDatabase>>,
21}
22
23impl CoverageMetricsState {
24    pub fn new(db: AnalyticsDatabase) -> Self {
25        let cell = tokio::sync::OnceCell::new();
26        let _ = cell.set(db);
27        Self { db: Arc::new(cell) }
28    }
29
30    async fn get_db(&self) -> Result<&AnalyticsDatabase, StatusCode> {
31        self.db.get().ok_or_else(|| {
32            error!("Analytics database not initialized");
33            StatusCode::SERVICE_UNAVAILABLE
34        })
35    }
36}
37
38/// Query parameters for coverage metrics endpoints
39#[derive(Debug, Deserialize)]
40pub struct CoverageQuery {
41    /// Workspace ID filter
42    pub workspace_id: Option<String>,
43    /// Organization ID filter
44    pub org_id: Option<String>,
45    /// Limit results
46    pub limit: Option<i64>,
47    /// Minimum coverage percentage (for endpoint coverage)
48    pub min_coverage: Option<f64>,
49    /// Maximum staleness days (for reality level staleness)
50    pub max_staleness_days: Option<i32>,
51}
52
53/// Get scenario usage metrics
54///
55/// GET /api/v2/analytics/scenarios/usage
56pub async fn get_scenario_usage(
57    axum::extract::Extension(state): axum::extract::Extension<CoverageMetricsState>,
58    Query(params): Query<CoverageQuery>,
59) -> Result<Json<ApiResponse<Vec<ScenarioUsageMetrics>>>, StatusCode> {
60    debug!("Getting scenario usage metrics");
61
62    let db = state.get_db().await?;
63    match db
64        .get_scenario_usage(params.workspace_id.as_deref(), params.org_id.as_deref(), params.limit)
65        .await
66    {
67        Ok(metrics) => Ok(Json(ApiResponse::success(metrics))),
68        Err(e) => {
69            error!("Failed to get scenario usage metrics: {}", e);
70            Err(StatusCode::INTERNAL_SERVER_ERROR)
71        }
72    }
73}
74
75/// Get persona CI hits
76///
77/// GET /api/v2/analytics/personas/ci-hits
78pub async fn get_persona_ci_hits(
79    axum::extract::Extension(state): axum::extract::Extension<CoverageMetricsState>,
80    Query(params): Query<CoverageQuery>,
81) -> Result<Json<ApiResponse<Vec<PersonaCIHit>>>, StatusCode> {
82    debug!("Getting persona CI hits");
83
84    let db = state.get_db().await?;
85    match db
86        .get_persona_ci_hits(params.workspace_id.as_deref(), params.org_id.as_deref(), params.limit)
87        .await
88    {
89        Ok(hits) => Ok(Json(ApiResponse::success(hits))),
90        Err(e) => {
91            error!("Failed to get persona CI hits: {}", e);
92            Err(StatusCode::INTERNAL_SERVER_ERROR)
93        }
94    }
95}
96
97/// Get endpoint coverage
98///
99/// GET /api/v2/analytics/endpoints/coverage
100pub async fn get_endpoint_coverage(
101    axum::extract::Extension(state): axum::extract::Extension<CoverageMetricsState>,
102    Query(params): Query<CoverageQuery>,
103) -> Result<Json<ApiResponse<Vec<EndpointCoverage>>>, StatusCode> {
104    debug!("Getting endpoint coverage");
105
106    let db = state.get_db().await?;
107    match db
108        .get_endpoint_coverage(
109            params.workspace_id.as_deref(),
110            params.org_id.as_deref(),
111            params.min_coverage,
112        )
113        .await
114    {
115        Ok(coverage) => Ok(Json(ApiResponse::success(coverage))),
116        Err(e) => {
117            error!("Failed to get endpoint coverage: {}", e);
118            Err(StatusCode::INTERNAL_SERVER_ERROR)
119        }
120    }
121}
122
123/// Get reality level staleness
124///
125/// GET /api/v2/analytics/reality-levels/staleness
126pub async fn get_reality_level_staleness(
127    axum::extract::Extension(state): axum::extract::Extension<CoverageMetricsState>,
128    Query(params): Query<CoverageQuery>,
129) -> Result<Json<ApiResponse<Vec<RealityLevelStaleness>>>, StatusCode> {
130    debug!("Getting reality level staleness");
131
132    let db = state.get_db().await?;
133    match db
134        .get_reality_level_staleness(
135            params.workspace_id.as_deref(),
136            params.org_id.as_deref(),
137            params.max_staleness_days,
138        )
139        .await
140    {
141        Ok(staleness) => Ok(Json(ApiResponse::success(staleness))),
142        Err(e) => {
143            error!("Failed to get reality level staleness: {}", e);
144            Err(StatusCode::INTERNAL_SERVER_ERROR)
145        }
146    }
147}
148
149/// Get drift percentage metrics
150///
151/// GET /api/v2/analytics/drift/percentage
152pub async fn get_drift_percentage(
153    axum::extract::Extension(state): axum::extract::Extension<CoverageMetricsState>,
154    Query(params): Query<CoverageQuery>,
155) -> Result<Json<ApiResponse<Vec<DriftPercentageMetrics>>>, StatusCode> {
156    debug!("Getting drift percentage metrics");
157
158    let db = state.get_db().await?;
159    match db
160        .get_drift_percentage(
161            params.workspace_id.as_deref(),
162            params.org_id.as_deref(),
163            params.limit,
164        )
165        .await
166    {
167        Ok(metrics) => Ok(Json(ApiResponse::success(metrics))),
168        Err(e) => {
169            error!("Failed to get drift percentage metrics: {}", e);
170            Err(StatusCode::INTERNAL_SERVER_ERROR)
171        }
172    }
173}
174
175/// Create coverage metrics router
176/// Note: Returns a router without state - handlers will get state from extensions
177pub fn coverage_metrics_router() -> axum::Router {
178    use axum::routing::get;
179
180    axum::Router::new()
181        .route("/scenarios/usage", get(get_scenario_usage))
182        .route("/personas/ci-hits", get(get_persona_ci_hits))
183        .route("/endpoints/coverage", get(get_endpoint_coverage))
184        .route("/reality-levels/staleness", get(get_reality_level_staleness))
185        .route("/drift/percentage", get(get_drift_percentage))
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    // ==================== CoverageQuery Tests ====================
193
194    #[test]
195    fn test_coverage_query_empty() {
196        let query = CoverageQuery {
197            workspace_id: None,
198            org_id: None,
199            limit: None,
200            min_coverage: None,
201            max_staleness_days: None,
202        };
203
204        assert!(query.workspace_id.is_none());
205        assert!(query.org_id.is_none());
206        assert!(query.limit.is_none());
207    }
208
209    #[test]
210    fn test_coverage_query_with_workspace() {
211        let query = CoverageQuery {
212            workspace_id: Some("ws-123".to_string()),
213            org_id: None,
214            limit: None,
215            min_coverage: None,
216            max_staleness_days: None,
217        };
218
219        assert_eq!(query.workspace_id, Some("ws-123".to_string()));
220    }
221
222    #[test]
223    fn test_coverage_query_with_org() {
224        let query = CoverageQuery {
225            workspace_id: None,
226            org_id: Some("org-456".to_string()),
227            limit: None,
228            min_coverage: None,
229            max_staleness_days: None,
230        };
231
232        assert_eq!(query.org_id, Some("org-456".to_string()));
233    }
234
235    #[test]
236    fn test_coverage_query_with_limit() {
237        let query = CoverageQuery {
238            workspace_id: None,
239            org_id: None,
240            limit: Some(100),
241            min_coverage: None,
242            max_staleness_days: None,
243        };
244
245        assert_eq!(query.limit, Some(100));
246    }
247
248    #[test]
249    fn test_coverage_query_with_min_coverage() {
250        let query = CoverageQuery {
251            workspace_id: None,
252            org_id: None,
253            limit: None,
254            min_coverage: Some(0.75),
255            max_staleness_days: None,
256        };
257
258        assert_eq!(query.min_coverage, Some(0.75));
259    }
260
261    #[test]
262    fn test_coverage_query_with_max_staleness() {
263        let query = CoverageQuery {
264            workspace_id: None,
265            org_id: None,
266            limit: None,
267            min_coverage: None,
268            max_staleness_days: Some(30),
269        };
270
271        assert_eq!(query.max_staleness_days, Some(30));
272    }
273
274    #[test]
275    fn test_coverage_query_full() {
276        let query = CoverageQuery {
277            workspace_id: Some("ws-full".to_string()),
278            org_id: Some("org-full".to_string()),
279            limit: Some(50),
280            min_coverage: Some(0.80),
281            max_staleness_days: Some(14),
282        };
283
284        assert_eq!(query.workspace_id, Some("ws-full".to_string()));
285        assert_eq!(query.org_id, Some("org-full".to_string()));
286        assert_eq!(query.limit, Some(50));
287        assert_eq!(query.min_coverage, Some(0.80));
288        assert_eq!(query.max_staleness_days, Some(14));
289    }
290
291    #[test]
292    fn test_coverage_query_deserialization() {
293        let json = r#"{
294            "workspace_id": "ws-deser",
295            "org_id": "org-deser",
296            "limit": 25,
297            "min_coverage": 0.5,
298            "max_staleness_days": 7
299        }"#;
300
301        let query: CoverageQuery = serde_json::from_str(json).unwrap();
302        assert_eq!(query.workspace_id, Some("ws-deser".to_string()));
303        assert_eq!(query.org_id, Some("org-deser".to_string()));
304        assert_eq!(query.limit, Some(25));
305        assert_eq!(query.min_coverage, Some(0.5));
306        assert_eq!(query.max_staleness_days, Some(7));
307    }
308
309    #[test]
310    fn test_coverage_query_partial_deserialization() {
311        let json = r#"{
312            "workspace_id": "ws-partial"
313        }"#;
314
315        let query: CoverageQuery = serde_json::from_str(json).unwrap();
316        assert_eq!(query.workspace_id, Some("ws-partial".to_string()));
317        assert!(query.org_id.is_none());
318        assert!(query.limit.is_none());
319    }
320
321    #[test]
322    fn test_coverage_query_empty_json() {
323        let json = r#"{}"#;
324
325        let query: CoverageQuery = serde_json::from_str(json).unwrap();
326        assert!(query.workspace_id.is_none());
327        assert!(query.org_id.is_none());
328        assert!(query.limit.is_none());
329        assert!(query.min_coverage.is_none());
330        assert!(query.max_staleness_days.is_none());
331    }
332
333    #[test]
334    fn test_coverage_query_debug() {
335        let query = CoverageQuery {
336            workspace_id: Some("debug-ws".to_string()),
337            org_id: None,
338            limit: Some(10),
339            min_coverage: None,
340            max_staleness_days: None,
341        };
342
343        let debug = format!("{:?}", query);
344        assert!(debug.contains("debug-ws"));
345        assert!(debug.contains("10"));
346    }
347
348    // ==================== CoverageMetricsState Tests ====================
349
350    #[test]
351    fn test_coverage_metrics_state_clone() {
352        // CoverageMetricsState is Clone, verify it compiles
353        // We can't easily test the actual clone without a real database
354        // but we verify the trait is implemented
355        fn assert_clone<T: Clone>() {}
356        assert_clone::<CoverageMetricsState>();
357    }
358
359    // ==================== Router Tests ====================
360
361    #[test]
362    fn test_coverage_metrics_router_creation() {
363        // Verify router can be created
364        let router = coverage_metrics_router();
365        // Router is created successfully - this is a compile-time check
366        let _ = router;
367    }
368
369    // ==================== Edge Cases ====================
370
371    #[test]
372    fn test_coverage_query_zero_limit() {
373        let query = CoverageQuery {
374            workspace_id: None,
375            org_id: None,
376            limit: Some(0),
377            min_coverage: None,
378            max_staleness_days: None,
379        };
380
381        assert_eq!(query.limit, Some(0));
382    }
383
384    #[test]
385    fn test_coverage_query_negative_limit() {
386        let query = CoverageQuery {
387            workspace_id: None,
388            org_id: None,
389            limit: Some(-1),
390            min_coverage: None,
391            max_staleness_days: None,
392        };
393
394        assert_eq!(query.limit, Some(-1));
395    }
396
397    #[test]
398    fn test_coverage_query_zero_coverage() {
399        let query = CoverageQuery {
400            workspace_id: None,
401            org_id: None,
402            limit: None,
403            min_coverage: Some(0.0),
404            max_staleness_days: None,
405        };
406
407        assert_eq!(query.min_coverage, Some(0.0));
408    }
409
410    #[test]
411    fn test_coverage_query_full_coverage() {
412        let query = CoverageQuery {
413            workspace_id: None,
414            org_id: None,
415            limit: None,
416            min_coverage: Some(1.0),
417            max_staleness_days: None,
418        };
419
420        assert_eq!(query.min_coverage, Some(1.0));
421    }
422
423    #[test]
424    fn test_coverage_query_over_100_coverage() {
425        // Edge case: coverage > 100% (shouldn't happen but test handling)
426        let query = CoverageQuery {
427            workspace_id: None,
428            org_id: None,
429            limit: None,
430            min_coverage: Some(1.5),
431            max_staleness_days: None,
432        };
433
434        assert_eq!(query.min_coverage, Some(1.5));
435    }
436
437    #[test]
438    fn test_coverage_query_negative_staleness() {
439        let query = CoverageQuery {
440            workspace_id: None,
441            org_id: None,
442            limit: None,
443            min_coverage: None,
444            max_staleness_days: Some(-7),
445        };
446
447        assert_eq!(query.max_staleness_days, Some(-7));
448    }
449
450    #[test]
451    fn test_coverage_query_large_values() {
452        let query = CoverageQuery {
453            workspace_id: Some("very-long-workspace-id-123456789".to_string()),
454            org_id: Some("very-long-org-id-987654321".to_string()),
455            limit: Some(i64::MAX),
456            min_coverage: Some(f64::MAX),
457            max_staleness_days: Some(i32::MAX),
458        };
459
460        assert!(query.workspace_id.is_some());
461        assert_eq!(query.limit, Some(i64::MAX));
462    }
463
464    #[test]
465    fn test_coverage_query_special_characters() {
466        let query = CoverageQuery {
467            workspace_id: Some("ws-special-!@#$%".to_string()),
468            org_id: Some("org/with/slashes".to_string()),
469            limit: None,
470            min_coverage: None,
471            max_staleness_days: None,
472        };
473
474        assert_eq!(query.workspace_id, Some("ws-special-!@#$%".to_string()));
475        assert_eq!(query.org_id, Some("org/with/slashes".to_string()));
476    }
477
478    #[test]
479    fn test_coverage_query_unicode() {
480        let query = CoverageQuery {
481            workspace_id: Some("workspace-日本語".to_string()),
482            org_id: Some("org-中文".to_string()),
483            limit: None,
484            min_coverage: None,
485            max_staleness_days: None,
486        };
487
488        assert_eq!(query.workspace_id, Some("workspace-日本語".to_string()));
489        assert_eq!(query.org_id, Some("org-中文".to_string()));
490    }
491}