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