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