Skip to main content

mockforge_sdk/
stub.rs

1//! Response stub configuration
2
3use mockforge_core::ResourceIdExtract as CoreResourceIdExtract;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7use std::sync::Arc;
8use tokio::sync::RwLock;
9
10/// Type alias for a dynamic response function
11pub type DynamicResponseFn = Arc<dyn Fn(&RequestContext) -> Value + Send + Sync>;
12
13/// Request context passed to dynamic response functions
14#[derive(Debug, Clone)]
15pub struct RequestContext {
16    /// HTTP method
17    pub method: String,
18    /// Request path
19    pub path: String,
20    /// Path parameters extracted from the URL
21    pub path_params: HashMap<String, String>,
22    /// Query parameters
23    pub query_params: HashMap<String, String>,
24    /// Request headers
25    pub headers: HashMap<String, String>,
26    /// Request body
27    pub body: Option<Value>,
28}
29
30/// State machine configuration for stateful stub responses
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct StateMachineConfig {
33    /// Resource type identifier (e.g., "order", "user", "payment")
34    pub resource_type: String,
35    /// Resource ID extraction configuration
36    #[serde(flatten)]
37    pub resource_id_extract: ResourceIdExtractConfig,
38    /// Initial state name
39    pub initial_state: String,
40    /// State-based response mappings (state name -> response override)
41    /// If provided, responses will vary based on current state
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub state_responses: Option<HashMap<String, StateResponseOverride>>,
44}
45
46/// Resource ID extraction configuration for state machines
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(tag = "extract_type", rename_all = "snake_case")]
49pub enum ResourceIdExtractConfig {
50    /// Extract from path parameter (e.g., "/`orders/{order_id`}" -> extract "`order_id`")
51    PathParam {
52        /// Path parameter name to extract
53        param: String,
54    },
55    /// Extract from `JSONPath` in request body
56    JsonPath {
57        /// `JSONPath` expression to extract the resource ID
58        path: String,
59    },
60    /// Extract from header value
61    Header {
62        /// Header name to extract the resource ID from
63        name: String,
64    },
65    /// Extract from query parameter
66    QueryParam {
67        /// Query parameter name to extract
68        param: String,
69    },
70}
71
72/// State-based response override
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct StateResponseOverride {
75    /// Optional status code override (if None, uses stub's default status)
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub status: Option<u16>,
78    /// Optional body override (if None, uses stub's default body)
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub body: Option<Value>,
81    /// Optional headers override (merged with stub's default headers)
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub headers: Option<HashMap<String, String>>,
84}
85
86/// Fault injection configuration for per-stub error and latency simulation
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct StubFaultInjectionConfig {
89    /// Enable fault injection for this stub
90    #[serde(default)]
91    pub enabled: bool,
92    /// HTTP error codes to inject (randomly selected if multiple)
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub http_errors: Option<Vec<u16>>,
95    /// Probability of injecting HTTP error (0.0-1.0, default: 1.0 if `http_errors` set)
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub http_error_probability: Option<f64>,
98    /// Inject timeout error (returns 504 Gateway Timeout)
99    #[serde(default)]
100    pub timeout_error: bool,
101    /// Timeout duration in milliseconds (only used if `timeout_error` is true)
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub timeout_ms: Option<u64>,
104    /// Probability of timeout error (0.0-1.0, default: 1.0 if `timeout_error` is true)
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub timeout_probability: Option<f64>,
107    /// Inject connection error (returns 503 Service Unavailable)
108    #[serde(default)]
109    pub connection_error: bool,
110    /// Probability of connection error (0.0-1.0, default: 1.0 if `connection_error` is true)
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub connection_error_probability: Option<f64>,
113}
114
115impl StubFaultInjectionConfig {
116    /// Create a simple HTTP error injection config
117    #[must_use]
118    pub fn http_error(codes: Vec<u16>) -> Self {
119        Self {
120            enabled: true,
121            http_errors: Some(codes),
122            http_error_probability: Some(1.0),
123            ..Default::default()
124        }
125    }
126
127    /// Create a timeout error injection config
128    #[must_use]
129    pub fn timeout(ms: u64) -> Self {
130        Self {
131            enabled: true,
132            timeout_error: true,
133            timeout_ms: Some(ms),
134            timeout_probability: Some(1.0),
135            ..Default::default()
136        }
137    }
138
139    /// Create a connection error injection config
140    #[must_use]
141    pub fn connection_error() -> Self {
142        Self {
143            enabled: true,
144            connection_error: true,
145            connection_error_probability: Some(1.0),
146            ..Default::default()
147        }
148    }
149}
150
151/// A response stub for mocking API endpoints
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ResponseStub {
154    /// HTTP method (GET, POST, PUT, DELETE, etc.)
155    pub method: String,
156    /// Path pattern (supports {{`path_params`}})
157    pub path: String,
158    /// HTTP status code
159    pub status: u16,
160    /// Response headers
161    pub headers: HashMap<String, String>,
162    /// Response body (supports templates like {{uuid}}, {{faker.name}})
163    pub body: Value,
164    /// Optional latency in milliseconds
165    pub latency_ms: Option<u64>,
166    /// Optional state machine configuration for stateful behavior
167    /// When set, responses will vary based on resource state
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub state_machine: Option<StateMachineConfig>,
170    /// Optional fault injection configuration for error simulation
171    /// When set, can inject errors, timeouts, or connection failures
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub fault_injection: Option<StubFaultInjectionConfig>,
174}
175
176impl ResponseStub {
177    /// Create a new response stub
178    pub fn new(method: impl Into<String>, path: impl Into<String>, body: Value) -> Self {
179        Self {
180            method: method.into(),
181            path: path.into(),
182            status: 200,
183            headers: HashMap::new(),
184            body,
185            latency_ms: None,
186            state_machine: None,
187            fault_injection: None,
188        }
189    }
190
191    /// Set the HTTP status code
192    #[must_use]
193    pub const fn status(mut self, status: u16) -> Self {
194        self.status = status;
195        self
196    }
197
198    /// Add a response header
199    #[must_use]
200    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
201        self.headers.insert(key.into(), value.into());
202        self
203    }
204
205    /// Set response latency in milliseconds
206    #[must_use]
207    pub const fn latency(mut self, ms: u64) -> Self {
208        self.latency_ms = Some(ms);
209        self
210    }
211
212    /// Set state machine configuration for stateful behavior
213    #[must_use]
214    pub fn with_state_machine(mut self, config: StateMachineConfig) -> Self {
215        self.state_machine = Some(config);
216        self
217    }
218
219    /// Check if this stub has state machine configuration
220    #[must_use]
221    pub const fn has_state_machine(&self) -> bool {
222        self.state_machine.is_some()
223    }
224
225    /// Get state machine configuration
226    #[must_use]
227    pub const fn state_machine(&self) -> Option<&StateMachineConfig> {
228        self.state_machine.as_ref()
229    }
230
231    /// Apply state-based response override if state machine is configured
232    ///
233    /// This method checks if the stub has state machine configuration and applies
234    /// state-based response overrides based on the current state.
235    ///
236    /// Returns a modified stub with state-specific overrides applied, or the original
237    /// stub if no state machine config or no override for current state.
238    #[must_use]
239    pub fn apply_state_override(&self, current_state: &str) -> Self {
240        let mut stub = self.clone();
241
242        if let Some(ref state_machine) = self.state_machine {
243            if let Some(ref state_responses) = state_machine.state_responses {
244                if let Some(override_config) = state_responses.get(current_state) {
245                    // Apply status override
246                    if let Some(status) = override_config.status {
247                        stub.status = status;
248                    }
249
250                    // Apply body override
251                    if let Some(ref body) = override_config.body {
252                        stub.body = body.clone();
253                    }
254
255                    // Merge headers
256                    if let Some(ref headers) = override_config.headers {
257                        for (key, value) in headers {
258                            stub.headers.insert(key.clone(), value.clone());
259                        }
260                    }
261                }
262            }
263        }
264
265        stub
266    }
267
268    /// Set fault injection configuration
269    #[must_use]
270    pub fn with_fault_injection(mut self, config: StubFaultInjectionConfig) -> Self {
271        self.fault_injection = Some(config);
272        self
273    }
274
275    /// Check if this stub has fault injection configured
276    #[must_use]
277    pub fn has_fault_injection(&self) -> bool {
278        self.fault_injection.as_ref().is_some_and(|f| f.enabled)
279    }
280
281    /// Get fault injection configuration
282    #[must_use]
283    pub const fn fault_injection(&self) -> Option<&StubFaultInjectionConfig> {
284        self.fault_injection.as_ref()
285    }
286}
287
288impl ResourceIdExtractConfig {
289    /// Convert to core's `ResourceIdExtract` enum
290    #[must_use]
291    pub fn to_core(&self) -> CoreResourceIdExtract {
292        match self {
293            Self::PathParam { param } => CoreResourceIdExtract::PathParam {
294                param: param.clone(),
295            },
296            Self::JsonPath { path } => CoreResourceIdExtract::JsonPath { path: path.clone() },
297            Self::Header { name } => CoreResourceIdExtract::Header { name: name.clone() },
298            Self::QueryParam { param } => CoreResourceIdExtract::QueryParam {
299                param: param.clone(),
300            },
301        }
302    }
303}
304
305/// Dynamic stub with runtime response generation
306pub struct DynamicStub {
307    /// HTTP method
308    pub method: String,
309    /// Path pattern
310    pub path: String,
311    /// HTTP status code (can be dynamic)
312    pub status: Arc<RwLock<u16>>,
313    /// Response headers (can be dynamic)
314    pub headers: Arc<RwLock<HashMap<String, String>>>,
315    /// Dynamic response function
316    pub response_fn: DynamicResponseFn,
317    /// Optional latency in milliseconds
318    pub latency_ms: Option<u64>,
319}
320
321impl DynamicStub {
322    /// Create a new dynamic stub
323    pub fn new<F>(method: impl Into<String>, path: impl Into<String>, response_fn: F) -> Self
324    where
325        F: Fn(&RequestContext) -> Value + Send + Sync + 'static,
326    {
327        Self {
328            method: method.into(),
329            path: path.into(),
330            status: Arc::new(RwLock::new(200)),
331            headers: Arc::new(RwLock::new(HashMap::new())),
332            response_fn: Arc::new(response_fn),
333            latency_ms: None,
334        }
335    }
336
337    /// Set the HTTP status code
338    pub async fn set_status(&self, status: u16) {
339        *self.status.write().await = status;
340    }
341
342    /// Get the current status code
343    pub async fn get_status(&self) -> u16 {
344        *self.status.read().await
345    }
346
347    /// Add a response header
348    pub async fn add_header(&self, key: String, value: String) {
349        self.headers.write().await.insert(key, value);
350    }
351
352    /// Remove a response header
353    pub async fn remove_header(&self, key: &str) {
354        self.headers.write().await.remove(key);
355    }
356
357    /// Get all headers (returns a clone)
358    ///
359    /// For more efficient read-only access, consider using `with_headers()` instead.
360    pub async fn get_headers(&self) -> HashMap<String, String> {
361        self.headers.read().await.clone()
362    }
363
364    /// Access headers without cloning via a callback
365    ///
366    /// This is more efficient than `get_headers()` when you only need to
367    /// read header values without modifying them.
368    ///
369    /// # Examples
370    ///
371    /// ```rust
372    /// # use mockforge_sdk::DynamicStub;
373    /// # use serde_json::json;
374    /// # async fn example() {
375    /// let stub = DynamicStub::new("GET", "/test", |_| json!({}));
376    /// stub.add_header("X-Custom".to_string(), "value".to_string()).await;
377    ///
378    /// // Efficient read-only access
379    /// let has_custom = stub.with_headers(|headers| {
380    ///     headers.contains_key("X-Custom")
381    /// }).await;
382    /// # }
383    /// ```
384    pub async fn with_headers<F, R>(&self, f: F) -> R
385    where
386        F: FnOnce(&HashMap<String, String>) -> R,
387    {
388        let headers = self.headers.read().await;
389        f(&headers)
390    }
391
392    /// Generate a response for a given request context
393    #[must_use]
394    pub fn generate_response(&self, ctx: &RequestContext) -> Value {
395        (self.response_fn)(ctx)
396    }
397
398    /// Set latency
399    #[must_use]
400    pub const fn with_latency(mut self, ms: u64) -> Self {
401        self.latency_ms = Some(ms);
402        self
403    }
404}
405
406/// Builder for creating `ResponseStub` instances with a fluent API
407///
408/// Provides a convenient way to construct response stubs with method chaining.
409///
410/// # Examples
411///
412/// ```rust
413/// use mockforge_sdk::StubBuilder;
414/// use serde_json::json;
415///
416/// let stub = StubBuilder::new("GET", "/api/users")
417///     .status(200)
418///     .header("Content-Type", "application/json")
419///     .body(json!({"users": []}))
420///     .latency(100)
421///     .build();
422/// ```
423pub struct StubBuilder {
424    method: String,
425    path: String,
426    status: u16,
427    headers: HashMap<String, String>,
428    body: Option<Value>,
429    latency_ms: Option<u64>,
430    state_machine: Option<StateMachineConfig>,
431    fault_injection: Option<StubFaultInjectionConfig>,
432}
433
434impl StubBuilder {
435    /// Create a new stub builder
436    ///
437    /// # Arguments
438    /// * `method` - HTTP method (GET, POST, PUT, DELETE, etc.)
439    /// * `path` - URL path pattern
440    pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
441        Self {
442            method: method.into(),
443            path: path.into(),
444            status: 200,
445            headers: HashMap::new(),
446            body: None,
447            latency_ms: None,
448            state_machine: None,
449            fault_injection: None,
450        }
451    }
452
453    /// Set the HTTP status code
454    #[must_use]
455    pub const fn status(mut self, status: u16) -> Self {
456        self.status = status;
457        self
458    }
459
460    /// Add a response header
461    #[must_use]
462    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
463        self.headers.insert(key.into(), value.into());
464        self
465    }
466
467    /// Set the response body
468    #[must_use]
469    pub fn body(mut self, body: Value) -> Self {
470        self.body = Some(body);
471        self
472    }
473
474    /// Set response latency in milliseconds
475    #[must_use]
476    pub const fn latency(mut self, ms: u64) -> Self {
477        self.latency_ms = Some(ms);
478        self
479    }
480
481    /// Set state machine configuration
482    #[must_use]
483    pub fn state_machine(mut self, config: StateMachineConfig) -> Self {
484        self.state_machine = Some(config);
485        self
486    }
487
488    /// Set fault injection configuration
489    #[must_use]
490    pub fn fault_injection(mut self, config: StubFaultInjectionConfig) -> Self {
491        self.fault_injection = Some(config);
492        self
493    }
494
495    /// Build the response stub
496    #[must_use]
497    pub fn build(self) -> ResponseStub {
498        ResponseStub {
499            method: self.method,
500            path: self.path,
501            status: self.status,
502            headers: self.headers,
503            body: self.body.unwrap_or(Value::Null),
504            latency_ms: self.latency_ms,
505            state_machine: self.state_machine,
506            fault_injection: self.fault_injection,
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use serde_json::json;
515
516    // ==================== RequestContext Tests ====================
517
518    #[test]
519    fn test_request_context_creation() {
520        let ctx = RequestContext {
521            method: "GET".to_string(),
522            path: "/api/users".to_string(),
523            path_params: HashMap::from([("id".to_string(), "123".to_string())]),
524            query_params: HashMap::from([("page".to_string(), "1".to_string())]),
525            headers: HashMap::from([("content-type".to_string(), "application/json".to_string())]),
526            body: Some(json!({"name": "test"})),
527        };
528
529        assert_eq!(ctx.method, "GET");
530        assert_eq!(ctx.path, "/api/users");
531        assert_eq!(ctx.path_params.get("id"), Some(&"123".to_string()));
532    }
533
534    #[test]
535    fn test_request_context_clone() {
536        let ctx = RequestContext {
537            method: "POST".to_string(),
538            path: "/api/users".to_string(),
539            path_params: HashMap::new(),
540            query_params: HashMap::new(),
541            headers: HashMap::new(),
542            body: None,
543        };
544
545        let cloned = ctx.clone();
546        assert_eq!(ctx.method, cloned.method);
547        assert_eq!(ctx.path, cloned.path);
548    }
549
550    // ==================== StateMachineConfig Tests ====================
551
552    #[test]
553    fn test_state_machine_config_serialize() {
554        let config = StateMachineConfig {
555            resource_type: "order".to_string(),
556            resource_id_extract: ResourceIdExtractConfig::PathParam {
557                param: "order_id".to_string(),
558            },
559            initial_state: "pending".to_string(),
560            state_responses: None,
561        };
562
563        let json = serde_json::to_string(&config).unwrap();
564        assert!(json.contains("order"));
565        assert!(json.contains("pending"));
566    }
567
568    #[test]
569    fn test_state_machine_config_with_responses() {
570        let mut responses = HashMap::new();
571        responses.insert(
572            "confirmed".to_string(),
573            StateResponseOverride {
574                status: Some(200),
575                body: Some(json!({"status": "confirmed"})),
576                headers: None,
577            },
578        );
579
580        let config = StateMachineConfig {
581            resource_type: "order".to_string(),
582            resource_id_extract: ResourceIdExtractConfig::PathParam {
583                param: "order_id".to_string(),
584            },
585            initial_state: "pending".to_string(),
586            state_responses: Some(responses),
587        };
588
589        assert!(config.state_responses.is_some());
590    }
591
592    // ==================== ResourceIdExtractConfig Tests ====================
593
594    #[test]
595    fn test_resource_id_extract_path_param() {
596        let config = ResourceIdExtractConfig::PathParam {
597            param: "user_id".to_string(),
598        };
599        let core = config.to_core();
600        match core {
601            CoreResourceIdExtract::PathParam { param } => assert_eq!(param, "user_id"),
602            _ => panic!("Expected PathParam"),
603        }
604    }
605
606    #[test]
607    fn test_resource_id_extract_json_path() {
608        let config = ResourceIdExtractConfig::JsonPath {
609            path: "$.data.id".to_string(),
610        };
611        let core = config.to_core();
612        match core {
613            CoreResourceIdExtract::JsonPath { path } => assert_eq!(path, "$.data.id"),
614            _ => panic!("Expected JsonPath"),
615        }
616    }
617
618    #[test]
619    fn test_resource_id_extract_header() {
620        let config = ResourceIdExtractConfig::Header {
621            name: "X-Resource-Id".to_string(),
622        };
623        let core = config.to_core();
624        match core {
625            CoreResourceIdExtract::Header { name } => assert_eq!(name, "X-Resource-Id"),
626            _ => panic!("Expected Header"),
627        }
628    }
629
630    #[test]
631    fn test_resource_id_extract_query_param() {
632        let config = ResourceIdExtractConfig::QueryParam {
633            param: "id".to_string(),
634        };
635        let core = config.to_core();
636        match core {
637            CoreResourceIdExtract::QueryParam { param } => assert_eq!(param, "id"),
638            _ => panic!("Expected QueryParam"),
639        }
640    }
641
642    // ==================== StateResponseOverride Tests ====================
643
644    #[test]
645    fn test_state_response_override_status_only() {
646        let override_config = StateResponseOverride {
647            status: Some(404),
648            body: None,
649            headers: None,
650        };
651        assert_eq!(override_config.status, Some(404));
652    }
653
654    #[test]
655    fn test_state_response_override_full() {
656        let mut headers = HashMap::new();
657        headers.insert("X-Custom".to_string(), "value".to_string());
658
659        let override_config = StateResponseOverride {
660            status: Some(200),
661            body: Some(json!({"data": "test"})),
662            headers: Some(headers),
663        };
664
665        assert_eq!(override_config.status, Some(200));
666        assert!(override_config.body.is_some());
667        assert!(override_config.headers.is_some());
668    }
669
670    // ==================== StubFaultInjectionConfig Tests ====================
671
672    #[test]
673    fn test_stub_fault_injection_default() {
674        let config = StubFaultInjectionConfig::default();
675        assert!(!config.enabled);
676        assert!(config.http_errors.is_none());
677        assert!(!config.timeout_error);
678        assert!(!config.connection_error);
679    }
680
681    #[test]
682    fn test_stub_fault_injection_http_error() {
683        let config = StubFaultInjectionConfig::http_error(vec![500, 502, 503]);
684        assert!(config.enabled);
685        assert_eq!(config.http_errors, Some(vec![500, 502, 503]));
686        assert_eq!(config.http_error_probability, Some(1.0));
687    }
688
689    #[test]
690    fn test_stub_fault_injection_timeout() {
691        let config = StubFaultInjectionConfig::timeout(5000);
692        assert!(config.enabled);
693        assert!(config.timeout_error);
694        assert_eq!(config.timeout_ms, Some(5000));
695        assert_eq!(config.timeout_probability, Some(1.0));
696    }
697
698    #[test]
699    fn test_stub_fault_injection_connection_error() {
700        let config = StubFaultInjectionConfig::connection_error();
701        assert!(config.enabled);
702        assert!(config.connection_error);
703        assert_eq!(config.connection_error_probability, Some(1.0));
704    }
705
706    // ==================== ResponseStub Tests ====================
707
708    #[test]
709    fn test_response_stub_new() {
710        let stub = ResponseStub::new("GET", "/api/users", json!({"users": []}));
711        assert_eq!(stub.method, "GET");
712        assert_eq!(stub.path, "/api/users");
713        assert_eq!(stub.status, 200);
714        assert!(stub.headers.is_empty());
715        assert!(stub.latency_ms.is_none());
716    }
717
718    #[test]
719    fn test_response_stub_status() {
720        let stub = ResponseStub::new("GET", "/api/users", json!({})).status(404);
721        assert_eq!(stub.status, 404);
722    }
723
724    #[test]
725    fn test_response_stub_header() {
726        let stub = ResponseStub::new("GET", "/api/users", json!({}))
727            .header("Content-Type", "application/json")
728            .header("X-Custom", "value");
729
730        assert_eq!(stub.headers.get("Content-Type"), Some(&"application/json".to_string()));
731        assert_eq!(stub.headers.get("X-Custom"), Some(&"value".to_string()));
732    }
733
734    #[test]
735    fn test_response_stub_latency() {
736        let stub = ResponseStub::new("GET", "/api/users", json!({})).latency(100);
737        assert_eq!(stub.latency_ms, Some(100));
738    }
739
740    #[test]
741    fn test_response_stub_with_state_machine() {
742        let state_config = StateMachineConfig {
743            resource_type: "user".to_string(),
744            resource_id_extract: ResourceIdExtractConfig::PathParam {
745                param: "user_id".to_string(),
746            },
747            initial_state: "active".to_string(),
748            state_responses: None,
749        };
750
751        let stub = ResponseStub::new("GET", "/api/users/{user_id}", json!({}))
752            .with_state_machine(state_config);
753
754        assert!(stub.has_state_machine());
755        assert!(stub.state_machine().is_some());
756    }
757
758    #[test]
759    fn test_response_stub_no_state_machine() {
760        let stub = ResponseStub::new("GET", "/api/users", json!({}));
761        assert!(!stub.has_state_machine());
762        assert!(stub.state_machine().is_none());
763    }
764
765    #[test]
766    fn test_response_stub_apply_state_override() {
767        let mut state_responses = HashMap::new();
768        state_responses.insert(
769            "inactive".to_string(),
770            StateResponseOverride {
771                status: Some(403),
772                body: Some(json!({"error": "User is inactive"})),
773                headers: Some(HashMap::from([("X-State".to_string(), "inactive".to_string())])),
774            },
775        );
776
777        let state_config = StateMachineConfig {
778            resource_type: "user".to_string(),
779            resource_id_extract: ResourceIdExtractConfig::PathParam {
780                param: "user_id".to_string(),
781            },
782            initial_state: "active".to_string(),
783            state_responses: Some(state_responses),
784        };
785
786        let stub = ResponseStub::new("GET", "/api/users/{user_id}", json!({"status": "ok"}))
787            .with_state_machine(state_config);
788
789        // Apply inactive state override
790        let overridden = stub.apply_state_override("inactive");
791        assert_eq!(overridden.status, 403);
792        assert_eq!(overridden.body, json!({"error": "User is inactive"}));
793        assert_eq!(overridden.headers.get("X-State"), Some(&"inactive".to_string()));
794    }
795
796    #[test]
797    fn test_response_stub_apply_state_override_no_match() {
798        let state_config = StateMachineConfig {
799            resource_type: "user".to_string(),
800            resource_id_extract: ResourceIdExtractConfig::PathParam {
801                param: "user_id".to_string(),
802            },
803            initial_state: "active".to_string(),
804            state_responses: None,
805        };
806
807        let stub = ResponseStub::new("GET", "/api/users/{user_id}", json!({"original": true}))
808            .status(200)
809            .with_state_machine(state_config);
810
811        // State override with no matching state should return original
812        let overridden = stub.apply_state_override("unknown");
813        assert_eq!(overridden.status, 200);
814        assert_eq!(overridden.body, json!({"original": true}));
815    }
816
817    #[test]
818    fn test_response_stub_with_fault_injection() {
819        let fault_config = StubFaultInjectionConfig::http_error(vec![500]);
820        let stub =
821            ResponseStub::new("GET", "/api/users", json!({})).with_fault_injection(fault_config);
822
823        assert!(stub.has_fault_injection());
824        assert!(stub.fault_injection().is_some());
825    }
826
827    #[test]
828    fn test_response_stub_no_fault_injection() {
829        let stub = ResponseStub::new("GET", "/api/users", json!({}));
830        assert!(!stub.has_fault_injection());
831    }
832
833    #[test]
834    fn test_response_stub_serialize() {
835        let stub = ResponseStub::new("POST", "/api/orders", json!({"id": 1}))
836            .status(201)
837            .header("Location", "/api/orders/1")
838            .latency(50);
839
840        let json = serde_json::to_string(&stub).unwrap();
841        assert!(json.contains("POST"));
842        assert!(json.contains("/api/orders"));
843        assert!(json.contains("201"));
844    }
845
846    // ==================== DynamicStub Tests ====================
847
848    #[test]
849    fn test_dynamic_stub_new() {
850        let stub = DynamicStub::new("GET", "/api/users", |ctx| json!({"path": ctx.path.clone()}));
851
852        assert_eq!(stub.method, "GET");
853        assert_eq!(stub.path, "/api/users");
854    }
855
856    #[tokio::test]
857    async fn test_dynamic_stub_status() {
858        let stub = DynamicStub::new("GET", "/test", |_| json!({}));
859        assert_eq!(stub.get_status().await, 200);
860
861        stub.set_status(404).await;
862        assert_eq!(stub.get_status().await, 404);
863    }
864
865    #[tokio::test]
866    async fn test_dynamic_stub_headers() {
867        let stub = DynamicStub::new("GET", "/test", |_| json!({}));
868
869        stub.add_header("X-Custom".to_string(), "value".to_string()).await;
870
871        let headers = stub.get_headers().await;
872        assert_eq!(headers.get("X-Custom"), Some(&"value".to_string()));
873
874        stub.remove_header("X-Custom").await;
875        let headers = stub.get_headers().await;
876        assert!(!headers.contains_key("X-Custom"));
877    }
878
879    #[tokio::test]
880    async fn test_dynamic_stub_with_headers() {
881        let stub = DynamicStub::new("GET", "/test", |_| json!({}));
882        stub.add_header("X-Test".to_string(), "test-value".to_string()).await;
883
884        let has_header = stub.with_headers(|headers| headers.contains_key("X-Test")).await;
885        assert!(has_header);
886    }
887
888    #[test]
889    fn test_dynamic_stub_generate_response() {
890        let stub = DynamicStub::new("GET", "/api/users/{id}", |ctx| {
891            let id = ctx.path_params.get("id").cloned().unwrap_or_default();
892            json!({"user_id": id})
893        });
894
895        let ctx = RequestContext {
896            method: "GET".to_string(),
897            path: "/api/users/123".to_string(),
898            path_params: HashMap::from([("id".to_string(), "123".to_string())]),
899            query_params: HashMap::new(),
900            headers: HashMap::new(),
901            body: None,
902        };
903
904        let response = stub.generate_response(&ctx);
905        assert_eq!(response, json!({"user_id": "123"}));
906    }
907
908    #[test]
909    fn test_dynamic_stub_with_latency() {
910        let stub = DynamicStub::new("GET", "/test", |_| json!({})).with_latency(100);
911        assert_eq!(stub.latency_ms, Some(100));
912    }
913
914    // ==================== StubBuilder Tests ====================
915
916    #[test]
917    fn test_stub_builder_basic() {
918        let stub = StubBuilder::new("GET", "/api/users").body(json!({"users": []})).build();
919
920        assert_eq!(stub.method, "GET");
921        assert_eq!(stub.path, "/api/users");
922        assert_eq!(stub.status, 200);
923    }
924
925    #[test]
926    fn test_stub_builder_status() {
927        let stub = StubBuilder::new("GET", "/api/users").status(404).build();
928
929        assert_eq!(stub.status, 404);
930    }
931
932    #[test]
933    fn test_stub_builder_headers() {
934        let stub = StubBuilder::new("GET", "/api/users")
935            .header("Content-Type", "application/json")
936            .header("X-Custom", "value")
937            .build();
938
939        assert_eq!(stub.headers.len(), 2);
940    }
941
942    #[test]
943    fn test_stub_builder_latency() {
944        let stub = StubBuilder::new("GET", "/api/users").latency(500).build();
945
946        assert_eq!(stub.latency_ms, Some(500));
947    }
948
949    #[test]
950    fn test_stub_builder_state_machine() {
951        let config = StateMachineConfig {
952            resource_type: "order".to_string(),
953            resource_id_extract: ResourceIdExtractConfig::PathParam {
954                param: "order_id".to_string(),
955            },
956            initial_state: "pending".to_string(),
957            state_responses: None,
958        };
959
960        let stub = StubBuilder::new("GET", "/api/orders/{order_id}").state_machine(config).build();
961
962        assert!(stub.state_machine.is_some());
963    }
964
965    #[test]
966    fn test_stub_builder_fault_injection() {
967        let fault = StubFaultInjectionConfig::http_error(vec![500]);
968
969        let stub = StubBuilder::new("GET", "/api/users").fault_injection(fault).build();
970
971        assert!(stub.fault_injection.is_some());
972    }
973
974    #[test]
975    fn test_stub_builder_full_chain() {
976        let stub = StubBuilder::new("POST", "/api/orders")
977            .status(201)
978            .header("Location", "/api/orders/1")
979            .body(json!({"id": 1, "status": "created"}))
980            .latency(100)
981            .build();
982
983        assert_eq!(stub.method, "POST");
984        assert_eq!(stub.path, "/api/orders");
985        assert_eq!(stub.status, 201);
986        assert_eq!(stub.headers.get("Location"), Some(&"/api/orders/1".to_string()));
987        assert_eq!(stub.latency_ms, Some(100));
988    }
989}