1use crate::{Error, RequestFingerprint, Result};
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use tokio::fs;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CustomFixture {
27 pub method: String,
29 pub path: String,
31 pub status: u16,
33 #[serde(default)]
35 pub response: serde_json::Value,
36 #[serde(default)]
38 pub headers: HashMap<String, String>,
39 #[serde(default)]
41 pub delay_ms: u64,
42}
43
44pub struct CustomFixtureLoader {
46 fixtures_dir: PathBuf,
48 enabled: bool,
50 fixtures: HashMap<String, HashMap<String, CustomFixture>>,
52}
53
54impl CustomFixtureLoader {
55 pub fn new(fixtures_dir: PathBuf, enabled: bool) -> Self {
57 Self {
58 fixtures_dir,
59 enabled,
60 fixtures: HashMap::new(),
61 }
62 }
63
64 pub async fn load_fixtures(&mut self) -> Result<()> {
66 if !self.enabled {
67 return Ok(());
68 }
69
70 if !self.fixtures_dir.exists() {
71 tracing::debug!(
72 "Custom fixtures directory does not exist: {}",
73 self.fixtures_dir.display()
74 );
75 return Ok(());
76 }
77
78 let mut entries = fs::read_dir(&self.fixtures_dir).await.map_err(|e| {
80 Error::generic(format!(
81 "Failed to read fixtures directory {}: {}",
82 self.fixtures_dir.display(),
83 e
84 ))
85 })?;
86
87 let mut loaded_count = 0;
88 while let Some(entry) = entries
89 .next_entry()
90 .await
91 .map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?
92 {
93 let path = entry.path();
94 if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
95 if let Err(e) = self.load_fixture_file(&path).await {
96 tracing::warn!("Failed to load fixture file {}: {}", path.display(), e);
97 } else {
98 loaded_count += 1;
99 }
100 }
101 }
102
103 tracing::info!(
104 "Loaded {} custom fixtures from {}",
105 loaded_count,
106 self.fixtures_dir.display()
107 );
108
109 Ok(())
110 }
111
112 async fn load_fixture_file(&mut self, path: &Path) -> Result<()> {
114 let content = fs::read_to_string(path).await.map_err(|e| {
115 Error::generic(format!("Failed to read fixture file {}: {}", path.display(), e))
116 })?;
117
118 let fixture: CustomFixture = serde_json::from_str(&content).map_err(|e| {
119 Error::generic(format!("Failed to parse fixture file {}: {}", path.display(), e))
120 })?;
121
122 if fixture.method.is_empty() || fixture.path.is_empty() {
124 return Err(Error::generic(format!(
125 "Invalid fixture in {}: method and path are required",
126 path.display()
127 )));
128 }
129
130 let method = fixture.method.to_uppercase();
132 let fixtures_by_method = self.fixtures.entry(method).or_default();
133 fixtures_by_method.insert(fixture.path.clone(), fixture);
134
135 Ok(())
136 }
137
138 pub fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
140 if !self.enabled {
141 return false;
142 }
143
144 self.find_matching_fixture(fingerprint).is_some()
145 }
146
147 pub fn load_fixture(&self, fingerprint: &RequestFingerprint) -> Option<CustomFixture> {
149 if !self.enabled {
150 return None;
151 }
152
153 self.find_matching_fixture(fingerprint).cloned()
154 }
155
156 fn find_matching_fixture(&self, fingerprint: &RequestFingerprint) -> Option<&CustomFixture> {
158 let method = fingerprint.method.to_uppercase();
159 let fixtures_by_method = self.fixtures.get(&method)?;
160
161 let request_path = &fingerprint.path;
162
163 if let Some(fixture) = fixtures_by_method.get(request_path) {
165 return Some(fixture);
166 }
167
168 for (pattern, fixture) in fixtures_by_method.iter() {
170 if self.path_matches(pattern, request_path) {
171 return Some(fixture);
172 }
173 }
174
175 None
176 }
177
178 fn path_matches(&self, pattern: &str, request_path: &str) -> bool {
184 let pattern_segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
187 let request_segments: Vec<&str> =
188 request_path.split('/').filter(|s| !s.is_empty()).collect();
189
190 if pattern_segments.len() != request_segments.len() {
191 return false;
192 }
193
194 for (pattern_seg, request_seg) in pattern_segments.iter().zip(request_segments.iter()) {
196 if pattern_seg.starts_with('{') && pattern_seg.ends_with('}') {
198 continue;
199 }
200 if pattern_seg != request_seg {
202 return false;
203 }
204 }
205
206 true
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use axum::http::{HeaderMap, Method, Uri};
214 use tempfile::TempDir;
215
216 fn create_test_fingerprint(method: &str, path: &str) -> RequestFingerprint {
217 let method = Method::from_bytes(method.as_bytes()).unwrap();
218 let uri: Uri = path.parse().unwrap();
219 RequestFingerprint::new(method, &uri, &HeaderMap::new(), None)
220 }
221
222 #[test]
223 fn test_path_matching_exact() {
224 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
225 assert!(loader.path_matches("/api/v1/apiaries", "/api/v1/apiaries"));
226 assert!(!loader.path_matches("/api/v1/apiaries", "/api/v1/hives"));
227 }
228
229 #[test]
230 fn test_path_matching_with_parameters() {
231 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
232 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001"));
233 assert!(loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/123"));
234 assert!(
235 !loader.path_matches("/api/v1/hives/{hiveId}", "/api/v1/hives/hive_001/inspections")
236 );
237 }
238
239 #[test]
240 fn test_path_matching_multiple_parameters() {
241 let loader = CustomFixtureLoader::new(PathBuf::from("/tmp"), true);
242 assert!(loader.path_matches(
243 "/api/v1/apiaries/{apiaryId}/hives/{hiveId}",
244 "/api/v1/apiaries/apiary_001/hives/hive_001"
245 ));
246 }
247
248 #[tokio::test]
249 async fn test_load_fixture() {
250 let temp_dir = TempDir::new().unwrap();
251 let fixtures_dir = temp_dir.path().to_path_buf();
252
253 let fixture_content = r#"{
255 "method": "GET",
256 "path": "/api/v1/apiaries",
257 "status": 200,
258 "response": {
259 "success": true,
260 "data": []
261 }
262}"#;
263
264 let fixture_file = fixtures_dir.join("apiaries-list.json");
265 fs::write(&fixture_file, fixture_content).await.unwrap();
266
267 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
269 loader.load_fixtures().await.unwrap();
270
271 let fingerprint = create_test_fingerprint("GET", "/api/v1/apiaries");
273 assert!(loader.has_fixture(&fingerprint));
274
275 let fixture = loader.load_fixture(&fingerprint).unwrap();
276 assert_eq!(fixture.method, "GET");
277 assert_eq!(fixture.path, "/api/v1/apiaries");
278 assert_eq!(fixture.status, 200);
279 }
280
281 #[tokio::test]
282 async fn test_load_fixture_with_path_parameter() {
283 let temp_dir = TempDir::new().unwrap();
284 let fixtures_dir = temp_dir.path().to_path_buf();
285
286 let fixture_content = r#"{
288 "method": "GET",
289 "path": "/api/v1/hives/{hiveId}",
290 "status": 200,
291 "response": {
292 "success": true,
293 "data": {
294 "id": "hive_001"
295 }
296 }
297}"#;
298
299 let fixture_file = fixtures_dir.join("hive-detail.json");
300 fs::write(&fixture_file, fixture_content).await.unwrap();
301
302 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
304 loader.load_fixtures().await.unwrap();
305
306 let fingerprint = create_test_fingerprint("GET", "/api/v1/hives/hive_001");
308 assert!(loader.has_fixture(&fingerprint));
309
310 let fixture = loader.load_fixture(&fingerprint).unwrap();
311 assert_eq!(fixture.path, "/api/v1/hives/{hiveId}");
312 }
313
314 #[tokio::test]
315 async fn test_load_multiple_fixtures() {
316 let temp_dir = TempDir::new().unwrap();
317 let fixtures_dir = temp_dir.path().to_path_buf();
318
319 let fixtures = vec![
321 (
322 "apiaries-list.json",
323 r#"{
324 "method": "GET",
325 "path": "/api/v1/apiaries",
326 "status": 200,
327 "response": {"items": []}
328}"#,
329 ),
330 (
331 "hive-detail.json",
332 r#"{
333 "method": "GET",
334 "path": "/api/v1/hives/{hiveId}",
335 "status": 200,
336 "response": {"id": "hive_001"}
337}"#,
338 ),
339 (
340 "user-profile.json",
341 r#"{
342 "method": "GET",
343 "path": "/api/v1/users/me",
344 "status": 200,
345 "response": {"id": "user_001"}
346}"#,
347 ),
348 ];
349
350 for (filename, content) in fixtures {
351 let fixture_file = fixtures_dir.join(filename);
352 fs::write(&fixture_file, content).await.unwrap();
353 }
354
355 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
357 loader.load_fixtures().await.unwrap();
358
359 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/apiaries")));
361 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/hives/hive_001")));
362 assert!(loader.has_fixture(&create_test_fingerprint("GET", "/api/v1/users/me")));
363 }
364
365 #[tokio::test]
366 async fn test_fixture_with_headers() {
367 let temp_dir = TempDir::new().unwrap();
368 let fixtures_dir = temp_dir.path().to_path_buf();
369
370 let fixture_content = r#"{
371 "method": "GET",
372 "path": "/api/v1/test",
373 "status": 201,
374 "response": {"result": "ok"},
375 "headers": {
376 "content-type": "application/json",
377 "x-custom-header": "test-value"
378 }
379}"#;
380
381 let fixture_file = fixtures_dir.join("test.json");
382 fs::write(&fixture_file, fixture_content).await.unwrap();
383
384 let mut loader = CustomFixtureLoader::new(fixtures_dir, true);
385 loader.load_fixtures().await.unwrap();
386
387 let fixture = loader.load_fixture(&create_test_fingerprint("GET", "/api/v1/test")).unwrap();
388
389 assert_eq!(fixture.status, 201);
390 assert_eq!(fixture.headers.get("content-type"), Some(&"application/json".to_string()));
391 assert_eq!(fixture.headers.get("x-custom-header"), Some(&"test-value".to_string()));
392 }
393
394 #[tokio::test]
395 async fn test_fixture_disabled() {
396 let temp_dir = TempDir::new().unwrap();
397 let fixtures_dir = temp_dir.path().to_path_buf();
398
399 let fixture_file = fixtures_dir.join("test.json");
400 fs::write(
401 &fixture_file,
402 r#"{"method": "GET", "path": "/test", "status": 200, "response": {}}"#,
403 )
404 .await
405 .unwrap();
406
407 let mut loader = CustomFixtureLoader::new(fixtures_dir, false);
408 loader.load_fixtures().await.unwrap();
409
410 assert!(!loader.has_fixture(&create_test_fingerprint("GET", "/test")));
412 assert!(loader.load_fixture(&create_test_fingerprint("GET", "/test")).is_none());
413 }
414}