Skip to main content

camel_component_mock/
matcher.rs

1//! Assertion matcher vocabulary for the mock testkit.
2//!
3//! [`BodyMatcher`] and [`HeaderMatcher`] describe expected received bodies
4//! and header values. They are assertion-side only: they never change
5//! producer behavior (the producer stays a sink per the component identity
6//! ruling).
7
8use std::fmt;
9
10use camel_component_api::Body;
11use regex::Regex;
12use serde_json::Value;
13
14use crate::assert::body_eq;
15
16/// A matcher over a received [`Body`].
17#[derive(Clone, Debug)]
18#[non_exhaustive]
19pub enum BodyMatcher {
20    /// The body equals the given value (variant-tagged structural equality).
21    Equals(Body),
22    /// The body text matches the given regular expression.
23    Regex(String),
24    /// The body text contains the given substring.
25    Contains(String),
26    /// The body text starts with the given prefix.
27    StartsWith(String),
28    /// The body text ends with the given suffix.
29    EndsWith(String),
30    /// The body is present (any variant except `Empty`).
31    Exists,
32    /// The body is a JSON object that is a superset of the given object.
33    JsonSubset(Value),
34}
35
36impl BodyMatcher {
37    /// Evaluate this matcher against a received body.
38    pub fn matches(&self, actual: &Body) -> bool {
39        match self {
40            BodyMatcher::Equals(expected) => body_eq(expected, actual),
41            BodyMatcher::Regex(pattern) => match actual {
42                Body::Text(text) => compile(pattern).is_some_and(|re| re.is_match(text)),
43                _ => false,
44            },
45            BodyMatcher::Contains(needle) => match actual {
46                Body::Text(text) => text.contains(needle),
47                _ => false,
48            },
49            BodyMatcher::StartsWith(prefix) => match actual {
50                Body::Text(text) => text.starts_with(prefix),
51                _ => false,
52            },
53            BodyMatcher::EndsWith(suffix) => match actual {
54                Body::Text(text) => text.ends_with(suffix),
55                _ => false,
56            },
57            BodyMatcher::Exists => !matches!(actual, Body::Empty),
58            BodyMatcher::JsonSubset(pattern) => {
59                pattern.is_object()
60                    && json_value(actual).is_some_and(|received| json_subset(pattern, &received))
61            }
62        }
63    }
64
65    /// The regex pattern, if this is a [`BodyMatcher::Regex`].
66    pub fn regex_pattern(&self) -> Option<&str> {
67        match self {
68            BodyMatcher::Regex(pattern) => Some(pattern),
69            _ => None,
70        }
71    }
72
73    /// A short note explaining why a non-matching body failed, when the
74    /// failure is a shape mismatch rather than a value mismatch.
75    pub fn mismatch_note(&self, actual: &Body) -> Option<&'static str> {
76        match self {
77            BodyMatcher::Regex(_)
78            | BodyMatcher::Contains(_)
79            | BodyMatcher::StartsWith(_)
80            | BodyMatcher::EndsWith(_) => match actual {
81                Body::Text(_) => None,
82                _ => Some("body is not text"),
83            },
84            BodyMatcher::JsonSubset(pattern) => {
85                if !pattern.is_object() {
86                    return Some("body is not JSON");
87                }
88                match json_value(actual) {
89                    None => Some("body is not JSON"),
90                    Some(received) => {
91                        if received.is_object() {
92                            None
93                        } else {
94                            Some("body is not a JSON object")
95                        }
96                    }
97                }
98            }
99            BodyMatcher::Equals(_) | BodyMatcher::Exists => None,
100        }
101    }
102}
103
104impl fmt::Display for BodyMatcher {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            BodyMatcher::Equals(v) => write!(f, "equals {}", compact_body(v)),
108            BodyMatcher::Regex(p) => write!(f, "regex {p}"),
109            BodyMatcher::Contains(n) => write!(f, "contains {n}"),
110            BodyMatcher::StartsWith(p) => write!(f, "startsWith {p}"),
111            BodyMatcher::EndsWith(s) => write!(f, "endsWith {s}"),
112            BodyMatcher::Exists => write!(f, "exists"),
113            BodyMatcher::JsonSubset(v) => write!(f, "jsonSubset {}", compact(v)),
114        }
115    }
116}
117
118/// A matcher over a received header value.
119#[derive(Clone, Debug)]
120#[non_exhaustive]
121pub enum HeaderMatcher {
122    /// The header value equals the given JSON value.
123    Equals(Value),
124    /// The header value (a string) matches the given regular expression.
125    Regex(String),
126    /// The header key is present (any value, including JSON null).
127    Exists,
128}
129
130impl HeaderMatcher {
131    /// Evaluate this matcher against a received header value.
132    pub fn matches(&self, actual: Option<&Value>) -> bool {
133        match self {
134            HeaderMatcher::Exists => actual.is_some(),
135            HeaderMatcher::Equals(expected) => match actual {
136                Some(a) => a == expected,
137                None => false,
138            },
139            HeaderMatcher::Regex(pattern) => match actual {
140                Some(Value::String(s)) => compile(pattern).is_some_and(|re| re.is_match(s)),
141                _ => false,
142            },
143        }
144    }
145
146    /// The regex pattern, if this is a [`HeaderMatcher::Regex`].
147    pub fn regex_pattern(&self) -> Option<&str> {
148        match self {
149            HeaderMatcher::Regex(pattern) => Some(pattern),
150            _ => None,
151        }
152    }
153
154    /// A short note explaining why a non-matching header failed, when the
155    /// failure is a shape mismatch rather than a value mismatch.
156    pub fn mismatch_note(&self, actual: Option<&Value>) -> Option<&'static str> {
157        match self {
158            HeaderMatcher::Regex(_) => match actual {
159                Some(Value::String(_)) => None,
160                _ => Some("value is not a string"),
161            },
162            HeaderMatcher::Equals(_) | HeaderMatcher::Exists => None,
163        }
164    }
165}
166
167impl fmt::Display for HeaderMatcher {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self {
170            HeaderMatcher::Equals(v) => write!(f, "equals {}", compact(v)),
171            HeaderMatcher::Regex(p) => write!(f, "regex {p}"),
172            HeaderMatcher::Exists => write!(f, "exists"),
173        }
174    }
175}
176
177/// Compile a regex pattern, returning `None` for an invalid pattern.
178fn compile(pattern: &str) -> Option<Regex> {
179    Regex::new(pattern).ok()
180}
181
182/// Render a JSON value compactly.
183fn compact(v: &Value) -> String {
184    serde_json::to_string(v).unwrap_or_else(|_| String::new())
185}
186
187/// Render a body compactly for display.
188pub(crate) fn compact_body(body: &Body) -> String {
189    match body {
190        Body::Json(v) => compact(v),
191        Body::Text(s) => s.clone(),
192        other => format!("{other:?}"),
193    }
194}
195
196/// Extract the JSON value from a body, if it is JSON or parseable text.
197fn json_value(body: &Body) -> Option<Value> {
198    match body {
199        Body::Json(v) => Some(v.clone()),
200        Body::Text(text) => serde_json::from_str(text).ok(),
201        _ => None,
202    }
203}
204
205/// Recursive JSON subset match: every pattern key must exist in `received`
206/// with a value that is JSON-equal or, for objects, a recursive subset.
207fn json_subset(pattern: &Value, received: &Value) -> bool {
208    match (pattern, received) {
209        (Value::Object(p), Value::Object(r)) => p
210            .iter()
211            .all(|(key, pv)| r.get(key).is_some_and(|rv| json_subset(pv, rv))),
212        (Value::Array(p), Value::Array(r)) => {
213            p.len() == r.len() && p.iter().zip(r.iter()).all(|(a, b)| a == b)
214        }
215        _ => pattern == received,
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use serde_json::json;
223
224    #[test]
225    fn regex_body_pass_and_fail() {
226        assert!(
227            BodyMatcher::Regex("^order-[0-9]+$".into()).matches(&Body::Text("order-42".into()))
228        );
229        assert!(
230            !BodyMatcher::Regex("^order-[0-9]+$".into()).matches(&Body::Text("refunded-42".into()))
231        );
232    }
233
234    #[test]
235    fn substring_and_anchor_matchers() {
236        let body = Body::Text("order-total-42".into());
237        assert!(BodyMatcher::Contains("total".into()).matches(&body));
238        assert!(BodyMatcher::StartsWith("order-".into()).matches(&body));
239        assert!(BodyMatcher::EndsWith("-42".into()).matches(&body));
240    }
241
242    #[test]
243    fn exists_body_variants() {
244        assert!(BodyMatcher::Exists.matches(&Body::Text("x".into())));
245        assert!(!BodyMatcher::Exists.matches(&Body::Empty));
246    }
247
248    #[test]
249    fn string_matchers_fail_non_text() {
250        let json_body = Body::Json(json!({"a": 1}));
251        let bytes_body = Body::Bytes(vec![97u8].into());
252        assert!(!BodyMatcher::Contains("a".into()).matches(&json_body));
253        assert!(!BodyMatcher::Contains("a".into()).matches(&bytes_body));
254        assert_eq!(
255            BodyMatcher::Contains("a".into()).mismatch_note(&json_body),
256            Some("body is not text")
257        );
258        assert_eq!(
259            BodyMatcher::Contains("a".into()).mismatch_note(&bytes_body),
260            Some("body is not text")
261        );
262    }
263
264    #[test]
265    fn json_subset_recursive_ignores_extra() {
266        let matcher = BodyMatcher::JsonSubset(json!({"status": "ok", "meta": {"seq": 3}}));
267        let body = Body::Json(json!({"id": 7, "status": "ok", "meta": {"seq": 3, "ts": 9}}));
268        assert!(matcher.matches(&body));
269    }
270
271    #[test]
272    fn json_subset_arrays_exact() {
273        let matcher = BodyMatcher::JsonSubset(json!({"tags": ["a", "b"]}));
274        assert!(!matcher.matches(&Body::Json(json!({"tags": ["b", "a"]}))));
275        assert!(matcher.matches(&Body::Json(json!({"tags": ["a", "b"]}))));
276    }
277
278    #[test]
279    fn json_subset_parses_text() {
280        let matcher = BodyMatcher::JsonSubset(json!({"status": "ok"}));
281        assert!(matcher.matches(&Body::Text("{\"status\": \"ok\"}".into())));
282        let bad = Body::Text("ok".into());
283        assert!(!matcher.matches(&bad));
284        assert_eq!(matcher.mismatch_note(&bad), Some("body is not JSON"));
285        assert!(!BodyMatcher::JsonSubset(json!(null)).matches(&Body::Json(json!(null))));
286        assert!(!BodyMatcher::JsonSubset(json!([1, 2])).matches(&Body::Json(json!([1, 2]))));
287    }
288
289    #[test]
290    fn json_subset_null_requires_null() {
291        let matcher = BodyMatcher::JsonSubset(json!({"err": null}));
292        assert!(matcher.matches(&Body::Json(json!({"err": null}))));
293        assert!(!matcher.matches(&Body::Json(json!({"err": 0}))));
294    }
295
296    #[test]
297    fn header_null_and_missing() {
298        assert!(HeaderMatcher::Exists.matches(Some(&Value::Null)));
299        assert!(!HeaderMatcher::Exists.matches(None));
300        assert!(HeaderMatcher::Equals(Value::Null).matches(Some(&Value::Null)));
301        let regex = HeaderMatcher::Regex("^a$".into());
302        assert!(!regex.matches(Some(&Value::Null)));
303        assert_eq!(
304            regex.mismatch_note(Some(&Value::Null)),
305            Some("value is not a string")
306        );
307    }
308}