Skip to main content

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