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 wildcard matching first (before regex, as wildcards are more specific)
196    if pattern.contains('*') {
197        return matches_wildcard_pattern(path, pattern);
198    }
199
200    // Try regex matching (only if no wildcards)
201    if let Ok(re) = Regex::new(pattern) {
202        if re.is_match(path) {
203            return true;
204        }
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 (most recent first)
345    let mut logs = logger.get_recent_logs(None).await;
346    // Reverse to get chronological order (oldest first) for sequence verification
347    logs.reverse();
348
349    // Find matches for each pattern in order
350    let mut log_idx = 0;
351    let mut all_matches = Vec::new();
352
353    for pattern in patterns {
354        // Find the next matching request after the last match
355        let mut found = false;
356        while log_idx < logs.len() {
357            if matches_verification_pattern(&logs[log_idx], pattern) {
358                all_matches.push(logs[log_idx].clone());
359                log_idx += 1;
360                found = true;
361                break;
362            }
363            log_idx += 1;
364        }
365
366        if !found {
367            let error_message = format!(
368                "Sequence verification failed: pattern {:?} not found in sequence",
369                pattern
370            );
371            return VerificationResult::failure(
372                all_matches.len(),
373                VerificationCount::Exactly(patterns.len()),
374                all_matches,
375                error_message,
376            );
377        }
378    }
379
380    VerificationResult::success(
381        all_matches.len(),
382        VerificationCount::Exactly(patterns.len()),
383        all_matches,
384    )
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::request_logger::{create_http_log_entry, CentralizedRequestLogger};
391    use chrono::Utc;
392    use std::collections::HashMap;
393
394    fn create_test_entry(method: &str, path: &str) -> RequestLogEntry {
395        create_http_log_entry(
396            method,
397            path,
398            200,
399            100,
400            Some("127.0.0.1".to_string()),
401            Some("test-agent".to_string()),
402            HashMap::new(),
403            1024,
404            None,
405        )
406    }
407
408    #[tokio::test]
409    async fn test_verify_exactly() {
410        let logger = CentralizedRequestLogger::new(100);
411        logger.log_request(create_test_entry("GET", "/api/users")).await;
412        logger.log_request(create_test_entry("GET", "/api/users")).await;
413        logger.log_request(create_test_entry("GET", "/api/users")).await;
414
415        let pattern = VerificationRequest {
416            method: Some("GET".to_string()),
417            path: Some("/api/users".to_string()),
418            query_params: HashMap::new(),
419            headers: HashMap::new(),
420            body_pattern: None,
421        };
422
423        let result = verify_requests(&logger, &pattern, VerificationCount::Exactly(3)).await;
424        assert!(result.matched);
425        assert_eq!(result.count, 3);
426    }
427
428    #[tokio::test]
429    async fn test_verify_at_least() {
430        let logger = CentralizedRequestLogger::new(100);
431        logger.log_request(create_test_entry("POST", "/api/orders")).await;
432        logger.log_request(create_test_entry("POST", "/api/orders")).await;
433
434        let pattern = VerificationRequest {
435            method: Some("POST".to_string()),
436            path: Some("/api/orders".to_string()),
437            query_params: HashMap::new(),
438            headers: HashMap::new(),
439            body_pattern: None,
440        };
441
442        let result = verify_at_least(&logger, &pattern, 2).await;
443        assert!(result.matched);
444        assert_eq!(result.count, 2);
445
446        let result2 = verify_at_least(&logger, &pattern, 1).await;
447        assert!(result2.matched);
448
449        let result3 = verify_at_least(&logger, &pattern, 3).await;
450        assert!(!result3.matched);
451    }
452
453    #[tokio::test]
454    async fn test_verify_never() {
455        let logger = CentralizedRequestLogger::new(100);
456        logger.log_request(create_test_entry("GET", "/api/users")).await;
457
458        let pattern = VerificationRequest {
459            method: Some("DELETE".to_string()),
460            path: Some("/api/users".to_string()),
461            query_params: HashMap::new(),
462            headers: HashMap::new(),
463            body_pattern: None,
464        };
465
466        let result = verify_never(&logger, &pattern).await;
467        assert!(result.matched);
468        assert_eq!(result.count, 0);
469    }
470
471    #[tokio::test]
472    async fn test_verify_sequence() {
473        let logger = CentralizedRequestLogger::new(100);
474        logger.log_request(create_test_entry("POST", "/api/users")).await;
475        logger.log_request(create_test_entry("GET", "/api/users/1")).await;
476        logger.log_request(create_test_entry("PUT", "/api/users/1")).await;
477
478        let patterns = vec![
479            VerificationRequest {
480                method: Some("POST".to_string()),
481                path: Some("/api/users".to_string()),
482                query_params: HashMap::new(),
483                headers: HashMap::new(),
484                body_pattern: None,
485            },
486            VerificationRequest {
487                method: Some("GET".to_string()),
488                path: Some("/api/users/1".to_string()),
489                query_params: HashMap::new(),
490                headers: HashMap::new(),
491                body_pattern: None,
492            },
493        ];
494
495        let result = verify_sequence(&logger, &patterns).await;
496        assert!(result.matched);
497        assert_eq!(result.count, 2);
498    }
499
500    #[test]
501    fn test_matches_path_pattern_exact() {
502        assert!(matches_path_pattern("/api/users", "/api/users"));
503        assert!(!matches_path_pattern("/api/users", "/api/posts"));
504    }
505
506    #[test]
507    fn test_matches_path_pattern_wildcard() {
508        assert!(matches_path_pattern("/api/users/1", "/api/users/*"));
509        assert!(matches_path_pattern("/api/users/123", "/api/users/*"));
510        assert!(!matches_path_pattern("/api/users/1/posts", "/api/users/*"));
511    }
512
513    #[test]
514    fn test_matches_path_pattern_double_wildcard() {
515        assert!(matches_path_pattern("/api/users/1", "/api/**"));
516        assert!(matches_path_pattern("/api/users/1/posts", "/api/**"));
517        assert!(matches_path_pattern("/api/users", "/api/**"));
518    }
519
520    #[test]
521    fn test_matches_path_pattern_regex() {
522        assert!(matches_path_pattern("/api/users/123", r"^/api/users/\d+$"));
523        assert!(!matches_path_pattern("/api/users/abc", r"^/api/users/\d+$"));
524    }
525
526    #[test]
527    fn test_matches_verification_pattern_method() {
528        let entry = create_test_entry("GET", "/api/users");
529        let pattern = VerificationRequest {
530            method: Some("GET".to_string()),
531            path: None,
532            query_params: HashMap::new(),
533            headers: HashMap::new(),
534            body_pattern: None,
535        };
536        assert!(matches_verification_pattern(&entry, &pattern));
537
538        let pattern2 = VerificationRequest {
539            method: Some("POST".to_string()),
540            path: None,
541            query_params: HashMap::new(),
542            headers: HashMap::new(),
543            body_pattern: None,
544        };
545        assert!(!matches_verification_pattern(&entry, &pattern2));
546    }
547
548    #[test]
549    fn test_matches_verification_pattern_path() {
550        let entry = create_test_entry("GET", "/api/users");
551        let pattern = VerificationRequest {
552            method: None,
553            path: Some("/api/users".to_string()),
554            query_params: HashMap::new(),
555            headers: HashMap::new(),
556            body_pattern: None,
557        };
558        assert!(matches_verification_pattern(&entry, &pattern));
559
560        let pattern2 = VerificationRequest {
561            method: None,
562            path: Some("/api/posts".to_string()),
563            query_params: HashMap::new(),
564            headers: HashMap::new(),
565            body_pattern: None,
566        };
567        assert!(!matches_verification_pattern(&entry, &pattern2));
568    }
569}