mockforge_ui/handlers/
failure_analysis.rs

1//! Failure analysis API handlers
2//!
3//! Provides endpoints for retrieving failure narratives and analyzing request failures.
4
5use axum::{
6    extract::{Json, Path},
7    http::StatusCode,
8    response::Json as ResponseJson,
9};
10use mockforge_core::failure_analysis::{FailureContextCollector, FailureNarrativeGenerator};
11use mockforge_core::intelligent_behavior::IntelligentBehaviorConfig;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15use std::sync::Arc;
16use tokio::sync::RwLock;
17use uuid::Uuid;
18
19use crate::models::ApiResponse;
20
21/// In-memory storage for failure narratives
22/// In a production system, this would be persisted to a database
23type FailureStorage = Arc<RwLock<HashMap<String, StoredFailure>>>;
24
25/// Stored failure with narrative
26#[derive(Debug, Clone, Serialize, Deserialize)]
27struct StoredFailure {
28    /// Request ID
29    request_id: String,
30    /// Failure context
31    context: mockforge_core::FailureContext,
32    /// Generated narrative
33    narrative: Option<mockforge_core::FailureNarrative>,
34    /// Timestamp
35    timestamp: chrono::DateTime<chrono::Utc>,
36}
37
38/// Global failure storage (in-memory)
39static FAILURE_STORAGE: once_cell::sync::Lazy<FailureStorage> =
40    once_cell::sync::Lazy::new(|| Arc::new(RwLock::new(HashMap::new())));
41
42/// Request to analyze a failure
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct AnalyzeFailureRequest {
45    /// Request method
46    pub method: String,
47    /// Request path
48    pub path: String,
49    /// Request headers
50    #[serde(default)]
51    pub headers: HashMap<String, String>,
52    /// Query parameters
53    #[serde(default)]
54    pub query_params: HashMap<String, String>,
55    /// Request body
56    pub body: Option<Value>,
57    /// Response status code (if available)
58    pub status_code: Option<u16>,
59    /// Response headers
60    #[serde(default)]
61    pub response_headers: HashMap<String, String>,
62    /// Response body
63    pub response_body: Option<Value>,
64    /// Duration in milliseconds
65    pub duration_ms: Option<u64>,
66    /// Error message
67    pub error_message: Option<String>,
68}
69
70/// Response from failure analysis
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct AnalyzeFailureResponse {
73    /// Request ID (generated)
74    pub request_id: String,
75    /// Failure context
76    pub context: mockforge_core::FailureContext,
77    /// Generated narrative
78    pub narrative: Option<mockforge_core::FailureNarrative>,
79    /// Error message (if analysis failed)
80    pub error: Option<String>,
81}
82
83/// Analyze a failure and generate a narrative
84///
85/// POST /api/v2/failures/analyze
86pub async fn analyze_failure(
87    Json(request): Json<AnalyzeFailureRequest>,
88) -> Result<ResponseJson<ApiResponse<AnalyzeFailureResponse>>, StatusCode> {
89    // Generate a request ID
90    let request_id = Uuid::new_v4().to_string();
91
92    // Create context collector
93    let collector = FailureContextCollector::new();
94
95    // Collect failure context
96    let context = collector
97        .collect_context_with_details(
98            &request.method,
99            &request.path,
100            request.headers,
101            request.query_params,
102            request.body,
103            request.status_code,
104            request.response_headers,
105            request.response_body,
106            request.duration_ms,
107            request.error_message,
108            vec![], // chaos_configs - would be populated from actual system
109            vec![], // consistency_rules - would be populated from actual system
110            None,   // contract_validation - would be populated from actual system
111            vec![], // behavioral_rules - would be populated from actual system
112            vec![], // hook_results - would be populated from actual system
113        )
114        .map_err(|e| {
115            tracing::error!("Failed to collect failure context: {}", e);
116            StatusCode::INTERNAL_SERVER_ERROR
117        })?;
118
119    // Generate narrative
120    let config = IntelligentBehaviorConfig::default();
121    let generator = FailureNarrativeGenerator::new(config);
122    let narrative = match generator.generate_narrative(&context).await {
123        Ok(narrative) => Some(narrative),
124        Err(e) => {
125            tracing::warn!("Failed to generate narrative: {}", e);
126            None
127        }
128    };
129
130    // Store failure
131    let stored = StoredFailure {
132        request_id: request_id.clone(),
133        context: context.clone(),
134        narrative: narrative.clone(),
135        timestamp: chrono::Utc::now(),
136    };
137
138    {
139        let mut storage = FAILURE_STORAGE.write().await;
140        storage.insert(request_id.clone(), stored);
141    }
142
143    let response = AnalyzeFailureResponse {
144        request_id,
145        context,
146        narrative,
147        error: None,
148    };
149
150    Ok(ResponseJson(ApiResponse::success(response)))
151}
152
153/// Get failure analysis by request ID
154///
155/// GET /api/v2/failures/{request_id}
156pub async fn get_failure_analysis(
157    Path(request_id): Path<String>,
158) -> Result<ResponseJson<ApiResponse<AnalyzeFailureResponse>>, StatusCode> {
159    let storage = FAILURE_STORAGE.read().await;
160    let stored = storage.get(&request_id).ok_or(StatusCode::NOT_FOUND)?;
161
162    let response = AnalyzeFailureResponse {
163        request_id: stored.request_id.clone(),
164        context: stored.context.clone(),
165        narrative: stored.narrative.clone(),
166        error: None,
167    };
168
169    Ok(ResponseJson(ApiResponse::success(response)))
170}
171
172/// List recent failures
173///
174/// GET /api/v2/failures/recent
175pub async fn list_recent_failures(
176) -> Result<ResponseJson<ApiResponse<Vec<FailureSummary>>>, StatusCode> {
177    let storage = FAILURE_STORAGE.read().await;
178
179    // Get all failures, sorted by timestamp (most recent first)
180    let mut failures: Vec<_> = storage
181        .values()
182        .map(|f| FailureSummary {
183            request_id: f.request_id.clone(),
184            method: f.context.request.method.clone(),
185            path: f.context.request.path.clone(),
186            status_code: f.context.response.as_ref().map(|r| r.status_code),
187            error_message: f.context.error_message.clone(),
188            timestamp: f.timestamp,
189            has_narrative: f.narrative.is_some(),
190        })
191        .collect();
192
193    failures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
194    failures.truncate(50); // Limit to 50 most recent
195
196    Ok(ResponseJson(ApiResponse::success(failures)))
197}
198
199/// Failure summary for listing
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct FailureSummary {
202    /// Request ID
203    pub request_id: String,
204    /// HTTP method
205    pub method: String,
206    /// Request path
207    pub path: String,
208    /// Status code (if available)
209    pub status_code: Option<u16>,
210    /// Error message
211    pub error_message: Option<String>,
212    /// Timestamp
213    pub timestamp: chrono::DateTime<chrono::Utc>,
214    /// Whether a narrative was generated
215    pub has_narrative: bool,
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    // ==================== AnalyzeFailureRequest Tests ====================
223
224    #[test]
225    fn test_analyze_failure_request_minimal() {
226        let request = AnalyzeFailureRequest {
227            method: "GET".to_string(),
228            path: "/api/users".to_string(),
229            headers: HashMap::new(),
230            query_params: HashMap::new(),
231            body: None,
232            status_code: None,
233            response_headers: HashMap::new(),
234            response_body: None,
235            duration_ms: None,
236            error_message: None,
237        };
238
239        assert_eq!(request.method, "GET");
240        assert_eq!(request.path, "/api/users");
241        assert!(request.headers.is_empty());
242    }
243
244    #[test]
245    fn test_analyze_failure_request_full() {
246        let mut headers = HashMap::new();
247        headers.insert("Content-Type".to_string(), "application/json".to_string());
248        headers.insert("Authorization".to_string(), "Bearer token".to_string());
249
250        let mut query_params = HashMap::new();
251        query_params.insert("page".to_string(), "1".to_string());
252
253        let request = AnalyzeFailureRequest {
254            method: "POST".to_string(),
255            path: "/api/orders".to_string(),
256            headers,
257            query_params,
258            body: Some(serde_json::json!({"item": "book", "quantity": 1})),
259            status_code: Some(500),
260            response_headers: HashMap::new(),
261            response_body: Some(serde_json::json!({"error": "Internal Server Error"})),
262            duration_ms: Some(1500),
263            error_message: Some("Database connection failed".to_string()),
264        };
265
266        assert_eq!(request.method, "POST");
267        assert_eq!(request.status_code, Some(500));
268        assert_eq!(request.duration_ms, Some(1500));
269        assert!(request.error_message.is_some());
270    }
271
272    #[test]
273    fn test_analyze_failure_request_serialization() {
274        let request = AnalyzeFailureRequest {
275            method: "DELETE".to_string(),
276            path: "/api/items/123".to_string(),
277            headers: HashMap::new(),
278            query_params: HashMap::new(),
279            body: None,
280            status_code: Some(404),
281            response_headers: HashMap::new(),
282            response_body: None,
283            duration_ms: Some(50),
284            error_message: Some("Not found".to_string()),
285        };
286
287        let json = serde_json::to_string(&request).unwrap();
288        assert!(json.contains("DELETE"));
289        assert!(json.contains("/api/items/123"));
290        assert!(json.contains("404"));
291    }
292
293    #[test]
294    fn test_analyze_failure_request_deserialization() {
295        let json = r#"{
296            "method": "PUT",
297            "path": "/api/profile",
298            "headers": {"Content-Type": "application/json"},
299            "query_params": {},
300            "body": {"name": "Test"},
301            "status_code": 400,
302            "response_headers": {},
303            "response_body": {"error": "Validation failed"},
304            "duration_ms": 100,
305            "error_message": "Invalid input"
306        }"#;
307
308        let request: AnalyzeFailureRequest = serde_json::from_str(json).unwrap();
309        assert_eq!(request.method, "PUT");
310        assert_eq!(request.path, "/api/profile");
311        assert_eq!(request.status_code, Some(400));
312        assert_eq!(request.error_message, Some("Invalid input".to_string()));
313    }
314
315    #[test]
316    fn test_analyze_failure_request_clone() {
317        let request = AnalyzeFailureRequest {
318            method: "GET".to_string(),
319            path: "/test".to_string(),
320            headers: HashMap::new(),
321            query_params: HashMap::new(),
322            body: None,
323            status_code: Some(200),
324            response_headers: HashMap::new(),
325            response_body: None,
326            duration_ms: Some(10),
327            error_message: None,
328        };
329
330        let cloned = request.clone();
331        assert_eq!(cloned.method, request.method);
332        assert_eq!(cloned.path, request.path);
333    }
334
335    // ==================== AnalyzeFailureResponse Tests ====================
336
337    fn create_test_failure_context() -> mockforge_core::FailureContext {
338        mockforge_core::FailureContext {
339            request: mockforge_core::failure_analysis::RequestDetails {
340                method: "GET".to_string(),
341                path: "/test".to_string(),
342                headers: HashMap::new(),
343                query_params: HashMap::new(),
344                body: None,
345            },
346            response: None,
347            chaos_configs: vec![],
348            consistency_rules: vec![],
349            contract_validation: None,
350            behavioral_rules: vec![],
351            hook_results: vec![],
352            error_message: None,
353            timestamp: chrono::Utc::now(),
354        }
355    }
356
357    #[test]
358    fn test_analyze_failure_response_creation() {
359        let response = AnalyzeFailureResponse {
360            request_id: "test-uuid-123".to_string(),
361            context: create_test_failure_context(),
362            narrative: None,
363            error: None,
364        };
365
366        assert_eq!(response.request_id, "test-uuid-123");
367        assert!(response.narrative.is_none());
368        assert!(response.error.is_none());
369    }
370
371    #[test]
372    fn test_analyze_failure_response_with_error() {
373        let response = AnalyzeFailureResponse {
374            request_id: "test-123".to_string(),
375            context: create_test_failure_context(),
376            narrative: None,
377            error: Some("Analysis failed: timeout".to_string()),
378        };
379
380        assert!(response.error.is_some());
381        assert_eq!(response.error.unwrap(), "Analysis failed: timeout");
382    }
383
384    #[test]
385    fn test_analyze_failure_response_serialization() {
386        let response = AnalyzeFailureResponse {
387            request_id: "uuid-456".to_string(),
388            context: create_test_failure_context(),
389            narrative: None,
390            error: None,
391        };
392
393        let json = serde_json::to_string(&response).unwrap();
394        assert!(json.contains("uuid-456"));
395        assert!(json.contains("request_id"));
396    }
397
398    #[test]
399    fn test_analyze_failure_response_clone() {
400        let response = AnalyzeFailureResponse {
401            request_id: "clone-test".to_string(),
402            context: create_test_failure_context(),
403            narrative: None,
404            error: Some("Test error".to_string()),
405        };
406
407        let cloned = response.clone();
408        assert_eq!(cloned.request_id, response.request_id);
409        assert_eq!(cloned.error, response.error);
410    }
411
412    // ==================== FailureSummary Tests ====================
413
414    #[test]
415    fn test_failure_summary_creation() {
416        let summary = FailureSummary {
417            request_id: "summary-123".to_string(),
418            method: "GET".to_string(),
419            path: "/api/test".to_string(),
420            status_code: Some(500),
421            error_message: Some("Internal error".to_string()),
422            timestamp: chrono::Utc::now(),
423            has_narrative: true,
424        };
425
426        assert_eq!(summary.request_id, "summary-123");
427        assert_eq!(summary.method, "GET");
428        assert!(summary.has_narrative);
429    }
430
431    #[test]
432    fn test_failure_summary_no_status_code() {
433        let summary = FailureSummary {
434            request_id: "no-status".to_string(),
435            method: "POST".to_string(),
436            path: "/api/action".to_string(),
437            status_code: None,
438            error_message: Some("Connection timeout".to_string()),
439            timestamp: chrono::Utc::now(),
440            has_narrative: false,
441        };
442
443        assert!(summary.status_code.is_none());
444        assert!(!summary.has_narrative);
445    }
446
447    #[test]
448    fn test_failure_summary_serialization() {
449        let summary = FailureSummary {
450            request_id: "serialize-test".to_string(),
451            method: "DELETE".to_string(),
452            path: "/api/item/1".to_string(),
453            status_code: Some(403),
454            error_message: Some("Forbidden".to_string()),
455            timestamp: chrono::DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
456                .unwrap()
457                .with_timezone(&chrono::Utc),
458            has_narrative: true,
459        };
460
461        let json = serde_json::to_string(&summary).unwrap();
462        assert!(json.contains("serialize-test"));
463        assert!(json.contains("DELETE"));
464        assert!(json.contains("403"));
465        assert!(json.contains("Forbidden"));
466    }
467
468    #[test]
469    fn test_failure_summary_deserialization() {
470        let json = r#"{
471            "request_id": "deser-test",
472            "method": "PUT",
473            "path": "/api/update",
474            "status_code": 422,
475            "error_message": "Unprocessable Entity",
476            "timestamp": "2024-01-15T12:00:00Z",
477            "has_narrative": false
478        }"#;
479
480        let summary: FailureSummary = serde_json::from_str(json).unwrap();
481        assert_eq!(summary.request_id, "deser-test");
482        assert_eq!(summary.method, "PUT");
483        assert_eq!(summary.status_code, Some(422));
484        assert!(!summary.has_narrative);
485    }
486
487    #[test]
488    fn test_failure_summary_clone() {
489        let summary = FailureSummary {
490            request_id: "clone-test".to_string(),
491            method: "PATCH".to_string(),
492            path: "/api/partial".to_string(),
493            status_code: Some(200),
494            error_message: None,
495            timestamp: chrono::Utc::now(),
496            has_narrative: true,
497        };
498
499        let cloned = summary.clone();
500        assert_eq!(cloned.request_id, summary.request_id);
501        assert_eq!(cloned.method, summary.method);
502        assert_eq!(cloned.has_narrative, summary.has_narrative);
503    }
504
505    #[test]
506    fn test_failure_summary_debug() {
507        let summary = FailureSummary {
508            request_id: "debug-test".to_string(),
509            method: "GET".to_string(),
510            path: "/debug".to_string(),
511            status_code: Some(200),
512            error_message: None,
513            timestamp: chrono::Utc::now(),
514            has_narrative: false,
515        };
516
517        let debug = format!("{:?}", summary);
518        assert!(debug.contains("debug-test"));
519        assert!(debug.contains("GET"));
520    }
521
522    // ==================== Edge Cases ====================
523
524    #[test]
525    fn test_analyze_failure_request_with_complex_body() {
526        let body = serde_json::json!({
527            "user": {
528                "name": "John",
529                "roles": ["admin", "user"],
530                "metadata": {
531                    "created": "2024-01-01"
532                }
533            },
534            "items": [1, 2, 3]
535        });
536
537        let request = AnalyzeFailureRequest {
538            method: "POST".to_string(),
539            path: "/api/complex".to_string(),
540            headers: HashMap::new(),
541            query_params: HashMap::new(),
542            body: Some(body.clone()),
543            status_code: Some(201),
544            response_headers: HashMap::new(),
545            response_body: None,
546            duration_ms: Some(500),
547            error_message: None,
548        };
549
550        assert!(request.body.is_some());
551        let body_value = request.body.unwrap();
552        assert!(body_value.get("user").is_some());
553    }
554
555    #[test]
556    fn test_failure_summary_various_http_methods() {
557        let methods = vec!["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"];
558
559        for method in methods {
560            let summary = FailureSummary {
561                request_id: format!("method-{}", method),
562                method: method.to_string(),
563                path: "/test".to_string(),
564                status_code: Some(200),
565                error_message: None,
566                timestamp: chrono::Utc::now(),
567                has_narrative: false,
568            };
569
570            assert_eq!(summary.method, method);
571        }
572    }
573
574    #[test]
575    fn test_failure_summary_various_status_codes() {
576        let status_codes = vec![200, 201, 400, 401, 403, 404, 500, 502, 503];
577
578        for code in status_codes {
579            let summary = FailureSummary {
580                request_id: format!("status-{}", code),
581                method: "GET".to_string(),
582                path: "/test".to_string(),
583                status_code: Some(code),
584                error_message: None,
585                timestamp: chrono::Utc::now(),
586                has_narrative: false,
587            };
588
589            assert_eq!(summary.status_code, Some(code));
590        }
591    }
592}