Skip to main content

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