#![allow(unused_imports)] #![allow(clippy::panic)] pub use std::{collections::HashMap, sync::Arc, time::Duration};
pub use fraiseql_error::{FraiseQLError, ValidationFieldError};
pub use serde_json::{Value, json};
pub use crate::{
utils::clock::Clock,
validation::{
custom_scalar::CustomScalarResult,
error_responses::GraphQLValidationResponse,
id_policy::{
IdValidator, NumericIdValidator, OpaqueIdValidator, UlidIdValidator, UuidIdValidator,
validate_ids,
},
validators::{RequiredValidator, create_validator_from_rule},
},
};
mod async_validators_tests {
#![allow(clippy::unwrap_used)]
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[tokio::test]
async fn test_email_valid_simple() {
let v = EmailFormatValidator::new();
v.validate_async("user@example.com", "email")
.await
.unwrap_or_else(|e| panic!("valid simple email should pass: {e}"));
}
#[tokio::test]
async fn test_email_valid_subdomain() {
let v = EmailFormatValidator::new();
v.validate_async("user@mail.example.co.uk", "email")
.await
.unwrap_or_else(|e| panic!("valid subdomain email should pass: {e}"));
}
#[tokio::test]
async fn test_email_valid_plus_addressing() {
let v = EmailFormatValidator::new();
v.validate_async("user+tag@example.com", "email")
.await
.unwrap_or_else(|e| panic!("valid plus-addressed email should pass: {e}"));
}
#[tokio::test]
async fn test_email_valid_corporate_domain() {
let v = EmailFormatValidator::new();
v.validate_async("alice@my-company.io", "email")
.await
.unwrap_or_else(|e| panic!("valid corporate email should pass: {e}"));
v.validate_async("bob@university.edu", "email")
.await
.unwrap_or_else(|e| panic!("valid edu email should pass: {e}"));
}
#[tokio::test]
async fn test_email_invalid_no_at() {
let v = EmailFormatValidator::new();
let result = v.validate_async("notanemail", "email").await;
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid email format")),
"expected Validation error about invalid email format, got: {result:?}"
);
}
#[tokio::test]
async fn test_email_invalid_no_tld() {
let v = EmailFormatValidator::new();
let result = v.validate_async("user@localhost", "email").await;
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid email format")),
"expected Validation error about invalid email format, got: {result:?}"
);
}
#[tokio::test]
async fn test_email_invalid_empty() {
let v = EmailFormatValidator::new();
let result = v.validate_async("", "email").await;
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid email format")),
"expected Validation error about invalid email format, got: {result:?}"
);
}
#[tokio::test]
async fn test_email_error_message_contains_field() {
let v = EmailFormatValidator::new();
let err = v.validate_async("bad", "contact_email").await.unwrap_err();
assert!(err.to_string().contains("contact_email"));
}
#[tokio::test]
async fn test_phone_valid_us() {
let v = PhoneE164Validator::new();
v.validate_async("+14155552671", "phone")
.await
.unwrap_or_else(|e| panic!("valid US phone should pass: {e}"));
}
#[tokio::test]
async fn test_phone_valid_uk() {
let v = PhoneE164Validator::new();
v.validate_async("+447911123456", "phone")
.await
.unwrap_or_else(|e| panic!("valid UK phone should pass: {e}"));
}
#[tokio::test]
async fn test_phone_valid_any_country_code() {
let v = PhoneE164Validator::new();
v.validate_async("+819012345678", "phone")
.await
.unwrap_or_else(|e| panic!("valid Japan phone should pass: {e}"));
v.validate_async("+5511987654321", "phone")
.await
.unwrap_or_else(|e| panic!("valid Brazil phone should pass: {e}"));
v.validate_async("+27821234567", "phone")
.await
.unwrap_or_else(|e| panic!("valid South Africa phone should pass: {e}"));
}
#[tokio::test]
async fn test_phone_invalid_missing_plus() {
let v = PhoneE164Validator::new();
let result = v.validate_async("14155552671", "phone").await;
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid E.164")),
"expected Validation error about invalid E.164 phone, got: {result:?}"
);
}
#[tokio::test]
async fn test_phone_invalid_too_short() {
let v = PhoneE164Validator::new();
let result = v.validate_async("+12345", "phone").await;
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid E.164")),
"expected Validation error about invalid E.164 phone, got: {result:?}"
);
}
#[tokio::test]
async fn test_phone_invalid_too_long() {
let v = PhoneE164Validator::new();
let result = v.validate_async("+1234567890123456", "phone").await;
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid E.164")),
"expected Validation error about invalid E.164 phone, got: {result:?}"
);
}
#[tokio::test]
async fn test_phone_invalid_leading_zero_country_code() {
let v = PhoneE164Validator::new();
let result = v.validate_async("+0441234567890", "phone").await;
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Invalid E.164")),
"expected Validation error about invalid E.164 phone, got: {result:?}"
);
}
#[tokio::test]
async fn test_phone_error_message_contains_field() {
let v = PhoneE164Validator::new();
let err = v.validate_async("bad", "mobile_number").await.unwrap_err();
assert!(err.to_string().contains("mobile_number"));
}
#[test]
fn test_async_validator_config() {
let config = AsyncValidatorConfig::new(AsyncValidatorProvider::EmailFormatCheck, 5000)
.with_cache_ttl(3600)
.with_field_pattern("*.email");
assert_eq!(config.provider, AsyncValidatorProvider::EmailFormatCheck);
assert_eq!(config.timeout, Duration::from_secs(5));
assert_eq!(config.cache_ttl_secs, 3600);
assert_eq!(config.field_pattern, "*.email");
}
#[test]
fn test_provider_display() {
assert_eq!(AsyncValidatorProvider::EmailFormatCheck.to_string(), "email_format_check");
assert_eq!(AsyncValidatorProvider::PhoneE164Check.to_string(), "phone_e164_check");
}
#[test]
fn test_email_validator_timeout_is_max() {
let v = EmailFormatValidator::new();
assert_eq!(v.timeout(), Duration::MAX);
}
#[test]
fn test_phone_validator_timeout_is_max() {
let v = PhoneE164Validator::new();
assert_eq!(v.timeout(), Duration::MAX);
}
}
mod checksum_tests {
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_luhn_valid_visa() {
assert!(LuhnValidator::validate("4532015112830366"));
}
#[test]
fn test_luhn_valid_another_visa() {
assert!(LuhnValidator::validate("4111111111111111"));
}
#[test]
fn test_luhn_invalid_checksum() {
assert!(!LuhnValidator::validate("4532015112830367"));
}
#[test]
fn test_luhn_invalid_non_digits() {
assert!(!LuhnValidator::validate("4532-0151-1283-0366"));
}
#[test]
fn test_luhn_empty_string() {
assert!(!LuhnValidator::validate(""));
}
#[test]
fn test_luhn_single_digit() {
assert!(LuhnValidator::validate("0"));
}
#[test]
fn test_luhn_all_zeros() {
assert!(LuhnValidator::validate("0000000000000000"));
}
#[test]
fn test_mod97_valid_iban_gb() {
assert!(Mod97Validator::validate("GB82WEST12345698765432"));
}
#[test]
fn test_mod97_valid_iban_de() {
assert!(Mod97Validator::validate("DE89370400440532013000"));
}
#[test]
fn test_mod97_invalid_checksum() {
assert!(!Mod97Validator::validate("GB82WEST12345698765433"));
}
#[test]
fn test_mod97_invalid_too_short() {
assert!(!Mod97Validator::validate("GB8"));
}
#[test]
fn test_mod97_invalid_special_chars() {
assert!(!Mod97Validator::validate("GB82-WEST-1234"));
}
#[test]
fn test_mod97_lowercase_conversion() {
assert!(Mod97Validator::validate("gb82west12345698765432"));
}
#[test]
fn test_mod97_error_message() {
assert_eq!(Mod97Validator::error_message(), "Invalid checksum (MOD-97 algorithm)");
}
#[test]
fn test_luhn_error_message() {
assert_eq!(LuhnValidator::error_message(), "Invalid checksum (Luhn algorithm)");
}
#[test]
fn test_luhn_exactly_25_digits_accepted() {
let value = "0".repeat(25);
assert!(LuhnValidator::validate(&value));
}
#[test]
fn test_luhn_26_digits_rejected_by_length_guard() {
let value = "0".repeat(26);
assert!(!LuhnValidator::validate(&value), "26-digit string must be rejected");
}
#[test]
fn test_mod97_exactly_34_chars_accepted_structure() {
let value = "A".repeat(34);
let _ = Mod97Validator::validate(&value);
}
#[test]
fn test_mod97_35_chars_rejected_by_length_guard() {
let value = "A".repeat(35);
assert!(
!Mod97Validator::validate(&value),
"35-char string must be rejected by length guard"
);
}
#[test]
fn test_mod97_valid_iban_within_length_limit() {
assert!(Mod97Validator::validate("GB82WEST12345698765432")); }
}
mod compile_time_tests {
#![allow(clippy::unwrap_used)]
use super::super::*;
#[allow(unused_imports)]
use super::*;
fn create_test_context() -> SchemaContext {
let mut types = HashMap::new();
let mut fields = HashMap::new();
types.insert(
"User".to_string(),
TypeDef {
name: "User".to_string(),
fields: vec![
"email".to_string(),
"age".to_string(),
"birthDate".to_string(),
"verified".to_string(),
],
},
);
fields.insert(("User".to_string(), "email".to_string()), FieldType::String);
fields.insert(("User".to_string(), "age".to_string()), FieldType::Integer);
fields.insert(("User".to_string(), "birthDate".to_string()), FieldType::Date);
fields.insert(("User".to_string(), "verified".to_string()), FieldType::Boolean);
types.insert(
"DateRange".to_string(),
TypeDef {
name: "DateRange".to_string(),
fields: vec!["startDate".to_string(), "endDate".to_string()],
},
);
fields.insert(("DateRange".to_string(), "startDate".to_string()), FieldType::Date);
fields.insert(("DateRange".to_string(), "endDate".to_string()), FieldType::Date);
SchemaContext { types, fields }
}
#[test]
fn test_valid_cross_field_comparison() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_cross_field_rule("DateRange", "startDate", "<", "endDate");
assert!(result.valid);
assert!(result.errors.is_empty());
assert!(result.sql_constraint.is_some());
}
#[test]
fn test_cross_field_type_mismatch() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_cross_field_rule("User", "age", "<", "verified");
assert!(!result.valid);
assert!(!result.errors.is_empty());
assert_eq!(result.errors[0].field, "age < verified");
}
#[test]
fn test_cross_field_left_field_not_found() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_cross_field_rule("User", "nonexistent", "<", "age");
assert!(!result.valid);
assert_eq!(result.errors[0].field, "nonexistent");
assert!(result.errors[0].message.contains("not found"));
}
#[test]
fn test_cross_field_right_field_not_found() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_cross_field_rule("User", "age", "<", "nonexistent");
assert!(!result.valid);
assert_eq!(result.errors[0].field, "nonexistent");
}
#[test]
fn test_cross_field_type_not_found() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_cross_field_rule("NonexistentType", "field", "<", "field2");
assert!(!result.valid);
assert!(result.errors[0].message.contains("not found"));
}
#[test]
fn test_same_types_compatible() {
let left = FieldType::Integer;
let right = FieldType::Integer;
assert!(left.is_comparable_with(&right));
}
#[test]
fn test_numeric_types_compatible() {
let left = FieldType::Integer;
let right = FieldType::Float;
assert!(left.is_comparable_with(&right));
}
#[test]
fn test_date_datetime_compatible() {
let left = FieldType::Date;
let right = FieldType::DateTime;
assert!(left.is_comparable_with(&right));
}
#[test]
fn test_string_number_incompatible() {
let left = FieldType::String;
let right = FieldType::Integer;
assert!(!left.is_comparable_with(&right));
}
#[test]
fn test_boolean_incompatible_with_numbers() {
let left = FieldType::Boolean;
let right = FieldType::Integer;
assert!(!left.is_comparable_with(&right));
}
#[test]
fn test_sql_constraint_generated() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_cross_field_rule("DateRange", "startDate", "<=", "endDate");
assert!(result.valid);
assert!(result.sql_constraint.is_some());
let sql = result
.sql_constraint
.expect("sql_constraint must be Some when result.valid is true");
assert!(sql.contains("CHECK"));
assert!(sql.contains("startDate"));
assert!(sql.contains("<="));
assert!(sql.contains("endDate"));
}
#[test]
fn test_sql_constraint_with_different_operators() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let operators = vec!["<", ">", "<=", ">=", "==", "!="];
for op in operators {
let result =
validator.validate_cross_field_rule("DateRange", "startDate", op, "endDate");
assert!(result.valid);
let sql =
result.sql_constraint.expect("sql_constraint must be Some for valid operator");
assert!(sql.contains(op) || op == "==" && sql.contains('='));
}
}
#[test]
fn test_valid_elo_expression() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_elo_expression("User", "age >= 18 && verified == true");
assert!(result.valid);
assert!(result.errors.is_empty());
}
#[test]
fn test_elo_expression_unknown_field() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_elo_expression("User", "nonexistent >= 18");
assert!(!result.valid);
assert!(!result.errors.is_empty());
}
#[test]
fn test_elo_expression_type_not_found() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_elo_expression("NonexistentType", "age >= 18");
assert!(!result.valid);
}
#[test]
fn test_elo_field_reference_extraction() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let fields = validator.extract_field_references("age >= 18 && verified == true");
assert!(fields.contains(&"age".to_string()));
assert!(fields.contains(&"verified".to_string()));
}
#[test]
fn test_elo_field_extraction_with_strings() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let fields = validator.extract_field_references("email matches \"pattern\" && age > 10");
assert!(fields.contains(&"email".to_string()));
assert!(fields.contains(&"age".to_string()));
assert!(!fields.contains(&"pattern".to_string())); }
#[test]
fn test_date_range_validation() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_cross_field_rule("DateRange", "startDate", "<=", "endDate");
assert!(result.valid);
let sql = result
.sql_constraint
.expect("sql_constraint must be Some when result.valid is true");
assert!(sql.contains("CHECK"));
}
#[test]
fn test_age_constraint() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_elo_expression("User", "age >= 18 && age <= 120");
assert!(result.valid);
}
#[test]
fn test_email_field_validation() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_elo_expression(
"User",
"email matches \"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}$\"",
);
assert!(result.valid);
}
#[test]
fn test_complex_user_validation() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_elo_expression(
"User",
"email matches pattern && age >= 18 && verified == true",
);
assert!(result.valid);
}
#[test]
fn test_suggestion_on_typo() {
let context = create_test_context();
let validator = CompileTimeValidator::new(context);
let result = validator.validate_cross_field_rule("User", "typ0", "<", "age");
assert!(!result.valid);
assert!(result.errors[0].suggestion.is_some());
assert!(
result.errors[0]
.suggestion
.as_ref()
.expect("suggestion must be Some for typo error")
.contains("Available fields")
);
}
}
mod composite_tests {
use super::super::*;
#[allow(unused_imports)]
use super::*;
use crate::validation::{composite::validate_single_rule, rules::ValidationRule};
#[test]
fn test_validate_all_passes() {
let rules = vec![
ValidationRule::Required,
ValidationRule::Length {
min: Some(5),
max: None,
},
];
let result = validate_all(&rules, "hello123", "password", true);
result.unwrap_or_else(|e| panic!("expected all validators to pass: {e}"));
}
#[test]
fn test_validate_all_fails_first() {
let rules = vec![
ValidationRule::Required,
ValidationRule::Length {
min: Some(10),
max: None,
},
];
let result = validate_all(&rules, "short", "password", true);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("All validators must pass")),
"expected Validation error with 'All validators must pass', got: {result:?}"
);
}
#[test]
fn test_validate_all_fails_second() {
let rules = vec![
ValidationRule::Required,
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
},
];
let result = validate_all(&rules, "Hello123", "username", true);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("All validators must pass")),
"expected Validation error with 'All validators must pass', got: {result:?}"
);
}
#[test]
fn test_validate_all_multiple_failures() {
let rules = vec![
ValidationRule::Required,
ValidationRule::Length {
min: Some(10),
max: None,
},
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
},
];
let result = validate_all(&rules, "Hi", "field", true);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("All validators must pass")),
"expected Validation error with 'All validators must pass', got: {result:?}"
);
}
#[test]
fn test_validate_any_passes_first() {
let rules = vec![
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
},
ValidationRule::Length {
min: Some(20),
max: None,
},
];
let result = validate_any(&rules, "abc", "field", true);
result.unwrap_or_else(|e| panic!("expected any validator to pass: {e}"));
}
#[test]
fn test_validate_any_passes_second() {
let rules = vec![
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
},
ValidationRule::Length {
min: Some(2),
max: None,
},
];
let result = validate_any(&rules, "Hi", "field", true);
result.unwrap_or_else(|e| panic!("expected any validator to pass: {e}"));
}
#[test]
fn test_validate_any_fails_all() {
let rules = vec![
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
},
ValidationRule::Length {
min: Some(20),
max: None,
},
];
let result = validate_any(&rules, "Hi", "field", true);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("At least one validator must pass")),
"expected Validation error with 'At least one validator must pass', got: {result:?}"
);
}
#[test]
fn test_validate_any_multiple_passes() {
let rules = vec![
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
},
ValidationRule::Length {
min: Some(2),
max: None,
},
ValidationRule::Enum {
values: vec!["hello".to_string(), "world".to_string()],
},
];
let result = validate_any(&rules, "hello", "field", true);
result.unwrap_or_else(|e| panic!("expected any validator to pass: {e}"));
}
#[test]
fn test_validate_not_passes_when_rule_fails() {
let rule = ValidationRule::Pattern {
pattern: CompiledPattern::new("^[0-9]+$").expect("valid regex"),
message: None,
};
let result = validate_not(&rule, "abc", "field", true);
result.unwrap_or_else(|e| panic!("expected not-validator to pass when rule fails: {e}"));
}
#[test]
fn test_validate_not_fails_when_rule_passes() {
let rule = ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
};
let result = validate_not(&rule, "abc", "field", true);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must fail but passed")),
"expected Validation error with 'must fail but passed', got: {result:?}"
);
}
#[test]
fn test_validate_optional_skips_absent() {
let rule = ValidationRule::Length {
min: Some(100),
max: None,
};
let result = validate_optional(&rule, "", "field", false);
result.unwrap_or_else(|e| panic!("expected optional to skip absent field: {e}"));
}
#[test]
fn test_validate_optional_applies_present() {
let rule = ValidationRule::Length {
min: Some(5),
max: None,
};
let result = validate_optional(&rule, "hello", "field", true);
result.unwrap_or_else(|e| panic!("expected optional to pass for valid present field: {e}"));
}
#[test]
fn test_validate_optional_fails_present() {
let rule = ValidationRule::Length {
min: Some(10),
max: None,
};
let result = validate_optional(&rule, "hi", "field", true);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error for present field failing rule, got: {result:?}"
);
}
#[test]
fn test_composite_operator_display() {
assert_eq!(CompositeOperator::All.to_string(), "All validators must pass");
assert_eq!(CompositeOperator::Any.to_string(), "At least one validator must pass");
assert_eq!(CompositeOperator::Not.to_string(), "Validator must fail");
assert_eq!(CompositeOperator::Optional.to_string(), "Optional validation");
}
#[test]
fn test_nested_all_and_pattern() {
let rules = vec![
ValidationRule::Required,
ValidationRule::Length {
min: Some(8),
max: Some(20),
},
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[A-Za-z0-9]+$").expect("valid regex"),
message: Some("Username must be alphanumeric".to_string()),
},
];
let result = validate_all(&rules, "User1234", "username", true);
result.unwrap_or_else(|e| panic!("expected nested all+pattern to pass: {e}"));
}
#[test]
fn test_nested_all_fails_on_length() {
let rules = vec![
ValidationRule::Required,
ValidationRule::Length {
min: Some(8),
max: Some(20),
},
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[A-Za-z0-9]+$").expect("valid regex"),
message: Some("Username must be alphanumeric".to_string()),
},
];
let result = validate_all(&rules, "Hi", "username", true);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("All validators must pass")),
"expected Validation error for length failure, got: {result:?}"
);
}
#[test]
fn test_strong_password_pattern_all() {
let rules = vec![
ValidationRule::Required,
ValidationRule::Length {
min: Some(8),
max: None,
},
ValidationRule::Pattern {
pattern: CompiledPattern::new("[A-Z]").expect("valid regex"),
message: Some("Must contain at least one uppercase letter".to_string()),
},
];
let result = validate_all(&rules, "Password123", "password", true);
result.unwrap_or_else(|e| panic!("expected strong password to pass: {e}"));
}
#[test]
fn test_enum_or_pattern_any() {
let rules = vec![
ValidationRule::Enum {
values: vec!["admin".to_string(), "user".to_string()],
},
ValidationRule::Pattern {
pattern: CompiledPattern::new("^guest_[0-9]+$").expect("valid regex"),
message: None,
},
];
let result = validate_any(&rules, "guest_123", "role", true);
result.unwrap_or_else(|e| panic!("expected enum-or-pattern any to pass: {e}"));
}
#[test]
fn test_not_numeric_for_string_field() {
let rule = ValidationRule::Pattern {
pattern: CompiledPattern::new("^[0-9]+$").expect("valid regex"),
message: None,
};
let result = validate_not(&rule, "abc123", "code", true);
result.unwrap_or_else(|e| panic!("expected not-numeric to pass for mixed string: {e}"));
}
#[test]
fn test_composite_error_display() {
let error = CompositeError {
operator: CompositeOperator::All,
errors: vec!["error1".to_string(), "error2".to_string()],
field: "field".to_string(),
};
let display_str = error.to_string();
assert!(display_str.contains("All validators must pass"));
assert!(display_str.contains("error1"));
assert!(display_str.contains("error2"));
}
#[test]
fn test_multiple_validators_with_required() {
let rules = vec![ValidationRule::Required];
let result = validate_all(&rules, "test", "field", true);
result.unwrap_or_else(|e| panic!("expected required validator to pass: {e}"));
}
#[test]
fn test_empty_rules_all() {
let rules: Vec<ValidationRule> = vec![];
let result = validate_all(&rules, "test", "field", true);
result.unwrap_or_else(|e| panic!("expected empty all-rules to pass vacuously: {e}"));
}
#[test]
fn test_empty_rules_any() {
let rules: Vec<ValidationRule> = vec![];
let result = validate_any(&rules, "test", "field", true);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("At least one validator must pass")),
"expected Validation error for empty any-rules, got: {result:?}"
);
}
#[test]
fn test_length_min_max() {
let rule = ValidationRule::Length {
min: Some(5),
max: Some(10),
};
let result = validate_single_rule(&rule, "hello", "password", true);
result.unwrap_or_else(|e| panic!("expected length check to pass for 'hello': {e}"));
let result = validate_single_rule(&rule, "hi", "password", true);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("at least")),
"expected Validation error for too-short value, got: {result:?}"
);
let result = validate_single_rule(&rule, "this_is_too_long", "password", true);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("at most")),
"expected Validation error for too-long value, got: {result:?}"
);
}
}
mod cross_field_tests {
use serde_json::json;
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_operator_parsing() {
assert_eq!(ComparisonOperator::from_str("<"), Some(ComparisonOperator::LessThan));
assert_eq!(ComparisonOperator::from_str("lt"), Some(ComparisonOperator::LessThan));
assert_eq!(ComparisonOperator::from_str("<="), Some(ComparisonOperator::LessEqual));
assert_eq!(ComparisonOperator::from_str("lte"), Some(ComparisonOperator::LessEqual));
assert_eq!(ComparisonOperator::from_str(">"), Some(ComparisonOperator::GreaterThan));
assert_eq!(ComparisonOperator::from_str("gt"), Some(ComparisonOperator::GreaterThan));
assert_eq!(ComparisonOperator::from_str(">="), Some(ComparisonOperator::GreaterEqual));
assert_eq!(ComparisonOperator::from_str("gte"), Some(ComparisonOperator::GreaterEqual));
assert_eq!(ComparisonOperator::from_str("=="), Some(ComparisonOperator::Equal));
assert_eq!(ComparisonOperator::from_str("eq"), Some(ComparisonOperator::Equal));
assert_eq!(ComparisonOperator::from_str("!="), Some(ComparisonOperator::NotEqual));
assert_eq!(ComparisonOperator::from_str("neq"), Some(ComparisonOperator::NotEqual));
assert_eq!(ComparisonOperator::from_str("invalid"), None);
}
#[test]
fn test_numeric_less_than() {
let input = json!({
"start": 10,
"end": 20
});
let result = validate_cross_field_comparison(
&input,
"start",
ComparisonOperator::LessThan,
"end",
None,
);
result.unwrap_or_else(|e| panic!("expected 10 < 20 to pass: {e}"));
}
#[test]
fn test_numeric_less_than_fails() {
let input = json!({
"start": 30,
"end": 20
});
let result = validate_cross_field_comparison(
&input,
"start",
ComparisonOperator::LessThan,
"end",
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be") && message.contains("less than")),
"expected Validation error for 30 < 20, got: {result:?}"
);
}
#[test]
fn test_numeric_equal() {
let input = json!({
"a": 42,
"b": 42
});
let result =
validate_cross_field_comparison(&input, "a", ComparisonOperator::Equal, "b", None);
result.unwrap_or_else(|e| panic!("expected 42 == 42 to pass: {e}"));
}
#[test]
fn test_numeric_not_equal() {
let input = json!({
"a": 10,
"b": 20
});
let result =
validate_cross_field_comparison(&input, "a", ComparisonOperator::NotEqual, "b", None);
result.unwrap_or_else(|e| panic!("expected 10 != 20 to pass: {e}"));
}
#[test]
fn test_numeric_greater_than_or_equal() {
let input = json!({
"min": 10,
"max": 10
});
let result = validate_cross_field_comparison(
&input,
"max",
ComparisonOperator::GreaterEqual,
"min",
None,
);
result.unwrap_or_else(|e| panic!("expected 10 >= 10 to pass: {e}"));
}
#[test]
fn test_string_comparison() {
let input = json!({
"start_name": "alice",
"end_name": "zoe"
});
let result = validate_cross_field_comparison(
&input,
"start_name",
ComparisonOperator::LessThan,
"end_name",
None,
);
result.unwrap_or_else(|e| panic!("expected 'alice' < 'zoe' to pass: {e}"));
}
#[test]
fn test_string_comparison_fails() {
let input = json!({
"start_name": "zoe",
"end_name": "alice"
});
let result = validate_cross_field_comparison(
&input,
"start_name",
ComparisonOperator::LessThan,
"end_name",
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be") && message.contains("less than")),
"expected Validation error for 'zoe' < 'alice', got: {result:?}"
);
}
#[test]
fn test_date_string_comparison() {
let input = json!({
"start_date": "2024-01-01",
"end_date": "2024-12-31"
});
let result = validate_cross_field_comparison(
&input,
"start_date",
ComparisonOperator::LessThan,
"end_date",
None,
);
result.unwrap_or_else(|e| panic!("expected date string comparison to pass: {e}"));
}
#[test]
fn test_float_comparison() {
let input = json!({
"price": 19.99,
"budget": 25.50
});
let result = validate_cross_field_comparison(
&input,
"price",
ComparisonOperator::LessThan,
"budget",
None,
);
result.unwrap_or_else(|e| panic!("expected 19.99 < 25.50 to pass: {e}"));
}
#[test]
fn test_missing_left_field() {
let input = json!({
"end": 20
});
let result = validate_cross_field_comparison(
&input,
"start",
ComparisonOperator::LessThan,
"end",
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not found")),
"expected Validation error for missing left field, got: {result:?}"
);
}
#[test]
fn test_missing_right_field() {
let input = json!({
"start": 10
});
let result = validate_cross_field_comparison(
&input,
"start",
ComparisonOperator::LessThan,
"end",
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not found")),
"expected Validation error for missing right field, got: {result:?}"
);
}
#[test]
fn test_null_fields_skipped() {
let input = json!({
"start": null,
"end": 20
});
let result = validate_cross_field_comparison(
&input,
"start",
ComparisonOperator::LessThan,
"end",
None,
);
result.unwrap_or_else(|e| panic!("expected null field to be skipped: {e}"));
}
#[test]
fn test_both_null_fields_skipped() {
let input = json!({
"start": null,
"end": null
});
let result = validate_cross_field_comparison(
&input,
"start",
ComparisonOperator::LessThan,
"end",
None,
);
result.unwrap_or_else(|e| panic!("expected both null fields to be skipped: {e}"));
}
#[test]
fn test_type_mismatch_error() {
let input = json!({
"start": 10,
"end": "twenty"
});
let result = validate_cross_field_comparison(
&input,
"start",
ComparisonOperator::LessThan,
"end",
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Cannot compare")),
"expected Validation error for type mismatch, got: {result:?}"
);
}
#[test]
fn test_error_includes_context_path() {
let input = json!({
"start": 30,
"end": 20
});
let result = validate_cross_field_comparison(
&input,
"start",
ComparisonOperator::LessThan,
"end",
Some("dateRange"),
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref path, .. }) if *path == Some("dateRange".to_string())),
"expected Validation error with path 'dateRange', got: {result:?}"
);
}
#[test]
fn test_error_message_includes_values() {
let input = json!({
"price": 100,
"max_price": 50
});
let result = validate_cross_field_comparison(
&input,
"price",
ComparisonOperator::LessThan,
"max_price",
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("price") && message.contains("max_price") && message.contains("100") && message.contains("50")),
"expected Validation error with field names and values, got: {result:?}"
);
}
#[test]
fn test_all_operators() {
let test_cases = vec![
(10, 20, ComparisonOperator::LessThan, true),
(10, 10, ComparisonOperator::LessEqual, true),
(20, 10, ComparisonOperator::GreaterThan, true),
(10, 10, ComparisonOperator::GreaterEqual, true),
(42, 42, ComparisonOperator::Equal, true),
(10, 20, ComparisonOperator::NotEqual, true),
(20, 10, ComparisonOperator::LessThan, false),
(10, 20, ComparisonOperator::GreaterThan, false),
];
for (left, right, op, should_pass) in test_cases {
let input = json!({ "a": left, "b": right });
let result = validate_cross_field_comparison(&input, "a", op, "b", None);
assert_eq!(
result.is_ok(),
should_pass,
"Failed for {} {} {}",
left,
op.symbol(),
right
);
}
}
#[test]
fn test_non_object_input() {
let input = json!([1, 2, 3]);
let result =
validate_cross_field_comparison(&input, "a", ComparisonOperator::LessThan, "b", None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not an object")),
"expected Validation error for non-object input, got: {result:?}"
);
}
#[test]
fn test_empty_object() {
let input = json!({});
let result = validate_cross_field_comparison(
&input,
"start",
ComparisonOperator::LessThan,
"end",
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("not found")),
"expected Validation error for empty object, got: {result:?}"
);
}
#[test]
fn test_zero_comparison() {
let input = json!({
"a": 0,
"b": 0
});
let result =
validate_cross_field_comparison(&input, "a", ComparisonOperator::Equal, "b", None);
result.unwrap_or_else(|e| panic!("expected 0 == 0 to pass: {e}"));
}
#[test]
fn test_negative_number_comparison() {
let input = json!({
"a": -10,
"b": 5
});
let result =
validate_cross_field_comparison(&input, "a", ComparisonOperator::LessThan, "b", None);
result.unwrap_or_else(|e| panic!("expected -10 < 5 to pass: {e}"));
}
#[test]
fn test_empty_string_comparison() {
let input = json!({
"a": "",
"b": "text"
});
let result =
validate_cross_field_comparison(&input, "a", ComparisonOperator::LessThan, "b", None);
result.unwrap_or_else(|e| panic!("expected '' < 'text' to pass: {e}"));
}
}
mod custom_scalar_tests {
#![allow(clippy::unwrap_used)]
use serde_json::{Value, json};
use super::super::CustomScalar;
use crate::error::{FraiseQLError, Result};
#[derive(Debug)]
struct EmailScalar;
#[allow(clippy::unnecessary_literal_bound)] impl CustomScalar for EmailScalar {
fn name(&self) -> &str {
"Email"
}
fn serialize(&self, value: &Value) -> Result<Value> {
Ok(value.clone())
}
fn parse_value(&self, value: &Value) -> Result<Value> {
let s = value
.as_str()
.ok_or_else(|| FraiseQLError::validation("Email must be a string"))?;
if !s.contains('@') {
return Err(FraiseQLError::validation(format!("invalid email: {s}")));
}
Ok(Value::String(s.to_string()))
}
fn parse_literal(&self, ast: &Value) -> Result<Value> {
self.parse_value(ast)
}
}
#[test]
fn test_name() {
let scalar = EmailScalar;
assert_eq!(scalar.name(), "Email");
}
#[test]
fn test_serialize_returns_value_unchanged() {
let scalar = EmailScalar;
let v = json!("user@example.com");
assert_eq!(scalar.serialize(&v).unwrap(), v);
}
#[test]
fn test_parse_value_valid_email() {
let scalar = EmailScalar;
let v = json!("user@example.com");
assert_eq!(scalar.parse_value(&v).unwrap(), v);
}
#[test]
fn test_parse_value_invalid_email_no_at() {
let scalar = EmailScalar;
let v = json!("notanemail");
assert!(
matches!(scalar.parse_value(&v), Err(crate::error::FraiseQLError::Validation { .. })),
"email without '@' should fail with Validation error, got: {:?}",
scalar.parse_value(&v)
);
}
#[test]
fn test_parse_value_non_string_input() {
let scalar = EmailScalar;
let v = json!(42);
let err = scalar.parse_value(&v).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("string") || msg.contains("Email"), "unexpected: {msg}");
}
#[test]
fn test_parse_literal_delegates_to_parse_value() {
let scalar = EmailScalar;
let v = json!("lit@example.com");
assert_eq!(scalar.parse_literal(&v).unwrap(), v);
}
#[test]
fn test_custom_scalar_result_type_alias_is_result_value() {
let _result: super::CustomScalarResult = Ok(json!("ok"));
}
}
mod date_validators_tests {
#![allow(clippy::unwrap_used)]
use chrono::Datelike;
use super::super::*;
#[allow(unused_imports)]
use super::*;
use crate::validation::date_validators::{
compare_dates, days_between, get_days_in_month, is_leap_year, parse_date,
};
fn years_ago(years: u32) -> String {
let today = chrono::Utc::now().date_naive();
let y = today.year() - i32::try_from(years).unwrap_or(0);
format!("{y}-{:02}-{:02}", today.month(), today.day())
}
fn today_str() -> String {
chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string()
}
#[test]
fn test_parse_date_valid() {
let result = parse_date("2026-02-08");
let parsed = result.unwrap_or_else(|e| panic!("valid date should parse: {e}"));
assert_eq!(parsed, (2026, 2, 8));
}
#[test]
fn test_parse_date_invalid_format() {
assert!(
matches!(parse_date("2026/02/08"), Err(FraiseQLError::Validation { .. })),
"slash-separated date should fail parsing"
);
assert!(
matches!(parse_date("02-08-2026"), Err(FraiseQLError::Validation { .. })),
"MM-DD-YYYY format should fail parsing"
);
}
#[test]
fn test_parse_date_invalid_month() {
assert!(
matches!(parse_date("2026-13-01"), Err(FraiseQLError::Validation { .. })),
"month 13 should fail validation"
);
assert!(
matches!(parse_date("2026-00-01"), Err(FraiseQLError::Validation { .. })),
"month 0 should fail validation"
);
}
#[test]
fn test_parse_date_invalid_day() {
assert!(
matches!(parse_date("2026-02-30"), Err(FraiseQLError::Validation { .. })),
"Feb 30 should fail validation"
);
assert!(
matches!(parse_date("2026-04-31"), Err(FraiseQLError::Validation { .. })),
"Apr 31 should fail validation"
);
}
#[test]
fn test_leap_year_detection() {
assert!(is_leap_year(2024));
assert!(is_leap_year(2000));
assert!(!is_leap_year(1900));
assert!(!is_leap_year(2025));
}
#[test]
fn test_days_in_month() {
assert_eq!(get_days_in_month(1, 2026), 31);
assert_eq!(get_days_in_month(2, 2024), 29); assert_eq!(get_days_in_month(2, 2026), 28); assert_eq!(get_days_in_month(4, 2026), 30);
}
#[test]
fn test_february_leap_year_edge_case() {
parse_date("2024-02-29")
.unwrap_or_else(|e| panic!("Feb 29 on leap year should parse: {e}"));
assert!(
matches!(parse_date("2024-02-30"), Err(FraiseQLError::Validation { .. })),
"Feb 30 on leap year should fail"
);
}
#[test]
fn test_february_non_leap_year_edge_case() {
parse_date("2025-02-28")
.unwrap_or_else(|e| panic!("Feb 28 on non-leap year should parse: {e}"));
assert!(
matches!(parse_date("2025-02-29"), Err(FraiseQLError::Validation { .. })),
"Feb 29 on non-leap year should fail"
);
}
#[test]
fn test_year_2000_leap_year() {
assert!(is_leap_year(2000));
parse_date("2000-02-29").unwrap_or_else(|e| panic!("Feb 29 in 2000 should parse: {e}"));
}
#[test]
fn test_year_1900_not_leap_year() {
assert!(!is_leap_year(1900));
assert!(
matches!(parse_date("1900-02-29"), Err(FraiseQLError::Validation { .. })),
"Feb 29 in 1900 (not leap) should fail"
);
}
#[test]
fn test_compare_dates() {
assert!(compare_dates((2026, 2, 8), (2026, 2, 7)) > 0);
assert!(compare_dates((2026, 2, 7), (2026, 2, 8)) < 0);
assert_eq!(compare_dates((2026, 2, 8), (2026, 2, 8)), 0);
assert!(compare_dates((2026, 3, 1), (2026, 2, 28)) > 0);
assert!(compare_dates((2027, 1, 1), (2026, 12, 31)) > 0);
}
#[test]
fn test_days_between_same_date() {
assert_eq!(days_between((2026, 2, 8), (2026, 2, 8)), 0);
}
#[test]
fn test_days_between_year_difference() {
let diff = days_between((2027, 2, 8), (2026, 2, 8));
assert!(diff > 0);
}
#[test]
fn test_min_date_passes() {
validate_min_date("2026-02-08", "2026-02-01")
.unwrap_or_else(|e| panic!("date after min should pass: {e}"));
validate_min_date("2026-02-08", "2026-02-08")
.unwrap_or_else(|e| panic!("date equal to min should pass: {e}"));
}
#[test]
fn test_min_date_fails() {
let result = validate_min_date("2026-02-08", "2026-02-09");
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"date before min should fail, got: {result:?}"
);
}
#[test]
fn test_max_date_passes() {
validate_max_date("2026-02-08", "2026-02-15")
.unwrap_or_else(|e| panic!("date before max should pass: {e}"));
validate_max_date("2026-02-08", "2026-02-08")
.unwrap_or_else(|e| panic!("date equal to max should pass: {e}"));
}
#[test]
fn test_max_date_fails() {
let result = validate_max_date("2026-02-08", "2026-02-07");
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"date after max should fail, got: {result:?}"
);
}
#[test]
fn test_date_range_passes() {
validate_date_range("2026-02-08", "2026-01-01", "2026-12-31")
.unwrap_or_else(|e| panic!("date within range should pass: {e}"));
}
#[test]
fn test_date_range_fails_below_min() {
let result = validate_date_range("2025-12-31", "2026-01-01", "2026-12-31");
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"date below range should fail, got: {result:?}"
);
}
#[test]
fn test_date_range_fails_above_max() {
let result = validate_date_range("2027-01-01", "2026-01-01", "2026-12-31");
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"date above range should fail, got: {result:?}"
);
}
#[test]
fn test_min_age_passes_clearly_old_enough() {
validate_min_age(&years_ago(50), 18)
.unwrap_or_else(|e| panic!("50yo should pass min_age=18: {e}"));
}
#[test]
fn test_min_age_fails_too_young() {
let result = validate_min_age(&years_ago(5), 18);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"5yo should fail min_age=18, got: {result:?}"
);
}
#[test]
fn test_min_age_birthday_today_exactly_18() {
validate_min_age(&years_ago(18), 18)
.unwrap_or_else(|e| panic!("exactly 18yo should pass min_age=18: {e}"));
}
#[test]
fn test_max_age_passes_clearly_young_enough() {
validate_max_age(&years_ago(5), 18)
.unwrap_or_else(|e| panic!("5yo should pass max_age=18: {e}"));
}
#[test]
fn test_max_age_fails_too_old() {
let result = validate_max_age(&years_ago(100), 90);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"100yo should fail max_age=90, got: {result:?}"
);
}
#[test]
fn test_max_days_in_future_today_passes() {
validate_max_days_in_future(&today_str(), 0)
.unwrap_or_else(|e| panic!("today should pass max_days_in_future=0: {e}"));
}
#[test]
fn test_max_days_in_future_past_date_passes() {
validate_max_days_in_future("2000-01-01", 0)
.unwrap_or_else(|e| panic!("past date should pass max_days_in_future: {e}"));
}
#[test]
fn test_max_days_in_future_far_future_fails() {
let result = validate_max_days_in_future("9999-12-31", 30);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"year 9999 should fail max_days_in_future=30, got: {result:?}"
);
}
#[test]
fn test_max_days_in_past_today_passes() {
validate_max_days_in_past(&today_str(), 0)
.unwrap_or_else(|e| panic!("today should pass max_days_in_past=0: {e}"));
}
#[test]
fn test_max_days_in_past_far_past_fails() {
let result = validate_max_days_in_past(&years_ago(50), 30);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"50 years ago should fail max_days_in_past=30, got: {result:?}"
);
}
}
mod elo_expressions_tests {
#![allow(clippy::unwrap_used)]
use super::super::*;
#[allow(unused_imports)]
use super::*;
fn create_test_user() -> Value {
json!({
"email": "user@example.com",
"age": 25,
"verified": true,
"birthDate": "2000-01-15",
"role": "user"
})
}
#[test]
fn test_simple_greater_than() {
let eval = EloExpressionEvaluator::new("age > 18".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_simple_greater_than_fails() {
let eval = EloExpressionEvaluator::new("age > 30".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(!result.valid);
}
#[test]
fn test_greater_or_equal() {
let eval = EloExpressionEvaluator::new("age >= 25".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_less_than() {
let eval = EloExpressionEvaluator::new("age < 30".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_less_or_equal() {
let eval = EloExpressionEvaluator::new("age <= 25".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_equality() {
let eval = EloExpressionEvaluator::new("role == \"user\"".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_inequality() {
let eval = EloExpressionEvaluator::new("role != \"admin\"".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_and_both_true() {
let eval = EloExpressionEvaluator::new("age > 18 && verified == true".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_and_first_false() {
let eval = EloExpressionEvaluator::new("age < 18 && verified == true".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(!result.valid);
}
#[test]
fn test_and_second_false() {
let eval = EloExpressionEvaluator::new("age > 18 && verified == false".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(!result.valid);
}
#[test]
fn test_or_both_true() {
let eval = EloExpressionEvaluator::new("age > 18 || role == \"admin\"".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_or_first_true() {
let eval = EloExpressionEvaluator::new("age > 18 || role == \"guest\"".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_or_second_true() {
let eval = EloExpressionEvaluator::new("age > 30 || role == \"user\"".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_or_both_false() {
let eval = EloExpressionEvaluator::new("age > 30 || role == \"admin\"".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(!result.valid);
}
#[test]
fn test_negation() {
let eval = EloExpressionEvaluator::new("!(role == \"admin\")".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_negation_of_true() {
let eval = EloExpressionEvaluator::new("!(verified == true)".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(!result.valid);
}
#[test]
fn test_matches_function() {
let eval = EloExpressionEvaluator::new(
"matches(email, \"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}$\")".to_string(),
);
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_matches_function_fails() {
let eval = EloExpressionEvaluator::new("matches(email, \"^[0-9]+$\")".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(!result.valid);
}
#[test]
fn test_contains_function() {
let eval = EloExpressionEvaluator::new("contains(email, \"example.com\")".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_contains_function_fails() {
let eval = EloExpressionEvaluator::new("contains(email, \"gmail.com\")".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(!result.valid);
}
#[test]
fn test_complex_and_or() {
let eval = EloExpressionEvaluator::new(
"age > 18 && (role == \"user\" || role == \"admin\")".to_string(),
);
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_complex_with_matches() {
let eval = EloExpressionEvaluator::new(
"age >= 18 && matches(email, \"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}$\")"
.to_string(),
);
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_complex_with_negation() {
let eval = EloExpressionEvaluator::new(
"!(role == \"banned\") && age > 18 && verified == true".to_string(),
);
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_field_access_string() {
let eval = EloExpressionEvaluator::new("email == \"user@example.com\"".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_field_access_number() {
let eval = EloExpressionEvaluator::new("age == 25".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_field_access_boolean() {
let eval = EloExpressionEvaluator::new("verified == true".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_unknown_function_error() {
let eval = EloExpressionEvaluator::new("unknown_func(email)".to_string());
let user = create_test_user();
let result = eval.evaluate(&user);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"unknown function should return Validation error, got: {result:?}"
);
}
#[test]
fn test_invalid_regex_error() {
let eval = EloExpressionEvaluator::new("matches(email, \"[\")".to_string());
let user = create_test_user();
let result = eval.evaluate(&user);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"invalid regex in matches() should return Validation error, got: {result:?}"
);
}
#[test]
fn test_wrong_argument_count_error() {
let eval = EloExpressionEvaluator::new("matches(email)".to_string());
let user = create_test_user();
let result = eval.evaluate(&user);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"wrong argument count for matches() should return Validation error, got: {result:?}"
);
}
#[test]
fn test_whitespace_handling() {
let eval = EloExpressionEvaluator::new(" age > 18 ".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_multiple_operators_precedence() {
let eval =
EloExpressionEvaluator::new("age > 20 && age < 30 && role == \"user\"".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_string_literal_quotes() {
let eval = EloExpressionEvaluator::new("role == 'user'".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_number_literals() {
let eval = EloExpressionEvaluator::new("age > 20".to_string());
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_email_validation_pattern() {
let eval = EloExpressionEvaluator::new(
"matches(email, \"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}$\")".to_string(),
);
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_user_creation_rules() {
let eval = EloExpressionEvaluator::new(
"age >= 18 && verified == true && role != \"banned\"".to_string(),
);
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(result.valid);
}
#[test]
fn test_admin_access_rules() {
let eval = EloExpressionEvaluator::new(
"(role == \"admin\" || role == \"moderator\") && verified == true".to_string(),
);
let user = create_test_user();
let result = eval.evaluate(&user).unwrap();
assert!(!result.valid); }
}
mod elo_rust_integration_tests {
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_config_default() {
let config = RustValidatorRegistryConfig::default();
assert!(config.enabled);
assert!(config.cache_validators);
assert_eq!(config.max_cache_size, 1000);
}
#[test]
fn test_validator_creation() {
let validator = EloRustValidator {
name: "test".to_string(),
elo_expression: "x > 0".to_string(),
generated_code: None,
};
assert_eq!(validator.name, "test");
}
#[test]
fn test_registry_register_get() {
let config = RustValidatorRegistryConfig::default();
let registry = RustValidatorRegistry::new(config);
let validator = EloRustValidator {
name: "test".to_string(),
elo_expression: "x > 0".to_string(),
generated_code: None,
};
registry.register(validator);
let retrieved = registry.get("test");
assert!(retrieved.is_some());
}
#[test]
fn test_registry_exists() {
let config = RustValidatorRegistryConfig::default();
let registry = RustValidatorRegistry::new(config);
let validator = EloRustValidator {
name: "test".to_string(),
elo_expression: "x > 0".to_string(),
generated_code: None,
};
registry.register(validator);
assert!(registry.exists("test"));
assert!(!registry.exists("nonexistent"));
}
#[test]
fn test_registry_count() {
let config = RustValidatorRegistryConfig::default();
let registry = RustValidatorRegistry::new(config);
for i in 0..5 {
let validator = EloRustValidator {
name: format!("v{}", i),
elo_expression: "true".to_string(),
generated_code: None,
};
registry.register(validator);
}
assert_eq!(registry.count(), 5);
}
#[test]
fn test_registry_remove() {
let config = RustValidatorRegistryConfig::default();
let registry = RustValidatorRegistry::new(config);
let validator = EloRustValidator {
name: "test".to_string(),
elo_expression: "true".to_string(),
generated_code: None,
};
registry.register(validator);
assert_eq!(registry.count(), 1);
registry.remove("test");
assert_eq!(registry.count(), 0);
}
#[test]
fn test_registry_is_enabled() {
let config = RustValidatorRegistryConfig {
enabled: true,
..Default::default()
};
let registry = RustValidatorRegistry::new(config);
assert!(registry.is_enabled());
let config2 = RustValidatorRegistryConfig {
enabled: false,
..Default::default()
};
let registry2 = RustValidatorRegistry::new(config2);
assert!(!registry2.is_enabled());
}
#[test]
fn test_registry_is_empty() {
let registry = RustValidatorRegistry::new(RustValidatorRegistryConfig::default());
assert!(registry.is_empty());
registry.register(EloRustValidator {
name: "v".to_string(),
elo_expression: "true".to_string(),
generated_code: None,
});
assert!(!registry.is_empty());
}
#[test]
fn test_registry_list_all() {
let registry = RustValidatorRegistry::new(RustValidatorRegistryConfig::default());
assert!(registry.list_all().is_empty());
for i in 0..3 {
registry.register(EloRustValidator {
name: format!("v{i}"),
elo_expression: "true".to_string(),
generated_code: None,
});
}
let all = registry.list_all();
assert_eq!(all.len(), 3);
}
#[test]
fn test_registry_clear() {
let registry = RustValidatorRegistry::new(RustValidatorRegistryConfig::default());
for i in 0..3 {
registry.register(EloRustValidator {
name: format!("v{i}"),
elo_expression: "true".to_string(),
generated_code: None,
});
}
assert_eq!(registry.count(), 3);
registry.clear();
assert_eq!(registry.count(), 0);
assert!(registry.is_empty());
}
#[test]
fn test_registry_config() {
let config = RustValidatorRegistryConfig {
enabled: true,
cache_validators: false,
max_cache_size: 500,
};
let registry = RustValidatorRegistry::new(config);
let cfg = registry.config();
assert!(cfg.enabled);
assert!(!cfg.cache_validators);
assert_eq!(cfg.max_cache_size, 500);
}
}
mod error_responses_tests {
#![allow(clippy::unwrap_used)]
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_create_empty_response() {
let response = GraphQLValidationResponse::new();
assert!(!response.has_errors());
assert_eq!(response.error_count, 0);
}
#[test]
fn test_add_single_error() {
let mut response = GraphQLValidationResponse::new();
let field_error = ValidationFieldError::new("email", "pattern", "Invalid email format");
response.add_field_error(field_error, None);
assert!(response.has_errors());
assert_eq!(response.error_count, 1);
assert_eq!(response.errors[0].extensions.as_ref().unwrap().rule_type, "pattern");
}
#[test]
fn test_add_multiple_errors() {
let mut response = GraphQLValidationResponse::new();
let errors = vec![
ValidationFieldError::new("email", "pattern", "Invalid email"),
ValidationFieldError::new("phone", "pattern", "Invalid phone"),
];
response.add_errors(errors);
assert_eq!(response.error_count, 2);
}
#[test]
fn test_path_parsing() {
let path = GraphQLValidationResponse::parse_path("user.email");
assert_eq!(path, vec!["user".to_string(), "email".to_string()]);
let path = GraphQLValidationResponse::parse_path("address.zipcode");
assert_eq!(path, vec!["address".to_string(), "zipcode".to_string()]);
}
#[test]
fn test_json_serialization() {
let mut response = GraphQLValidationResponse::new();
let field_error = ValidationFieldError::new("field1", "rule1", "Error message");
response.add_field_error(field_error, Some(serde_json::json!({"detail": "extra"})));
let json = response.to_graphql_errors();
assert!(json["error_count"].is_number());
assert!(json["errors"].is_array());
}
#[test]
fn test_from_fraiseql_error() {
let error = FraiseQLError::Validation {
message: "Validation failed".to_string(),
path: Some("user.email".to_string()),
};
let response = GraphQLValidationResponse::from_error(&error);
assert!(response.is_some());
let response = response.unwrap();
assert_eq!(response.error_count, 1);
}
#[test]
fn test_context_inclusion() {
let mut response = GraphQLValidationResponse::new();
let field_error = ValidationFieldError::new("password", "length", "Too short");
let context = serde_json::json!({"minimum_length": 12, "provided_length": 8});
response.add_field_error(field_error, Some(context));
assert!(response.errors[0].extensions.as_ref().unwrap().context.is_some());
}
}
mod id_policy_tests {
#![allow(clippy::unwrap_used)]
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_validate_valid_uuid() {
let result = validate_id("550e8400-e29b-41d4-a716-446655440000", IDPolicy::UUID);
result.unwrap_or_else(|e| panic!("valid UUID should pass: {e}"));
}
#[test]
fn test_validate_valid_uuid_uppercase() {
let result = validate_id("550E8400-E29B-41D4-A716-446655440000", IDPolicy::UUID);
result.unwrap_or_else(|e| panic!("uppercase UUID should pass: {e}"));
}
#[test]
fn test_validate_valid_uuid_mixed_case() {
let result = validate_id("550e8400-E29b-41d4-A716-446655440000", IDPolicy::UUID);
result.unwrap_or_else(|e| panic!("mixed-case UUID should pass: {e}"));
}
#[test]
fn test_validate_nil_uuid() {
let result = validate_id("00000000-0000-0000-0000-000000000000", IDPolicy::UUID);
result.unwrap_or_else(|e| panic!("nil UUID should pass: {e}"));
}
#[test]
fn test_validate_max_uuid() {
let result = validate_id("ffffffff-ffff-ffff-ffff-ffffffffffff", IDPolicy::UUID);
result.unwrap_or_else(|e| panic!("max UUID should pass: {e}"));
}
#[test]
fn test_validate_uuid_wrong_length() {
let result = validate_id("550e8400-e29b-41d4-a716", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"short UUID string should fail with Validation error, got: {result:?}"
);
let err = result.unwrap_err();
assert_eq!(err.policy, IDPolicy::UUID);
assert!(err.message.contains("36 characters"));
}
#[test]
fn test_validate_uuid_extra_chars() {
let result = validate_id("550e8400-e29b-41d4-a716-446655440000x", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"extra chars should fail UUID validation, got: {result:?}"
);
}
#[test]
fn test_validate_uuid_missing_hyphens() {
let result = validate_id("550e8400e29b41d4a716446655440000", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"UUID without hyphens should fail, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("36 characters"));
}
#[test]
fn test_validate_uuid_wrong_segment_lengths() {
let result = validate_id("550e840-e29b-41d4-a716-4466554400001", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"UUID with wrong segment lengths should fail, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("segment"));
}
#[test]
fn test_validate_uuid_non_hex_chars() {
let result = validate_id("550e8400-e29b-41d4-a716-44665544000g", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"UUID with non-hex chars should fail, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("non-hexadecimal"));
}
#[test]
fn test_validate_uuid_special_chars() {
let result = validate_id("550e8400-e29b-41d4-a716-4466554400@0", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"special chars should fail UUID validation, got: {result:?}"
);
}
#[test]
fn test_validate_uuid_empty_string() {
let result = validate_id("", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"empty string should fail UUID validation, got: {result:?}"
);
}
#[test]
fn test_opaque_accepts_any_string() {
validate_id("not-a-uuid", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
validate_id("anything", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
validate_id("12345", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
validate_id("special@chars!#$%", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
}
#[test]
fn test_opaque_accepts_empty_string() {
validate_id("", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept empty string: {e}"));
}
#[test]
fn test_opaque_accepts_uuid() {
validate_id("550e8400-e29b-41d4-a716-446655440000", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept UUID string: {e}"));
}
#[test]
fn test_validate_multiple_valid_uuids() {
let ids = vec![
"550e8400-e29b-41d4-a716-446655440000",
"6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"6ba7b811-9dad-11d1-80b4-00c04fd430c8",
];
validate_ids(&ids, IDPolicy::UUID)
.unwrap_or_else(|e| panic!("all valid UUIDs should pass: {e}"));
}
#[test]
fn test_validate_multiple_fails_on_first_invalid() {
let ids = vec![
"550e8400-e29b-41d4-a716-446655440000",
"invalid-id",
"6ba7b811-9dad-11d1-80b4-00c04fd430c8",
];
let result = validate_ids(&ids, IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"batch with invalid ID should fail, got: {result:?}"
);
assert_eq!(result.unwrap_err().value, "invalid-id");
}
#[test]
fn test_validate_multiple_opaque_all_pass() {
let ids = vec!["anything", "goes", "here", "12345"];
validate_ids(&ids, IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept all strings: {e}"));
}
#[test]
fn test_policy_enforces_uuid() {
assert!(IDPolicy::UUID.enforces_uuid());
assert!(!IDPolicy::OPAQUE.enforces_uuid());
}
#[test]
fn test_policy_as_str() {
assert_eq!(IDPolicy::UUID.as_str(), "uuid");
assert_eq!(IDPolicy::OPAQUE.as_str(), "opaque");
}
#[test]
fn test_policy_default() {
assert_eq!(IDPolicy::default(), IDPolicy::UUID);
}
#[test]
fn test_policy_display() {
assert_eq!(format!("{}", IDPolicy::UUID), "uuid");
assert_eq!(format!("{}", IDPolicy::OPAQUE), "opaque");
}
#[test]
fn test_security_prevent_sql_injection_via_uuid() {
let result = validate_id("'; DROP TABLE users; --", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"SQL injection string should fail UUID validation, got: {result:?}"
);
}
#[test]
fn test_security_prevent_path_traversal_via_uuid() {
let result = validate_id("../../etc/passwd", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"path traversal string should fail UUID validation, got: {result:?}"
);
}
#[test]
fn test_security_opaque_policy_accepts_any_format() {
validate_id("'; DROP TABLE users; --", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept SQL injection string: {e}"));
validate_id("../../etc/passwd", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept path traversal string: {e}"));
}
#[test]
fn test_validation_error_contains_policy_info() {
let err = validate_id("invalid", IDPolicy::UUID).unwrap_err();
assert_eq!(err.policy, IDPolicy::UUID);
assert_eq!(err.value, "invalid");
assert!(!err.message.is_empty());
}
#[test]
fn test_uuid_validator_valid() {
let validator = UuidIdValidator;
let result = validator.validate("550e8400-e29b-41d4-a716-446655440000");
result.unwrap_or_else(|e| panic!("valid UUID should pass UuidIdValidator: {e}"));
}
#[test]
fn test_uuid_validator_invalid() {
let validator = UuidIdValidator;
let result = validator.validate("not-a-uuid");
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"invalid string should fail UuidIdValidator, got: {result:?}"
);
assert_eq!(result.unwrap_err().value, "not-a-uuid");
}
#[test]
fn test_uuid_validator_format_name() {
let validator = UuidIdValidator;
assert_eq!(validator.format_name(), "UUID");
}
#[test]
fn test_uuid_validator_nil_uuid() {
let validator = UuidIdValidator;
validator
.validate("00000000-0000-0000-0000-000000000000")
.unwrap_or_else(|e| panic!("nil UUID should pass UuidIdValidator: {e}"));
}
#[test]
fn test_uuid_validator_uppercase() {
let validator = UuidIdValidator;
validator
.validate("550E8400-E29B-41D4-A716-446655440000")
.unwrap_or_else(|e| panic!("uppercase UUID should pass UuidIdValidator: {e}"));
}
#[test]
fn test_numeric_validator_valid_positive() {
let validator = NumericIdValidator;
validator
.validate("12345")
.unwrap_or_else(|e| panic!("positive int should pass: {e}"));
validator.validate("0").unwrap_or_else(|e| panic!("zero should pass: {e}"));
validator
.validate("9223372036854775807")
.unwrap_or_else(|e| panic!("i64::MAX should pass: {e}"));
}
#[test]
fn test_numeric_validator_valid_negative() {
let validator = NumericIdValidator;
validator
.validate("-1")
.unwrap_or_else(|e| panic!("negative int should pass: {e}"));
validator
.validate("-12345")
.unwrap_or_else(|e| panic!("negative int should pass: {e}"));
validator
.validate("-9223372036854775808")
.unwrap_or_else(|e| panic!("i64::MIN should pass: {e}"));
}
#[test]
fn test_numeric_validator_invalid_float() {
let validator = NumericIdValidator;
let result = validator.validate("123.45");
assert!(
matches!(result, Err(IDValidationError { .. })),
"float string should fail NumericIdValidator, got: {result:?}"
);
let err = result.unwrap_err();
assert_eq!(err.value, "123.45");
}
#[test]
fn test_numeric_validator_invalid_non_numeric() {
let validator = NumericIdValidator;
let result = validator.validate("abc123");
assert!(
matches!(result, Err(IDValidationError { .. })),
"non-numeric string should fail NumericIdValidator, got: {result:?}"
);
}
#[test]
fn test_numeric_validator_overflow() {
let validator = NumericIdValidator;
let result = validator.validate("9223372036854775808");
assert!(
matches!(result, Err(IDValidationError { .. })),
"i64 overflow should fail NumericIdValidator, got: {result:?}"
);
}
#[test]
fn test_numeric_validator_empty_string() {
let validator = NumericIdValidator;
let result = validator.validate("");
assert!(
matches!(result, Err(IDValidationError { .. })),
"empty string should fail NumericIdValidator, got: {result:?}"
);
}
#[test]
fn test_numeric_validator_format_name() {
let validator = NumericIdValidator;
assert_eq!(validator.format_name(), "integer");
}
#[test]
fn test_ulid_validator_valid() {
let validator = UlidIdValidator;
validator
.validate("01ARZ3NDEKTSV4RRFFQ69G5FAV")
.unwrap_or_else(|e| panic!("valid ULID should pass: {e}"));
}
#[test]
fn test_ulid_validator_valid_all_digits() {
let validator = UlidIdValidator;
validator
.validate("01234567890123456789012345")
.unwrap_or_else(|e| panic!("all-digit ULID should pass: {e}"));
}
#[test]
fn test_ulid_validator_valid_all_uppercase() {
let validator = UlidIdValidator;
validator
.validate("ABCDEFGHJKMNPQRSTVWXYZ0123")
.unwrap_or_else(|e| panic!("all-uppercase ULID should pass: {e}"));
}
#[test]
fn test_ulid_validator_invalid_length_short() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5F");
assert!(
matches!(result, Err(IDValidationError { .. })),
"short ULID should fail UlidIdValidator, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("26 characters"));
}
#[test]
fn test_ulid_validator_invalid_length_long() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAVA");
assert!(
matches!(result, Err(IDValidationError { .. })),
"long ULID should fail UlidIdValidator, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("26 characters"));
}
#[test]
fn test_ulid_validator_invalid_lowercase() {
let validator = UlidIdValidator;
let result = validator.validate("01arz3ndektsv4rrffq69g5fav");
assert!(
matches!(result, Err(IDValidationError { .. })),
"lowercase should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_invalid_char_i() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAI");
assert!(
matches!(result, Err(IDValidationError { .. })),
"char 'I' should fail UlidIdValidator, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("Crockford base32"));
}
#[test]
fn test_ulid_validator_invalid_char_l() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAL");
assert!(
matches!(result, Err(IDValidationError { .. })),
"char 'L' should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_invalid_char_o() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAO");
assert!(
matches!(result, Err(IDValidationError { .. })),
"char 'O' should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_invalid_char_u() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAU");
assert!(
matches!(result, Err(IDValidationError { .. })),
"char 'U' should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_invalid_special_chars() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FA-");
assert!(
matches!(result, Err(IDValidationError { .. })),
"special char should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_empty_string() {
let validator = UlidIdValidator;
let result = validator.validate("");
assert!(
matches!(result, Err(IDValidationError { .. })),
"empty string should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_format_name() {
let validator = UlidIdValidator;
assert_eq!(validator.format_name(), "ULID");
}
#[test]
fn test_opaque_validator_any_string() {
let validator = OpaqueIdValidator;
validator
.validate("anything")
.unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
validator
.validate("12345")
.unwrap_or_else(|e| panic!("opaque should accept digits: {e}"));
validator
.validate("special@chars!#$%")
.unwrap_or_else(|e| panic!("opaque should accept special chars: {e}"));
validator
.validate("")
.unwrap_or_else(|e| panic!("opaque should accept empty string: {e}"));
}
#[test]
fn test_opaque_validator_malicious_strings() {
let validator = OpaqueIdValidator;
validator
.validate("'; DROP TABLE users; --")
.unwrap_or_else(|e| panic!("opaque should accept SQL injection: {e}"));
validator
.validate("../../etc/passwd")
.unwrap_or_else(|e| panic!("opaque should accept path traversal: {e}"));
validator
.validate("<script>alert('xss')</script>")
.unwrap_or_else(|e| panic!("opaque should accept XSS: {e}"));
}
#[test]
fn test_opaque_validator_uuid() {
let validator = OpaqueIdValidator;
validator
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("opaque should accept UUID: {e}"));
}
#[test]
fn test_opaque_validator_format_name() {
let validator = OpaqueIdValidator;
assert_eq!(validator.format_name(), "opaque");
}
#[test]
fn test_validators_trait_object() {
let validators: Vec<Box<dyn IdValidator>> = vec![
Box::new(UuidIdValidator),
Box::new(NumericIdValidator),
Box::new(UlidIdValidator),
Box::new(OpaqueIdValidator),
];
for validator in validators {
let name = validator.format_name();
assert!(!name.is_empty());
}
}
#[test]
fn test_validator_selection_by_id_format() {
let uuid = "550e8400-e29b-41d4-a716-446655440000";
let numeric = "12345";
let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let uuid_validator = UuidIdValidator;
let numeric_validator = NumericIdValidator;
let ulid_validator = UlidIdValidator;
uuid_validator
.validate(uuid)
.unwrap_or_else(|e| panic!("UUID validator should accept UUID: {e}"));
numeric_validator
.validate(numeric)
.unwrap_or_else(|e| panic!("numeric validator should accept number: {e}"));
ulid_validator
.validate(ulid)
.unwrap_or_else(|e| panic!("ULID validator should accept ULID: {e}"));
assert!(
matches!(uuid_validator.validate(numeric), Err(IDValidationError { .. })),
"UUID validator should reject numeric ID"
);
assert!(
matches!(numeric_validator.validate(uuid), Err(IDValidationError { .. })),
"numeric validator should reject UUID"
);
assert!(
matches!(ulid_validator.validate(numeric), Err(IDValidationError { .. })),
"ULID validator should reject numeric ID"
);
}
#[test]
fn test_id_validation_profile_uuid() {
let profile = IDValidationProfile::uuid();
assert_eq!(profile.name, "uuid");
profile
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("UUID profile should accept valid UUID: {e}"));
assert!(
matches!(profile.validate("not-a-uuid"), Err(IDValidationError { .. })),
"UUID profile should reject invalid string"
);
}
#[test]
fn test_id_validation_profile_numeric() {
let profile = IDValidationProfile::numeric();
assert_eq!(profile.name, "numeric");
profile
.validate("12345")
.unwrap_or_else(|e| panic!("numeric profile should accept number: {e}"));
assert!(
matches!(profile.validate("not-a-number"), Err(IDValidationError { .. })),
"numeric profile should reject non-number"
);
}
#[test]
fn test_id_validation_profile_ulid() {
let profile = IDValidationProfile::ulid();
assert_eq!(profile.name, "ulid");
profile
.validate("01ARZ3NDEKTSV4RRFFQ69G5FAV")
.unwrap_or_else(|e| panic!("ULID profile should accept valid ULID: {e}"));
assert!(
matches!(profile.validate("not-a-ulid"), Err(IDValidationError { .. })),
"ULID profile should reject invalid string"
);
}
#[test]
fn test_id_validation_profile_opaque() {
let profile = IDValidationProfile::opaque();
assert_eq!(profile.name, "opaque");
profile
.validate("anything")
.unwrap_or_else(|e| panic!("opaque profile should accept any string: {e}"));
profile
.validate("12345")
.unwrap_or_else(|e| panic!("opaque profile should accept digits: {e}"));
profile
.validate("special@chars!#$%")
.unwrap_or_else(|e| panic!("opaque profile should accept special chars: {e}"));
}
#[test]
fn test_id_validation_profile_by_name() {
assert!(IDValidationProfile::by_name("uuid").is_some(), "uuid profile should exist");
assert!(
IDValidationProfile::by_name("numeric").is_some(),
"numeric profile should exist"
);
assert!(IDValidationProfile::by_name("ulid").is_some(), "ulid profile should exist");
assert!(IDValidationProfile::by_name("opaque").is_some(), "opaque profile should exist");
assert!(
IDValidationProfile::by_name("UUID").is_some(),
"UUID (uppercase) should resolve"
);
assert!(
IDValidationProfile::by_name("NUMERIC").is_some(),
"NUMERIC (uppercase) should resolve"
);
assert!(
IDValidationProfile::by_name("ULID").is_some(),
"ULID (uppercase) should resolve"
);
assert!(
IDValidationProfile::by_name("integer").is_some(),
"integer alias should resolve"
);
assert!(IDValidationProfile::by_name("string").is_some(), "string alias should resolve");
assert!(
IDValidationProfile::by_name("invalid").is_none(),
"unknown name should return None"
);
}
#[test]
fn test_id_validation_profile_by_name_uuid_validation() {
let profile = IDValidationProfile::by_name("uuid").unwrap();
assert_eq!(profile.name, "uuid");
profile
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("UUID profile by name should accept valid UUID: {e}"));
}
#[test]
fn test_id_validation_profile_by_name_numeric_validation() {
let profile = IDValidationProfile::by_name("numeric").unwrap();
assert_eq!(profile.name, "numeric");
profile
.validate("12345")
.unwrap_or_else(|e| panic!("numeric profile by name should accept number: {e}"));
}
#[test]
fn test_id_validation_profile_by_name_integer_alias() {
let profile_numeric = IDValidationProfile::by_name("numeric").unwrap();
let profile_integer = IDValidationProfile::by_name("integer").unwrap();
profile_numeric
.validate("12345")
.unwrap_or_else(|e| panic!("numeric profile should accept number: {e}"));
profile_integer
.validate("12345")
.unwrap_or_else(|e| panic!("integer alias should accept number: {e}"));
assert!(
matches!(profile_numeric.validate("not-a-number"), Err(IDValidationError { .. })),
"numeric profile should reject non-number"
);
assert!(
matches!(profile_integer.validate("not-a-number"), Err(IDValidationError { .. })),
"integer alias should reject non-number"
);
}
#[test]
fn test_id_validation_profile_by_name_string_alias() {
let profile_opaque = IDValidationProfile::by_name("opaque").unwrap();
let profile_string = IDValidationProfile::by_name("string").unwrap();
profile_opaque
.validate("anything")
.unwrap_or_else(|e| panic!("opaque profile should accept any string: {e}"));
profile_string
.validate("anything")
.unwrap_or_else(|e| panic!("string alias should accept any string: {e}"));
}
#[test]
fn test_validation_profile_type_as_validator() {
let uuid_type = ValidationProfileType::Uuid(UuidIdValidator);
uuid_type
.as_validator()
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("UUID profile type should accept valid UUID: {e}"));
let numeric_type = ValidationProfileType::Numeric(NumericIdValidator);
numeric_type
.as_validator()
.validate("12345")
.unwrap_or_else(|e| panic!("numeric profile type should accept number: {e}"));
let ulid_type = ValidationProfileType::Ulid(UlidIdValidator);
ulid_type
.as_validator()
.validate("01ARZ3NDEKTSV4RRFFQ69G5FAV")
.unwrap_or_else(|e| panic!("ULID profile type should accept valid ULID: {e}"));
let opaque_type = ValidationProfileType::Opaque(OpaqueIdValidator);
opaque_type
.as_validator()
.validate("any_value")
.unwrap_or_else(|e| panic!("opaque profile type should accept any string: {e}"));
}
#[test]
fn test_id_validation_profile_clone() {
let profile1 = IDValidationProfile::uuid();
let profile2 = profile1.clone();
assert_eq!(profile1.name, profile2.name);
profile1
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("original profile should accept valid UUID: {e}"));
profile2
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("cloned profile should accept valid UUID: {e}"));
}
#[test]
fn test_all_profiles_available() {
let profiles = [
IDValidationProfile::uuid(),
IDValidationProfile::numeric(),
IDValidationProfile::ulid(),
IDValidationProfile::opaque(),
];
assert_eq!(profiles.len(), 4);
assert_eq!(profiles[0].name, "uuid");
assert_eq!(profiles[1].name, "numeric");
assert_eq!(profiles[2].name, "ulid");
assert_eq!(profiles[3].name, "opaque");
}
}
mod inheritance_tests {
#![allow(clippy::unwrap_used)]
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_override_mode() {
let parent = vec![
ValidationRule::Required,
ValidationRule::Length {
min: Some(5),
max: None,
},
];
let child = vec![ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
}];
let result = inherit_validation_rules(&parent, &child, InheritanceMode::Override);
assert_eq!(result.len(), 1);
assert!(matches!(result[0], ValidationRule::Pattern { .. }));
}
#[test]
fn test_merge_mode() {
let parent = vec![
ValidationRule::Required,
ValidationRule::Length {
min: Some(5),
max: None,
},
];
let child = vec![ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
}];
let result = inherit_validation_rules(&parent, &child, InheritanceMode::Merge);
assert_eq!(result.len(), 3);
}
#[test]
fn test_child_first_mode() {
let parent = vec![ValidationRule::Required];
let child = vec![ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
}];
let result = inherit_validation_rules(&parent, &child, InheritanceMode::ChildFirst);
assert_eq!(result.len(), 2);
assert!(matches!(result[0], ValidationRule::Pattern { .. }));
assert!(matches!(result[1], ValidationRule::Required));
}
#[test]
fn test_parent_first_mode() {
let parent = vec![ValidationRule::Required];
let child = vec![ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
}];
let result = inherit_validation_rules(&parent, &child, InheritanceMode::ParentFirst);
assert_eq!(result.len(), 2);
assert!(matches!(result[0], ValidationRule::Required));
assert!(matches!(result[1], ValidationRule::Pattern { .. }));
}
#[test]
fn test_registry_register_type() {
let mut registry = ValidationRuleRegistry::new();
let rules = vec![RuleMetadata::new(ValidationRule::Required, "UserInput")];
registry.register_type("UserInput", rules);
assert!(registry.rules_by_type.contains_key("UserInput"));
}
#[test]
fn test_registry_set_parent() {
let mut registry = ValidationRuleRegistry::new();
registry.set_parent("AdminUserInput", "UserInput");
assert_eq!(registry.get_parent("AdminUserInput"), Some("UserInput"));
}
#[test]
fn test_registry_has_parent() {
let mut registry = ValidationRuleRegistry::new();
registry.set_parent("ChildType", "ParentType");
assert!(registry.has_parent("ChildType"));
assert!(!registry.has_parent("ParentType"));
}
#[test]
fn test_registry_get_rules_with_merge() {
let mut registry = ValidationRuleRegistry::new();
let parent_rules = vec![RuleMetadata::new(ValidationRule::Required, "UserInput")];
registry.register_type("UserInput", parent_rules);
let child_rules = vec![RuleMetadata::new(
ValidationRule::Length {
min: Some(5),
max: None,
},
"AdminUserInput",
)];
registry.register_type("AdminUserInput", child_rules);
registry.set_parent("AdminUserInput", "UserInput");
let inherited = registry.get_rules("AdminUserInput", InheritanceMode::Merge);
assert_eq!(inherited.len(), 2);
}
#[test]
fn test_registry_get_rules_with_override() {
let mut registry = ValidationRuleRegistry::new();
let parent_rules = vec![RuleMetadata::new(ValidationRule::Required, "UserInput")];
registry.register_type("UserInput", parent_rules);
let child_rules = vec![RuleMetadata::new(
ValidationRule::Length {
min: Some(5),
max: None,
},
"AdminUserInput",
)];
registry.register_type("AdminUserInput", child_rules);
registry.set_parent("AdminUserInput", "UserInput");
let inherited = registry.get_rules("AdminUserInput", InheritanceMode::Override);
assert_eq!(inherited.len(), 1);
assert!(matches!(inherited[0].rule, ValidationRule::Length { .. }));
}
#[test]
fn test_validate_inheritance_success() {
let mut registry = ValidationRuleRegistry::new();
let parent_rules = vec![RuleMetadata::new(ValidationRule::Required, "UserInput")];
registry.register_type("UserInput", parent_rules);
let result = validate_inheritance("AdminUserInput", "UserInput", ®istry);
result.unwrap_or_else(|e| panic!("inheritance from registered parent should succeed: {e}"));
}
#[test]
fn test_validate_inheritance_parent_not_found() {
let registry = ValidationRuleRegistry::new();
let result = validate_inheritance("AdminUserInput", "NonExistent", ®istry);
assert!(result.is_err(), "inheritance from unknown parent should fail, got: {result:?}");
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_validate_inheritance_circular() {
let mut registry = ValidationRuleRegistry::new();
let user_rules = vec![RuleMetadata::new(ValidationRule::Required, "UserInput")];
registry.register_type("UserInput", user_rules);
let admin_rules = vec![RuleMetadata::new(
ValidationRule::Required,
"AdminUserInput",
)];
registry.register_type("AdminUserInput", admin_rules);
registry.set_parent("UserInput", "AdminUserInput");
registry.set_parent("AdminUserInput", "UserInput");
let result = validate_inheritance("UserInput", "AdminUserInput", ®istry);
assert!(result.is_err(), "circular inheritance should fail, got: {result:?}");
assert!(result.unwrap_err().contains("Circular"));
}
#[test]
fn test_multi_level_inheritance() {
let mut registry = ValidationRuleRegistry::new();
let grandparent_rules = vec![RuleMetadata::new(ValidationRule::Required, "BaseInput")];
registry.register_type("BaseInput", grandparent_rules);
let parent_rules = vec![RuleMetadata::new(
ValidationRule::Length {
min: Some(5),
max: None,
},
"UserInput",
)];
registry.register_type("UserInput", parent_rules);
registry.set_parent("UserInput", "BaseInput");
let child_rules = vec![RuleMetadata::new(
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
},
"AdminUserInput",
)];
registry.register_type("AdminUserInput", child_rules);
registry.set_parent("AdminUserInput", "UserInput");
let inherited = registry.get_rules("AdminUserInput", InheritanceMode::Merge);
assert_eq!(inherited.len(), 3);
}
#[test]
fn test_rule_metadata_non_overrideable() {
let rule = RuleMetadata::new(ValidationRule::Required, "UserInput").non_overrideable();
assert!(!rule.overrideable);
assert!(!rule.inherited);
}
#[test]
fn test_rule_metadata_as_inherited() {
let mut rule = RuleMetadata::new(ValidationRule::Required, "UserInput");
rule = rule.as_inherited();
assert!(rule.inherited);
assert!(rule.overrideable);
}
#[test]
fn test_inheritance_mode_description() {
assert!(!InheritanceMode::Override.description().is_empty());
assert!(!InheritanceMode::Merge.description().is_empty());
assert!(!InheritanceMode::ChildFirst.description().is_empty());
assert!(!InheritanceMode::ParentFirst.description().is_empty());
}
#[test]
fn test_complex_inheritance_scenario() {
let mut registry = ValidationRuleRegistry::new();
let base_rules = vec![
RuleMetadata::new(ValidationRule::Required, "BaseInput"),
RuleMetadata::new(
ValidationRule::Length {
min: Some(5),
max: None,
},
"BaseInput",
),
];
registry.register_type("BaseInput", base_rules);
let user_rules = vec![RuleMetadata::new(
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
},
"UserInput",
)];
registry.register_type("UserInput", user_rules);
registry.set_parent("UserInput", "BaseInput");
let admin_rules = vec![RuleMetadata::new(
ValidationRule::Enum {
values: vec!["admin".to_string(), "moderator".to_string()],
},
"AdminUserInput",
)];
registry.register_type("AdminUserInput", admin_rules);
registry.set_parent("AdminUserInput", "UserInput");
let inherited = registry.get_rules("AdminUserInput", InheritanceMode::Merge);
assert_eq!(inherited.len(), 4);
}
#[test]
fn test_empty_child_rules() {
let parent = vec![ValidationRule::Required];
let child: Vec<ValidationRule> = vec![];
let result = inherit_validation_rules(&parent, &child, InheritanceMode::Merge);
assert_eq!(result.len(), 1);
}
#[test]
fn test_empty_parent_rules() {
let parent: Vec<ValidationRule> = vec![];
let child = vec![ValidationRule::Required];
let result = inherit_validation_rules(&parent, &child, InheritanceMode::Merge);
assert_eq!(result.len(), 1);
}
#[test]
fn test_empty_both_rules() {
let parent: Vec<ValidationRule> = vec![];
let child: Vec<ValidationRule> = vec![];
let result = inherit_validation_rules(&parent, &child, InheritanceMode::Merge);
assert!(result.is_empty());
}
}
mod input_object_tests {
#![allow(clippy::unwrap_used)]
use serde_json::json;
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_any_of_passes() {
let input = json!({
"email": "user@example.com",
"phone": null,
"address": null
});
let rules = vec![InputObjectRule::AnyOf {
fields: vec![
"email".to_string(),
"phone".to_string(),
"address".to_string(),
],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| panic!("any_of should pass when email is present: {e}"));
}
#[test]
fn test_any_of_fails() {
let input = json!({
"email": null,
"phone": null,
"address": null
});
let rules = vec![InputObjectRule::AnyOf {
fields: vec![
"email".to_string(),
"phone".to_string(),
"address".to_string(),
],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("At least one of")),
"expected Validation error about missing fields, got: {result:?}"
);
}
#[test]
fn test_one_of_passes() {
let input = json!({
"entityId": "123",
"entityPayload": null
});
let rules = vec![InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("one_of should pass when exactly one field is present: {e}")
});
}
#[test]
fn test_one_of_fails_both_present() {
let input = json!({
"entityId": "123",
"entityPayload": { "name": "test" }
});
let rules = vec![InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Exactly one of")),
"expected Validation error about exactly one field, got: {result:?}"
);
}
#[test]
fn test_one_of_fails_neither_present() {
let input = json!({
"entityId": null,
"entityPayload": null
});
let rules = vec![InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Exactly one of")),
"expected Validation error about exactly one field, got: {result:?}"
);
}
#[test]
fn test_conditional_required_passes() {
let input = json!({
"isPremium": true,
"paymentMethod": "credit_card"
});
let rules = vec![InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("conditional_required should pass when condition is met: {e}")
});
}
#[test]
fn test_conditional_required_fails() {
let input = json!({
"isPremium": true,
"paymentMethod": null
});
let rules = vec![InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must also be provided")),
"expected Validation error about missing conditional fields, got: {result:?}"
);
}
#[test]
fn test_conditional_required_skips_when_condition_false() {
let input = json!({
"isPremium": null,
"paymentMethod": null
});
let rules = vec![InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("conditional_required should skip when condition field is null: {e}")
});
}
#[test]
fn test_required_if_absent_passes() {
let input = json!({
"addressId": null,
"street": "123 Main St",
"city": "Springfield",
"zip": "12345"
});
let rules = vec![InputObjectRule::RequiredIfAbsent {
absent_field: "addressId".to_string(),
then_fields: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("required_if_absent should pass when all then_fields are provided: {e}")
});
}
#[test]
fn test_required_if_absent_fails() {
let input = json!({
"addressId": null,
"street": "123 Main St",
"city": null,
"zip": "12345"
});
let rules = vec![InputObjectRule::RequiredIfAbsent {
absent_field: "addressId".to_string(),
then_fields: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be provided")),
"expected Validation error about missing required fields, got: {result:?}"
);
}
#[test]
fn test_required_if_absent_skips_when_field_present() {
let input = json!({
"addressId": "addr_123",
"street": null,
"city": null,
"zip": null
});
let rules = vec![InputObjectRule::RequiredIfAbsent {
absent_field: "addressId".to_string(),
then_fields: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("required_if_absent should skip when absent_field is present: {e}")
});
}
#[test]
fn test_multiple_rules_all_pass() {
let input = json!({
"entityId": "123",
"entityPayload": null,
"isPremium": true,
"paymentMethod": "credit_card"
});
let rules = vec![
InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
},
InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
},
];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| panic!("multiple rules should all pass: {e}"));
}
#[test]
fn test_multiple_rules_one_fails() {
let input = json!({
"entityId": "123",
"entityPayload": null,
"isPremium": true,
"paymentMethod": null
});
let rules = vec![
InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
},
InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
},
];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error when one rule fails, got: {result:?}"
);
}
#[test]
fn test_multiple_rules_both_fail() {
let input = json!({
"entityId": "123",
"entityPayload": { "name": "test" },
"isPremium": true,
"paymentMethod": null
});
let rules = vec![
InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
},
InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
},
];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. })
if message.contains("Exactly one") || message.contains("must also be provided")),
"expected aggregated Validation error with both failures, got: {result:?}"
);
}
#[test]
fn test_error_aggregation() {
let input = json!({
"entityId": "123",
"entityPayload": { "name": "test" },
"isPremium": true,
"paymentMethod": null
});
let rules = vec![
InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
},
InputObjectRule::ConditionalRequired {
if_field: "isPremium".to_string(),
then_fields: vec!["paymentMethod".to_string()],
},
];
let result = validate_input_object(&input, &rules, Some("createInput"));
match result {
Err(FraiseQLError::Validation {
ref message,
ref path,
}) => {
assert_eq!(*path, Some("createInput".to_string()));
assert!(message.contains("failed"), "expected 'failed' in message, got: {message}");
},
other => panic!("expected Validation error with custom path, got: {other:?}"),
}
}
#[test]
fn test_conditional_required_multiple_fields() {
let input = json!({
"isInternational": true,
"customsCode": "ABC123",
"importDuties": "50.00"
});
let rules = vec![InputObjectRule::ConditionalRequired {
if_field: "isInternational".to_string(),
then_fields: vec!["customsCode".to_string(), "importDuties".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("conditional_required with multiple fields should pass: {e}")
});
}
#[test]
fn test_conditional_required_multiple_fields_one_missing() {
let input = json!({
"isInternational": true,
"customsCode": "ABC123",
"importDuties": null
});
let rules = vec![InputObjectRule::ConditionalRequired {
if_field: "isInternational".to_string(),
then_fields: vec!["customsCode".to_string(), "importDuties".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must also be provided")),
"expected Validation error about missing conditional field, got: {result:?}"
);
}
#[test]
fn test_validation_result_aggregation() {
let mut result = InputObjectValidationResult::new();
assert!(!result.has_errors());
assert_eq!(result.error_count, 0);
result.add_error("Error 1".to_string());
assert!(result.has_errors());
assert_eq!(result.error_count, 1);
result.add_errors(vec!["Error 2".to_string(), "Error 3".to_string()]);
assert_eq!(result.error_count, 3);
}
#[test]
fn test_validation_result_into_result_success() {
let result = InputObjectValidationResult::new();
result
.into_result()
.unwrap_or_else(|e| panic!("empty result should be Ok: {e}"));
}
#[test]
fn test_validation_result_into_result_failure() {
let mut result = InputObjectValidationResult::new();
result.add_error("Test error".to_string());
let outcome = result.into_result();
assert!(
matches!(outcome, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Test error")),
"expected Validation error containing 'Test error', got: {outcome:?}"
);
}
#[test]
fn test_non_object_input() {
let input = json!([1, 2, 3]);
let rules = vec![InputObjectRule::AnyOf {
fields: vec!["field".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("must be an object")),
"expected Validation error about non-object input, got: {result:?}"
);
}
#[test]
fn test_empty_rules() {
let input = json!({"field": "value"});
let rules: Vec<InputObjectRule> = vec![];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| panic!("empty rules should always pass: {e}"));
}
#[test]
fn test_custom_validator_not_implemented() {
let input = json!({"field": "value"});
let rules = vec![InputObjectRule::Custom {
name: "myValidator".to_string(),
}];
let result = validate_input_object(&input, &rules, None);
match result {
Err(FraiseQLError::Validation { ref message, .. }) => {
assert!(
message.contains("myValidator"),
"expected 'myValidator' in message, got: {message}"
);
assert!(
message.contains("InputValidatorRegistry"),
"expected 'InputValidatorRegistry' in message, got: {message}"
);
},
other => {
panic!("expected Validation error about unregistered validator, got: {other:?}")
},
}
}
#[test]
fn test_complex_create_or_reference_pattern() {
let input = json!({
"entityId": "123",
"name": null,
"description": null
});
let rules = vec![InputObjectRule::OneOf {
fields: vec!["entityId".to_string(), "name".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("create_or_reference pattern should pass with entityId: {e}")
});
}
#[test]
fn test_complex_address_pattern() {
let input = json!({
"addressId": null,
"street": "123 Main St",
"city": "Springfield",
"zip": "12345"
});
let rules = vec![InputObjectRule::RequiredIfAbsent {
absent_field: "addressId".to_string(),
then_fields: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
}];
let result = validate_input_object(&input, &rules, None);
result.unwrap_or_else(|e| {
panic!("address pattern should pass with all fields provided: {e}")
});
}
}
mod input_processor_tests {
#![allow(clippy::unwrap_used)]
use serde_json::json;
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_process_valid_uuid_id() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"userId": "550e8400-e29b-41d4-a716-446655440000"
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("valid UUID should pass: {e}"));
}
#[test]
fn test_process_invalid_uuid_id() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"userId": "invalid-id"
});
let result = process_variables(&variables, &config);
let err = result.expect_err("invalid UUID should fail validation");
assert!(
err.field_path.contains("userId"),
"expected field_path to contain 'userId', got: {}",
err.field_path
);
}
#[test]
fn test_process_multiple_ids() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"userId": "550e8400-e29b-41d4-a716-446655440000",
"postId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"name": "John"
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("multiple valid UUIDs should pass: {e}"));
}
#[test]
fn test_process_nested_ids() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"input": {
"userId": "550e8400-e29b-41d4-a716-446655440000",
"profile": {
"authorId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
}
}
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("nested valid UUIDs should pass: {e}"));
}
#[test]
fn test_process_nested_invalid_id() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"input": {
"userId": "550e8400-e29b-41d4-a716-446655440000",
"profile": {
"authorId": "invalid"
}
}
});
let result = process_variables(&variables, &config);
let err = result.expect_err("nested invalid UUID should fail");
assert!(
err.field_path.contains("authorId"),
"expected field_path to contain 'authorId', got: {}",
err.field_path
);
}
#[test]
fn test_process_array_of_ids() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"userIds": [
"550e8400-e29b-41d4-a716-446655440000",
"6ba7b810-9dad-11d1-80b4-00c04fd430c8"
]
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("array of valid UUIDs should pass: {e}"));
}
#[test]
fn test_process_array_with_invalid_id() {
let mut config = InputProcessingConfig::strict_uuid();
config.add_id_field("userIds".to_string());
let variables = json!({
"userIds": [
"550e8400-e29b-41d4-a716-446655440000",
"invalid-id"
]
});
let result = process_variables(&variables, &config);
let err = result.expect_err("array with invalid UUID should fail");
assert!(
err.field_path.contains("userIds"),
"expected field_path to contain 'userIds', got: {}",
err.field_path
);
}
#[test]
fn test_opaque_policy_accepts_any_id() {
let config = InputProcessingConfig::opaque();
let variables = json!({
"userId": "any-string-here"
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("opaque policy should accept any ID: {e}"));
}
#[test]
fn test_disabled_validation_skips_checks() {
let mut config = InputProcessingConfig::strict_uuid();
config.validate_ids = false;
let variables = json!({
"userId": "invalid-id"
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("disabled validation should skip checks: {e}"));
}
#[test]
fn test_custom_id_field_names() {
let mut config = InputProcessingConfig::strict_uuid();
config.add_id_field("customId".to_string());
let variables = json!({
"customId": "550e8400-e29b-41d4-a716-446655440000"
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| panic!("custom ID field with valid UUID should pass: {e}"));
}
#[test]
fn test_process_null_variables() {
let config = InputProcessingConfig::strict_uuid();
let result = process_variables(&Value::Null, &config);
let value = result.unwrap_or_else(|e| panic!("null variables should pass: {e}"));
assert!(value.is_null(), "expected null output, got: {value:?}");
}
#[test]
fn test_non_id_fields_pass_through() {
let config = InputProcessingConfig::strict_uuid();
let variables = json!({
"name": "not-a-uuid",
"email": "invalid-format@email",
"age": 25
});
let result = process_variables(&variables, &config);
result.unwrap_or_else(|e| {
panic!("non-ID fields should pass through without validation: {e}")
});
}
}
mod mutual_exclusivity_tests {
use serde_json::json;
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_one_of_validator_exactly_one_present() {
let input = json!({
"entityId": "123",
"entityPayload": null
});
let result = OneOfValidator::validate(
&input,
&["entityId".to_string(), "entityPayload".to_string()],
None,
);
result.unwrap_or_else(|e| panic!("expected exactly-one to pass with one present: {e}"));
}
#[test]
fn test_one_of_validator_both_present() {
let input = json!({
"entityId": "123",
"entityPayload": { "name": "test" }
});
let result = OneOfValidator::validate(
&input,
&["entityId".to_string(), "entityPayload".to_string()],
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Exactly one of")),
"expected Validation error for both fields present, got: {result:?}"
);
}
#[test]
fn test_one_of_validator_neither_present() {
let input = json!({
"entityId": null,
"entityPayload": null
});
let result = OneOfValidator::validate(
&input,
&["entityId".to_string(), "entityPayload".to_string()],
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Exactly one of")),
"expected Validation error for neither field present, got: {result:?}"
);
}
#[test]
fn test_one_of_validator_missing_field() {
let input = json!({
"entityId": "123"
});
let result = OneOfValidator::validate(
&input,
&["entityId".to_string(), "entityPayload".to_string()],
None,
);
result.unwrap_or_else(|e| {
panic!("expected exactly-one to pass with one field missing from object: {e}")
});
}
#[test]
fn test_any_of_validator_one_present() {
let input = json!({
"email": "user@example.com",
"phone": null,
"address": null
});
let result = AnyOfValidator::validate(
&input,
&[
"email".to_string(),
"phone".to_string(),
"address".to_string(),
],
None,
);
result.unwrap_or_else(|e| panic!("expected any-of to pass with one present: {e}"));
}
#[test]
fn test_any_of_validator_multiple_present() {
let input = json!({
"email": "user@example.com",
"phone": "+1234567890",
"address": null
});
let result = AnyOfValidator::validate(
&input,
&[
"email".to_string(),
"phone".to_string(),
"address".to_string(),
],
None,
);
result.unwrap_or_else(|e| panic!("expected any-of to pass with multiple present: {e}"));
}
#[test]
fn test_any_of_validator_none_present() {
let input = json!({
"email": null,
"phone": null,
"address": null
});
let result = AnyOfValidator::validate(
&input,
&[
"email".to_string(),
"phone".to_string(),
"address".to_string(),
],
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("At least one of")),
"expected Validation error for no fields present, got: {result:?}"
);
}
#[test]
fn test_conditional_required_validator_condition_met_requirement_met() {
let input = json!({
"isPremium": true,
"paymentMethod": "credit_card"
});
let result = ConditionalRequiredValidator::validate(
&input,
"isPremium",
&["paymentMethod".to_string()],
None,
);
result.unwrap_or_else(|e| {
panic!("expected conditional-required to pass when requirement met: {e}")
});
}
#[test]
fn test_conditional_required_validator_condition_met_requirement_missing() {
let input = json!({
"isPremium": true,
"paymentMethod": null
});
let result = ConditionalRequiredValidator::validate(
&input,
"isPremium",
&["paymentMethod".to_string()],
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Since") && message.contains("must also be provided")),
"expected Validation error for missing conditional requirement, got: {result:?}"
);
}
#[test]
fn test_conditional_required_validator_condition_not_met() {
let input = json!({
"isPremium": null,
"paymentMethod": null
});
let result = ConditionalRequiredValidator::validate(
&input,
"isPremium",
&["paymentMethod".to_string()],
None,
);
result.unwrap_or_else(|e| {
panic!("expected conditional-required to pass when condition not met: {e}")
});
}
#[test]
fn test_conditional_required_validator_multiple_requirements() {
let input = json!({
"isInternational": true,
"customsCode": "ABC123",
"importDuties": "50.00"
});
let result = ConditionalRequiredValidator::validate(
&input,
"isInternational",
&["customsCode".to_string(), "importDuties".to_string()],
None,
);
result.unwrap_or_else(|e| {
panic!("expected conditional-required to pass with all requirements met: {e}")
});
}
#[test]
fn test_conditional_required_validator_one_requirement_missing() {
let input = json!({
"isInternational": true,
"customsCode": "ABC123",
"importDuties": null
});
let result = ConditionalRequiredValidator::validate(
&input,
"isInternational",
&["customsCode".to_string(), "importDuties".to_string()],
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Since") && message.contains("must also be provided")),
"expected Validation error for one missing requirement, got: {result:?}"
);
}
#[test]
fn test_required_if_absent_validator_field_absent_requirements_met() {
let input = json!({
"addressId": null,
"street": "123 Main St",
"city": "Springfield",
"zip": "12345"
});
let result = RequiredIfAbsentValidator::validate(
&input,
"addressId",
&["street".to_string(), "city".to_string(), "zip".to_string()],
None,
);
result.unwrap_or_else(|e| {
panic!("expected required-if-absent to pass when requirements met: {e}")
});
}
#[test]
fn test_required_if_absent_validator_field_absent_requirements_missing() {
let input = json!({
"addressId": null,
"street": "123 Main St",
"city": null,
"zip": "12345"
});
let result = RequiredIfAbsentValidator::validate(
&input,
"addressId",
&["street".to_string(), "city".to_string(), "zip".to_string()],
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Since") && message.contains("must be provided")),
"expected Validation error for missing requirements when field absent, got: {result:?}"
);
}
#[test]
fn test_required_if_absent_validator_field_present() {
let input = json!({
"addressId": "addr_123",
"street": null,
"city": null,
"zip": null
});
let result = RequiredIfAbsentValidator::validate(
&input,
"addressId",
&["street".to_string(), "city".to_string(), "zip".to_string()],
None,
);
result.unwrap_or_else(|e| {
panic!("expected required-if-absent to pass when field present: {e}")
});
}
#[test]
fn test_required_if_absent_validator_all_missing_from_object() {
let input = json!({});
let result = RequiredIfAbsentValidator::validate(
&input,
"addressId",
&["street".to_string(), "city".to_string()],
None,
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref message, .. }) if message.contains("Since") && message.contains("must be provided")),
"expected Validation error for all fields missing from empty object, got: {result:?}"
);
}
#[test]
fn test_error_messages_include_context() {
let input = json!({
"entityId": "123",
"entityPayload": { "name": "test" }
});
let result = OneOfValidator::validate(
&input,
&["entityId".to_string(), "entityPayload".to_string()],
Some("createInput"),
);
assert!(
matches!(result, Err(FraiseQLError::Validation { ref path, .. }) if *path == Some("createInput".to_string())),
"expected Validation error with path 'createInput', got: {result:?}"
);
}
}
mod rate_limiting_tests {
use super::super::*;
#[allow(unused_imports)]
use super::*;
use crate::validation::rate_limiting::DimensionRateLimiter;
#[test]
fn test_dimension_rate_limiter_allows_within_limit() {
let limiter = DimensionRateLimiter::new(3, 60);
limiter.check("key").unwrap_or_else(|e| panic!("expected Ok on request 1: {e}"));
limiter.check("key").unwrap_or_else(|e| panic!("expected Ok on request 2: {e}"));
limiter.check("key").unwrap_or_else(|e| panic!("expected Ok on request 3: {e}"));
}
#[test]
fn test_dimension_rate_limiter_rejects_over_limit() {
let limiter = DimensionRateLimiter::new(2, 60);
limiter.check("key").unwrap_or_else(|e| panic!("expected Ok on request 1: {e}"));
limiter.check("key").unwrap_or_else(|e| panic!("expected Ok on request 2: {e}"));
assert!(
matches!(limiter.check("key"), Err(FraiseQLError::RateLimited { .. })),
"expected RateLimited error on request 3, got: {:?}",
limiter.check("key")
);
}
#[test]
fn test_dimension_rate_limiter_per_key() {
let limiter = DimensionRateLimiter::new(2, 60);
limiter
.check("key1")
.unwrap_or_else(|e| panic!("expected Ok for key1 request 1: {e}"));
limiter
.check("key1")
.unwrap_or_else(|e| panic!("expected Ok for key1 request 2: {e}"));
limiter
.check("key2")
.unwrap_or_else(|e| panic!("expected Ok for key2 request 1 (independent key): {e}"));
}
#[test]
fn test_dimension_rate_limiter_clear() {
let limiter = DimensionRateLimiter::new(1, 60);
limiter.check("key").unwrap_or_else(|e| panic!("expected Ok before limit: {e}"));
assert!(
matches!(limiter.check("key"), Err(FraiseQLError::RateLimited { .. })),
"expected RateLimited error at limit, got: {:?}",
limiter.check("key")
);
limiter.clear();
limiter.check("key").unwrap_or_else(|e| panic!("expected Ok after clear: {e}"));
}
#[test]
fn test_config_defaults() {
let config = ValidationRateLimitingConfig::default();
assert!(config.enabled);
assert!(config.validation_errors_max_requests > 0);
assert!(config.depth_errors_max_requests > 0);
assert!(config.complexity_errors_max_requests > 0);
assert!(config.malformed_errors_max_requests > 0);
assert!(config.async_validation_errors_max_requests > 0);
}
#[test]
fn test_validation_limiter_independent_dimensions() {
let config = ValidationRateLimitingConfig::default();
let limiter = ValidationRateLimiter::new(&config);
let key = "test-key";
for _ in 0..100 {
let _ = limiter.check_validation_errors(key);
}
assert!(
matches!(limiter.check_validation_errors(key), Err(FraiseQLError::RateLimited { .. })),
"expected RateLimited after exhausting validation_errors quota"
);
limiter
.check_depth_errors(key)
.unwrap_or_else(|e| panic!("depth_errors should still allow: {e}"));
limiter
.check_complexity_errors(key)
.unwrap_or_else(|e| panic!("complexity_errors should still allow: {e}"));
limiter
.check_malformed_errors(key)
.unwrap_or_else(|e| panic!("malformed_errors should still allow: {e}"));
limiter
.check_async_validation_errors(key)
.unwrap_or_else(|e| panic!("async_validation_errors should still allow: {e}"));
}
#[test]
fn test_validation_limiter_clone_shares_state() {
let config = ValidationRateLimitingConfig::default();
let limiter1 = ValidationRateLimiter::new(&config);
let limiter2 = limiter1.clone();
let key = "shared-key";
for _ in 0..100 {
let _ = limiter1.check_validation_errors(key);
}
assert!(
matches!(limiter2.check_validation_errors(key), Err(FraiseQLError::RateLimited { .. })),
"cloned limiter should share rate limit state"
);
}
#[test]
fn test_window_rollover_does_not_leak_across_windows() {
use std::time::Duration;
use crate::utils::clock::ManualClock;
let clock = ManualClock::new();
let clock_arc: Arc<dyn Clock> = Arc::new(clock.clone());
let config = ValidationRateLimitingConfig {
enabled: true,
validation_errors_max_requests: 2,
validation_errors_window_secs: 60,
..ValidationRateLimitingConfig::default()
};
let limiter = ValidationRateLimiter::new_with_clock(&config, clock_arc);
limiter
.check_validation_errors("u1")
.unwrap_or_else(|e| panic!("expected Ok on 1st request: {e}")); limiter
.check_validation_errors("u1")
.unwrap_or_else(|e| panic!("expected Ok on 2nd request: {e}")); assert!(
matches!(limiter.check_validation_errors("u1"), Err(FraiseQLError::RateLimited { .. })),
"expected RateLimited on 3rd request (over limit)"
);
clock.advance(Duration::from_secs(61));
limiter
.check_validation_errors("u1")
.unwrap_or_else(|e| panic!("expected Ok after window rollover: {e}")); }
#[test]
fn test_window_exact_boundary_triggers_rollover() {
use std::time::Duration;
use crate::utils::clock::ManualClock;
let clock = ManualClock::new();
let clock_arc: Arc<dyn Clock> = Arc::new(clock.clone());
let window_secs = 60u64;
let max = 2u32;
let limiter = DimensionRateLimiter::new_with_clock(max, window_secs, clock_arc);
for _ in 0..max {
limiter.check("u").unwrap_or_else(|e| panic!("expected Ok filling window: {e}"));
}
assert!(
matches!(limiter.check("u"), Err(FraiseQLError::RateLimited { .. })),
"expected RateLimited when over limit"
);
clock.advance(Duration::from_secs(window_secs));
limiter
.check("u")
.unwrap_or_else(|e| panic!("expected Ok at exact window boundary (>= not >): {e}"));
}
#[test]
fn test_max_requests_zero_disables_limiter() {
let limiter = DimensionRateLimiter::new(0, 60);
for i in 0..10u32 {
limiter
.check("key")
.unwrap_or_else(|e| panic!("expected Ok with max_requests=0 on request {i}: {e}"));
}
}
#[test]
fn test_window_secs_zero_does_not_panic() {
use crate::utils::clock::ManualClock;
let clock_arc: Arc<dyn Clock> = Arc::new(ManualClock::new());
let limiter = DimensionRateLimiter::new_with_clock(5, 0, clock_arc);
limiter
.check("key")
.unwrap_or_else(|e| panic!("expected Ok with window_secs=0 (1st): {e}"));
limiter
.check("key")
.unwrap_or_else(|e| panic!("expected Ok with window_secs=0 (2nd): {e}"));
limiter
.check("key")
.unwrap_or_else(|e| panic!("expected Ok with window_secs=0 (3rd): {e}"));
}
}
mod rich_scalars_tests {
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_email_valid() {
assert!(EmailValidator::validate("user@example.com"));
assert!(EmailValidator::validate("john.doe@company.co.uk"));
}
#[test]
fn test_email_invalid() {
assert!(!EmailValidator::validate("invalid.email"));
assert!(!EmailValidator::validate("user@"));
assert!(!EmailValidator::validate("@example.com"));
assert!(!EmailValidator::validate("user@localhost"));
assert!(!EmailValidator::validate("user@example"));
}
#[test]
fn test_email_empty() {
assert!(!EmailValidator::validate(""));
}
#[test]
fn test_phone_valid_plus_format() {
assert!(PhoneNumberValidator::validate("+1234567890"));
assert!(PhoneNumberValidator::validate("+33612345678"));
}
#[test]
fn test_phone_valid_no_plus() {
assert!(PhoneNumberValidator::validate("1234567890"));
}
#[test]
fn test_phone_invalid() {
assert!(!PhoneNumberValidator::validate("+0123456789")); assert!(!PhoneNumberValidator::validate(""));
}
#[test]
fn test_vin_valid() {
assert!(VinValidator::validate("3G1FB1E30D1109186"));
assert!(VinValidator::validate("JH2RC5004LM200591"));
}
#[test]
fn test_vin_valid_lowercase() {
assert!(VinValidator::validate("3g1fb1e30d1109186"));
}
#[test]
fn test_vin_invalid_length() {
assert!(!VinValidator::validate("3G1FB1E30D110918"));
assert!(!VinValidator::validate("3G1FB1E30D11091861"));
}
#[test]
fn test_vin_invalid_chars() {
assert!(!VinValidator::validate("3G1FB1E30D110918I")); assert!(!VinValidator::validate("3G1FB1E30D110918O")); assert!(!VinValidator::validate("3G1FB1E30D110918Q")); }
#[test]
fn test_vin_empty_rejected_by_length_guard() {
assert!(!VinValidator::validate(""), "empty string rejected before regex");
}
#[test]
fn test_vin_16_chars_rejected_by_length_guard() {
assert!(!VinValidator::validate("3G1FB1E30D110918"), "16-char VIN rejected");
}
#[test]
fn test_vin_18_chars_rejected_by_length_guard() {
assert!(!VinValidator::validate("3G1FB1E30D11091862"), "18-char VIN rejected");
}
#[test]
fn test_vin_very_long_string_rejected_by_length_guard() {
let long_input = "A".repeat(100);
assert!(!VinValidator::validate(&long_input), "100-char string rejected");
}
#[test]
fn test_country_code_valid() {
let validator = CountryCodeValidator::new();
assert!(validator.validate("US"));
assert!(validator.validate("GB"));
assert!(validator.validate("DE"));
assert!(validator.validate("FR"));
}
#[test]
fn test_country_code_lowercase() {
let validator = CountryCodeValidator::new();
assert!(validator.validate("us"));
assert!(validator.validate("gb"));
}
#[test]
fn test_country_code_invalid() {
let validator = CountryCodeValidator::new();
assert!(!validator.validate("XX"));
assert!(!validator.validate("USA"));
assert!(!validator.validate("U"));
}
}
mod rules_tests {
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_required_rule() {
let rule = ValidationRule::Required;
assert!(rule.is_required());
}
#[test]
fn test_pattern_rule() {
let rule = ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: Some("Only lowercase letters allowed".to_string()),
};
assert!(!rule.is_required());
let desc = rule.description();
assert_eq!(desc, "Only lowercase letters allowed");
}
#[test]
fn test_length_rule() {
let rule = ValidationRule::Length {
min: Some(5),
max: Some(10),
};
let desc = rule.description();
assert!(desc.contains('5'));
assert!(desc.contains("10"));
}
#[test]
fn test_rule_serialization() {
let rule = ValidationRule::Enum {
values: vec!["active".to_string(), "inactive".to_string()],
};
let json = serde_json::to_string(&rule).expect("serialization failed");
let deserialized: ValidationRule =
serde_json::from_str(&json).expect("deserialization failed");
assert!(matches!(deserialized, ValidationRule::Enum { .. }));
}
#[test]
fn test_composite_all_rule() {
let rule = ValidationRule::All(vec![
ValidationRule::Required,
ValidationRule::Pattern {
pattern: CompiledPattern::new("^[a-z]+$").expect("valid regex"),
message: None,
},
]);
let desc = rule.description();
assert!(desc.contains("All rules"));
}
#[test]
fn test_one_of_rule() {
let rule = ValidationRule::OneOf {
fields: vec!["entityId".to_string(), "entityPayload".to_string()],
};
assert!(!rule.is_required());
let desc = rule.description();
assert!(desc.contains("Exactly one"));
assert!(desc.contains("entityId"));
assert!(desc.contains("entityPayload"));
}
#[test]
fn test_any_of_rule() {
let rule = ValidationRule::AnyOf {
fields: vec![
"email".to_string(),
"phone".to_string(),
"address".to_string(),
],
};
let desc = rule.description();
assert!(desc.contains("At least one"));
assert!(desc.contains("email"));
assert!(desc.contains("phone"));
assert!(desc.contains("address"));
}
#[test]
fn test_conditional_required_rule() {
let rule = ValidationRule::ConditionalRequired {
if_field_present: "entityId".to_string(),
then_required: vec!["createdAt".to_string(), "updatedAt".to_string()],
};
let desc = rule.description();
assert!(desc.contains("If"));
assert!(desc.contains("entityId"));
assert!(desc.contains("createdAt"));
assert!(desc.contains("updatedAt"));
}
#[test]
fn test_required_if_absent_rule() {
let rule = ValidationRule::RequiredIfAbsent {
absent_field: "addressId".to_string(),
then_required: vec!["street".to_string(), "city".to_string(), "zip".to_string()],
};
let desc = rule.description();
assert!(desc.contains("If"));
assert!(desc.contains("addressId"));
assert!(desc.contains("absent"));
assert!(desc.contains("street"));
}
#[test]
fn test_one_of_serialization() {
let rule = ValidationRule::OneOf {
fields: vec!["id".to_string(), "payload".to_string()],
};
let json = serde_json::to_string(&rule).expect("serialization failed");
let deserialized: ValidationRule =
serde_json::from_str(&json).expect("deserialization failed");
assert!(matches!(deserialized, ValidationRule::OneOf { .. }));
}
#[test]
fn test_conditional_required_serialization() {
let rule = ValidationRule::ConditionalRequired {
if_field_present: "isPremium".to_string(),
then_required: vec!["paymentMethod".to_string()],
};
let json = serde_json::to_string(&rule).expect("serialization failed");
let deserialized: ValidationRule =
serde_json::from_str(&json).expect("deserialization failed");
assert!(matches!(deserialized, ValidationRule::ConditionalRequired { .. }));
}
#[test]
fn test_required_if_absent_serialization() {
let rule = ValidationRule::RequiredIfAbsent {
absent_field: "presetId".to_string(),
then_required: vec!["settings".to_string()],
};
let json = serde_json::to_string(&rule).expect("serialization failed");
let deserialized: ValidationRule =
serde_json::from_str(&json).expect("deserialization failed");
assert!(matches!(deserialized, ValidationRule::RequiredIfAbsent { .. }));
}
}
mod scalar_validator_tests {
#![allow(clippy::unwrap_used)]
use serde_json::{Value, json};
use super::super::*;
#[allow(unused_imports)]
use super::*;
use crate::error::{FraiseQLError, Result};
#[derive(Debug)]
struct PassthroughScalar;
#[allow(clippy::unnecessary_literal_bound)] impl CustomScalar for PassthroughScalar {
fn name(&self) -> &str {
"Passthrough"
}
fn serialize(&self, value: &Value) -> Result<Value> {
Ok(value.clone())
}
fn parse_value(&self, value: &Value) -> Result<Value> {
Ok(value.clone())
}
fn parse_literal(&self, ast: &Value) -> Result<Value> {
Ok(ast.clone())
}
}
#[derive(Debug)]
struct FailScalar;
#[allow(clippy::unnecessary_literal_bound)] impl CustomScalar for FailScalar {
fn name(&self) -> &str {
"AlwaysFail"
}
fn serialize(&self, _: &Value) -> Result<Value> {
Err(FraiseQLError::validation("serialize always fails"))
}
fn parse_value(&self, _: &Value) -> Result<Value> {
Err(FraiseQLError::validation("parse_value always fails"))
}
fn parse_literal(&self, _: &Value) -> Result<Value> {
Err(FraiseQLError::validation("parse_literal always fails"))
}
}
#[test]
fn test_validation_context_as_str_serialize() {
assert_eq!(ValidationContext::Serialize.as_str(), "serialize");
}
#[test]
fn test_validation_context_as_str_parse_value() {
assert_eq!(ValidationContext::ParseValue.as_str(), "parseValue");
}
#[test]
fn test_validation_context_as_str_parse_literal() {
assert_eq!(ValidationContext::ParseLiteral.as_str(), "parseLiteral");
}
#[test]
fn test_validation_context_eq() {
assert_eq!(ValidationContext::Serialize, ValidationContext::Serialize);
assert_ne!(ValidationContext::Serialize, ValidationContext::ParseValue);
}
#[test]
fn test_scalar_validation_error_new() {
let err = ScalarValidationError::new("Email", "parseValue", "not an email");
assert_eq!(err.scalar_name, "Email");
assert_eq!(err.context, "parseValue");
assert_eq!(err.message, "not an email");
}
#[test]
fn test_scalar_validation_error_display() {
let err = ScalarValidationError::new("Email", "parseValue", "bad input");
let s = format!("{err}");
assert!(s.contains("Email"), "missing scalar name: {s}");
assert!(s.contains("parseValue"), "missing context: {s}");
assert!(s.contains("bad input"), "missing message: {s}");
}
#[test]
fn test_scalar_validation_error_into_fraiseql_error() {
let err = ScalarValidationError::new("T", "serialize", "oops");
let fraiseql_err = err.into_fraiseql_error();
let msg = format!("{fraiseql_err}");
assert!(msg.contains("oops"), "error message lost: {msg}");
}
#[test]
fn test_validate_serialize_success() {
let scalar = PassthroughScalar;
let v = json!("hello");
let result = validate_custom_scalar(&scalar, &v, ValidationContext::Serialize);
assert_eq!(result.unwrap(), v);
}
#[test]
fn test_validate_parse_value_success() {
let scalar = PassthroughScalar;
let v = json!(42);
let result = validate_custom_scalar(&scalar, &v, ValidationContext::ParseValue);
assert_eq!(result.unwrap(), v);
}
#[test]
fn test_validate_parse_literal_success() {
let scalar = PassthroughScalar;
let v = json!(true);
let result = validate_custom_scalar(&scalar, &v, ValidationContext::ParseLiteral);
assert_eq!(result.unwrap(), v);
}
#[test]
fn test_validate_serialize_failure_wraps_error() {
let scalar = FailScalar;
let err =
validate_custom_scalar(&scalar, &json!("x"), ValidationContext::Serialize).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("AlwaysFail") || msg.contains("serialize"),
"unexpected error message: {msg}"
);
}
#[test]
fn test_validate_parse_value_failure_wraps_error() {
let scalar = FailScalar;
let err = validate_custom_scalar(&scalar, &json!("x"), ValidationContext::ParseValue)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("AlwaysFail") || msg.contains("parseValue"),
"unexpected error message: {msg}"
);
}
#[test]
fn test_validate_parse_literal_failure_wraps_error() {
let scalar = FailScalar;
let err = validate_custom_scalar(&scalar, &json!("x"), ValidationContext::ParseLiteral)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("AlwaysFail") || msg.contains("parseLiteral"),
"unexpected error message: {msg}"
);
}
#[test]
fn test_convenience_fn_success() {
let scalar = PassthroughScalar;
let v = json!("text");
assert_eq!(validate_custom_scalar_parse_value(&scalar, &v).unwrap(), v);
}
#[test]
fn test_convenience_fn_failure() {
let scalar = FailScalar;
let result = validate_custom_scalar_parse_value(&scalar, &json!("x"));
assert!(
matches!(result, Err(FraiseQLError::Validation { .. })),
"expected Validation error, got: {result:?}"
);
}
}
mod validators_tests {
#![allow(clippy::unwrap_used)]
use super::super::*;
#[allow(unused_imports)]
use super::*;
#[test]
fn test_pattern_validator() {
let validator = PatternValidator::new_default_message("^[a-z]+$").unwrap();
assert!(validator.validate_pattern("hello"));
assert!(!validator.validate_pattern("Hello"));
assert!(!validator.validate_pattern("hello123"));
}
#[test]
fn test_pattern_validator_validation() {
let validator = PatternValidator::new_default_message("^[a-z]+$").unwrap();
validator
.validate("hello", "name")
.unwrap_or_else(|e| panic!("lowercase-only string should pass pattern: {e}"));
assert!(
matches!(validator.validate("Hello", "name"), Err(FraiseQLError::Validation { .. })),
"mixed-case string should fail pattern with Validation error"
);
}
#[test]
fn test_length_validator() {
let validator = LengthValidator::new(Some(3), Some(10));
assert!(validator.validate_length("hello"));
assert!(!validator.validate_length("ab"));
assert!(!validator.validate_length("this is too long"));
}
#[test]
fn test_length_validator_error_message() {
let validator = LengthValidator::new(Some(5), Some(10));
let msg = validator.error_message();
assert!(msg.contains('5'));
assert!(msg.contains("10"));
}
#[test]
fn test_range_validator() {
let validator = RangeValidator::new(Some(0), Some(100));
assert!(validator.validate_range(50));
assert!(!validator.validate_range(-1));
assert!(!validator.validate_range(101));
}
#[test]
fn test_enum_validator() {
let validator = EnumValidator::new(vec![
"active".to_string(),
"inactive".to_string(),
"pending".to_string(),
]);
assert!(validator.validate_enum("active"));
assert!(!validator.validate_enum("unknown"));
}
#[test]
fn test_required_validator() {
let validator = RequiredValidator;
validator
.validate("hello", "name")
.unwrap_or_else(|e| panic!("non-empty string should pass required validator: {e}"));
assert!(
matches!(validator.validate("", "name"), Err(FraiseQLError::Validation { .. })),
"empty string should fail required validator with Validation error"
);
}
#[test]
fn test_create_validator_from_rule() {
let rule = ValidationRule::Pattern {
pattern: CompiledPattern::new("^test").expect("valid regex"),
message: None,
};
let validator = create_validator_from_rule(&rule);
assert!(validator.is_some());
}
}
mod js_codegen_tests {
use crate::validation::js_codegen::JsCodegen;
#[test]
fn new_returns_default_instance() {
let _cg = JsCodegen::new();
}
#[test]
fn emit_validator_produces_function() {
let cg = JsCodegen::new();
let js = cg.emit_validator("User", "age >= 18");
assert!(
js.contains("export function validate_User(data)"),
"should produce named export function"
);
assert!(js.contains("errors"), "should include errors array");
}
#[test]
fn emit_validator_translates_comparison() {
let cg = JsCodegen::new();
let js = cg.emit_validator("Item", "price > 0");
assert!(
js.contains("data.price > 0"),
"field comparison should reference data.field: {js}"
);
}
#[test]
fn emit_validator_translates_equality_to_strict() {
let cg = JsCodegen::new();
let js = cg.emit_validator("Flag", "status == 1");
assert!(js.contains("==="), "== should become === in JavaScript: {js}");
}
#[test]
fn emit_validator_translates_not_equal_to_strict() {
let cg = JsCodegen::new();
let js = cg.emit_validator("Flag", "status != 0");
assert!(js.contains("!=="), "!= should become !== in JavaScript: {js}");
}
#[test]
fn emit_module_includes_header_and_multiple_validators() {
let cg = JsCodegen::new();
let module = cg.emit_module(&[("User", "age >= 18"), ("Post", "length(title) > 0")]);
assert!(module.contains("Generated by FraiseQL"), "module should include header comment");
assert!(module.contains("validate_User"), "module should include User validator");
assert!(module.contains("validate_Post"), "module should include Post validator");
}
#[test]
fn emit_module_empty_list() {
let cg = JsCodegen::new();
let module = cg.emit_module(&[]);
assert!(
module.contains("Generated by FraiseQL"),
"empty module should still include header"
);
assert!(!module.contains("export function"), "empty module should have no functions");
}
#[test]
fn elo_to_js_logical_and() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("a > 1 && b < 10");
assert!(js.contains("&&"), "should preserve AND operator: {js}");
}
#[test]
fn elo_to_js_logical_or() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("x == 1 || y == 2");
assert!(js.contains("||"), "should preserve OR operator: {js}");
}
#[test]
fn elo_to_js_negation() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("!active");
assert!(js.contains("!data.active"), "should negate field ref: {js}");
}
#[test]
fn elo_to_js_length_function() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("length(name) > 0");
assert!(
js.contains("(data.name || '').length"),
"length() should map to JS string length: {js}"
);
}
#[test]
fn elo_to_js_contains_function() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("contains(tags, \"rust\")");
assert!(js.contains(".includes("), "contains() should map to JS includes: {js}");
}
#[test]
fn elo_to_js_matches_function() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("matches(email, \"^[^@]+@\")");
assert!(js.contains("new RegExp("), "matches() should produce RegExp in JS: {js}");
}
#[test]
fn elo_to_js_numeric_literal() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("count > 42");
assert!(js.contains("42"), "numeric literal should pass through: {js}");
}
#[test]
fn elo_to_js_boolean_literal() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("active == true");
assert!(js.contains("true"), "boolean literal should pass through: {js}");
}
#[test]
fn elo_to_js_parenthesized_expression() {
let cg = JsCodegen::new();
let js = cg.elo_to_js("(a > 1)");
assert!(js.starts_with('(') && js.ends_with(')'), "should preserve parentheses: {js}");
}
}