mockforge_core/
verification.rs

1//! Request verification API for MockForge
2//!
3//! This module provides WireMock-style programmatic verification of requests,
4//! allowing test code to verify that specific requests were made (or not made)
5//! with various count assertions.
6//!
7//! ## Example
8//!
9//! ```rust,no_run
10//! use mockforge_core::verification::{VerificationRequest, VerificationCount, verify_requests};
11//! use mockforge_core::request_logger::get_global_logger;
12//!
13//! // Verify that GET /api/users was called exactly 3 times
14//! let pattern = VerificationRequest {
15//!     method: Some("GET".to_string()),
16//!     path: Some("/api/users".to_string()),
17//!     query_params: std::collections::HashMap::new(),
18//!     headers: std::collections::HashMap::new(),
19//!     body_pattern: None,
20//! };
21//!
22//! let logger = get_global_logger().unwrap();
23//! let result = verify_requests(logger, &pattern, VerificationCount::Exactly(3)).await;
24//! assert!(result.matched, "Expected GET /api/users to be called exactly 3 times");
25//! ```
26
27use crate::request_logger::RequestLogEntry;
28use regex::Regex;
29use serde::{Deserialize, Serialize};
30use std::collections::HashMap;
31
32/// Pattern for matching requests during verification
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
34pub struct VerificationRequest {
35    /// HTTP method to match (e.g., "GET", "POST"). Case-insensitive.
36    /// If None, matches any method.
37    pub method: Option<String>,
38
39    /// URL path to match. Supports exact match, wildcards (*, **), and regex.
40    /// If None, matches any path.
41    pub path: Option<String>,
42
43    /// Query parameters to match (all must be present and match).
44    /// If empty, query parameters are not checked.
45    pub query_params: HashMap<String, String>,
46
47    /// Headers to match (all must be present and match). Case-insensitive header names.
48    /// If empty, headers are not checked.
49    pub headers: HashMap<String, String>,
50
51    /// Request body pattern to match. Supports exact match or regex.
52    /// If None, body is not checked.
53    pub body_pattern: Option<String>,
54}
55
56/// Count assertion for verification
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum VerificationCount {
60    /// Request must be made exactly N times
61    Exactly(usize),
62    /// Request must be made at least N times
63    AtLeast(usize),
64    /// Request must be made at most N times
65    AtMost(usize),
66    /// Request must never be made (count must be 0)
67    Never,
68    /// Request must be made at least once (count >= 1)
69    AtLeastOnce,
70}
71
72/// Result of a verification operation
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct VerificationResult {
75    /// Whether the verification passed
76    pub matched: bool,
77    /// Actual count of matching requests
78    pub count: usize,
79    /// Expected count assertion
80    pub expected: VerificationCount,
81    /// All matching request log entries (for inspection)
82    pub matches: Vec<RequestLogEntry>,
83    /// Error message if verification failed
84    pub error_message: Option<String>,
85}
86
87impl VerificationResult {
88    /// Create a successful verification result
89    pub fn success(
90        count: usize,
91        expected: VerificationCount,
92        matches: Vec<RequestLogEntry>,
93    ) -> Self {
94        Self {
95            matched: true,
96            count,
97            expected,
98            matches,
99            error_message: None,
100        }
101    }
102
103    /// Create a failed verification result
104    pub fn failure(
105        count: usize,
106        expected: VerificationCount,
107        matches: Vec<RequestLogEntry>,
108        error_message: String,
109    ) -> Self {
110        Self {
111            matched: false,
112            count,
113            expected,
114            matches,
115            error_message: Some(error_message),
116        }
117    }
118}
119
120/// Check if a request log entry matches the verification pattern
121pub fn matches_verification_pattern(
122    entry: &RequestLogEntry,
123    pattern: &VerificationRequest,
124) -> bool {
125    // Check HTTP method (case-insensitive)
126    if let Some(ref expected_method) = pattern.method {
127        if entry.method.to_uppercase() != expected_method.to_uppercase() {
128            return false;
129        }
130    }
131
132    // Check path (supports exact match, wildcards, and regex)
133    if let Some(ref expected_path) = pattern.path {
134        if !matches_path_pattern(&entry.path, expected_path) {
135            return false;
136        }
137    }
138
139    // Check query parameters
140    // Check query parameters
141    if !pattern.query_params.is_empty() {
142        for (key, expected_value) in &pattern.query_params {
143            let found_value = entry.query_params.get(key);
144            if found_value != Some(expected_value) {
145                return false;
146            }
147        }
148    }
149
150    // Check headers (case-insensitive header names)
151    for (key, expected_value) in &pattern.headers {
152        let header_key_lower = key.to_lowercase();
153        let found = entry
154            .headers
155            .iter()
156            .any(|(k, v)| k.to_lowercase() == header_key_lower && v == expected_value);
157
158        if !found {
159            return false;
160        }
161    }
162
163    // Check body pattern
164    // Note: RequestLogEntry doesn't store request body directly.
165    // This would need to be enhanced or we'd need to check metadata.
166    // For now, we'll skip body checking if body_pattern is specified but body isn't available.
167    if let Some(ref body_pattern) = pattern.body_pattern {
168        // Try to get body from metadata if available
169        if let Some(body_str) = entry.metadata.get("request_body") {
170            if !matches_body_pattern(body_str, body_pattern) {
171                return false;
172            }
173        } else {
174            // If body pattern is specified but body isn't available, we can't verify
175            // This is a limitation - we might want to return false for strict matching
176            // or skip for now. Let's skip for now.
177        }
178    }
179
180    true
181}
182
183/// Match a path against a pattern (supports exact, wildcard, and regex)
184fn matches_path_pattern(path: &str, pattern: &str) -> bool {
185    // Exact match
186    if pattern == path {
187        return true;
188    }
189
190    // Root wildcard matches everything
191    if pattern == "*" {
192        return true;
193    }
194
195    // Try regex matching
196    if let Ok(re) = Regex::new(pattern) {
197        if re.is_match(path) {
198            return true;
199        }
200    }
201
202    // Try wildcard matching
203    if pattern.contains('*') {
204        return matches_wildcard_pattern(path, pattern);
205    }
206
207    false
208}
209
210/// Match a path against a wildcard pattern (* and **)
211fn matches_wildcard_pattern(path: &str, pattern: &str) -> bool {
212    let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
213    let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
214
215    match_wildcard_segments(&pattern_parts, &path_parts, 0, 0)
216}
217
218/// Recursive function to match path segments with wildcards
219fn match_wildcard_segments(
220    pattern_parts: &[&str],
221    path_parts: &[&str],
222    pattern_idx: usize,
223    path_idx: usize,
224) -> bool {
225    // If we've consumed both patterns and paths, it's a match
226    if pattern_idx == pattern_parts.len() && path_idx == path_parts.len() {
227        return true;
228    }
229
230    // If we've consumed the pattern but not the path, no match
231    if pattern_idx == pattern_parts.len() {
232        return false;
233    }
234
235    let current_pattern = pattern_parts[pattern_idx];
236
237    match current_pattern {
238        "*" => {
239            // Single wildcard: match exactly one segment
240            if path_idx < path_parts.len() {
241                // Try consuming one segment
242                if match_wildcard_segments(pattern_parts, path_parts, pattern_idx + 1, path_idx + 1)
243                {
244                    return true;
245                }
246            }
247            false
248        }
249        "**" => {
250            // Double wildcard: match zero or more segments
251            // Try matching zero segments first
252            if match_wildcard_segments(pattern_parts, path_parts, pattern_idx + 1, path_idx) {
253                return true;
254            }
255            // Try matching one or more segments
256            if path_idx < path_parts.len()
257                && match_wildcard_segments(pattern_parts, path_parts, pattern_idx, path_idx + 1)
258            {
259                return true;
260            }
261            false
262        }
263        _ => {
264            // Exact segment match
265            if path_idx < path_parts.len() && path_parts[path_idx] == current_pattern {
266                match_wildcard_segments(pattern_parts, path_parts, pattern_idx + 1, path_idx + 1)
267            } else {
268                false
269            }
270        }
271    }
272}
273
274/// Match a body against a pattern (supports exact match or regex)
275fn matches_body_pattern(body: &str, pattern: &str) -> bool {
276    // Try regex first
277    if let Ok(re) = Regex::new(pattern) {
278        re.is_match(body)
279    } else {
280        // Fall back to exact match
281        body == pattern
282    }
283}
284
285/// Verify requests against a pattern and count assertion
286pub async fn verify_requests(
287    logger: &crate::request_logger::CentralizedRequestLogger,
288    pattern: &VerificationRequest,
289    expected: VerificationCount,
290) -> VerificationResult {
291    // Get all logs
292    let logs = logger.get_recent_logs(None).await;
293
294    // Find matching requests
295    let matches: Vec<RequestLogEntry> = logs
296        .into_iter()
297        .filter(|entry| matches_verification_pattern(entry, pattern))
298        .collect();
299
300    let count = matches.len();
301
302    // Check count assertion
303    let matched = match &expected {
304        VerificationCount::Exactly(n) => count == *n,
305        VerificationCount::AtLeast(n) => count >= *n,
306        VerificationCount::AtMost(n) => count <= *n,
307        VerificationCount::Never => count == 0,
308        VerificationCount::AtLeastOnce => count >= 1,
309    };
310
311    if matched {
312        VerificationResult::success(count, expected, matches)
313    } else {
314        let error_message = format!(
315            "Verification failed: expected {:?}, but found {} matching requests",
316            expected, count
317        );
318        VerificationResult::failure(count, expected, matches, error_message)
319    }
320}
321
322/// Verify that a request was never made
323pub async fn verify_never(
324    logger: &crate::request_logger::CentralizedRequestLogger,
325    pattern: &VerificationRequest,
326) -> VerificationResult {
327    verify_requests(logger, pattern, VerificationCount::Never).await
328}
329
330/// Verify that a request was made at least N times
331pub async fn verify_at_least(
332    logger: &crate::request_logger::CentralizedRequestLogger,
333    pattern: &VerificationRequest,
334    min: usize,
335) -> VerificationResult {
336    verify_requests(logger, pattern, VerificationCount::AtLeast(min)).await
337}
338
339/// Verify that requests occurred in a specific sequence
340pub async fn verify_sequence(
341    logger: &crate::request_logger::CentralizedRequestLogger,
342    patterns: &[VerificationRequest],
343) -> VerificationResult {
344    // Get all logs
345    let logs = logger.get_recent_logs(None).await;
346
347    // Find matches for each pattern in order
348    let mut log_idx = 0;
349    let mut all_matches = Vec::new();
350
351    for pattern in patterns {
352        // Find the next matching request after the last match
353        let mut found = false;
354        while log_idx < logs.len() {
355            if matches_verification_pattern(&logs[log_idx], pattern) {
356                all_matches.push(logs[log_idx].clone());
357                log_idx += 1;
358                found = true;
359                break;
360            }
361            log_idx += 1;
362        }
363
364        if !found {
365            let error_message = format!(
366                "Sequence verification failed: pattern {:?} not found in sequence",
367                pattern
368            );
369            return VerificationResult::failure(
370                all_matches.len(),
371                VerificationCount::Exactly(patterns.len()),
372                all_matches,
373                error_message,
374            );
375        }
376    }
377
378    VerificationResult::success(
379        all_matches.len(),
380        VerificationCount::Exactly(patterns.len()),
381        all_matches,
382    )
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::request_logger::{create_http_log_entry, CentralizedRequestLogger};
389    use chrono::Utc;
390    use std::collections::HashMap;
391
392    fn create_test_entry(method: &str, path: &str) -> RequestLogEntry {
393        create_http_log_entry(
394            method,
395            path,
396            200,
397            100,
398            Some("127.0.0.1".to_string()),
399            Some("test-agent".to_string()),
400            HashMap::new(),
401            1024,
402            None,
403        )
404    }
405
406    #[tokio::test]
407    async fn test_verify_exactly() {
408        let logger = CentralizedRequestLogger::new(100);
409        logger.log_request(create_test_entry("GET", "/api/users")).await;
410        logger.log_request(create_test_entry("GET", "/api/users")).await;
411        logger.log_request(create_test_entry("GET", "/api/users")).await;
412
413        let pattern = VerificationRequest {
414            method: Some("GET".to_string()),
415            path: Some("/api/users".to_string()),
416            query_params: HashMap::new(),
417            headers: HashMap::new(),
418            body_pattern: None,
419        };
420
421        let result = verify_requests(&logger, &pattern, VerificationCount::Exactly(3)).await;
422        assert!(result.matched);
423        assert_eq!(result.count, 3);
424    }
425
426    #[tokio::test]
427    async fn test_verify_at_least() {
428        let logger = CentralizedRequestLogger::new(100);
429        logger.log_request(create_test_entry("POST", "/api/orders")).await;
430        logger.log_request(create_test_entry("POST", "/api/orders")).await;
431
432        let pattern = VerificationRequest {
433            method: Some("POST".to_string()),
434            path: Some("/api/orders".to_string()),
435            query_params: HashMap::new(),
436            headers: HashMap::new(),
437            body_pattern: None,
438        };
439
440        let result = verify_at_least(&logger, &pattern, 2).await;
441        assert!(result.matched);
442        assert_eq!(result.count, 2);
443
444        let result2 = verify_at_least(&logger, &pattern, 1).await;
445        assert!(result2.matched);
446
447        let result3 = verify_at_least(&logger, &pattern, 3).await;
448        assert!(!result3.matched);
449    }
450
451    #[tokio::test]
452    async fn test_verify_never() {
453        let logger = CentralizedRequestLogger::new(100);
454        logger.log_request(create_test_entry("GET", "/api/users")).await;
455
456        let pattern = VerificationRequest {
457            method: Some("DELETE".to_string()),
458            path: Some("/api/users".to_string()),
459            query_params: HashMap::new(),
460            headers: HashMap::new(),
461            body_pattern: None,
462        };
463
464        let result = verify_never(&logger, &pattern).await;
465        assert!(result.matched);
466        assert_eq!(result.count, 0);
467    }
468
469    #[tokio::test]
470    async fn test_verify_sequence() {
471        let logger = CentralizedRequestLogger::new(100);
472        logger.log_request(create_test_entry("POST", "/api/users")).await;
473        logger.log_request(create_test_entry("GET", "/api/users/1")).await;
474        logger.log_request(create_test_entry("PUT", "/api/users/1")).await;
475
476        let patterns = vec![
477            VerificationRequest {
478                method: Some("POST".to_string()),
479                path: Some("/api/users".to_string()),
480                query_params: HashMap::new(),
481                headers: HashMap::new(),
482                body_pattern: None,
483            },
484            VerificationRequest {
485                method: Some("GET".to_string()),
486                path: Some("/api/users/1".to_string()),
487                query_params: HashMap::new(),
488                headers: HashMap::new(),
489                body_pattern: None,
490            },
491        ];
492
493        let result = verify_sequence(&logger, &patterns).await;
494        assert!(result.matched);
495        assert_eq!(result.count, 2);
496    }
497
498    #[test]
499    fn test_matches_path_pattern_exact() {
500        assert!(matches_path_pattern("/api/users", "/api/users"));
501        assert!(!matches_path_pattern("/api/users", "/api/posts"));
502    }
503
504    #[test]
505    fn test_matches_path_pattern_wildcard() {
506        assert!(matches_path_pattern("/api/users/1", "/api/users/*"));
507        assert!(matches_path_pattern("/api/users/123", "/api/users/*"));
508        assert!(!matches_path_pattern("/api/users/1/posts", "/api/users/*"));
509    }
510
511    #[test]
512    fn test_matches_path_pattern_double_wildcard() {
513        assert!(matches_path_pattern("/api/users/1", "/api/**"));
514        assert!(matches_path_pattern("/api/users/1/posts", "/api/**"));
515        assert!(matches_path_pattern("/api/users", "/api/**"));
516    }
517
518    #[test]
519    fn test_matches_path_pattern_regex() {
520        assert!(matches_path_pattern("/api/users/123", r"^/api/users/\d+$"));
521        assert!(!matches_path_pattern("/api/users/abc", r"^/api/users/\d+$"));
522    }
523
524    #[test]
525    fn test_matches_verification_pattern_method() {
526        let entry = create_test_entry("GET", "/api/users");
527        let pattern = VerificationRequest {
528            method: Some("GET".to_string()),
529            path: None,
530            query_params: HashMap::new(),
531            headers: HashMap::new(),
532            body_pattern: None,
533        };
534        assert!(matches_verification_pattern(&entry, &pattern));
535
536        let pattern2 = VerificationRequest {
537            method: Some("POST".to_string()),
538            path: None,
539            query_params: HashMap::new(),
540            headers: HashMap::new(),
541            body_pattern: None,
542        };
543        assert!(!matches_verification_pattern(&entry, &pattern2));
544    }
545
546    #[test]
547    fn test_matches_verification_pattern_path() {
548        let entry = create_test_entry("GET", "/api/users");
549        let pattern = VerificationRequest {
550            method: None,
551            path: Some("/api/users".to_string()),
552            query_params: HashMap::new(),
553            headers: HashMap::new(),
554            body_pattern: None,
555        };
556        assert!(matches_verification_pattern(&entry, &pattern));
557
558        let pattern2 = VerificationRequest {
559            method: None,
560            path: Some("/api/posts".to_string()),
561            query_params: HashMap::new(),
562            headers: HashMap::new(),
563            body_pattern: None,
564        };
565        assert!(!matches_verification_pattern(&entry, &pattern2));
566    }
567}