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