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
178            .request
179            .ok_or_else(|| Error::generic("Nested fixture missing 'request' object".to_string()))?;
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 = [
215            "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE",
216        ];
217        if !valid_methods.contains(&method_upper.as_str()) {
218            tracing::warn!(
219                "Fixture {} uses non-standard HTTP method: {}",
220                file_path.display(),
221                fixture.method
222            );
223        }
224
225        // Validate status code
226        if fixture.status < 100 || fixture.status >= 600 {
227            return Err(Error::generic(format!(
228                "Invalid fixture in {}: status code {} is not a valid HTTP status code (100-599)",
229                file_path.display(),
230                fixture.status
231            )));
232        }
233
234        Ok(())
235    }
236
237    /// Load all fixtures from the directory
238    pub async fn load_fixtures(&mut self) -> Result<()> {
239        if !self.enabled {
240            return Ok(());
241        }
242
243        if !self.fixtures_dir.exists() {
244            tracing::debug!(
245                "Custom fixtures directory does not exist: {}",
246                self.fixtures_dir.display()
247            );
248            return Ok(());
249        }
250
251        // Reset stats
252        self.stats = LoadStats::default();
253
254        // Scan directory for JSON files
255        let mut entries = fs::read_dir(&self.fixtures_dir).await.map_err(|e| {
256            Error::generic(format!(
257                "Failed to read fixtures directory {}: {}",
258                self.fixtures_dir.display(),
259                e
260            ))
261        })?;
262
263        while let Some(entry) = entries
264            .next_entry()
265            .await
266            .map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?
267        {
268            let path = entry.path();
269            if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
270                match self.load_fixture_file(&path).await {
271                    Ok(LoadResult::Loaded) => {
272                        self.stats.loaded += 1;
273                    }
274                    Ok(LoadResult::Skipped) => {
275                        self.stats.skipped += 1;
276                    }
277                    Err(e) => {
278                        self.stats.failed += 1;
279                        tracing::warn!("Failed to load fixture file {}: {}", path.display(), e);
280                    }
281                }
282            }
283        }
284
285        // Log summary
286        tracing::info!(
287            "Fixture loading complete: {} loaded, {} failed, {} skipped from {}",
288            self.stats.loaded,
289            self.stats.failed,
290            self.stats.skipped,
291            self.fixtures_dir.display()
292        );
293
294        Ok(())
295    }
296
297    /// Load a single fixture file
298    async fn load_fixture_file(&mut self, path: &Path) -> Result<LoadResult> {
299        let content = fs::read_to_string(path).await.map_err(|e| {
300            Error::generic(format!("Failed to read fixture file {}: {}", path.display(), e))
301        })?;
302
303        // Check if this is a template file that should be skipped
304        if Self::should_skip_file(&content) {
305            tracing::debug!("Skipping template file: {}", path.display());
306            return Ok(LoadResult::Skipped);
307        }
308
309        // Try to parse as flat format first
310        let fixture = match serde_json::from_str::<CustomFixture>(&content) {
311            Ok(mut fixture) => {
312                // Normalize path
313                fixture.path = Self::normalize_path(&fixture.path);
314                fixture
315            }
316            Err(_) => {
317                // Try nested format
318                let nested: NestedFixture = serde_json::from_str(&content).map_err(|e| {
319                    Error::generic(format!(
320                        "Failed to parse fixture file {}: not a valid flat or nested format. Error: {}",
321                        path.display(),
322                        e
323                    ))
324                })?;
325
326                // Convert nested to flat
327                Self::convert_nested_to_flat(nested)?
328            }
329        };
330
331        // Validate fixture
332        Self::validate_fixture(&fixture, path)?;
333
334        // Store fixture by method and path pattern
335        let method = fixture.method.to_uppercase();
336        let fixtures_by_method = self.fixtures.entry(method.clone()).or_default();
337
338        // Check for duplicate paths (warn but allow)
339        if fixtures_by_method.contains_key(&fixture.path) {
340            tracing::warn!(
341                "Duplicate fixture path '{}' for method '{}' in file {} (overwriting previous)",
342                fixture.path,
343                method,
344                path.display()
345            );
346        }
347
348        fixtures_by_method.insert(fixture.path.clone(), fixture);
349
350        Ok(LoadResult::Loaded)
351    }
352
353    /// Check if a fixture exists for the given request fingerprint
354    pub fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
355        if !self.enabled {
356            return false;
357        }
358
359        self.find_matching_fixture(fingerprint).is_some()
360    }
361
362    /// Load a fixture for the given request fingerprint
363    pub fn load_fixture(&self, fingerprint: &RequestFingerprint) -> Option<CustomFixture> {
364        if !self.enabled {
365            return None;
366        }
367
368        self.find_matching_fixture(fingerprint).cloned()
369    }
370
371    /// Find a matching fixture for the request fingerprint
372    fn find_matching_fixture(&self, fingerprint: &RequestFingerprint) -> Option<&CustomFixture> {
373        let method = fingerprint.method.to_uppercase();
374        let fixtures_by_method = self.fixtures.get(&method)?;
375
376        // Normalize the request path for matching
377        let request_path = Self::normalize_path(&fingerprint.path);
378
379        // Debug logging
380        tracing::debug!(
381            "Fixture matching: method={}, fingerprint.path='{}', normalized='{}', available fixtures: {:?}",
382            method,
383            fingerprint.path,
384            request_path,
385            fixtures_by_method.keys().collect::<Vec<_>>()
386        );
387
388        // Try exact match first (with normalized path)
389        if let Some(fixture) = fixtures_by_method.get(&request_path) {
390            tracing::debug!("Found exact fixture match: {} {}", method, request_path);
391            return Some(fixture);
392        }
393
394        // Try pattern matching for path parameters
395        for (pattern, fixture) in fixtures_by_method.iter() {
396            if self.path_matches(pattern, &request_path) {
397                tracing::debug!(
398                    "Found pattern fixture match: {} {} (pattern: {})",
399                    method,
400                    request_path,
401                    pattern
402                );
403                return Some(fixture);
404            }
405        }
406
407        tracing::debug!("No fixture match found for: {} {}", method, request_path);
408        None
409    }
410
411    /// Check if a request path matches a fixture path pattern
412    ///
413    /// Supports path parameters using curly braces:
414    /// - Pattern: `/api/v1/hives/{hiveId}` matches `/api/v1/hives/hive_001`, `/api/v1/hives/123`, etc.
415    /// - Paths are normalized before matching (trailing slashes removed, multiple slashes collapsed)
416    fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
417        // Normalize both paths before matching
418        let normalized_pattern = Self::normalize_path(pattern);
419        let normalized_request = Self::normalize_path(request_path);
420
421        // Simple pattern matching without full regex (for performance)
422        // Split both paths into segments
423        let pattern_segments: Vec<&str> =
424            normalized_pattern.split('/').filter(|s| !s.is_empty()).collect();
425        let request_segments: Vec<&str> =
426            normalized_request.split('/').filter(|s| !s.is_empty()).collect();
427
428        if pattern_segments.len() != request_segments.len() {
429            return false;
430        }
431
432        // Compare segments
433        for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
434            // If pattern segment is a parameter (starts with { and ends with }), it matches anything
435            if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
436                continue;
437            }
438            // Otherwise, segments must match exactly
439            if pattern_seg != request_seg {
440                return false;
441            }
442        }
443
444        true
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use axum::http::{HeaderMap, Method, Uri};
452    use tempfile::TempDir;
453
454    fn create_test_fingerprint(method: &str, path: &str) -> RequestFingerprint {
455        let method = Method::from_bytes(method.as_bytes()).unwrap();
456        let uri: Uri = path.parse().unwrap();
457        RequestFingerprint::new(method, &uri, &HeaderMap::new(), None)
458    }
459
460    #[test]
461    fn test_path_matching_exact() {
462        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
463        assert!(loader.path_matches("/api/v1/apiaries", "/api/v1/apiaries"));
464        assert!(!loader.path_matches("/api/v1/apiaries", "/api/v1/hives"));
465    }
466
467    #[test]
468    fn test_path_matching_with_parameters() {
469        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
470        assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001"));
471        assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/123"));
472        assert!(
473            !loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/inspections")
474        );
475    }
476
477    #[test]
478    fn test_path_matching_multiple_parameters() {
479        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
480        assert!(loader.path_matches(
481            "/api/v1/apiaries/{apiaryId}/hives/{hiveId}",
482            "/api/v1/apiaries/apiary_001/hives/hive_001"
483        ));
484    }
485
486    #[tokio::test]
487    async fn test_load_fixture() {
488        let temp_dir = TempDir::new().unwrap();
489        let fixtures_dir = temp_dir.path().to_path_buf();
490
491        // Create a test fixture file
492        let fixture_content = r#"{
493  "method": "GET",
494  "path": "/api/v1/apiaries",
495  "status": 200,
496  "response": {
497    "success": true,
498    "data": []
499  }
500}"#;
501
502        let fixture_file = fixtures_dir.join("apiaries-list.json");
503        fs::write(&fixture_file, fixture_content).await.unwrap();
504
505        // Load fixtures
506        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
507        loader.load_fixtures().await.unwrap();
508
509        // Check if fixture is loaded
510        let fingerprint = create_test_fingerprint("GET", "/api/v1/apiaries");
511        assert!(loader.has_fixture(&fingerprint));
512
513        let fixture = loader.load_fixture(&fingerprint).unwrap();
514        assert_eq!(fixture.method, "GET");
515        assert_eq!(fixture.path, "/api/v1/apiaries");
516        assert_eq!(fixture.status, 200);
517    }
518
519    #[tokio::test]
520    async fn test_load_fixture_with_path_parameter() {
521        let temp_dir = TempDir::new().unwrap();
522        let fixtures_dir = temp_dir.path().to_path_buf();
523
524        // Create a test fixture file with path parameter
525        let fixture_content = r#"{
526  "method": "GET",
527  "path": "/api/v1/hives/{hiveId}",
528  "status": 200,
529  "response": {
530    "success": true,
531    "data": {
532      "id": "hive_001"
533    }
534  }
535}"#;
536
537        let fixture_file = fixtures_dir.join("hive-detail.json");
538        fs::write(&fixture_file, fixture_content).await.unwrap();
539
540        // Load fixtures
541        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
542        loader.load_fixtures().await.unwrap();
543
544        // Check if fixture matches with actual path
545        let fingerprint = create_test_fingerprint("GET", "/api/v1/hives/hive_001");
546        assert!(loader.has_fixture(&fingerprint));
547
548        let fixture = loader.load_fixture(&fingerprint).unwrap();
549        assert_eq!(fixture.path, "/api/v1/hives/{hiveId}");
550    }
551
552    #[tokio::test]
553    async fn test_load_multiple_fixtures() {
554        let temp_dir = TempDir::new().unwrap();
555        let fixtures_dir = temp_dir.path().to_path_buf();
556
557        // Create multiple fixture files
558        let fixtures = vec![
559            (
560                "apiaries-list.json",
561                r#"{
562  "method": "GET",
563  "path": "/api/v1/apiaries",
564  "status": 200,
565  "response": {"items": []}
566}"#,
567            ),
568            (
569                "hive-detail.json",
570                r#"{
571  "method": "GET",
572  "path": "/api/v1/hives/{hiveId}",
573  "status": 200,
574  "response": {"id": "hive_001"}
575}"#,
576            ),
577            (
578                "user-profile.json",
579                r#"{
580  "method": "GET",
581  "path": "/api/v1/users/me",
582  "status": 200,
583  "response": {"id": "user_001"}
584}"#,
585            ),
586        ];
587
588        for (filename, content) in fixtures {
589            let fixture_file = fixtures_dir.join(filename);
590            fs::write(&fixture_file, content).await.unwrap();
591        }
592
593        // Load fixtures
594        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
595        loader.load_fixtures().await.unwrap();
596
597        // Verify all fixtures are loaded
598        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/apiaries")));
599        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/hives/hive_001")));
600        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/users/me")));
601    }
602
603    #[tokio::test]
604    async fn test_fixture_with_headers() {
605        let temp_dir = TempDir::new().unwrap();
606        let fixtures_dir = temp_dir.path().to_path_buf();
607
608        let fixture_content = r#"{
609  "method": "GET",
610  "path": "/api/v1/test",
611  "status": 201,
612  "response": {"result": "ok"},
613  "headers": {
614    "content-type": "application/json",
615    "x-custom-header": "test-value"
616  }
617}"#;
618
619        let fixture_file = fixtures_dir.join("test.json");
620        fs::write(&fixture_file, fixture_content).await.unwrap();
621
622        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
623        loader.load_fixtures().await.unwrap();
624
625        let fixture = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/test")).unwrap();
626
627        assert_eq!(fixture.status, 201);
628        assert_eq!(fixture.headers.get("content-type"), Some(&"application/json".to_string()));
629        assert_eq!(fixture.headers.get("x-custom-header"), Some(&"test-value".to_string()));
630    }
631
632    #[tokio::test]
633    async fn test_fixture_disabled() {
634        let temp_dir = TempDir::new().unwrap();
635        let fixtures_dir = temp_dir.path().to_path_buf();
636
637        let fixture_file = fixtures_dir.join("test.json");
638        fs::write(
639            &fixture_file,
640            r#"{"method": "GET", "path": "/test", "status": 200, "response": {}}"#,
641        )
642        .await
643        .unwrap();
644
645        let mut loader = CustomFixtureLoader::new(fixtures_dir, false);
646        loader.load_fixtures().await.unwrap();
647
648        // Should not find fixture when disabled
649        assert!(!loader.has_fixture(&create_test_fingerprint("GET", "/test")));
650        assert!(loader.load_fixture(&create_test_fingerprint("GET", "/test")).is_none());
651    }
652
653    #[tokio::test]
654    async fn test_load_nested_format() {
655        let temp_dir = TempDir::new().unwrap();
656        let fixtures_dir = temp_dir.path().to_path_buf();
657
658        // Create a nested format fixture
659        let fixture_content = r#"{
660          "request": {
661            "method": "POST",
662            "path": "/api/auth/login"
663          },
664          "response": {
665            "status": 200,
666            "headers": {
667              "Content-Type": "application/json"
668            },
669            "body": {
670              "access_token": "test_token",
671              "user": {
672                "id": "user_001"
673              }
674            }
675          }
676        }"#;
677
678        let fixture_file = fixtures_dir.join("auth-login.json");
679        fs::write(&fixture_file, fixture_content).await.unwrap();
680
681        // Load fixtures
682        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
683        loader.load_fixtures().await.unwrap();
684
685        // Check if fixture is loaded
686        let fingerprint = create_test_fingerprint("POST", "/api/auth/login");
687        assert!(loader.has_fixture(&fingerprint));
688
689        let fixture = loader.load_fixture(&fingerprint).unwrap();
690        assert_eq!(fixture.method, "POST");
691        assert_eq!(fixture.path, "/api/auth/login");
692        assert_eq!(fixture.status, 200);
693        assert_eq!(fixture.headers.get("Content-Type"), Some(&"application/json".to_string()));
694        assert!(fixture.response.get("access_token").is_some());
695    }
696
697    #[test]
698    fn test_path_normalization() {
699        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
700
701        // Test trailing slash removal
702        assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test/"), "/api/v1/test");
703        assert_eq!(CustomFixtureLoader::normalize_path("/api/v1/test"), "/api/v1/test");
704
705        // Root path should remain as /
706        assert_eq!(CustomFixtureLoader::normalize_path("/"), "/");
707
708        // Multiple slashes should be collapsed
709        assert_eq!(CustomFixtureLoader::normalize_path("/api//v1///test"), "/api/v1/test");
710
711        // Paths without leading slash should get one
712        assert_eq!(CustomFixtureLoader::normalize_path("api/v1/test"), "/api/v1/test");
713
714        // Whitespace should be trimmed
715        assert_eq!(CustomFixtureLoader::normalize_path(" /api/v1/test "), "/api/v1/test");
716    }
717
718    #[test]
719    fn test_path_matching_with_normalization() {
720        let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
721
722        // Test that trailing slashes don't prevent matching
723        assert!(loader.path_matches("/api/v1/test", "/api/v1/test/"));
724        assert!(loader.path_matches("/api/v1/test/", "/api/v1/test"));
725
726        // Test multiple slashes
727        assert!(loader.path_matches("/api/v1/test", "/api//v1///test"));
728
729        // Test path parameters still work
730        assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/"));
731    }
732
733    #[tokio::test]
734    async fn test_skip_template_files() {
735        let temp_dir = TempDir::new().unwrap();
736        let fixtures_dir = temp_dir.path().to_path_buf();
737
738        // Create a template file
739        let template_content = r#"{
740          "_comment": "This is a template",
741          "_usage": "Use this for errors",
742          "error": {
743            "code": "ERROR_CODE"
744          }
745        }"#;
746
747        let template_file = fixtures_dir.join("error-template.json");
748        fs::write(&template_file, template_content).await.unwrap();
749
750        // Create a valid fixture
751        let valid_fixture = r#"{
752          "method": "GET",
753          "path": "/api/test",
754          "status": 200,
755          "response": {}
756        }"#;
757        let valid_file = fixtures_dir.join("valid.json");
758        fs::write(&valid_file, valid_fixture).await.unwrap();
759
760        // Load fixtures
761        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
762        loader.load_fixtures().await.unwrap();
763
764        // Valid fixture should work
765        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
766
767        // Template file should be skipped (no fixture for a path that doesn't exist)
768        // We can't easily test this without accessing internal state, but the fact that
769        // the valid fixture loads means the template was skipped
770    }
771
772    #[tokio::test]
773    async fn test_skip_scenario_files() {
774        let temp_dir = TempDir::new().unwrap();
775        let fixtures_dir = temp_dir.path().to_path_buf();
776
777        // Create a scenario file (not a fixture)
778        let scenario_content = r#"{
779          "scenario": "demo",
780          "presentation_mode": true,
781          "apiaries": []
782        }"#;
783
784        let scenario_file = fixtures_dir.join("demo-scenario.json");
785        fs::write(&scenario_file, scenario_content).await.unwrap();
786
787        // Create a valid fixture
788        let valid_fixture = r#"{
789          "method": "GET",
790          "path": "/api/test",
791          "status": 200,
792          "response": {}
793        }"#;
794        let valid_file = fixtures_dir.join("valid.json");
795        fs::write(&valid_file, valid_fixture).await.unwrap();
796
797        // Load fixtures
798        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
799        loader.load_fixtures().await.unwrap();
800
801        // Valid fixture should work
802        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
803    }
804
805    #[tokio::test]
806    async fn test_mixed_format_fixtures() {
807        let temp_dir = TempDir::new().unwrap();
808        let fixtures_dir = temp_dir.path().to_path_buf();
809
810        // Create flat format fixture
811        let flat_fixture = r#"{
812          "method": "GET",
813          "path": "/api/v1/flat",
814          "status": 200,
815          "response": {"type": "flat"}
816        }"#;
817
818        // Create nested format fixture
819        let nested_fixture = r#"{
820          "request": {
821            "method": "GET",
822            "path": "/api/v1/nested"
823          },
824          "response": {
825            "status": 200,
826            "body": {"type": "nested"}
827          }
828        }"#;
829
830        fs::write(fixtures_dir.join("flat.json"), flat_fixture).await.unwrap();
831        fs::write(fixtures_dir.join("nested.json"), nested_fixture).await.unwrap();
832
833        // Load fixtures
834        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
835        loader.load_fixtures().await.unwrap();
836
837        // Both should work
838        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/flat")));
839        assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/nested")));
840
841        let flat = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/flat")).unwrap();
842        assert_eq!(flat.response.get("type").and_then(|v| v.as_str()), Some("flat"));
843
844        let nested =
845            loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/nested")).unwrap();
846        assert_eq!(nested.response.get("type").and_then(|v| v.as_str()), Some("nested"));
847    }
848
849    #[tokio::test]
850    async fn test_validation_errors() {
851        let temp_dir = TempDir::new().unwrap();
852        let fixtures_dir = temp_dir.path().to_path_buf();
853
854        // Test missing method
855        let no_method = r#"{
856          "path": "/api/test",
857          "status": 200,
858          "response": {}
859        }"#;
860        fs::write(fixtures_dir.join("no-method.json"), no_method).await.unwrap();
861
862        // Test invalid status code
863        let invalid_status = r#"{
864          "method": "GET",
865          "path": "/api/test",
866          "status": 999,
867          "response": {}
868        }"#;
869        fs::write(fixtures_dir.join("invalid-status.json"), invalid_status)
870            .await
871            .unwrap();
872
873        // Load fixtures - should handle errors gracefully
874        let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
875        let result = loader.load_fixtures().await;
876
877        // Should not crash, but should log warnings
878        assert!(result.is_ok());
879    }
880}