1use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, PartialEq)]
28#[non_exhaustive]
29pub enum CountBound {
30 Exact(u64),
32 AtLeast(u64),
34 AtMost(u64),
36 Range(u64, u64),
38}
39
40#[derive(Debug, Clone, PartialEq)]
43#[non_exhaustive]
44pub enum PathFilter {
45 Exact(String),
47 Contains(String),
49 Matches(String),
51}
52
53#[derive(Debug, Clone, PartialEq)]
56pub struct RequestExpectation {
57 pub bound: CountBound,
59 pub method: Option<String>,
61 pub path: Option<PathFilter>,
63 pub query: Option<BTreeMap<String, String>>,
67}
68
69#[derive(Debug, Clone, PartialEq)]
81#[non_exhaustive]
82pub enum Expectation {
83 Equals(serde_json::Value),
85 Regex(String),
87 Contains(String),
89 StartsWith(String),
91 EndsWith(String),
93 Exists,
95 JsonSubset(serde_json::Value),
97}
98
99pub fn bound_holds(bound: &CountBound, actual: usize) -> bool {
103 let actual = actual as u64;
104 match bound {
105 CountBound::Exact(n) => actual == *n,
106 CountBound::AtLeast(n) => actual >= *n,
107 CountBound::AtMost(n) => actual <= *n,
108 CountBound::Range(min, max) => actual >= *min && actual <= *max,
109 }
110}
111
112pub fn settles_early(bound: &CountBound, actual: usize) -> bool {
119 match bound {
120 CountBound::Exact(_) | CountBound::AtLeast(_) => bound_holds(bound, actual),
121 CountBound::AtMost(_) | CountBound::Range(..) => false,
122 }
123}
124
125pub fn above_ceiling(bound: &CountBound, actual: usize) -> bool {
130 let actual = actual as u64;
131 match bound {
132 CountBound::Exact(_) | CountBound::AtLeast(_) => false,
133 CountBound::AtMost(n) => actual > *n,
134 CountBound::Range(_, max) => actual > *max,
135 }
136}
137
138pub fn query_pairs(path_and_query: &str) -> Vec<(String, String)> {
143 match path_and_query.split_once('?') {
144 Some((_, query)) => form_urlencoded::parse(query.as_bytes())
145 .map(|(key, value)| (key.into_owned(), value.into_owned()))
146 .collect(),
147 None => Vec::new(),
148 }
149}
150
151pub fn matching_count<'a>(
165 requests: impl IntoIterator<Item = (&'a str, &'a str)>,
166 method: Option<&str>,
167 path_filter: Option<&PathFilter>,
168 query: Option<&BTreeMap<String, String>>,
169) -> usize {
170 let matches_regex = match path_filter {
174 Some(PathFilter::Matches(pattern)) => regex::Regex::new(pattern).ok(),
175 _ => None,
176 };
177 requests
178 .into_iter()
179 .filter(|(request_method, path)| {
180 let path_matches = match path_filter {
181 None => true,
182 Some(PathFilter::Exact(p)) => p.as_str() == *path,
183 Some(PathFilter::Contains(s)) => path.contains(s.as_str()),
184 Some(PathFilter::Matches(_)) => {
185 matches_regex.as_ref().is_some_and(|re| re.is_match(path))
186 }
187 };
188 let query_subset = query.is_none_or(|declared| {
189 let pairs = query_pairs(path);
190 declared
191 .iter()
192 .all(|(key, value)| pairs.iter().any(|(k, v)| k == key && v == value))
193 });
194 method.is_none_or(|m| m.eq_ignore_ascii_case(request_method))
195 && path_matches
196 && query_subset
197 })
198 .count()
199}
200
201pub fn render_bound(bound: &CountBound) -> String {
207 match bound {
208 CountBound::Exact(n) => format!("expected {n}"),
209 CountBound::AtLeast(n) => format!("expected at least {n}"),
210 CountBound::AtMost(n) => format!("expected at most {n}"),
211 CountBound::Range(min, max) => format!("expected between {min} and {max}"),
212 }
213}
214
215pub fn expectation_matches(expectation: &Expectation, value: &serde_json::Value) -> bool {
222 match expectation {
223 Expectation::Equals(expected) => value == expected,
224 Expectation::Regex(pattern) => {
225 regex::Regex::new(pattern).is_ok_and(|regex| regex.is_match(&stringify(value)))
226 }
227 Expectation::Contains(needle) => stringify(value).contains(needle),
228 Expectation::StartsWith(prefix) => stringify(value).starts_with(prefix),
229 Expectation::EndsWith(suffix) => stringify(value).ends_with(suffix),
230 Expectation::Exists => value != &serde_json::Value::Null,
231 Expectation::JsonSubset(pattern) => json_subset(pattern, value),
232 }
233}
234
235pub fn stringify(value: &serde_json::Value) -> String {
238 match value {
239 serde_json::Value::String(text) => text.clone(),
240 other => other.to_string(),
241 }
242}
243
244fn json_subset(pattern: &serde_json::Value, actual: &serde_json::Value) -> bool {
248 match (pattern, actual) {
249 (serde_json::Value::Object(pattern_object), serde_json::Value::Object(actual_object)) => {
250 pattern_object.iter().all(|(key, pattern_value)| {
251 actual_object
252 .get(key)
253 .is_some_and(|actual_value| json_subset(pattern_value, actual_value))
254 })
255 }
256 _ => pattern == actual,
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn bound_holds_covers_every_form_at_edges() {
266 let cases = [
267 (CountBound::Exact(2), [false, false, true, false, false]),
268 (CountBound::AtLeast(2), [false, false, true, true, true]),
269 (CountBound::AtMost(2), [true, true, true, false, false]),
270 (CountBound::Range(1, 3), [false, true, true, true, false]),
271 ];
272 for (bound, holds) in cases {
273 for (count, expected) in holds.into_iter().enumerate() {
274 assert_eq!(
275 bound_holds(&bound, count),
276 expected,
277 "{bound:?} at count {count}"
278 );
279 }
280 }
281 }
282
283 #[test]
284 fn settles_early_absence_claims_never_settle() {
285 for count in 0..=5usize {
286 assert!(
287 !settles_early(&CountBound::AtMost(5), count),
288 "AtMost(5) at count {count}"
289 );
290 assert!(
291 !settles_early(&CountBound::Range(0, 5), count),
292 "Range(0, 5) at count {count}"
293 );
294 assert_eq!(
295 settles_early(&CountBound::Exact(2), count),
296 count == 2,
297 "Exact(2) at count {count}"
298 );
299 assert_eq!(
300 settles_early(&CountBound::AtLeast(2), count),
301 count >= 2,
302 "AtLeast(2) at count {count}"
303 );
304 }
305 }
306
307 #[test]
308 fn above_ceiling_only_upper_breaches() {
309 assert!(above_ceiling(&CountBound::AtMost(2), 3));
310 assert!(above_ceiling(&CountBound::Range(1, 2), 3));
311 assert!(!above_ceiling(&CountBound::Exact(2), 99));
312 assert!(!above_ceiling(&CountBound::AtLeast(2), 99));
313 assert!(!above_ceiling(&CountBound::AtMost(2), 2));
314 }
315
316 #[test]
317 fn matching_count_query_subset_order_and_encoding_independent() {
318 let declared = BTreeMap::from([
319 ("a".to_string(), "1".to_string()),
320 ("b".to_string(), "2".to_string()),
321 ]);
322 let reordered_and_encoded = [("GET", "/x?b=2&a=1"), ("POST", "/x?a=%31&b=%32")];
323 assert_eq!(
324 matching_count(reordered_and_encoded, None, None, Some(&declared)),
325 2
326 );
327 assert_eq!(
328 matching_count([("GET", "/x?a=1")], None, None, Some(&declared)),
329 0
330 );
331
332 let only_a = BTreeMap::from([("a".to_string(), "1".to_string())]);
333 assert_eq!(
334 matching_count([("GET", "/x?a=1")], None, None, Some(&only_a)),
335 1
336 );
337 assert_eq!(
338 matching_count([("GET", "/x?b=2")], None, None, Some(&declared)),
339 0
340 );
341 }
342
343 #[test]
344 fn matching_count_invalid_regex_fails_closed() {
345 let filter = PathFilter::Matches("(".to_string());
346 assert_eq!(
347 matching_count([("GET", "/anything")], None, Some(&filter), None),
348 0
349 );
350 }
351
352 #[test]
353 fn matching_count_method_case_insensitive() {
354 let requests = [("POST", "/o"), ("GET", "/o")];
355 assert_eq!(matching_count(requests, Some("post"), None, None), 1);
356 }
357
358 #[test]
359 fn matching_count_path_forms() {
360 let requests = [("GET", "/o?a=1"), ("POST", "/o?a=1&x=2"), ("GET", "/diff")];
361 let exact = PathFilter::Exact("/o?a=1".to_string());
362 let contains = PathFilter::Contains("/o".to_string());
363 let matches = PathFilter::Matches("^/o".to_string());
364 assert_eq!(matching_count(requests, None, Some(&exact), None), 1);
365 assert_eq!(matching_count(requests, None, Some(&contains), None), 2);
366 assert_eq!(matching_count(requests, None, Some(&matches), None), 2);
367 }
368
369 #[test]
370 fn query_pairs_no_question_mark() {
371 assert!(query_pairs("/noquery").is_empty());
372 assert_eq!(
373 query_pairs("/q?a=1"),
374 vec![("a".to_string(), "1".to_string())]
375 );
376 }
377
378 #[test]
379 fn query_pairs_plus_decoding() {
380 assert_eq!(
381 query_pairs("/x?a=1+2"),
382 vec![("a".to_string(), "1 2".to_string())]
383 );
384 }
385
386 #[test]
387 fn expectation_matches_string_forms() {
388 let value = serde_json::json!("hello world");
389 assert!(expectation_matches(
390 &Expectation::Contains("world".to_string()),
391 &value
392 ));
393 assert!(expectation_matches(
394 &Expectation::StartsWith("hello".to_string()),
395 &value
396 ));
397 assert!(expectation_matches(
398 &Expectation::EndsWith("world".to_string()),
399 &value
400 ));
401 assert!(expectation_matches(&Expectation::Exists, &value));
402 assert!(expectation_matches(
403 &Expectation::Regex("^hello".to_string()),
404 &value
405 ));
406 assert!(expectation_matches(
407 &Expectation::Equals(serde_json::json!("hello world")),
408 &value
409 ));
410 assert!(!expectation_matches(
411 &Expectation::Contains("nope".to_string()),
412 &value
413 ));
414 }
415
416 #[test]
417 fn expectation_matches_object_forms() {
418 let value = serde_json::json!({"n": "café", "s": "hello world"});
419 assert!(expectation_matches(
420 &Expectation::Equals(serde_json::json!({"n": "café", "s": "hello world"})),
421 &value
422 ));
423 assert!(expectation_matches(
424 &Expectation::JsonSubset(serde_json::json!({"n": "café"})),
425 &value
426 ));
427 assert!(expectation_matches(
428 &Expectation::Regex("caf".to_string()),
429 &value
430 ));
431 assert!(expectation_matches(&Expectation::Exists, &value));
432 assert!(!expectation_matches(
433 &Expectation::JsonSubset(serde_json::json!({"n": "other"})),
434 &value
435 ));
436 assert!(!expectation_matches(
437 &Expectation::Exists,
438 &serde_json::Value::Null
439 ));
440 assert!(!expectation_matches(
441 &Expectation::Regex("(".to_string()),
442 &value
443 ));
444 }
445
446 #[test]
447 fn json_subset_recursive_objects() {
448 let actual = serde_json::json!({"user": {"name": "María", "role": "admin"}, "extra": 1});
449 assert!(expectation_matches(
450 &Expectation::JsonSubset(serde_json::json!({"user": {"name": "María"}})),
451 &actual
452 ));
453 assert!(!expectation_matches(
454 &Expectation::JsonSubset(serde_json::json!({"user": {"name": "other"}})),
455 &actual
456 ));
457 }
458
459 #[test]
460 fn render_bound_forms() {
461 let bounds = [
462 CountBound::Exact(3),
463 CountBound::AtLeast(3),
464 CountBound::AtMost(2),
465 CountBound::Range(2, 4),
466 ];
467 let rendered: Vec<String> = bounds.iter().map(render_bound).collect();
468 for text in &rendered {
469 assert!(!text.is_empty(), "empty render for {text:?}");
470 }
471 for (index, left) in rendered.iter().enumerate() {
472 for right in &rendered[index + 1..] {
473 assert_ne!(left, right, "duplicate render `{left}`");
474 }
475 }
476 }
477}