Skip to main content

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