use guardflow::validators::{
MatchesRegex, MaxLength, MinLength, NoPii, NotEmpty, OneOf, Profanity, Truncate, ValidJson,
};
use guardflow::{
AsyncGuard, CustomAsyncValidator, Guard, guard_with_retry, guard_with_retry_async,
};
#[test]
fn guard_collects_every_failure_not_just_the_first() {
let guard = Guard::new().with(NotEmpty).with(MaxLength(5));
let result = guard.check("this is way too long");
assert!(!result.passed);
assert_eq!(result.failures.len(), 1);
assert_eq!(result.failures[0].validator, "max_length");
}
#[test]
fn guard_passes_when_every_validator_passes() {
let guard = Guard::new().with(NotEmpty).with(MaxLength(20));
let result = guard.check("short and valid");
assert!(result.passed);
assert!(result.failures.is_empty());
}
#[test]
fn not_empty_fails_on_whitespace_only() {
let guard = Guard::new().with(NotEmpty);
assert!(!guard.check(" ").passed);
}
#[test]
fn min_length_fails_below_threshold() {
let guard = Guard::new().with(MinLength(10));
assert!(!guard.check("short").passed);
assert!(guard.check("this is long enough").passed);
}
#[test]
fn matches_regex_validates_shape() {
let guard = Guard::new().with(MatchesRegex::new(r"^\d{3}-\d{4}$").unwrap());
assert!(guard.check("123-4567").passed);
assert!(!guard.check("not a match").passed);
}
#[test]
fn one_of_requires_exact_membership() {
let guard = Guard::new().with(OneOf::new(["yes", "no", "maybe"]));
assert!(guard.check("yes").passed);
assert!(!guard.check("Yes").passed);
}
#[test]
fn valid_json_rejects_malformed_input() {
let guard = Guard::new().with(ValidJson);
assert!(guard.check(r#"{"a": 1}"#).passed);
assert!(!guard.check("{not json").passed);
}
#[test]
fn no_pii_flags_email_addresses() {
let guard = Guard::new().with(NoPii::default());
assert!(guard.check("no personal data here").passed);
assert!(!guard.check("contact me at alice@example.com").passed);
}
#[tokio::test]
async fn guard_with_retry_stops_at_first_passing_attempt() {
let guard = Guard::new().with(MinLength(5));
let mut attempts = 0;
let result = guard_with_retry(&guard, 5, |_| {
attempts += 1;
let attempt = attempts;
async move {
if attempt < 3 {
"no".to_string()
} else {
"long enough".to_string()
}
}
})
.await;
assert_eq!(result, Ok("long enough".to_string()));
assert_eq!(attempts, 3);
}
#[tokio::test]
async fn guard_with_retry_returns_last_failure_after_exhausting_attempts() {
let guard = Guard::new().with(MinLength(100));
let result = guard_with_retry(&guard, 2, |_| async { "too short".to_string() }).await;
let Err(failure) = result else {
panic!("expected failure after exhausting attempts");
};
assert!(!failure.passed);
}
#[tokio::test]
async fn async_guard_skips_the_network_call_when_a_sync_check_already_fails() {
let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let called_in_closure = called.clone();
let guard = AsyncGuard::new()
.with(MinLength(100))
.with_async(CustomAsyncValidator::new(
"would_call_an_api",
move |_text| {
let called = called_in_closure.clone();
async move {
called.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
},
));
let result = guard.check("too short").await;
assert!(!result.passed);
assert!(!called.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn async_guard_runs_the_async_validator_once_sync_checks_pass() {
let guard = AsyncGuard::new()
.with(MinLength(3))
.with_async(CustomAsyncValidator::new(
"no_refund_promises",
|text| async move {
if text.contains("guaranteed refund") {
Err("promises a refund outside policy".to_string())
} else {
Ok(())
}
},
));
assert!(guard.check("Your order will ship tomorrow.").await.passed);
let result = guard
.check("I can offer you a guaranteed refund today.")
.await;
assert!(!result.passed);
assert_eq!(result.failures[0].validator, "no_refund_promises");
}
#[tokio::test]
async fn guard_with_retry_async_retries_until_the_async_validator_passes() {
let guard = AsyncGuard::new().with_async(CustomAsyncValidator::new(
"no_refund_promises",
|text| async move {
if text.contains("guaranteed refund") {
Err("promises a refund outside policy".to_string())
} else {
Ok(())
}
},
));
let mut attempts = 0;
let result = guard_with_retry_async(&guard, 3, |_| {
attempts += 1;
let attempt = attempts;
async move {
if attempt < 2 {
"I can offer you a guaranteed refund today.".to_string()
} else {
"Your order qualifies for standard support.".to_string()
}
}
})
.await;
assert_eq!(
result,
Ok("Your order qualifies for standard support.".to_string())
);
assert_eq!(attempts, 2);
}
#[test]
fn check_treats_a_fixable_truncation_as_a_failure() {
let guard = Guard::new().with(Truncate(5));
assert!(!guard.check("way too long").passed);
}
#[test]
fn fix_applies_the_truncation_and_reports_it_as_passing() {
let guard = Guard::new().with(Truncate(5));
let (fixed, result) = guard.fix("way too long");
assert_eq!(fixed, "way t");
assert!(result.passed);
}
#[test]
fn fix_runs_later_validators_against_the_already_fixed_text() {
let guard = Guard::new().with(Truncate(5)).with(MaxLength(5));
let (fixed, result) = guard.fix("way too long");
assert_eq!(fixed, "way t");
assert!(result.passed);
}
#[test]
fn fix_still_reports_failures_that_cannot_be_fixed() {
let guard = Guard::new().with(Truncate(100)).with(NotEmpty);
let (fixed, result) = guard.fix("");
assert_eq!(fixed, "");
assert!(!result.passed);
}
#[test]
fn profanity_flags_whole_word_matches_case_insensitively() {
let guard = Guard::new().with(Profanity::new(["heck"]));
assert!(guard.check("a perfectly fine sentence").passed);
assert!(!guard.check("what the HECK").passed);
assert!(guard.check("hecking around is a different word").passed);
}