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 camel_matchers::{Expectation, expectation_matches};
12use regex::Regex;
13use serde_json::Value;
14
15use crate::assert::body_eq;
16
17/// A matcher over a received [`Body`].
18#[derive(Clone, Debug)]
19#[non_exhaustive]
20pub enum BodyMatcher {
21    /// The body equals the given value (variant-tagged structural equality).
22    Equals(Body),
23    /// The body text matches the given regular expression.
24    Regex(String),
25    /// The body text contains the given substring.
26    Contains(String),
27    /// The body text starts with the given prefix.
28    StartsWith(String),
29    /// The body text ends with the given suffix.
30    EndsWith(String),
31    /// The body is present (any variant except `Empty`).
32    Exists,
33    /// The body is a JSON object that is a superset of the given object.
34    JsonSubset(Value),
35}
36
37impl BodyMatcher {
38    /// Evaluate this matcher against a received body.
39    pub fn matches(&self, actual: &Body) -> bool {
40        match self {
41            BodyMatcher::Equals(expected) => body_eq(expected, actual),
42            BodyMatcher::Regex(pattern) => text_only(actual).is_some_and(|value| {
43                expectation_matches(&Expectation::Regex(pattern.clone()), &value)
44            }),
45            BodyMatcher::Contains(needle) => text_only(actual).is_some_and(|value| {
46                expectation_matches(&Expectation::Contains(needle.clone()), &value)
47            }),
48            BodyMatcher::StartsWith(prefix) => text_only(actual).is_some_and(|value| {
49                expectation_matches(&Expectation::StartsWith(prefix.clone()), &value)
50            }),
51            BodyMatcher::EndsWith(suffix) => text_only(actual).is_some_and(|value| {
52                expectation_matches(&Expectation::EndsWith(suffix.clone()), &value)
53            }),
54            BodyMatcher::Exists => !matches!(actual, Body::Empty),
55            BodyMatcher::JsonSubset(pattern) => {
56                pattern.is_object()
57                    && json_value(actual).is_some_and(|received| {
58                        expectation_matches(&Expectation::JsonSubset(pattern.clone()), &received)
59                    })
60            }
61        }
62    }
63
64    /// The regex pattern, if this is a [`BodyMatcher::Regex`].
65    pub fn regex_pattern(&self) -> Option<&str> {
66        match self {
67            BodyMatcher::Regex(pattern) => Some(pattern),
68            _ => None,
69        }
70    }
71
72    /// A short note explaining why a non-matching body failed, when the
73    /// failure is a shape mismatch rather than a value mismatch.
74    pub fn mismatch_note(&self, actual: &Body) -> Option<&'static str> {
75        match self {
76            BodyMatcher::Regex(_)
77            | BodyMatcher::Contains(_)
78            | BodyMatcher::StartsWith(_)
79            | BodyMatcher::EndsWith(_) => match actual {
80                Body::Text(_) => None,
81                _ => Some("body is not text"),
82            },
83            BodyMatcher::JsonSubset(pattern) => {
84                if !pattern.is_object() {
85                    return Some("body is not JSON");
86                }
87                match json_value(actual) {
88                    None => Some("body is not JSON"),
89                    Some(received) => {
90                        if received.is_object() {
91                            None
92                        } else {
93                            Some("body is not a JSON object")
94                        }
95                    }
96                }
97            }
98            BodyMatcher::Equals(_) | BodyMatcher::Exists => None,
99        }
100    }
101}
102
103impl fmt::Display for BodyMatcher {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            BodyMatcher::Equals(v) => write!(f, "equals {}", compact_body(v)),
107            BodyMatcher::Regex(p) => write!(f, "regex {p}"),
108            BodyMatcher::Contains(n) => write!(f, "contains {n}"),
109            BodyMatcher::StartsWith(p) => write!(f, "startsWith {p}"),
110            BodyMatcher::EndsWith(s) => write!(f, "endsWith {s}"),
111            BodyMatcher::Exists => write!(f, "exists"),
112            BodyMatcher::JsonSubset(v) => write!(f, "jsonSubset {}", compact(v)),
113        }
114    }
115}
116
117/// A matcher over a received header value.
118#[derive(Clone, Debug)]
119#[non_exhaustive]
120pub enum HeaderMatcher {
121    /// The header value equals the given JSON value.
122    Equals(Value),
123    /// The header value (a string) matches the given regular expression.
124    Regex(String),
125    /// The header key is present (any value, including JSON null).
126    Exists,
127}
128
129impl HeaderMatcher {
130    /// Evaluate this matcher against a received header value.
131    pub fn matches(&self, actual: Option<&Value>) -> bool {
132        match self {
133            HeaderMatcher::Exists => actual.is_some(),
134            HeaderMatcher::Equals(expected) => match actual {
135                Some(a) => a == expected,
136                None => false,
137            },
138            HeaderMatcher::Regex(pattern) => match actual {
139                Some(Value::String(s)) => compile(pattern).is_some_and(|re| re.is_match(s)),
140                _ => false,
141            },
142        }
143    }
144
145    /// The regex pattern, if this is a [`HeaderMatcher::Regex`].
146    pub fn regex_pattern(&self) -> Option<&str> {
147        match self {
148            HeaderMatcher::Regex(pattern) => Some(pattern),
149            _ => None,
150        }
151    }
152
153    /// A short note explaining why a non-matching header failed, when the
154    /// failure is a shape mismatch rather than a value mismatch.
155    pub fn mismatch_note(&self, actual: Option<&Value>) -> Option<&'static str> {
156        match self {
157            HeaderMatcher::Regex(_) => match actual {
158                Some(Value::String(_)) => None,
159                _ => Some("value is not a string"),
160            },
161            HeaderMatcher::Equals(_) | HeaderMatcher::Exists => None,
162        }
163    }
164}
165
166impl fmt::Display for HeaderMatcher {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        match self {
169            HeaderMatcher::Equals(v) => write!(f, "equals {}", compact(v)),
170            HeaderMatcher::Regex(p) => write!(f, "regex {p}"),
171            HeaderMatcher::Exists => write!(f, "exists"),
172        }
173    }
174}
175
176/// Compile a regex pattern, returning `None` for an invalid pattern.
177fn compile(pattern: &str) -> Option<Regex> {
178    Regex::new(pattern).ok()
179}
180
181/// Render a JSON value compactly.
182fn compact(v: &Value) -> String {
183    serde_json::to_string(v).unwrap_or_else(|_| String::new())
184}
185
186/// Render a body compactly for display.
187pub(crate) fn compact_body(body: &Body) -> String {
188    match body {
189        Body::Json(v) => compact(v),
190        Body::Text(s) => s.clone(),
191        other => format!("{other:?}"),
192    }
193}
194
195/// The body text as a JSON string value, if the body is text. Every
196/// other body variant projects to `None` so the string verbs fail
197/// closed on non-text bodies.
198fn text_only(body: &Body) -> Option<Value> {
199    match body {
200        Body::Text(text) => Some(Value::String(text.clone())),
201        _ => None,
202    }
203}
204
205/// Extract the JSON value from a body, if it is JSON or parseable text.
206fn json_value(body: &Body) -> Option<Value> {
207    match body {
208        Body::Json(v) => Some(v.clone()),
209        Body::Text(text) => serde_json::from_str(text).ok(),
210        _ => None,
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use serde_json::json;
218
219    #[test]
220    fn regex_body_pass_and_fail() {
221        assert!(
222            BodyMatcher::Regex("^order-[0-9]+$".into()).matches(&Body::Text("order-42".into()))
223        );
224        assert!(
225            !BodyMatcher::Regex("^order-[0-9]+$".into()).matches(&Body::Text("refunded-42".into()))
226        );
227    }
228
229    #[test]
230    fn substring_and_anchor_matchers() {
231        let body = Body::Text("order-total-42".into());
232        assert!(BodyMatcher::Contains("total".into()).matches(&body));
233        assert!(BodyMatcher::StartsWith("order-".into()).matches(&body));
234        assert!(BodyMatcher::EndsWith("-42".into()).matches(&body));
235    }
236
237    #[test]
238    fn exists_body_variants() {
239        assert!(BodyMatcher::Exists.matches(&Body::Text("x".into())));
240        assert!(!BodyMatcher::Exists.matches(&Body::Empty));
241    }
242
243    #[test]
244    fn string_matchers_fail_non_text() {
245        let json_body = Body::Json(json!({"a": 1}));
246        let bytes_body = Body::Bytes(vec![97u8].into());
247        assert!(!BodyMatcher::Contains("a".into()).matches(&json_body));
248        assert!(!BodyMatcher::Contains("a".into()).matches(&bytes_body));
249        assert_eq!(
250            BodyMatcher::Contains("a".into()).mismatch_note(&json_body),
251            Some("body is not text")
252        );
253        assert_eq!(
254            BodyMatcher::Contains("a".into()).mismatch_note(&bytes_body),
255            Some("body is not text")
256        );
257    }
258
259    #[test]
260    fn string_verbs_delegate_through_text_projection() {
261        let pattern = "^order-[0-9]+$".to_string();
262        let body = Body::Text("order-42".into());
263        assert!(BodyMatcher::Regex(pattern.clone()).matches(&body));
264        // Same verdict as the shared algebra applied to the projected
265        // `Value::String`: the matcher is a thin projection plus
266        // delegation.
267        assert_eq!(
268            BodyMatcher::Regex(pattern.clone()).matches(&body),
269            expectation_matches(
270                &Expectation::Regex(pattern.clone()),
271                &Value::String("order-42".into())
272            )
273        );
274        // A JSON body projects no text, so `contains` fails even though
275        // the serialized JSON would contain the needle.
276        let json_body = Body::Json(json!({"total": 42}));
277        assert!(!BodyMatcher::Contains("total".into()).matches(&json_body));
278    }
279
280    #[test]
281    fn non_text_bodies_fail_closed_for_string_verbs() {
282        let matchers = [
283            BodyMatcher::Regex("x".into()),
284            BodyMatcher::Contains("x".into()),
285            BodyMatcher::StartsWith("x".into()),
286            BodyMatcher::EndsWith("x".into()),
287        ];
288        let json_body = Body::Json(json!({"x": 1}));
289        let bytes_body = Body::Bytes(vec![120u8].into());
290        for matcher in &matchers {
291            assert!(!matcher.matches(&json_body), "{matcher:?} over json");
292            assert!(!matcher.matches(&bytes_body), "{matcher:?} over bytes");
293            assert!(!matcher.matches(&Body::Empty), "{matcher:?} over empty");
294            assert_eq!(matcher.mismatch_note(&json_body), Some("body is not text"));
295            assert_eq!(matcher.mismatch_note(&bytes_body), Some("body is not text"));
296        }
297    }
298
299    #[test]
300    fn json_subset_local_duplicate_deleted() {
301        // Grep oracle (ADR-0072 step 2): the local recursive-subset
302        // implementation was deleted in favor of the shared algebra in
303        // `camel-matchers`; this file must not define it anymore. The
304        // needle is the deleted definition's signature, assembled from
305        // split literals so this file's own source text does not contain
306        // the contiguous needle (test fn names such as
307        // `json_subset_arrays_exact` share the prefix and must not trip
308        // the oracle).
309        let source = include_str!("matcher.rs");
310        let needle = concat!("fn json_", "subset(pattern");
311        assert!(!source.contains(needle));
312    }
313
314    #[test]
315    fn json_subset_delegation_preserves_verdicts() {
316        let matcher = BodyMatcher::JsonSubset(json!({"status": "ok", "meta": {"seq": 3}}));
317        let superset = Body::Json(json!({"id": 7, "status": "ok", "meta": {"seq": 3, "ts": 9}}));
318        assert!(matcher.matches(&superset));
319        let mismatched = Body::Json(json!({"status": "ok", "meta": {"seq": 4}}));
320        assert!(!matcher.matches(&mismatched));
321        // A scalar pattern fails regardless of the body: the
322        // object-pattern guard rejects it before delegation.
323        assert!(!BodyMatcher::JsonSubset(json!(5)).matches(&Body::Json(json!(5))));
324        assert!(!BodyMatcher::JsonSubset(json!(5)).matches(&Body::Text("5".into())));
325    }
326
327    #[test]
328    fn json_subset_recursive_ignores_extra() {
329        let matcher = BodyMatcher::JsonSubset(json!({"status": "ok", "meta": {"seq": 3}}));
330        let body = Body::Json(json!({"id": 7, "status": "ok", "meta": {"seq": 3, "ts": 9}}));
331        assert!(matcher.matches(&body));
332    }
333
334    #[test]
335    fn json_subset_arrays_exact() {
336        let matcher = BodyMatcher::JsonSubset(json!({"tags": ["a", "b"]}));
337        assert!(!matcher.matches(&Body::Json(json!({"tags": ["b", "a"]}))));
338        assert!(matcher.matches(&Body::Json(json!({"tags": ["a", "b"]}))));
339    }
340
341    #[test]
342    fn json_subset_parses_text() {
343        let matcher = BodyMatcher::JsonSubset(json!({"status": "ok"}));
344        assert!(matcher.matches(&Body::Text("{\"status\": \"ok\"}".into())));
345        let bad = Body::Text("ok".into());
346        assert!(!matcher.matches(&bad));
347        assert_eq!(matcher.mismatch_note(&bad), Some("body is not JSON"));
348        assert!(!BodyMatcher::JsonSubset(json!(null)).matches(&Body::Json(json!(null))));
349        assert!(!BodyMatcher::JsonSubset(json!([1, 2])).matches(&Body::Json(json!([1, 2]))));
350    }
351
352    #[test]
353    fn json_subset_null_requires_null() {
354        let matcher = BodyMatcher::JsonSubset(json!({"err": null}));
355        assert!(matcher.matches(&Body::Json(json!({"err": null}))));
356        assert!(!matcher.matches(&Body::Json(json!({"err": 0}))));
357    }
358
359    #[test]
360    fn header_null_and_missing() {
361        assert!(HeaderMatcher::Exists.matches(Some(&Value::Null)));
362        assert!(!HeaderMatcher::Exists.matches(None));
363        assert!(HeaderMatcher::Equals(Value::Null).matches(Some(&Value::Null)));
364        let regex = HeaderMatcher::Regex("^a$".into());
365        assert!(!regex.matches(Some(&Value::Null)));
366        assert_eq!(
367            regex.mismatch_note(Some(&Value::Null)),
368            Some("value is not a string")
369        );
370    }
371}