Skip to main content

mockforge_recorder/
sync_snapshots.rs

1//! Shadow Snapshot Mode - Store canonical before/after datasets per endpoint scenario
2//!
3//! This module provides functionality to store snapshots of API responses before and after
4//! sync operations, enabling timeline visualization of how endpoints evolve over time.
5
6use crate::diff::ComparisonResult;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::HashMap;
11
12/// Snapshot data for a single point in time
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SnapshotData {
15    /// HTTP status code
16    pub status_code: u16,
17    /// Response headers
18    pub headers: HashMap<String, String>,
19    /// Response body as raw bytes
20    pub body: Vec<u8>,
21    /// Response body as parsed JSON (if applicable)
22    pub body_json: Option<Value>,
23}
24
25/// Sync snapshot capturing before/after state for an endpoint
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SyncSnapshot {
28    /// Unique snapshot ID
29    pub id: String,
30    /// Endpoint path (e.g., "/api/users/{id}")
31    pub endpoint: String,
32    /// HTTP method (e.g., "GET", "POST")
33    pub method: String,
34    /// Sync cycle ID (groups snapshots from the same sync operation)
35    pub sync_cycle_id: String,
36    /// Timestamp when snapshot was created
37    pub timestamp: DateTime<Utc>,
38    /// Original fixture data (before sync)
39    pub before: SnapshotData,
40    /// New upstream response (after sync)
41    pub after: SnapshotData,
42    /// Comparison result showing differences
43    pub changes: ComparisonResult,
44    /// Response time before sync (milliseconds)
45    pub response_time_before: Option<u64>,
46    /// Response time after sync (milliseconds)
47    pub response_time_after: Option<u64>,
48}
49
50impl SyncSnapshot {
51    /// Create a new sync snapshot
52    #[allow(clippy::too_many_arguments)]
53    pub fn new(
54        endpoint: String,
55        method: String,
56        sync_cycle_id: String,
57        before: SnapshotData,
58        after: SnapshotData,
59        changes: ComparisonResult,
60        response_time_before: Option<u64>,
61        response_time_after: Option<u64>,
62    ) -> Self {
63        let id = format!(
64            "snapshot_{}_{}_{}",
65            endpoint.replace('/', "_").replace(['{', '}'], ""),
66            method.to_lowercase(),
67            &uuid::Uuid::new_v4().to_string()[..8]
68        );
69
70        Self {
71            id,
72            endpoint,
73            method,
74            sync_cycle_id,
75            timestamp: Utc::now(),
76            before,
77            after,
78            changes,
79            response_time_before,
80            response_time_after,
81        }
82    }
83}
84
85/// Timeline data for visualizing endpoint evolution
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct EndpointTimeline {
88    /// Endpoint path
89    pub endpoint: String,
90    /// HTTP method
91    pub method: String,
92    /// List of snapshots in chronological order
93    pub snapshots: Vec<SyncSnapshot>,
94    /// Response time trends (timestamp -> response_time_ms)
95    pub response_time_trends: Vec<(DateTime<Utc>, Option<u64>)>,
96    /// Status code changes over time (timestamp -> status_code)
97    pub status_code_history: Vec<(DateTime<Utc>, u16)>,
98    /// Common error patterns detected
99    pub error_patterns: Vec<ErrorPattern>,
100}
101
102/// Error pattern detected in response history
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ErrorPattern {
105    /// Status code
106    pub status_code: u16,
107    /// Error message pattern (if extractable)
108    pub message_pattern: Option<String>,
109    /// Number of occurrences
110    pub occurrences: usize,
111    /// First occurrence timestamp
112    pub first_seen: DateTime<Utc>,
113    /// Last occurrence timestamp
114    pub last_seen: DateTime<Utc>,
115}
116
117/// Summary statistics for endpoint evolution
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct EndpointEvolutionSummary {
120    /// Endpoint path
121    pub endpoint: String,
122    /// HTTP method
123    pub method: String,
124    /// Total number of snapshots
125    pub total_snapshots: usize,
126    /// Number of changes detected
127    pub total_changes: usize,
128    /// Average response time (milliseconds)
129    pub avg_response_time: Option<f64>,
130    /// Most common status code
131    pub most_common_status: Option<u16>,
132    /// Field-level change frequency (field_path -> count)
133    pub field_change_frequency: HashMap<String, usize>,
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn create_test_summary(total_differences: usize) -> crate::diff::ComparisonSummary {
141        crate::diff::ComparisonSummary {
142            total_differences,
143            added_fields: 0,
144            removed_fields: 0,
145            changed_fields: total_differences,
146            type_changes: 0,
147        }
148    }
149
150    fn create_test_comparison_result(
151        matches: bool,
152        differences: Vec<crate::diff::Difference>,
153    ) -> crate::diff::ComparisonResult {
154        crate::diff::ComparisonResult {
155            matches,
156            status_match: matches,
157            headers_match: matches,
158            body_match: matches,
159            differences: differences.clone(),
160            summary: create_test_summary(differences.len()),
161        }
162    }
163
164    fn create_test_snapshot_data() -> SnapshotData {
165        let mut headers = HashMap::new();
166        headers.insert("content-type".to_string(), "application/json".to_string());
167
168        SnapshotData {
169            status_code: 200,
170            headers,
171            body: b"test body".to_vec(),
172            body_json: Some(serde_json::json!({"status": "ok"})),
173        }
174    }
175
176    #[test]
177    fn test_snapshot_data_creation() {
178        let snapshot = create_test_snapshot_data();
179
180        assert_eq!(snapshot.status_code, 200);
181        assert_eq!(snapshot.headers.get("content-type").unwrap(), "application/json");
182        assert_eq!(snapshot.body, b"test body");
183        assert!(snapshot.body_json.is_some());
184    }
185
186    #[test]
187    fn test_sync_snapshot_new() {
188        let before = create_test_snapshot_data();
189        let mut after = create_test_snapshot_data();
190        after.status_code = 201;
191
192        let differences = vec![crate::diff::Difference::new(
193            "$.status_code".to_string(),
194            crate::diff::DifferenceType::Changed {
195                path: "$.status_code".to_string(),
196                original: "200".to_string(),
197                current: "201".to_string(),
198            },
199        )];
200        let comparison = create_test_comparison_result(false, differences);
201
202        let snapshot = SyncSnapshot::new(
203            "/api/users".to_string(),
204            "GET".to_string(),
205            "cycle-123".to_string(),
206            before.clone(),
207            after.clone(),
208            comparison,
209            Some(100),
210            Some(120),
211        );
212
213        assert!(snapshot.id.contains("snapshot"));
214        assert_eq!(snapshot.endpoint, "/api/users");
215        assert_eq!(snapshot.method, "GET");
216        assert_eq!(snapshot.sync_cycle_id, "cycle-123");
217        assert_eq!(snapshot.before.status_code, 200);
218        assert_eq!(snapshot.after.status_code, 201);
219        assert_eq!(snapshot.response_time_before, Some(100));
220        assert_eq!(snapshot.response_time_after, Some(120));
221    }
222
223    #[test]
224    fn test_sync_snapshot_id_generation() {
225        let before = create_test_snapshot_data();
226        let after = create_test_snapshot_data();
227
228        let comparison = create_test_comparison_result(true, vec![]);
229
230        let snapshot1 = SyncSnapshot::new(
231            "/api/users/{id}".to_string(),
232            "GET".to_string(),
233            "cycle-1".to_string(),
234            before.clone(),
235            after.clone(),
236            comparison.clone(),
237            None,
238            None,
239        );
240
241        let snapshot2 = SyncSnapshot::new(
242            "/api/users/{id}".to_string(),
243            "GET".to_string(),
244            "cycle-2".to_string(),
245            before.clone(),
246            after.clone(),
247            comparison,
248            None,
249            None,
250        );
251
252        // IDs should be different (contain UUID)
253        assert_ne!(snapshot1.id, snapshot2.id);
254
255        // But should have similar structure
256        assert!(snapshot1.id.starts_with("snapshot_"));
257        assert!(snapshot2.id.starts_with("snapshot_"));
258    }
259
260    #[test]
261    fn test_sync_snapshot_serialization() {
262        let before = create_test_snapshot_data();
263        let after = create_test_snapshot_data();
264
265        let comparison = create_test_comparison_result(true, vec![]);
266
267        let snapshot = SyncSnapshot::new(
268            "/api/test".to_string(),
269            "POST".to_string(),
270            "cycle-abc".to_string(),
271            before,
272            after,
273            comparison,
274            Some(50),
275            Some(55),
276        );
277
278        let json = serde_json::to_string(&snapshot).unwrap();
279
280        assert!(json.contains("/api/test"));
281        assert!(json.contains("POST"));
282        assert!(json.contains("cycle-abc"));
283    }
284
285    #[test]
286    fn test_sync_snapshot_deserialization() {
287        let before = create_test_snapshot_data();
288        let after = create_test_snapshot_data();
289
290        let comparison = create_test_comparison_result(true, vec![]);
291
292        let original = SyncSnapshot::new(
293            "/api/test".to_string(),
294            "GET".to_string(),
295            "cycle-xyz".to_string(),
296            before,
297            after,
298            comparison,
299            Some(100),
300            Some(105),
301        );
302
303        let json = serde_json::to_string(&original).unwrap();
304        let deserialized: SyncSnapshot = serde_json::from_str(&json).unwrap();
305
306        assert_eq!(deserialized.endpoint, original.endpoint);
307        assert_eq!(deserialized.method, original.method);
308        assert_eq!(deserialized.sync_cycle_id, original.sync_cycle_id);
309        assert_eq!(deserialized.response_time_before, original.response_time_before);
310        assert_eq!(deserialized.response_time_after, original.response_time_after);
311    }
312
313    #[test]
314    fn test_endpoint_timeline_creation() {
315        let snapshots = vec![];
316
317        let timeline = EndpointTimeline {
318            endpoint: "/api/users".to_string(),
319            method: "GET".to_string(),
320            snapshots,
321            response_time_trends: vec![],
322            status_code_history: vec![],
323            error_patterns: vec![],
324        };
325
326        assert_eq!(timeline.endpoint, "/api/users");
327        assert_eq!(timeline.method, "GET");
328        assert!(timeline.snapshots.is_empty());
329    }
330
331    #[test]
332    fn test_endpoint_timeline_with_snapshots() {
333        let before = create_test_snapshot_data();
334        let after = create_test_snapshot_data();
335
336        let comparison = create_test_comparison_result(true, vec![]);
337
338        let snapshot = SyncSnapshot::new(
339            "/api/users".to_string(),
340            "GET".to_string(),
341            "cycle-1".to_string(),
342            before,
343            after,
344            comparison,
345            Some(100),
346            Some(100),
347        );
348
349        let now = Utc::now();
350        let response_time_trends = vec![(now, Some(100))];
351        let status_code_history = vec![(now, 200)];
352
353        let timeline = EndpointTimeline {
354            endpoint: "/api/users".to_string(),
355            method: "GET".to_string(),
356            snapshots: vec![snapshot],
357            response_time_trends,
358            status_code_history,
359            error_patterns: vec![],
360        };
361
362        assert_eq!(timeline.snapshots.len(), 1);
363        assert_eq!(timeline.response_time_trends.len(), 1);
364        assert_eq!(timeline.status_code_history.len(), 1);
365    }
366
367    #[test]
368    fn test_error_pattern_creation() {
369        let now = Utc::now();
370
371        let pattern = ErrorPattern {
372            status_code: 404,
373            message_pattern: Some("Not found".to_string()),
374            occurrences: 5,
375            first_seen: now,
376            last_seen: now,
377        };
378
379        assert_eq!(pattern.status_code, 404);
380        assert_eq!(pattern.message_pattern, Some("Not found".to_string()));
381        assert_eq!(pattern.occurrences, 5);
382    }
383
384    #[test]
385    fn test_error_pattern_serialization() {
386        let now = Utc::now();
387
388        let pattern = ErrorPattern {
389            status_code: 500,
390            message_pattern: Some("Internal server error".to_string()),
391            occurrences: 3,
392            first_seen: now,
393            last_seen: now,
394        };
395
396        let json = serde_json::to_string(&pattern).unwrap();
397
398        assert!(json.contains("500"));
399        assert!(json.contains("Internal server error"));
400        assert!(json.contains("3"));
401    }
402
403    #[test]
404    fn test_endpoint_evolution_summary_creation() {
405        let mut field_change_frequency = HashMap::new();
406        field_change_frequency.insert("$.user.name".to_string(), 5);
407        field_change_frequency.insert("$.user.email".to_string(), 3);
408
409        let summary = EndpointEvolutionSummary {
410            endpoint: "/api/users".to_string(),
411            method: "GET".to_string(),
412            total_snapshots: 10,
413            total_changes: 8,
414            avg_response_time: Some(125.5),
415            most_common_status: Some(200),
416            field_change_frequency,
417        };
418
419        assert_eq!(summary.endpoint, "/api/users");
420        assert_eq!(summary.method, "GET");
421        assert_eq!(summary.total_snapshots, 10);
422        assert_eq!(summary.total_changes, 8);
423        assert_eq!(summary.avg_response_time, Some(125.5));
424        assert_eq!(summary.most_common_status, Some(200));
425        assert_eq!(summary.field_change_frequency.len(), 2);
426    }
427
428    #[test]
429    fn test_endpoint_evolution_summary_serialization() {
430        let mut field_change_frequency = HashMap::new();
431        field_change_frequency.insert("$.status".to_string(), 2);
432
433        let summary = EndpointEvolutionSummary {
434            endpoint: "/api/test".to_string(),
435            method: "POST".to_string(),
436            total_snapshots: 5,
437            total_changes: 3,
438            avg_response_time: Some(100.0),
439            most_common_status: Some(201),
440            field_change_frequency,
441        };
442
443        let json = serde_json::to_string(&summary).unwrap();
444
445        assert!(json.contains("/api/test"));
446        assert!(json.contains("POST"));
447        assert!(json.contains("5"));
448    }
449
450    #[test]
451    fn test_snapshot_data_with_no_json() {
452        let mut headers = HashMap::new();
453        headers.insert("content-type".to_string(), "text/plain".to_string());
454
455        let snapshot = SnapshotData {
456            status_code: 200,
457            headers,
458            body: b"plain text".to_vec(),
459            body_json: None,
460        };
461
462        assert!(snapshot.body_json.is_none());
463        assert_eq!(snapshot.body, b"plain text");
464    }
465
466    #[test]
467    fn test_sync_snapshot_with_no_response_times() {
468        let before = create_test_snapshot_data();
469        let after = create_test_snapshot_data();
470
471        let comparison = create_test_comparison_result(true, vec![]);
472
473        let snapshot = SyncSnapshot::new(
474            "/api/test".to_string(),
475            "GET".to_string(),
476            "cycle-1".to_string(),
477            before,
478            after,
479            comparison,
480            None,
481            None,
482        );
483
484        assert_eq!(snapshot.response_time_before, None);
485        assert_eq!(snapshot.response_time_after, None);
486    }
487
488    #[test]
489    fn test_endpoint_timeline_serialization() {
490        let timeline = EndpointTimeline {
491            endpoint: "/api/users".to_string(),
492            method: "GET".to_string(),
493            snapshots: vec![],
494            response_time_trends: vec![],
495            status_code_history: vec![],
496            error_patterns: vec![],
497        };
498
499        let json = serde_json::to_string(&timeline).unwrap();
500
501        assert!(json.contains("/api/users"));
502        assert!(json.contains("GET"));
503    }
504
505    #[test]
506    fn test_snapshot_data_clone() {
507        let snapshot = create_test_snapshot_data();
508        let cloned = snapshot.clone();
509
510        assert_eq!(snapshot.status_code, cloned.status_code);
511        assert_eq!(snapshot.body, cloned.body);
512    }
513
514    #[test]
515    fn test_sync_snapshot_clone() {
516        let before = create_test_snapshot_data();
517        let after = create_test_snapshot_data();
518
519        let comparison = create_test_comparison_result(true, vec![]);
520
521        let snapshot = SyncSnapshot::new(
522            "/api/test".to_string(),
523            "GET".to_string(),
524            "cycle-1".to_string(),
525            before,
526            after,
527            comparison,
528            Some(100),
529            Some(100),
530        );
531
532        let cloned = snapshot.clone();
533
534        assert_eq!(snapshot.id, cloned.id);
535        assert_eq!(snapshot.endpoint, cloned.endpoint);
536        assert_eq!(snapshot.method, cloned.method);
537    }
538
539    #[test]
540    fn test_error_pattern_clone() {
541        let now = Utc::now();
542
543        let pattern = ErrorPattern {
544            status_code: 404,
545            message_pattern: Some("Not found".to_string()),
546            occurrences: 5,
547            first_seen: now,
548            last_seen: now,
549        };
550
551        let cloned = pattern.clone();
552
553        assert_eq!(pattern.status_code, cloned.status_code);
554        assert_eq!(pattern.message_pattern, cloned.message_pattern);
555        assert_eq!(pattern.occurrences, cloned.occurrences);
556    }
557}