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