mockforge_core/
custom_fixture.rs

1//! Custom fixture format support for simple JSON fixtures
2//!
3//! Supports fixtures in two formats:
4//!
5//! **Flat format** (preferred):
6//! ```json
7//! {
8//!   "method": "GET",
9//!   "path": "/api/v1/endpoint",
10//!   "status": 200,
11//!   "response": { /* response body */ },
12//!   "headers": { /* optional */ },
13//!   "delay_ms": 0 /* optional */
14//! }
15//! ```
16//!
17//! **Nested format** (also supported):
18//! ```json
19//! {
20//!   "request": {
21//!     "method": "GET",
22//!     "path": "/api/v1/endpoint"
23//!   },
24//!   "response": {
25//!     "status": 200,
26//!     "headers": { /* optional */ },
27//!     "body": { /* response body */ }
28//!   }
29//! }
30//! ```
31//!
32//! Path matching supports path parameters using curly braces:
33//! - `/api/v1/hives/{hiveId}` matches `/api/v1/hives/hive_001`
34//!
35//! Paths are automatically normalized (trailing slashes removed, multiple slashes collapsed)
36
37use crate::{Error, RequestFingerprint, Result};
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use std::collections::HashMap;
41use std::path::{Path, PathBuf};
42use tokio::fs;
43
44/// Custom fixture structure matching the simple JSON format
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CustomFixture {
47    /// HTTP method (GET, POST, PUT, PATCH, DELETE)
48    pub method: String,
49    /// Request path (supports path parameters like {hiveId})
50    pub path: String,
51    /// Response status code
52    pub status: u16,
53    /// Response body (can be any JSON value)
54    #[serde(default)]
55    pub response: serde_json::Value,
56    /// Optional response headers
57    #[serde(default)]
58    pub headers: HashMap<String, String>,
59    /// Optional response delay in milliseconds
60    #[serde(default)]
61    pub delay_ms: u64,
62}
63
64/// Nested fixture format for backward compatibility
65#[derive(Debug, Deserialize)]
66pub struct NestedFixture {
67    /// Request configuration for the fixture
68    pub request: Option<NestedRequest>,
69    /// Response configuration for the fixture
70    pub response: Option<NestedResponse>,
71}
72
73/// Request portion of a nested fixture
74#[derive(Debug, Deserialize)]
75pub struct NestedRequest {
76    /// HTTP method for the request
77    pub method: String,
78    /// URL path pattern for the request
79    pub path: String,
80}
81
82/// Response portion of a nested fixture
83#[derive(Debug, Deserialize)]
84pub struct NestedResponse {
85    /// HTTP status code for the response
86    pub status: u16,
87    /// HTTP headers for the response
88    #[serde(default)]
89    pub headers: HashMap<String, String>,
90    /// Response body content
91    pub body: Value,
92}
93
94/// Result of loading a fixture file
95#[derive(Debug)]
96enum LoadResult {
97    Loaded,
98    Skipped,
99}
100
101/// Custom fixture loader that scans a directory for fixture files
102pub struct CustomFixtureLoader {
103    /// Directory containing custom fixtures
104    fixtures_dir: PathBuf,
105    /// Whether custom fixtures are enabled
106    enabled: bool,
107    /// Loaded fixtures cache (method -> path pattern -> fixture)
108    fixtures: HashMap<String, HashMap<String, CustomFixture>>,
109    /// Statistics for loaded fixtures
110    stats: LoadStats,
111}
112
113/// Statistics about fixture loading
114#[derive(Debug, Default)]
115struct LoadStats {
116    loaded: usize,
117    failed: usize,
118    skipped: usize,
119}
120
121impl CustomFixtureLoader {
122    /// Create a new custom fixture loader
123    pub fn new(fixtures_dir: PathBuf, enabled: bool) -> Self {
124        Self {
125            fixtures_dir,
126            enabled,
127            fixtures: HashMap::new(),
128            stats: LoadStats::default(),
129        }
130    }
131
132    /// Normalize a path by removing trailing slashes (except root) and collapsing multiple slashes
133    /// Also strips query strings from the path (query strings are handled separately in RequestFingerprint)
134    pub fn normalize_path(path: &str) -> String {
135        let mut normalized = path.trim().to_string();
136        
137        // Strip query string if present (query strings are handled separately)
138        if let Some(query_start) = normalized.find('?') {
139            normalized = normalized[..query_start].to_string();
140        }
141        
142        // Collapse multiple slashes into one
143        while normalized.contains("//") {
144            normalized = normalized.replace("//", "/");
145        }
146        
147        // Remove trailing slash (except for root path)
148        if normalized.len() > 1 && normalized.ends_with('/') {
149            normalized.pop();
150        }
151        
152        // Ensure path starts with /
153        if !normalized.starts_with('/') {
154            normalized = format!("/{}", normalized);
155        }
156        
157        normalized
158    }
159
160    /// Check if a file should be skipped (template files, etc.)
161    pub fn should_skip_file(content: &str) -> bool {
162        // Check for template indicators
163        if content.contains("\"_comment\"") || content.contains("\"_usage\"") {
164            return true;
165        }
166        
167        // Check if it's a scenario/config file (not a fixture)
168        if content.contains("\"scenario\"") || content.contains("\"presentation_mode\"") {
169            return true;
170        }
171        
172        false
173    }
174
175    /// Convert nested fixture format to flat format
176    pub fn convert_nested_to_flat(nested: NestedFixture) -> Result<CustomFixture> {
177        let request = nested.request.ok_or_else(|| {
178            Error::generic("Nested fixture missing 'request' object".to_string())
179        })?;
180        
181        let response = nested.response.ok_or_else(|| {
182            Error::generic("Nested fixture missing 'response' object".to_string())
183        })?;
184
185        Ok(CustomFixture {
186            method: request.method,
187            path: Self::normalize_path(&request.path),
188            status: response.status,
189            response: response.body,
190            headers: response.headers,
191            delay_ms: 0,
192        })
193    }
194
195    /// Validate a fixture has required fields and valid values
196    pub fn validate_fixture(fixture: &CustomFixture, file_path: &Path) -> Result<()> {
197        // Check required fields
198        if fixture.method.is_empty() {
199            return Err(Error::generic(format!(
200                "Invalid fixture in {}: method is required and cannot be empty",
201                file_path.display()
202            )));
203        }
204        
205        if fixture.path.is_empty() {
206            return Err(Error::generic(format!(
207                "Invalid fixture in {}: path is required and cannot be empty",
208                file_path.display()
209            )));
210        }
211
212        // Validate HTTP method
213        let method_upper = fixture.method.to_uppercase();
214        let valid_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"];
215        if !valid_methods.contains(&method_upper.as_str()) {
216            tracing::warn!(
217                "Fixture {} uses non-standard HTTP method: {}",
218                file_path.display(),
219                fixture.method
220            );
221        }
222
223        // Validate status code
224        if fixture.status < 100 || fixture.status >= 600 {
225            return Err(Error::generic(format!(
226                "Invalid fixture in {}: status code {} is not a valid HTTP status code (100-599)",
227                file_path.display(),
228                fixture.status
229            )));
230        }
231
232        Ok(())
233    }
234
235    /// Load all fixtures from the directory
236    pub async fn load_fixtures(&mut self) -> Result<()> {
237        if !self.enabled {
238            return Ok(());
239        }
240
241        if !self.fixtures_dir.exists() {
242            tracing::debug!(
243                "Custom fixtures directory does not exist: {}",
244                self.fixtures_dir.display()
245            );
246            return Ok(());
247        }
248
249        // Reset stats
250        self.stats = LoadStats::default();
251
252        // Scan directory for JSON files
253        let mut entries = fs::read_dir(&self.fixtures_dir).await.map_err(|e| {
254            Error::generic(format!(
255                "Failed to read fixtures directory {}: {}",
256                self.fixtures_dir.display(),
257                e
258            ))
259        })?;
260
261        while let Some(entry) = entries
262            .next_entry()
263            .await
264            .map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?
265        {
266            let path = entry.path();
267            if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
268                match self.load_fixture_file(&path).await {
269                    Ok(LoadResult::Loaded) => {
270                        self.stats.loaded += 1;
271                    }
272                    Ok(LoadResult::Skipped) => {
273                        self.stats.skipped += 1;
274                    }
275                    Err(e) => {
276                        self.stats.failed += 1;
277                        tracing::warn!("Failed to load fixture file {}: {}", path.display(), e);
278                    }
279                }
280            }
281        }
282
283        // Log summary
284        tracing::info!(
285            "Fixture loading complete: {} loaded, {} failed, {} skipped from {}",
286            self.stats.loaded,
287            self.stats.failed,
288            self.stats.skipped,
289            self.fixtures_dir.display()
290        );
291
292        Ok(())
293    }
294
295    /// Load a single fixture file
296    async fn load_fixture_file(&mut self, path: &Path) -> Result<LoadResult> {
297        let content = fs::read_to_string(path).await.map_err(|e| {
298            Error::generic(format!("Failed to read fixture file {}: {}", path.display(), e))
299        })?;
300
301        // Check if this is a template file that should be skipped
302        if Self::should_skip_file(&content) {
303            tracing::debug!("Skipping template file: {}", path.display());
304            return Ok(LoadResult::Skipped);
305        }
306
307        // Try to parse as flat format first
308        let fixture = match serde_json::from_str::<CustomFixture>(&content) {
309            Ok(mut fixture) => {
310                // Normalize path
311                fixture.path = Self::normalize_path(&fixture.path);
312                fixture
313            }
314            Err(_) => {
315                // Try nested format
316                let nested: NestedFixture = serde_json::from_str(&content).map_err(|e| {
317                    Error::generic(format!(
318                        "Failed to parse fixture file {}: not a valid flat or nested format. Error: {}",
319                        path.display(),
320                        e
321                    ))
322                })?;
323                
324                // Convert nested to flat
325                Self::convert_nested_to_flat(nested)?
326            }
327        };
328
329        // Validate fixture
330        Self::validate_fixture(&fixture, path)?;
331
332        // Store fixture by method and path pattern
333        let method = fixture.method.to_uppercase();
334        let fixtures_by_method = self.fixtures.entry(method.clone()).or_default();
335        
336        // Check for duplicate paths (warn but allow)
337        if fixtures_by_method.contains_key(&fixture.path) {
338            tracing::warn!(
339                "Duplicate fixture path '{}' for method '{}' in file {} (overwriting previous)",
340                fixture.path,
341                method,
342                path.display()
343            );
344        }
345        
346        fixtures_by_method.insert(fixture.path.clone(), fixture);
347
348        Ok(LoadResult::Loaded)
349    }
350
351    /// Check if a fixture exists for the given request fingerprint
352    pub fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
353        if !self.enabled {
354            return false;
355        }
356
357        self.find_matching_fixture(fingerprint).is_some()
358    }
359
360    /// Load a fixture for the given request fingerprint
361    pub fn load_fixture(&self, fingerprint: &RequestFingerprint) -> Option<CustomFixture> {
362        if !self.enabled {
363            return None;
364        }
365
366        self.find_matching_fixture(fingerprint).cloned()
367    }
368
369    /// Find a matching fixture for the request fingerprint
370    fn find_matching_fixture(&self, fingerprint: &RequestFingerprint) -> Option<&CustomFixture> {
371        let method = fingerprint.method.to_uppercase();
372        let fixtures_by_method = self.fixtures.get(&method)?;
373
374        // Normalize the request path for matching
375        let request_path = Self::normalize_path(&fingerprint.path);
376
377        // Try exact match first (with normalized path)
378        if let Some(fixture) = fixtures_by_method.get(&request_path) {
379            tracing::debug!(
380                "Found exact fixture match: {} {}",
381                method,
382                request_path
383            );
384            return Some(fixture);
385        }
386
387        // Try pattern matching for path parameters
388        for (pattern, fixture) in fixtures_by_method.iter() {
389            if self.path_matches(pattern, &request_path) {
390                tracing::debug!(
391                    "Found pattern fixture match: {} {} (pattern: {})",
392                    method,
393                    request_path,
394                    pattern
395                );
396                return Some(fixture);
397            }
398        }
399
400        tracing::debug!(
401            "No fixture match found for: {} {}",
402            method,
403            request_path
404        );
405        None
406    }
407
408    /// Check if a request path matches a fixture path pattern
409    ///
410    /// Supports path parameters using curly braces:
411    /// - Pattern: `/api/v1/hives/{hiveId}` matches `/api/v1/hives/hive_001`, `/api/v1/hives/123`, etc.
412    /// - Paths are normalized before matching (trailing slashes removed, multiple slashes collapsed)
413    fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
414        // Normalize both paths before matching
415        let normalized_pattern = Self::normalize_path(pattern);
416        let normalized_request = Self::normalize_path(request_path);
417
418        // Simple pattern matching without full regex (for performance)
419        // Split both paths into segments
420        let pattern_segments: Vec<&str> = normalized_pattern
421            .split('/')
422            .filter(|s| !s.is_empty())
423            .collect();
424        let request_segments: Vec<&str> = normalized_request
425            .split('/')
426            .filter(|s| !s.is_empty())
427            .collect();
428
429        if pattern_segments.len() != request_segments.len() {
430            return false;
431        }
432
433        // Compare segments
434        for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
435            // If pattern segment is a parameter (starts with { and ends with }), it matches anything
436            if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
437                continue;
438            }
439            // Otherwise, segments must match exactly
440            if pattern_seg != request_seg {
441                return false;
442            }
443        }
444
445        true
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use axum::http::{HeaderMap, Method, Uri};
453    use tempfile::TempDir;
454
455    fn create_test_fingerprint(method: &str, path: &str) -> RequestFingerprint {
456        let method = Method::from_bytes(method.as_bytes()).unwrap();
457        let uri: Uri = path.parse().unwrap();
458        RequestFingerprint::new(method, &uri, &HeaderMap::new(), None)
459    }
460
461    #[test]
462    fn test_path_matching_exact() {
463        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
464        assert!(loader.path_matches("/api/v1/apiaries", "/api/v1/apiaries"));
465        assert!(!loader.path_matches("/api/v1/apiaries", "/api/v1/hives"));
466    }
467
468    #[test]
469    fn test_path_matching_with_parameters() {
470        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
471        assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001"));
472        assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/123"));
473        assert!(
474            !loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/inspections")
475        );
476    }
477
478    #[test]
479    fn test_path_matching_multiple_parameters() {
480        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
481        assert!(loader.path_matches(
482            "/api/v1/apiaries/{apiaryId}/hives/{hiveId}",
483            "/api/v1/apiaries/apiary_001/hives/hive_001"
484        ));
485    }
486
487    #[tokio::test]
488    async fn test_load_fixture() {
489        let temp_dir = TempDir::new().unwrap();
490        let fixtures_dir = temp_dir.path().to_path_buf();
491
492        // Create a test fixture file
493        let fixture_content = r#"{
494  "method": "GET",
495  "path": "/api/v1/apiaries",
496  "status": 200,
497  "response": {
498    "success": true,
499    "data": []
500  }
501}"#;
502
503        let fixture_file = fixtures_dir.join("apiaries-list.json");
504        fs::write(&fixture_file, fixture_content).await.unwrap();
505
506        // Load fixtures
507        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
508        loader.load_fixtures().await.unwrap();
509
510        // Check if fixture is loaded
511        let fingerprint = create_test_fingerprint("GET", "/api/v1/apiaries");
512        assert!(loader.has_fixture(&fingerprint));
513
514        let fixture = loader.load_fixture(&fingerprint).unwrap();
515        assert_eq!(fixture.method, "GET");
516        assert_eq!(fixture.path, "/api/v1/apiaries");
517        assert_eq!(fixture.status, 200);
518    }
519
520    #[tokio::test]
521    async fn test_load_fixture_with_path_parameter() {
522        let temp_dir = TempDir::new().unwrap();
523        let fixtures_dir = temp_dir.path().to_path_buf();
524
525        // Create a test fixture file with path parameter
526        let fixture_content = r#"{
527  "method": "GET",
528  "path": "/api/v1/hives/{hiveId}",
529  "status": 200,
530  "response": {
531    "success": true,
532    "data": {
533      "id": "hive_001"
534    }
535  }
536}"#;
537
538        let fixture_file = fixtures_dir.join("hive-detail.json");
539        fs::write(&fixture_file, fixture_content).await.unwrap();
540
541        // Load fixtures
542        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
543        loader.load_fixtures().await.unwrap();
544
545        // Check if fixture matches with actual path
546        let fingerprint = create_test_fingerprint("GET", "/api/v1/hives/hive_001");
547        assert!(loader.has_fixture(&fingerprint));
548
549        let fixture = loader.load_fixture(&fingerprint).unwrap();
550        assert_eq!(fixture.path, "/api/v1/hives/{hiveId}");
551    }
552
553    #[tokio::test]
554    async fn test_load_multiple_fixtures() {
555        let temp_dir = TempDir::new().unwrap();
556        let fixtures_dir = temp_dir.path().to_path_buf();
557
558        // Create multiple fixture files
559        let fixtures = vec![
560            (
561                "apiaries-list.json",
562                r#"{
563  "method": "GET",
564  "path": "/api/v1/apiaries",
565  "status": 200,
566  "response": {"items": []}
567}"#,
568            ),
569            (
570                "hive-detail.json",
571                r#"{
572  "method": "GET",
573  "path": "/api/v1/hives/{hiveId}",
574  "status": 200,
575  "response": {"id": "hive_001"}
576}"#,
577            ),
578            (
579                "user-profile.json",
580                r#"{
581  "method": "GET",
582  "path": "/api/v1/users/me",
583  "status": 200,
584  "response": {"id": "user_001"}
585}"#,
586            ),
587        ];
588
589        for (filename, content) in fixtures {
590            let fixture_file = fixtures_dir.join(filename);
591            fs::write(&fixture_file, content).await.unwrap();
592        }
593
594        // Load fixtures
595        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
596        loader.load_fixtures().await.unwrap();
597
598        // Verify all fixtures are loaded
599        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/apiaries")));
600        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/hives/hive_001")));
601        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/users/me")));
602    }
603
604    #[tokio::test]
605    async fn test_fixture_with_headers() {
606        let temp_dir = TempDir::new().unwrap();
607        let fixtures_dir = temp_dir.path().to_path_buf();
608
609        let fixture_content = r#"{
610  "method": "GET",
611  "path": "/api/v1/test",
612  "status": 201,
613  "response": {"result": "ok"},
614  "headers": {
615    "content-type": "application/json",
616    "x-custom-header": "test-value"
617  }
618}"#;
619
620        let fixture_file = fixtures_dir.join("test.json");
621        fs::write(&fixture_file, fixture_content).await.unwrap();
622
623        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
624        loader.load_fixtures().await.unwrap();
625
626        let fixture = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/test")).unwrap();
627
628        assert_eq!(fixture.status, 201);
629        assert_eq!(fixture.headers.get("content-type"), Some(&"application/json".to_string()));
630        assert_eq!(fixture.headers.get("x-custom-header"), Some(&"test-value".to_string()));
631    }
632
633    #[tokio::test]
634    async fn test_fixture_disabled() {
635        let temp_dir = TempDir::new().unwrap();
636        let fixtures_dir = temp_dir.path().to_path_buf();
637
638        let fixture_file = fixtures_dir.join("test.json");
639        fs::write(
640            &fixture_file,
641            r#"{"method": "GET", "path": "/test", "status": 200, "response": {}}"#,
642        )
643        .await
644        .unwrap();
645
646        let mut loader = CustomFixtureLoader::new(fixtures_dir, false);
647        loader.load_fixtures().await.unwrap();
648
649        // Should not find fixture when disabled
650        assert!(!loader.has_fixture(&create_test_fingerprint("GET", "/test")));
651        assert!(loader.load_fixture(&create_test_fingerprint("GET", "/test")).is_none());
652    }
653
654    #[tokio::test]
655    async fn test_load_nested_format() {
656        let temp_dir = TempDir::new().unwrap();
657        let fixtures_dir = temp_dir.path().to_path_buf();
658
659        // Create a nested format fixture
660        let fixture_content = r#"{
661          "request": {
662            "method": "POST",
663            "path": "/api/auth/login"
664          },
665          "response": {
666            "status": 200,
667            "headers": {
668              "Content-Type": "application/json"
669            },
670            "body": {
671              "access_token": "test_token",
672              "user": {
673                "id": "user_001"
674              }
675            }
676          }
677        }"#;
678
679        let fixture_file = fixtures_dir.join("auth-login.json");
680        fs::write(&fixture_file, fixture_content).await.unwrap();
681
682        // Load fixtures
683        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
684        loader.load_fixtures().await.unwrap();
685
686        // Check if fixture is loaded
687        let fingerprint = create_test_fingerprint("POST", "/api/auth/login");
688        assert!(loader.has_fixture(&fingerprint));
689
690        let fixture = loader.load_fixture(&fingerprint).unwrap();
691        assert_eq!(fixture.method, "POST");
692        assert_eq!(fixture.path, "/api/auth/login");
693        assert_eq!(fixture.status, 200);
694        assert_eq!(
695            fixture.headers.get("Content-Type"),
696            Some(&"application/json".to_string())
697        );
698        assert!(fixture.response.get("access_token").is_some());
699    }
700
701    #[test]
702    fn test_path_normalization() {
703        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
704        
705        // Test trailing slash removal
706        assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test/"), "/api/v1/test");
707        assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test"), "/api/v1/test");
708        
709        // Root path should remain as /
710        assert_eq!(CustomFixtureLoader::normalize_path("/"), "/");
711        
712        // Multiple slashes should be collapsed
713        assert_eq!(CustomFixtureLoader::normalize_path("/api//v1///test"), "/api/v1/test");
714        
715        // Paths without leading slash should get one
716        assert_eq!(CustomFixtureLoader::normalize_path("api/v1/test"), "/api/v1/test");
717        
718        // Whitespace should be trimmed
719        assert_eq!(CustomFixtureLoader::normalize_path(" /api/v1/test "), "/api/v1/test");
720    }
721
722    #[test]
723    fn test_path_matching_with_normalization() {
724        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
725        
726        // Test that trailing slashes don't prevent matching
727        assert!(loader.path_matches("/api/v1/test", "/api/v1/test/"));
728        assert!(loader.path_matches("/api/v1/test/", "/api/v1/test"));
729        
730        // Test multiple slashes
731        assert!(loader.path_matches("/api/v1/test", "/api//v1///test"));
732        
733        // Test path parameters still work
734        assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/"));
735    }
736
737    #[tokio::test]
738    async fn test_skip_template_files() {
739        let temp_dir = TempDir::new().unwrap();
740        let fixtures_dir = temp_dir.path().to_path_buf();
741
742        // Create a template file
743        let template_content = r#"{
744          "_comment": "This is a template",
745          "_usage": "Use this for errors",
746          "error": {
747            "code": "ERROR_CODE"
748          }
749        }"#;
750
751        let template_file = fixtures_dir.join("error-template.json");
752        fs::write(&template_file, template_content).await.unwrap();
753
754        // Create a valid fixture
755        let valid_fixture = r#"{
756          "method": "GET",
757          "path": "/api/test",
758          "status": 200,
759          "response": {}
760        }"#;
761        let valid_file = fixtures_dir.join("valid.json");
762        fs::write(&valid_file, valid_fixture).await.unwrap();
763
764        // Load fixtures
765        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
766        loader.load_fixtures().await.unwrap();
767
768        // Valid fixture should work
769        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
770        
771        // Template file should be skipped (no fixture for a path that doesn't exist)
772        // We can't easily test this without accessing internal state, but the fact that
773        // the valid fixture loads means the template was skipped
774    }
775
776    #[tokio::test]
777    async fn test_skip_scenario_files() {
778        let temp_dir = TempDir::new().unwrap();
779        let fixtures_dir = temp_dir.path().to_path_buf();
780
781        // Create a scenario file (not a fixture)
782        let scenario_content = r#"{
783          "scenario": "demo",
784          "presentation_mode": true,
785          "apiaries": []
786        }"#;
787
788        let scenario_file = fixtures_dir.join("demo-scenario.json");
789        fs::write(&scenario_file, scenario_content).await.unwrap();
790
791        // Create a valid fixture
792        let valid_fixture = r#"{
793          "method": "GET",
794          "path": "/api/test",
795          "status": 200,
796          "response": {}
797        }"#;
798        let valid_file = fixtures_dir.join("valid.json");
799        fs::write(&valid_file, valid_fixture).await.unwrap();
800
801        // Load fixtures
802        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
803        loader.load_fixtures().await.unwrap();
804
805        // Valid fixture should work
806        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
807    }
808
809    #[tokio::test]
810    async fn test_mixed_format_fixtures() {
811        let temp_dir = TempDir::new().unwrap();
812        let fixtures_dir = temp_dir.path().to_path_buf();
813
814        // Create flat format fixture
815        let flat_fixture = r#"{
816          "method": "GET",
817          "path": "/api/v1/flat",
818          "status": 200,
819          "response": {"type": "flat"}
820        }"#;
821
822        // Create nested format fixture
823        let nested_fixture = r#"{
824          "request": {
825            "method": "GET",
826            "path": "/api/v1/nested"
827          },
828          "response": {
829            "status": 200,
830            "body": {"type": "nested"}
831          }
832        }"#;
833
834        fs::write(fixtures_dir.join("flat.json"), flat_fixture).await.unwrap();
835        fs::write(fixtures_dir.join("nested.json"), nested_fixture).await.unwrap();
836
837        // Load fixtures
838        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
839        loader.load_fixtures().await.unwrap();
840
841        // Both should work
842        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/flat")));
843        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/nested")).is_some());
844        
845        let flat = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/flat")).unwrap();
846        assert_eq!(flat.response.get("type").and_then(|v| v.as_str()), Some("flat"));
847        
848        let nested = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/nested")).unwrap();
849        assert_eq!(nested.response.get("type").and_then(|v| v.as_str()), Some("nested"));
850    }
851
852    #[tokio::test]
853    async fn test_validation_errors() {
854        let temp_dir = TempDir::new().unwrap();
855        let fixtures_dir = temp_dir.path().to_path_buf();
856
857        // Test missing method
858        let no_method = r#"{
859          "path": "/api/test",
860          "status": 200,
861          "response": {}
862        }"#;
863        fs::write(fixtures_dir.join("no-method.json"), no_method).await.unwrap();
864
865        // Test invalid status code
866        let invalid_status = r#"{
867          "method": "GET",
868          "path": "/api/test",
869          "status": 999,
870          "response": {}
871        }"#;
872        fs::write(fixtures_dir.join("invalid-status.json"), invalid_status).await.unwrap();
873
874        // Load fixtures - should handle errors gracefully
875        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
876        let result = loader.load_fixtures().await;
877        
878        // Should not crash, but should log warnings
879        assert!(result.is_ok());
880    }
881}