1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct CustomFixture {
48 pub method: String,
50 pub path: String,
52 pub status: u16,
54 #[serde(default)]
56 pub response: Value,
57 #[serde(default)]
59 pub headers: HashMap<String, String>,
60 #[serde(default)]
62 pub delay_ms: u64,
63}
64
65#[derive(Debug, Deserialize)]
67pub struct NestedFixture {
68 pub request: Option<NestedRequest>,
70 pub response: Option<NestedResponse>,
72}
73
74#[derive(Debug, Deserialize)]
76pub struct NestedRequest {
77 pub method: String,
79 pub path: String,
81}
82
83#[derive(Debug, Deserialize)]
85pub struct NestedResponse {
86 pub status: u16,
88 #[serde(default)]
90 pub headers: HashMap<String, String>,
91 pub body: Value,
93}
94
95#[derive(Debug)]
97enum LoadResult {
98 Loaded,
99 Skipped,
100}
101
102pub struct CustomFixtureLoader {
104 fixtures_dir: PathBuf,
106 enabled: bool,
108 fixtures: HashMap<String, HashMap<String, CustomFixture>>,
110 stats: LoadStats,
112}
113
114#[derive(Debug, Default)]
116struct LoadStats {
117 loaded: usize,
118 failed: usize,
119 skipped: usize,
120}
121
122impl CustomFixtureLoader {
123 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 pub fn normalize_path(path: &str) -> String {
136 let mut normalized = path.trim().to_string();
137
138 if let Some(query_start) = normalized.find('?') {
140 normalized = normalized[..query_start].to_string();
141 }
142
143 while normalized.contains("//") {
145 normalized = normalized.replace("//", "/");
146 }
147
148 if normalized.len() > 1 && normalized.ends_with('/') {
150 normalized.pop();
151 }
152
153 if !normalized.starts_with('/') {
155 normalized = format!("/{}", normalized);
156 }
157
158 normalized
159 }
160
161 pub fn should_skip_file(content: &str) -> bool {
163 if content.contains("\"_comment\"") || content.contains("\"_usage\"") {
165 return true;
166 }
167
168 if content.contains("\"scenario\"") || content.contains("\"presentation_mode\"") {
170 return true;
171 }
172
173 false
174 }
175
176 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 pub fn validate_fixture(fixture: &CustomFixture, file_path: &Path) -> Result<()> {
198 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 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 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 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 self.stats = LoadStats::default();
254
255 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 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 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 if Self::should_skip_file(&content) {
308 tracing::debug!("Skipping template file: {}", path.display());
309 return Ok(LoadResult::Skipped);
310 }
311
312 let fixture = match serde_json::from_str::<CustomFixture>(&content) {
314 Ok(mut fixture) => {
315 fixture.path = Self::normalize_path(&fixture.path);
317 fixture
318 }
319 Err(_) => {
320 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 Self::convert_nested_to_flat(nested)?
331 }
332 };
333
334 Self::validate_fixture(&fixture, path)?;
336
337 let method = fixture.method.to_uppercase();
339 let fixtures_by_method = self.fixtures.entry(method.clone()).or_default();
340
341 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 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 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 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 let request_path = Self::normalize_path(&fingerprint.path);
381
382 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 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 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 fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
420 let normalized_pattern = Self::normalize_path(pattern);
422 let normalized_request = Self::normalize_path(request_path);
423
424 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 for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
437 if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
439 continue;
440 }
441 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 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
510 loader.load_fixtures().await.unwrap();
511
512 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 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
545 loader.load_fixtures().await.unwrap();
546
547 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 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
598 loader.load_fixtures().await.unwrap();
599
600 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 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 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
686 loader.load_fixtures().await.unwrap();
687
688 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 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 assert_eq!(CustomFixtureLoader::normalize_path("/"), "/");
710
711 assert_eq!(CustomFixtureLoader::normalize_path("/api//v1///test"), "/api/v1/test");
713
714 assert_eq!(CustomFixtureLoader::normalize_path("api/v1/test"), "/api/v1/test");
716
717 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 assert!(loader.path_matches("/api/v1/test", "/api/v1/test/"));
727 assert!(loader.path_matches("/api/v1/test/", "/api/v1/test"));
728
729 assert!(loader.path_matches("/api/v1/test", "/api//v1///test"));
731
732 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 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 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
765 loader.load_fixtures().await.unwrap();
766
767 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/test")));
769
770 }
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 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 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
802 loader.load_fixtures().await.unwrap();
803
804 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 let flat_fixture = r#"{
815 "method": "GET",
816 "path": "/api/v1/flat",
817 "status": 200,
818 "response": {"type": "flat"}
819 }"#;
820
821 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
838 loader.load_fixtures().await.unwrap();
839
840 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 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 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 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
878 let result = loader.load_fixtures().await;
879
880 assert!(result.is_ok());
882 }
883}