Skip to main content

mockforge_http/management/
mod.rs

1/// Management API for MockForge
2///
3/// Provides REST endpoints for controlling mocks, server configuration,
4/// and integration with developer tools (VS Code extension, CI/CD, etc.)
5mod ai_gen;
6mod chaos_admin;
7mod conformance;
8mod health;
9mod import_export;
10mod migration;
11mod mocks;
12mod protocols;
13mod proxy;
14mod rule_explanations;
15mod traffic_to_openapi;
16
17// `ai_gen.rs` was split into four topic files under #656; the route
18// wiring below pulls handlers from each via these glob re-exports.
19pub use ai_gen::*;
20pub use chaos_admin::*;
21pub(crate) use conformance::{clear_conformance_violations, get_conformance_violations};
22pub use health::*;
23pub use import_export::*;
24pub use proxy::{BodyTransformRequest, ProxyRuleRequest, ProxyRuleResponse};
25pub use rule_explanations::*;
26pub use traffic_to_openapi::*;
27
28use axum::{
29    body::Body,
30    extract::State,
31    http::{HeaderName, HeaderValue, Request, StatusCode},
32    response::{IntoResponse, Response},
33    routing::{delete, get, post, put},
34    Router,
35};
36use mockforge_openapi::OpenApiSpec;
37use mockforge_proxy::config::ProxyConfig;
38use serde::{Deserialize, Serialize};
39use std::sync::Arc;
40use tokio::sync::{broadcast, RwLock};
41
42/// Default broadcast channel capacity for message events
43#[cfg(any(feature = "mqtt", feature = "kafka"))]
44const DEFAULT_MESSAGE_BROADCAST_CAPACITY: usize = 1000;
45
46/// Get the broadcast channel capacity from environment or use default
47#[cfg(any(feature = "mqtt", feature = "kafka"))]
48fn get_message_broadcast_capacity() -> usize {
49    std::env::var("MOCKFORGE_MESSAGE_BROADCAST_CAPACITY")
50        .ok()
51        .and_then(|s| s.parse().ok())
52        .unwrap_or(DEFAULT_MESSAGE_BROADCAST_CAPACITY)
53}
54
55/// Message event types for real-time monitoring
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(tag = "protocol", content = "data")]
58#[serde(rename_all = "lowercase")]
59pub enum MessageEvent {
60    #[cfg(feature = "mqtt")]
61    /// MQTT message event
62    Mqtt(MqttMessageEvent),
63    #[cfg(feature = "kafka")]
64    /// Kafka message event
65    Kafka(KafkaMessageEvent),
66}
67
68#[cfg(feature = "mqtt")]
69/// MQTT message event for real-time monitoring
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct MqttMessageEvent {
72    /// MQTT topic name
73    pub topic: String,
74    /// Message payload content
75    pub payload: String,
76    /// Quality of Service level (0, 1, or 2)
77    pub qos: u8,
78    /// Whether the message is retained
79    pub retain: bool,
80    /// RFC3339 formatted timestamp
81    pub timestamp: String,
82}
83
84#[cfg(feature = "kafka")]
85#[allow(missing_docs)]
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct KafkaMessageEvent {
88    pub topic: String,
89    pub key: Option<String>,
90    pub value: String,
91    pub partition: i32,
92    pub offset: i64,
93    pub headers: Option<std::collections::HashMap<String, String>>,
94    pub timestamp: String,
95}
96
97/// Mock configuration representation
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct MockConfig {
100    /// Unique identifier for the mock. Auto-generated by `create_mock` when
101    /// omitted on input — clients can POST without an `id` and the server
102    /// will assign one.
103    #[serde(default, skip_serializing_if = "String::is_empty")]
104    pub id: String,
105    /// Human-readable name for the mock. Optional on input; defaults to an
106    /// empty string (consumers can leave it blank when programmatically
107    /// creating mocks via the management API).
108    #[serde(default)]
109    pub name: String,
110    /// HTTP method (GET, POST, etc.)
111    pub method: String,
112    /// API path pattern to match
113    pub path: String,
114    /// Response configuration
115    pub response: MockResponse,
116    /// Whether this mock is currently enabled
117    #[serde(default = "default_true")]
118    pub enabled: bool,
119    /// Optional latency to inject in milliseconds
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub latency_ms: Option<u64>,
122    /// Optional HTTP status code override
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub status_code: Option<u16>,
125    /// Request matching criteria (headers, query params, body patterns)
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub request_match: Option<RequestMatchCriteria>,
128    /// Priority for mock ordering (higher priority mocks are matched first)
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub priority: Option<i32>,
131    /// Scenario name for stateful mocking
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub scenario: Option<String>,
134    /// Required scenario state for this mock to be active
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub required_scenario_state: Option<String>,
137    /// New scenario state after this mock is matched
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub new_scenario_state: Option<String>,
140}
141
142fn default_true() -> bool {
143    true
144}
145
146/// Mock response configuration
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct MockResponse {
149    /// Response body as JSON
150    pub body: serde_json::Value,
151    /// Optional custom response headers
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub headers: Option<std::collections::HashMap<String, String>>,
154}
155
156/// Request matching criteria for advanced request matching
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158pub struct RequestMatchCriteria {
159    /// Headers that must be present and match (case-insensitive header names)
160    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
161    pub headers: std::collections::HashMap<String, String>,
162    /// Query parameters that must be present and match
163    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
164    pub query_params: std::collections::HashMap<String, String>,
165    /// Request body pattern (supports exact match or regex)
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub body_pattern: Option<String>,
168    /// JSONPath expression for JSON body matching
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub json_path: Option<String>,
171    /// XPath expression for XML body matching
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub xpath: Option<String>,
174    /// Custom matcher expression (e.g., "headers.content-type == \"application/json\"")
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub custom_matcher: Option<String>,
177}
178
179/// Check if a request matches the given mock configuration
180///
181/// This function implements comprehensive request matching including:
182/// - Method and path matching
183/// - Header matching (with regex support)
184/// - Query parameter matching
185/// - Body pattern matching (exact, regex, JSONPath, XPath)
186/// - Custom matcher expressions
187pub fn mock_matches_request(
188    mock: &MockConfig,
189    method: &str,
190    path: &str,
191    headers: &std::collections::HashMap<String, String>,
192    query_params: &std::collections::HashMap<String, String>,
193    body: Option<&[u8]>,
194) -> bool {
195    use regex::Regex;
196
197    // Check if mock is enabled
198    if !mock.enabled {
199        return false;
200    }
201
202    // Check method (case-insensitive)
203    if mock.method.to_uppercase() != method.to_uppercase() {
204        return false;
205    }
206
207    // Check path pattern (supports wildcards and path parameters)
208    if !path_matches_pattern(&mock.path, path) {
209        return false;
210    }
211
212    // Check request matching criteria if present
213    if let Some(criteria) = &mock.request_match {
214        // Check headers
215        for (key, expected_value) in &criteria.headers {
216            let header_key_lower = key.to_lowercase();
217            let found = headers.iter().find(|(k, _)| k.to_lowercase() == header_key_lower);
218
219            if let Some((_, actual_value)) = found {
220                // Try regex match first, then exact match
221                if let Ok(re) = Regex::new(expected_value) {
222                    if !re.is_match(actual_value) {
223                        return false;
224                    }
225                } else if actual_value != expected_value {
226                    return false;
227                }
228            } else {
229                return false; // Header not found
230            }
231        }
232
233        // Check query parameters
234        for (key, expected_value) in &criteria.query_params {
235            if let Some(actual_value) = query_params.get(key) {
236                if actual_value != expected_value {
237                    return false;
238                }
239            } else {
240                return false; // Query param not found
241            }
242        }
243
244        // Check body pattern
245        if let Some(pattern) = &criteria.body_pattern {
246            if let Some(body_bytes) = body {
247                let body_str = String::from_utf8_lossy(body_bytes);
248                // Try regex first, then exact match
249                if let Ok(re) = Regex::new(pattern) {
250                    if !re.is_match(&body_str) {
251                        return false;
252                    }
253                } else if body_str.as_ref() != pattern {
254                    return false;
255                }
256            } else {
257                return false; // Body required but not present
258            }
259        }
260
261        // Check JSONPath (simplified implementation)
262        if let Some(json_path) = &criteria.json_path {
263            if let Some(body_bytes) = body {
264                if let Ok(body_str) = std::str::from_utf8(body_bytes) {
265                    if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(body_str) {
266                        // Simple JSONPath check
267                        if !json_path_exists(&json_value, json_path) {
268                            return false;
269                        }
270                    }
271                }
272            }
273        }
274
275        // Check XPath (supports a focused subset)
276        if let Some(xpath) = &criteria.xpath {
277            if let Some(body_bytes) = body {
278                if let Ok(body_str) = std::str::from_utf8(body_bytes) {
279                    if !xml_xpath_exists(body_str, xpath) {
280                        return false;
281                    }
282                } else {
283                    return false;
284                }
285            } else {
286                return false; // Body required but not present
287            }
288        }
289
290        // Check custom matcher
291        if let Some(custom) = &criteria.custom_matcher {
292            if !evaluate_custom_matcher(custom, method, path, headers, query_params, body) {
293                return false;
294            }
295        }
296    }
297
298    true
299}
300
301/// Check if a path matches a pattern (supports wildcards and path parameters)
302fn path_matches_pattern(pattern: &str, path: &str) -> bool {
303    // Exact match
304    if pattern == path {
305        return true;
306    }
307
308    // Wildcard match
309    if pattern == "*" {
310        return true;
311    }
312
313    // Path parameter matching (e.g., /users/{id} matches /users/123)
314    let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
315    let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
316
317    if pattern_parts.len() != path_parts.len() {
318        // Check for wildcard patterns
319        if pattern.contains('*') {
320            return matches_wildcard_pattern(pattern, path);
321        }
322        return false;
323    }
324
325    for (pattern_part, path_part) in pattern_parts.iter().zip(path_parts.iter()) {
326        // Check for path parameters {param}
327        if pattern_part.starts_with('{') && pattern_part.ends_with('}') {
328            continue; // Matches any value
329        }
330
331        if pattern_part != path_part {
332            return false;
333        }
334    }
335
336    true
337}
338
339/// Check if path matches a wildcard pattern
340fn matches_wildcard_pattern(pattern: &str, path: &str) -> bool {
341    use regex::Regex;
342
343    // Convert pattern to regex
344    let regex_pattern = pattern.replace('*', ".*").replace('?', ".?");
345
346    if let Ok(re) = Regex::new(&format!("^{}$", regex_pattern)) {
347        return re.is_match(path);
348    }
349
350    false
351}
352
353/// Check if a JSONPath exists in a JSON value
354///
355/// Supports:
356/// - `$` — root element
357/// - `$.field.subfield` — nested object access
358/// - `$.items[0].name` — array index access
359/// - `$.items[*]` — array wildcard (checks array is non-empty)
360fn json_path_exists(json: &serde_json::Value, json_path: &str) -> bool {
361    let path = if json_path == "$" {
362        return true;
363    } else if let Some(p) = json_path.strip_prefix("$.") {
364        p
365    } else if let Some(p) = json_path.strip_prefix('$') {
366        p.strip_prefix('.').unwrap_or(p)
367    } else {
368        json_path
369    };
370
371    let mut current = json;
372    for segment in split_json_path_segments(path) {
373        match segment {
374            JsonPathSegment::Field(name) => {
375                if let Some(obj) = current.as_object() {
376                    if let Some(value) = obj.get(name) {
377                        current = value;
378                    } else {
379                        return false;
380                    }
381                } else {
382                    return false;
383                }
384            }
385            JsonPathSegment::Index(idx) => {
386                if let Some(arr) = current.as_array() {
387                    if let Some(value) = arr.get(idx) {
388                        current = value;
389                    } else {
390                        return false;
391                    }
392                } else {
393                    return false;
394                }
395            }
396            JsonPathSegment::Wildcard => {
397                if let Some(arr) = current.as_array() {
398                    return !arr.is_empty();
399                }
400                return false;
401            }
402        }
403    }
404    true
405}
406
407enum JsonPathSegment<'a> {
408    Field(&'a str),
409    Index(usize),
410    Wildcard,
411}
412
413/// Split a JSONPath (without the leading `$`) into segments
414fn split_json_path_segments(path: &str) -> Vec<JsonPathSegment<'_>> {
415    let mut segments = Vec::new();
416    for part in path.split('.') {
417        if part.is_empty() {
418            continue;
419        }
420        if let Some(bracket_start) = part.find('[') {
421            let field_name = &part[..bracket_start];
422            if !field_name.is_empty() {
423                segments.push(JsonPathSegment::Field(field_name));
424            }
425            let bracket_content = &part[bracket_start + 1..part.len() - 1];
426            if bracket_content == "*" {
427                segments.push(JsonPathSegment::Wildcard);
428            } else if let Ok(idx) = bracket_content.parse::<usize>() {
429                segments.push(JsonPathSegment::Index(idx));
430            }
431        } else {
432            segments.push(JsonPathSegment::Field(part));
433        }
434    }
435    segments
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
439struct XPathSegment {
440    name: String,
441    text_equals: Option<String>,
442}
443
444fn parse_xpath_segment(segment: &str) -> Option<XPathSegment> {
445    if segment.is_empty() {
446        return None;
447    }
448
449    let trimmed = segment.trim();
450    if let Some(bracket_start) = trimmed.find('[') {
451        if !trimmed.ends_with(']') {
452            return None;
453        }
454
455        let name = trimmed[..bracket_start].trim();
456        let predicate = &trimmed[bracket_start + 1..trimmed.len() - 1];
457        let predicate = predicate.trim();
458
459        // Support simple predicate: [text()="value"] or [text()='value']
460        if let Some(raw) = predicate.strip_prefix("text()=") {
461            let raw = raw.trim();
462            if raw.len() >= 2
463                && ((raw.starts_with('"') && raw.ends_with('"'))
464                    || (raw.starts_with('\'') && raw.ends_with('\'')))
465            {
466                let text = raw[1..raw.len() - 1].to_string();
467                if !name.is_empty() {
468                    return Some(XPathSegment {
469                        name: name.to_string(),
470                        text_equals: Some(text),
471                    });
472                }
473            }
474        }
475
476        None
477    } else {
478        Some(XPathSegment {
479            name: trimmed.to_string(),
480            text_equals: None,
481        })
482    }
483}
484
485fn segment_matches(node: roxmltree::Node<'_, '_>, segment: &XPathSegment) -> bool {
486    if !node.is_element() {
487        return false;
488    }
489    if node.tag_name().name() != segment.name {
490        return false;
491    }
492    match &segment.text_equals {
493        Some(expected) => node.text().map(str::trim).unwrap_or_default() == expected,
494        None => true,
495    }
496}
497
498/// Check if an XPath expression matches an XML body.
499///
500/// Supported subset:
501/// - Absolute paths: `/root/child/item`
502/// - Descendant search: `//item` and `//parent/child`
503/// - Optional text predicate per segment: `item[text()="value"]`
504fn xml_xpath_exists(xml_body: &str, xpath: &str) -> bool {
505    let doc = match roxmltree::Document::parse(xml_body) {
506        Ok(doc) => doc,
507        Err(err) => {
508            tracing::warn!("Failed to parse XML for XPath matching: {}", err);
509            return false;
510        }
511    };
512
513    let expr = xpath.trim();
514    if expr.is_empty() {
515        return false;
516    }
517
518    let (is_descendant, path_str) = if let Some(rest) = expr.strip_prefix("//") {
519        (true, rest)
520    } else if let Some(rest) = expr.strip_prefix('/') {
521        (false, rest)
522    } else {
523        tracing::warn!("Unsupported XPath expression (must start with / or //): {}", expr);
524        return false;
525    };
526
527    let segments: Vec<XPathSegment> = path_str
528        .split('/')
529        .filter(|s| !s.trim().is_empty())
530        .filter_map(parse_xpath_segment)
531        .collect();
532
533    if segments.is_empty() {
534        return false;
535    }
536
537    if is_descendant {
538        let first = &segments[0];
539        for node in doc.descendants().filter(|n| segment_matches(*n, first)) {
540            let mut frontier = vec![node];
541            for segment in &segments[1..] {
542                let mut next_frontier = Vec::new();
543                for parent in &frontier {
544                    for child in parent.children().filter(|n| segment_matches(*n, segment)) {
545                        next_frontier.push(child);
546                    }
547                }
548                if next_frontier.is_empty() {
549                    frontier.clear();
550                    break;
551                }
552                frontier = next_frontier;
553            }
554            if !frontier.is_empty() {
555                return true;
556            }
557        }
558        false
559    } else {
560        let mut frontier = vec![doc.root_element()];
561        for (index, segment) in segments.iter().enumerate() {
562            let mut next_frontier = Vec::new();
563            for parent in &frontier {
564                if index == 0 {
565                    if segment_matches(*parent, segment) {
566                        next_frontier.push(*parent);
567                    }
568                    continue;
569                }
570                for child in parent.children().filter(|n| segment_matches(*n, segment)) {
571                    next_frontier.push(child);
572                }
573            }
574            if next_frontier.is_empty() {
575                return false;
576            }
577            frontier = next_frontier;
578        }
579        !frontier.is_empty()
580    }
581}
582
583/// Evaluate a custom matcher expression
584fn evaluate_custom_matcher(
585    expression: &str,
586    method: &str,
587    path: &str,
588    headers: &std::collections::HashMap<String, String>,
589    query_params: &std::collections::HashMap<String, String>,
590    body: Option<&[u8]>,
591) -> bool {
592    use regex::Regex;
593
594    let expr = expression.trim();
595
596    // Handle equality expressions (field == "value")
597    if expr.contains("==") {
598        let parts: Vec<&str> = expr.split("==").map(|s| s.trim()).collect();
599        if parts.len() != 2 {
600            return false;
601        }
602
603        let field = parts[0];
604        let expected_value = parts[1].trim_matches('"').trim_matches('\'');
605
606        match field {
607            "method" => method == expected_value,
608            "path" => path == expected_value,
609            _ if field.starts_with("headers.") => {
610                let header_name = &field[8..];
611                headers.get(header_name).map(|v| v == expected_value).unwrap_or(false)
612            }
613            _ if field.starts_with("query.") => {
614                let param_name = &field[6..];
615                query_params.get(param_name).map(|v| v == expected_value).unwrap_or(false)
616            }
617            _ => false,
618        }
619    }
620    // Handle regex match expressions (field =~ "pattern")
621    else if expr.contains("=~") {
622        let parts: Vec<&str> = expr.split("=~").map(|s| s.trim()).collect();
623        if parts.len() != 2 {
624            return false;
625        }
626
627        let field = parts[0];
628        let pattern = parts[1].trim_matches('"').trim_matches('\'');
629
630        if let Ok(re) = Regex::new(pattern) {
631            match field {
632                "method" => re.is_match(method),
633                "path" => re.is_match(path),
634                _ if field.starts_with("headers.") => {
635                    let header_name = &field[8..];
636                    headers.get(header_name).map(|v| re.is_match(v)).unwrap_or(false)
637                }
638                _ if field.starts_with("query.") => {
639                    let param_name = &field[6..];
640                    query_params.get(param_name).map(|v| re.is_match(v)).unwrap_or(false)
641                }
642                _ => false,
643            }
644        } else {
645            false
646        }
647    }
648    // Handle contains expressions (field contains "value")
649    else if expr.contains("contains") {
650        let parts: Vec<&str> = expr.split("contains").map(|s| s.trim()).collect();
651        if parts.len() != 2 {
652            return false;
653        }
654
655        let field = parts[0];
656        let search_value = parts[1].trim_matches('"').trim_matches('\'');
657
658        match field {
659            "path" => path.contains(search_value),
660            _ if field.starts_with("headers.") => {
661                let header_name = &field[8..];
662                headers.get(header_name).map(|v| v.contains(search_value)).unwrap_or(false)
663            }
664            _ if field.starts_with("body") => {
665                if let Some(body_bytes) = body {
666                    let body_str = String::from_utf8_lossy(body_bytes);
667                    body_str.contains(search_value)
668                } else {
669                    false
670                }
671            }
672            _ => false,
673        }
674    } else {
675        // Unknown expression format
676        tracing::warn!("Unknown custom matcher expression format: {}", expr);
677        false
678    }
679}
680
681/// Server statistics
682#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct ServerStats {
684    /// Server uptime in seconds
685    pub uptime_seconds: u64,
686    /// Total number of requests processed
687    pub total_requests: u64,
688    /// Number of active mock configurations
689    pub active_mocks: usize,
690    /// Number of currently enabled mocks
691    pub enabled_mocks: usize,
692    /// Number of registered API routes
693    pub registered_routes: usize,
694}
695
696/// Server configuration info
697#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct ServerConfig {
699    /// MockForge version string
700    pub version: String,
701    /// Server port number
702    pub port: u16,
703    /// Whether an OpenAPI spec is loaded
704    pub has_openapi_spec: bool,
705    /// Optional path to the OpenAPI spec file
706    #[serde(skip_serializing_if = "Option::is_none")]
707    pub spec_path: Option<String>,
708}
709
710/// Shared state for the management API
711#[derive(Clone)]
712pub struct ManagementState {
713    /// Collection of mock configurations
714    pub mocks: Arc<RwLock<Vec<MockConfig>>>,
715    /// Optional OpenAPI specification
716    pub spec: Option<Arc<OpenApiSpec>>,
717    /// Optional path to the OpenAPI spec file
718    pub spec_path: Option<String>,
719    /// Server port number
720    pub port: u16,
721    /// Server start time for uptime calculation
722    pub start_time: std::time::Instant,
723    /// Counter for total requests processed
724    pub request_counter: Arc<RwLock<u64>>,
725    /// Optional proxy configuration for migration pipeline
726    pub proxy_config: Option<Arc<RwLock<ProxyConfig>>>,
727    /// Optional SMTP registry for email mocking
728    #[cfg(feature = "smtp")]
729    pub smtp_registry: Option<Arc<mockforge_smtp::SmtpSpecRegistry>>,
730    /// Optional MQTT session manager (the live listener state) for the admin
731    /// API. Shared with the running listener so the admin reflects the clients
732    /// and topics actually being served (issue #730).
733    #[cfg(feature = "mqtt")]
734    pub mqtt_sessions: Option<Arc<mockforge_mqtt::SessionManager>>,
735    /// Optional Kafka broker for event streaming
736    #[cfg(feature = "kafka")]
737    pub kafka_broker: Option<Arc<mockforge_kafka::KafkaMockBroker>>,
738    /// Optional AMQP broker for exchange/queue mocking
739    #[cfg(feature = "amqp")]
740    pub amqp_broker: Option<Arc<mockforge_amqp::AmqpBroker>>,
741    /// Broadcast channel for message events (MQTT & Kafka)
742    #[cfg(any(feature = "mqtt", feature = "kafka"))]
743    pub message_events: Arc<broadcast::Sender<MessageEvent>>,
744    /// State machine manager for scenario state machines
745    pub state_machine_manager:
746        Arc<RwLock<mockforge_scenarios::state_machine::ScenarioStateMachineManager>>,
747    /// Optional WebSocket broadcast channel for real-time updates
748    pub ws_broadcast: Option<Arc<broadcast::Sender<crate::management_ws::MockEvent>>>,
749    /// Lifecycle hook registry for extensibility
750    pub lifecycle_hooks: Option<Arc<mockforge_core::lifecycle::LifecycleHookRegistry>>,
751    /// Rule explanations storage (in-memory for now)
752    pub rule_explanations: Arc<
753        RwLock<
754            std::collections::HashMap<
755                String,
756                mockforge_foundation::intelligent_behavior::rule_types::RuleExplanation,
757            >,
758        >,
759    >,
760    /// Optional chaos API state for chaos config management
761    #[cfg(feature = "chaos")]
762    pub chaos_api_state: Option<Arc<mockforge_chaos::api::ChaosApiState>>,
763    /// Optional server configuration for profile application
764    pub server_config: Option<Arc<RwLock<mockforge_core::config::ServerConfig>>>,
765    /// Conformance testing state
766    #[cfg(feature = "conformance")]
767    pub conformance_state: crate::handlers::conformance::ConformanceState,
768}
769
770impl ManagementState {
771    /// Create a new management state
772    ///
773    /// # Arguments
774    /// * `spec` - Optional OpenAPI specification
775    /// * `spec_path` - Optional path to the OpenAPI spec file
776    /// * `port` - Server port number
777    pub fn new(spec: Option<Arc<OpenApiSpec>>, spec_path: Option<String>, port: u16) -> Self {
778        Self {
779            mocks: Arc::new(RwLock::new(Vec::new())),
780            spec,
781            spec_path,
782            port,
783            start_time: std::time::Instant::now(),
784            request_counter: Arc::new(RwLock::new(0)),
785            proxy_config: None,
786            #[cfg(feature = "smtp")]
787            smtp_registry: None,
788            #[cfg(feature = "mqtt")]
789            mqtt_sessions: None,
790            #[cfg(feature = "kafka")]
791            kafka_broker: None,
792            #[cfg(feature = "amqp")]
793            amqp_broker: None,
794            #[cfg(any(feature = "mqtt", feature = "kafka"))]
795            message_events: {
796                let capacity = get_message_broadcast_capacity();
797                let (tx, _) = broadcast::channel(capacity);
798                Arc::new(tx)
799            },
800            state_machine_manager: Arc::new(RwLock::new(
801                mockforge_scenarios::state_machine::ScenarioStateMachineManager::new(),
802            )),
803            ws_broadcast: None,
804            lifecycle_hooks: None,
805            rule_explanations: Arc::new(RwLock::new(std::collections::HashMap::new())),
806            #[cfg(feature = "chaos")]
807            chaos_api_state: None,
808            server_config: None,
809            #[cfg(feature = "conformance")]
810            conformance_state: crate::handlers::conformance::ConformanceState::new(),
811        }
812    }
813
814    /// Add lifecycle hook registry to management state
815    pub fn with_lifecycle_hooks(
816        mut self,
817        hooks: Arc<mockforge_core::lifecycle::LifecycleHookRegistry>,
818    ) -> Self {
819        self.lifecycle_hooks = Some(hooks);
820        self
821    }
822
823    /// Add WebSocket broadcast channel to management state
824    pub fn with_ws_broadcast(
825        mut self,
826        ws_broadcast: Arc<broadcast::Sender<crate::management_ws::MockEvent>>,
827    ) -> Self {
828        self.ws_broadcast = Some(ws_broadcast);
829        self
830    }
831
832    /// Add proxy configuration to management state
833    pub fn with_proxy_config(mut self, proxy_config: Arc<RwLock<ProxyConfig>>) -> Self {
834        self.proxy_config = Some(proxy_config);
835        self
836    }
837
838    #[cfg(feature = "smtp")]
839    /// Add SMTP registry to management state
840    pub fn with_smtp_registry(
841        mut self,
842        smtp_registry: Arc<mockforge_smtp::SmtpSpecRegistry>,
843    ) -> Self {
844        self.smtp_registry = Some(smtp_registry);
845        self
846    }
847
848    #[cfg(feature = "mqtt")]
849    /// Add the MQTT session manager (live listener state) to management state
850    pub fn with_mqtt_sessions(
851        mut self,
852        mqtt_sessions: Arc<mockforge_mqtt::SessionManager>,
853    ) -> Self {
854        self.mqtt_sessions = Some(mqtt_sessions);
855        self
856    }
857
858    #[cfg(feature = "kafka")]
859    /// Add Kafka broker to management state
860    pub fn with_kafka_broker(
861        mut self,
862        kafka_broker: Arc<mockforge_kafka::KafkaMockBroker>,
863    ) -> Self {
864        self.kafka_broker = Some(kafka_broker);
865        self
866    }
867
868    #[cfg(feature = "amqp")]
869    /// Add AMQP broker to management state
870    pub fn with_amqp_broker(mut self, amqp_broker: Arc<mockforge_amqp::AmqpBroker>) -> Self {
871        self.amqp_broker = Some(amqp_broker);
872        self
873    }
874
875    #[cfg(feature = "chaos")]
876    /// Add chaos API state to management state
877    pub fn with_chaos_api_state(
878        mut self,
879        chaos_api_state: Arc<mockforge_chaos::api::ChaosApiState>,
880    ) -> Self {
881        self.chaos_api_state = Some(chaos_api_state);
882        self
883    }
884
885    /// Add server configuration to management state
886    pub fn with_server_config(
887        mut self,
888        server_config: Arc<RwLock<mockforge_core::config::ServerConfig>>,
889    ) -> Self {
890        self.server_config = Some(server_config);
891        self
892    }
893}
894
895/// Build the management API router
896pub fn management_router(state: ManagementState) -> Router {
897    let router = Router::new()
898        .route("/capabilities", get(get_capabilities))
899        .route("/health", get(health_check))
900        .route("/stats", get(get_stats))
901        .route("/config", get(get_config))
902        .route("/config/validate", post(validate_config))
903        .route("/config/bulk", post(bulk_update_config))
904        .route("/mocks", get(mocks::list_mocks))
905        .route("/mocks", post(mocks::create_mock))
906        .route("/mocks/{id}", get(mocks::get_mock))
907        .route("/mocks/{id}", put(mocks::update_mock))
908        .route("/mocks/{id}", delete(mocks::delete_mock))
909        .route("/export", get(export_mocks))
910        .route("/import", post(import_mocks))
911        .route("/spec", get(get_openapi_spec))
912        // Issue #79 round 12 — server-side spec violation feed for the
913        // new TUI "Conformance" screen. Backed by the bounded ring
914        // buffer in `mockforge_foundation::conformance_violations` that
915        // the OpenAPI router populates whenever
916        // `validate_request_with_all` rejects an incoming request.
917        .route("/conformance/violations", get(get_conformance_violations))
918        .route("/conformance/violations", delete(clear_conformance_violations));
919
920    #[cfg(feature = "smtp")]
921    let router = router
922        .route("/smtp/mailbox", get(protocols::list_smtp_emails))
923        .route("/smtp/mailbox", delete(protocols::clear_smtp_mailbox))
924        .route("/smtp/mailbox/{id}", get(protocols::get_smtp_email))
925        .route("/smtp/mailbox/export", get(protocols::export_smtp_mailbox))
926        .route("/smtp/mailbox/search", get(protocols::search_smtp_emails));
927
928    #[cfg(not(feature = "smtp"))]
929    let router = router;
930
931    // MQTT routes
932    #[cfg(feature = "mqtt")]
933    let router = router
934        .route("/mqtt/stats", get(protocols::get_mqtt_stats))
935        .route("/mqtt/clients", get(protocols::get_mqtt_clients))
936        .route("/mqtt/topics", get(protocols::get_mqtt_topics))
937        .route("/mqtt/clients/{client_id}", delete(protocols::disconnect_mqtt_client))
938        .route("/mqtt/messages/stream", get(protocols::mqtt_messages_stream))
939        .route("/mqtt/publish", post(protocols::publish_mqtt_message_handler))
940        .route("/mqtt/publish/batch", post(protocols::publish_mqtt_batch_handler));
941
942    #[cfg(not(feature = "mqtt"))]
943    let router = router
944        .route("/mqtt/publish", post(protocols::publish_mqtt_message_handler))
945        .route("/mqtt/publish/batch", post(protocols::publish_mqtt_batch_handler));
946
947    #[cfg(feature = "kafka")]
948    let router = router
949        .route("/kafka/stats", get(protocols::get_kafka_stats))
950        .route("/kafka/topics", get(protocols::get_kafka_topics))
951        .route("/kafka/topics/{topic}", get(protocols::get_kafka_topic))
952        .route("/kafka/groups", get(protocols::get_kafka_groups))
953        .route("/kafka/groups/{group_id}", get(protocols::get_kafka_group))
954        .route("/kafka/produce", post(protocols::produce_kafka_message))
955        .route("/kafka/produce/batch", post(protocols::produce_kafka_batch))
956        .route("/kafka/messages/stream", get(protocols::kafka_messages_stream));
957
958    #[cfg(not(feature = "kafka"))]
959    let router = router;
960
961    // AMQP routes
962    #[cfg(feature = "amqp")]
963    let router = router
964        .route("/amqp/stats", get(protocols::get_amqp_stats))
965        .route("/amqp/exchanges", get(protocols::get_amqp_exchanges))
966        .route("/amqp/exchanges", post(protocols::declare_amqp_exchange))
967        .route("/amqp/exchanges/{name}", delete(protocols::delete_amqp_exchange))
968        .route("/amqp/exchanges/{name}/bindings", post(protocols::add_amqp_binding))
969        .route("/amqp/queues", get(protocols::get_amqp_queues))
970        .route("/amqp/queues", post(protocols::declare_amqp_queue))
971        .route("/amqp/publish", post(protocols::publish_amqp_message));
972
973    #[cfg(not(feature = "amqp"))]
974    let router = router;
975
976    // Migration pipeline routes
977    let router = router
978        .route("/migration/routes", get(migration::get_migration_routes))
979        .route("/migration/routes/{pattern}/toggle", post(migration::toggle_route_migration))
980        .route("/migration/routes/{pattern}", put(migration::set_route_migration_mode))
981        .route("/migration/groups/{group}/toggle", post(migration::toggle_group_migration))
982        .route("/migration/groups/{group}", put(migration::set_group_migration_mode))
983        .route("/migration/groups", get(migration::get_migration_groups))
984        .route("/migration/status", get(migration::get_migration_status));
985
986    // Proxy replacement rules routes
987    let router = router
988        .route("/proxy/rules", get(proxy::list_proxy_rules))
989        .route("/proxy/rules", post(proxy::create_proxy_rule))
990        .route("/proxy/rules/{id}", get(proxy::get_proxy_rule))
991        .route("/proxy/rules/{id}", put(proxy::update_proxy_rule))
992        .route("/proxy/rules/{id}", delete(proxy::delete_proxy_rule))
993        .route("/proxy/inspect", get(proxy::get_proxy_inspect));
994
995    // AI-powered features
996    let router = router.route("/ai/generate-spec", post(generate_ai_spec));
997
998    // Snapshot diff endpoints
999    let router = router.nest(
1000        "/snapshot-diff",
1001        crate::handlers::snapshot_diff::snapshot_diff_router(state.clone()),
1002    );
1003
1004    #[cfg(feature = "behavioral-cloning")]
1005    let router = router.route("/mockai/generate-openapi", post(generate_openapi_from_traffic));
1006
1007    let router = router
1008        .route("/mockai/learn", post(learn_from_examples))
1009        .route("/mockai/rules/explanations", get(list_rule_explanations))
1010        .route("/mockai/rules/{id}/explanation", get(get_rule_explanation))
1011        .route("/chaos/config", get(get_chaos_config))
1012        .route("/chaos/config", post(update_chaos_config))
1013        .route("/network/profiles", get(list_network_profiles))
1014        .route("/network/profile/apply", post(apply_network_profile));
1015
1016    // State machine API routes
1017    let router =
1018        router.nest("/state-machines", crate::state_machine_api::create_state_machine_routes());
1019
1020    // Conformance testing API routes
1021    #[cfg(feature = "conformance")]
1022    let router = router.nest_service(
1023        "/conformance",
1024        crate::handlers::conformance::conformance_router(state.conformance_state.clone()),
1025    );
1026    #[cfg(not(feature = "conformance"))]
1027    let router = router;
1028
1029    router.with_state(state)
1030}
1031
1032/// Build the management API router with UI Builder support
1033pub fn management_router_with_ui_builder(
1034    state: ManagementState,
1035    server_config: mockforge_core::config::ServerConfig,
1036) -> Router {
1037    use crate::ui_builder::{create_ui_builder_router, UIBuilderState};
1038
1039    // Create the base management router
1040    let management = management_router(state);
1041
1042    // Create UI Builder state and router
1043    let ui_builder_state = UIBuilderState::new(server_config);
1044    let ui_builder = create_ui_builder_router(ui_builder_state);
1045
1046    // Nest UI Builder under /ui-builder
1047    management.nest("/ui-builder", ui_builder)
1048}
1049
1050/// Build management router with spec import API
1051pub fn management_router_with_spec_import(state: ManagementState) -> Router {
1052    use crate::spec_import::{spec_import_router, SpecImportState};
1053
1054    // Create base management router
1055    let management = management_router(state);
1056
1057    // Merge with spec import router
1058    Router::new()
1059        .merge(management)
1060        .merge(spec_import_router(SpecImportState::new()))
1061}
1062
1063/// Match an incoming request against mocks registered via the
1064/// `POST /__mockforge/api/mocks` endpoint and return the first match's
1065/// response (ordered by descending priority). Returns `None` if nothing
1066/// matches, so the caller can chain it with its existing 404.
1067///
1068/// This is what lets the `@mockforge-dev/sdk` Node.js SDK register a stub
1069/// dynamically and have subsequent HTTP requests actually hit it — without
1070/// it, stubs live in `ManagementState` but no route dispatches to them, so
1071/// every SDK test would 404.
1072pub async fn serve_dynamic_mock(state: &ManagementState, req: Request<Body>) -> Option<Response> {
1073    let method = req.method().as_str().to_string();
1074    let path = req.uri().path().to_string();
1075
1076    let query_params: std::collections::HashMap<String, String> = req
1077        .uri()
1078        .query()
1079        .map(|q| url::form_urlencoded::parse(q.as_bytes()).into_owned().collect())
1080        .unwrap_or_default();
1081
1082    let headers: std::collections::HashMap<String, String> = req
1083        .headers()
1084        .iter()
1085        .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.as_str().to_string(), v.to_string())))
1086        .collect();
1087
1088    let (_parts, body) = req.into_parts();
1089    let body_bytes = axum::body::to_bytes(body, 1024 * 1024).await.ok()?;
1090    let body_opt: Option<&[u8]> = if body_bytes.is_empty() {
1091        None
1092    } else {
1093        Some(&body_bytes)
1094    };
1095
1096    let mocks = state.mocks.read().await;
1097
1098    let mut candidates: Vec<&MockConfig> = mocks
1099        .iter()
1100        .filter(|m| mock_matches_request(m, &method, &path, &headers, &query_params, body_opt))
1101        .collect();
1102    if candidates.is_empty() {
1103        return None;
1104    }
1105    candidates.sort_by_key(|m| -(m.priority.unwrap_or(0)));
1106    let mock = candidates.first()?;
1107
1108    if let Some(ms) = mock.latency_ms {
1109        if ms > 0 {
1110            tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
1111        }
1112    }
1113
1114    let status = mock
1115        .status_code
1116        .and_then(|c| StatusCode::from_u16(c).ok())
1117        .unwrap_or(StatusCode::OK);
1118
1119    // Honor MOCKFORGE_RESPONSE_TEMPLATE_EXPAND for mocks created via the
1120    // management API. The full templater in mockforge-core handles
1121    // `{{faker.email}}`, `{{uuid}}`, `{{randInt …}}`, etc. — it's gated
1122    // on the env var because it's the same opt-in as everywhere else
1123    // (config-loaded routes, OpenAPI overrides). Run inside spawn_blocking
1124    // because the templater's rng() and faker providers are not Send-safe
1125    // across `.await` points.
1126    let template_expand = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
1127        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1128        .unwrap_or(false);
1129    let body_value = if template_expand {
1130        let body_clone = mock.response.body.clone();
1131        match tokio::task::spawn_blocking(move || {
1132            mockforge_core::templating::expand_tokens(&body_clone)
1133        })
1134        .await
1135        {
1136            Ok(expanded) => expanded,
1137            Err(_) => mock.response.body.clone(),
1138        }
1139    } else {
1140        mock.response.body.clone()
1141    };
1142
1143    let body_bytes_out = serde_json::to_vec(&body_value).unwrap_or_default();
1144    let mut response = Response::builder().status(status);
1145
1146    let mut has_content_type = false;
1147    if let Some(h) = &mock.response.headers {
1148        for (k, v) in h {
1149            if k.eq_ignore_ascii_case("content-type") {
1150                has_content_type = true;
1151            }
1152            if let (Ok(name), Ok(value)) =
1153                (HeaderName::from_bytes(k.as_bytes()), HeaderValue::from_str(v))
1154            {
1155                response = response.header(name, value);
1156            }
1157        }
1158    }
1159    if !has_content_type {
1160        response = response.header("content-type", "application/json");
1161    }
1162
1163    Some(
1164        response
1165            .body(Body::from(body_bytes_out))
1166            .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
1167    )
1168}
1169
1170/// Axum fallback handler for the main router: tries to serve a dynamic mock,
1171/// or returns 404. Only invoked when nothing else in the router matched.
1172///
1173/// Wire via `.fallback_service(axum::routing::any(dynamic_mock_fallback).with_state(state))`
1174/// so the handler's own `State<ManagementState>` extractor is satisfied
1175/// without forcing the parent router to adopt `ManagementState` as its state.
1176pub async fn dynamic_mock_fallback(
1177    State(state): State<ManagementState>,
1178    req: Request<Body>,
1179) -> Response {
1180    // Issue #79 round 13 — capture the request method/path/query
1181    // before the body is consumed so we can record unmatched 404s
1182    // (Srikanth's (a) ask: surface paths the spec doesn't know about).
1183    // Only records when no dynamic mock matches — if a dynamic mock
1184    // serves the request, the path WAS handled, just not by OpenAPI.
1185    let method = req.method().as_str().to_string();
1186    let path = req.uri().path().to_string();
1187    let query = req.uri().query().unwrap_or_default().to_string();
1188
1189    match serve_dynamic_mock(&state, req).await {
1190        Some(resp) => resp,
1191        None => {
1192            // Issue #79 round 14 — shadow mode returns 200 for unknown
1193            // paths (instead of 404) while still recording them, so a
1194            // proxy replay flows through non-blocking. The recorded
1195            // `status` reflects what the client actually saw.
1196            let shadow = mockforge_foundation::unknown_paths::shadow_mode_enabled();
1197            let status = if shadow {
1198                StatusCode::OK
1199            } else {
1200                StatusCode::NOT_FOUND
1201            };
1202            mockforge_foundation::unknown_paths::record(
1203                mockforge_foundation::unknown_paths::UnknownPathRequest {
1204                    timestamp: chrono::Utc::now(),
1205                    method,
1206                    path,
1207                    client_ip: "unknown".to_string(),
1208                    query,
1209                    status: status.as_u16(),
1210                },
1211            );
1212            if shadow {
1213                // Minimal JSON stub so clients expecting a body don't
1214                // choke. Shadow mode is for traffic-replay observability,
1215                // not realistic response shapes.
1216                (
1217                    StatusCode::OK,
1218                    [(http::header::CONTENT_TYPE, "application/json")],
1219                    r#"{"shadow":true,"matched":false}"#,
1220                )
1221                    .into_response()
1222            } else {
1223                StatusCode::NOT_FOUND.into_response()
1224            }
1225        }
1226    }
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231    use super::*;
1232
1233    #[tokio::test]
1234    async fn test_create_and_get_mock() {
1235        let state = ManagementState::new(None, None, 3000);
1236
1237        let mock = MockConfig {
1238            id: "test-1".to_string(),
1239            name: "Test Mock".to_string(),
1240            method: "GET".to_string(),
1241            path: "/test".to_string(),
1242            response: MockResponse {
1243                body: serde_json::json!({"message": "test"}),
1244                headers: None,
1245            },
1246            enabled: true,
1247            latency_ms: None,
1248            status_code: Some(200),
1249            request_match: None,
1250            priority: None,
1251            scenario: None,
1252            required_scenario_state: None,
1253            new_scenario_state: None,
1254        };
1255
1256        // Create mock
1257        {
1258            let mut mocks = state.mocks.write().await;
1259            mocks.push(mock.clone());
1260        }
1261
1262        // Get mock
1263        let mocks = state.mocks.read().await;
1264        let found = mocks.iter().find(|m| m.id == "test-1");
1265        assert!(found.is_some());
1266        assert_eq!(found.unwrap().name, "Test Mock");
1267    }
1268
1269    #[tokio::test]
1270    async fn test_server_stats() {
1271        let state = ManagementState::new(None, None, 3000);
1272
1273        // Add some mocks
1274        {
1275            let mut mocks = state.mocks.write().await;
1276            mocks.push(MockConfig {
1277                id: "1".to_string(),
1278                name: "Mock 1".to_string(),
1279                method: "GET".to_string(),
1280                path: "/test1".to_string(),
1281                response: MockResponse {
1282                    body: serde_json::json!({}),
1283                    headers: None,
1284                },
1285                enabled: true,
1286                latency_ms: None,
1287                status_code: Some(200),
1288                request_match: None,
1289                priority: None,
1290                scenario: None,
1291                required_scenario_state: None,
1292                new_scenario_state: None,
1293            });
1294            mocks.push(MockConfig {
1295                id: "2".to_string(),
1296                name: "Mock 2".to_string(),
1297                method: "POST".to_string(),
1298                path: "/test2".to_string(),
1299                response: MockResponse {
1300                    body: serde_json::json!({}),
1301                    headers: None,
1302                },
1303                enabled: false,
1304                latency_ms: None,
1305                status_code: Some(201),
1306                request_match: None,
1307                priority: None,
1308                scenario: None,
1309                required_scenario_state: None,
1310                new_scenario_state: None,
1311            });
1312        }
1313
1314        let mocks = state.mocks.read().await;
1315        assert_eq!(mocks.len(), 2);
1316        assert_eq!(mocks.iter().filter(|m| m.enabled).count(), 1);
1317    }
1318
1319    #[test]
1320    fn test_mock_matches_request_with_xpath_absolute_path() {
1321        let mock = MockConfig {
1322            id: "xpath-1".to_string(),
1323            name: "XPath Match".to_string(),
1324            method: "POST".to_string(),
1325            path: "/xml".to_string(),
1326            response: MockResponse {
1327                body: serde_json::json!({"ok": true}),
1328                headers: None,
1329            },
1330            enabled: true,
1331            latency_ms: None,
1332            status_code: Some(200),
1333            request_match: Some(RequestMatchCriteria {
1334                xpath: Some("/root/order/id".to_string()),
1335                ..Default::default()
1336            }),
1337            priority: None,
1338            scenario: None,
1339            required_scenario_state: None,
1340            new_scenario_state: None,
1341        };
1342
1343        let body = br#"<root><order><id>123</id></order></root>"#;
1344        let headers = std::collections::HashMap::new();
1345        let query = std::collections::HashMap::new();
1346
1347        assert!(mock_matches_request(&mock, "POST", "/xml", &headers, &query, Some(body)));
1348    }
1349
1350    #[test]
1351    fn test_mock_matches_request_with_xpath_text_predicate() {
1352        let mock = MockConfig {
1353            id: "xpath-2".to_string(),
1354            name: "XPath Predicate Match".to_string(),
1355            method: "POST".to_string(),
1356            path: "/xml".to_string(),
1357            response: MockResponse {
1358                body: serde_json::json!({"ok": true}),
1359                headers: None,
1360            },
1361            enabled: true,
1362            latency_ms: None,
1363            status_code: Some(200),
1364            request_match: Some(RequestMatchCriteria {
1365                xpath: Some("//order/id[text()='123']".to_string()),
1366                ..Default::default()
1367            }),
1368            priority: None,
1369            scenario: None,
1370            required_scenario_state: None,
1371            new_scenario_state: None,
1372        };
1373
1374        let body = br#"<root><order><id>123</id></order></root>"#;
1375        let headers = std::collections::HashMap::new();
1376        let query = std::collections::HashMap::new();
1377
1378        assert!(mock_matches_request(&mock, "POST", "/xml", &headers, &query, Some(body)));
1379    }
1380
1381    #[test]
1382    fn test_mock_matches_request_with_xpath_no_match() {
1383        let mock = MockConfig {
1384            id: "xpath-3".to_string(),
1385            name: "XPath No Match".to_string(),
1386            method: "POST".to_string(),
1387            path: "/xml".to_string(),
1388            response: MockResponse {
1389                body: serde_json::json!({"ok": true}),
1390                headers: None,
1391            },
1392            enabled: true,
1393            latency_ms: None,
1394            status_code: Some(200),
1395            request_match: Some(RequestMatchCriteria {
1396                xpath: Some("//order/id[text()='456']".to_string()),
1397                ..Default::default()
1398            }),
1399            priority: None,
1400            scenario: None,
1401            required_scenario_state: None,
1402            new_scenario_state: None,
1403        };
1404
1405        let body = br#"<root><order><id>123</id></order></root>"#;
1406        let headers = std::collections::HashMap::new();
1407        let query = std::collections::HashMap::new();
1408
1409        assert!(!mock_matches_request(&mock, "POST", "/xml", &headers, &query, Some(body)));
1410    }
1411}