use postmortem::{JsonPath, Schema, SchemaErrors};
use serde_json::json;
use stillwater::Validation;
fn unwrap_failure<T: std::fmt::Debug, E>(v: Validation<T, E>) -> E {
v.into_result().unwrap_err()
}
#[test]
fn test_custom_validator_success() {
let schema = Schema::object()
.field("quantity", Schema::integer().positive())
.field("unit_price", Schema::integer().non_negative())
.field("total", Schema::integer().non_negative())
.custom(|obj, path| {
let qty = obj.get("quantity").and_then(|v| v.as_i64()).unwrap_or(0);
let price = obj.get("unit_price").and_then(|v| v.as_i64()).unwrap_or(0);
let total = obj.get("total").and_then(|v| v.as_i64()).unwrap_or(0);
if qty * price != total {
Validation::Failure(SchemaErrors::single(
postmortem::SchemaError::new(
path.push_field("total"),
"total must equal quantity * unit_price",
)
.with_code("invalid_total"),
))
} else {
Validation::Success(())
}
});
let result = schema.validate(
&json!({
"quantity": 5,
"unit_price": 10,
"total": 50
}),
&JsonPath::root(),
);
assert!(result.is_success());
}
#[test]
fn test_custom_validator_failure() {
let schema = Schema::object()
.field("quantity", Schema::integer().positive())
.field("unit_price", Schema::integer().non_negative())
.field("total", Schema::integer().non_negative())
.custom(|obj, path| {
let qty = obj.get("quantity").and_then(|v| v.as_i64()).unwrap_or(0);
let price = obj.get("unit_price").and_then(|v| v.as_i64()).unwrap_or(0);
let total = obj.get("total").and_then(|v| v.as_i64()).unwrap_or(0);
if qty * price != total {
Validation::Failure(SchemaErrors::single(
postmortem::SchemaError::new(
path.push_field("total"),
"total must equal quantity * unit_price",
)
.with_code("invalid_total"),
))
} else {
Validation::Success(())
}
});
let result = schema.validate(
&json!({
"quantity": 5,
"unit_price": 10,
"total": 30 }),
&JsonPath::root(),
);
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert_eq!(errors.first().code, "invalid_total");
}
#[test]
fn test_require_if_condition_met() {
let schema = Schema::object()
.field("method", Schema::string())
.optional("card_number", Schema::string())
.require_if("method", |v| v == &json!("card"), "card_number");
let result = schema.validate(
&json!({
"method": "card"
}),
&JsonPath::root(),
);
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert_eq!(errors.first().code, "conditional_required");
assert!(errors.first().message.contains("card_number"));
}
#[test]
fn test_require_if_condition_not_met() {
let schema = Schema::object()
.field("method", Schema::string())
.optional("card_number", Schema::string())
.require_if("method", |v| v == &json!("card"), "card_number");
let result = schema.validate(
&json!({
"method": "cash"
}),
&JsonPath::root(),
);
assert!(result.is_success());
}
#[test]
fn test_require_if_with_required_field() {
let schema = Schema::object()
.field("method", Schema::string())
.optional("card_number", Schema::string())
.require_if("method", |v| v == &json!("card"), "card_number");
let result = schema.validate(
&json!({
"method": "card",
"card_number": "1234567890123456"
}),
&JsonPath::root(),
);
assert!(result.is_success());
}
#[test]
fn test_mutually_exclusive_both_present() {
let schema = Schema::object()
.optional("email", Schema::string())
.optional("phone", Schema::string())
.mutually_exclusive("email", "phone");
let result = schema.validate(
&json!({
"email": "user@example.com",
"phone": "+1234567890"
}),
&JsonPath::root(),
);
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert_eq!(errors.first().code, "mutually_exclusive");
}
#[test]
fn test_mutually_exclusive_one_present() {
let schema = Schema::object()
.optional("email", Schema::string())
.optional("phone", Schema::string())
.mutually_exclusive("email", "phone");
let result = schema.validate(
&json!({
"email": "user@example.com"
}),
&JsonPath::root(),
);
assert!(result.is_success());
let result = schema.validate(
&json!({
"phone": "+1234567890"
}),
&JsonPath::root(),
);
assert!(result.is_success());
}
#[test]
fn test_mutually_exclusive_none_present() {
let schema = Schema::object()
.optional("email", Schema::string())
.optional("phone", Schema::string())
.mutually_exclusive("email", "phone");
let result = schema.validate(&json!({}), &JsonPath::root());
assert!(result.is_success());
}
#[test]
fn test_mutually_exclusive_with_null() {
let schema = Schema::object()
.optional("email", Schema::string())
.optional("phone", Schema::string())
.mutually_exclusive("email", "phone");
let result = schema.validate(
&json!({
"email": "user@example.com"
}),
&JsonPath::root(),
);
assert!(result.is_success());
let result = schema.validate(
&json!({
"phone": "+1234567890"
}),
&JsonPath::root(),
);
assert!(result.is_success());
let result = schema.validate(&json!({}), &JsonPath::root());
assert!(result.is_success());
}
#[test]
fn test_at_least_one_of_none_present() {
let schema = Schema::object()
.optional("email", Schema::string())
.optional("phone", Schema::string())
.at_least_one_of(["email", "phone"]);
let result = schema.validate(&json!({}), &JsonPath::root());
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert_eq!(errors.first().code, "at_least_one_required");
}
#[test]
fn test_at_least_one_of_one_present() {
let schema = Schema::object()
.optional("email", Schema::string())
.optional("phone", Schema::string())
.at_least_one_of(["email", "phone"]);
let result = schema.validate(
&json!({
"email": "user@example.com"
}),
&JsonPath::root(),
);
assert!(result.is_success());
}
#[test]
fn test_at_least_one_of_both_present() {
let schema = Schema::object()
.optional("email", Schema::string())
.optional("phone", Schema::string())
.at_least_one_of(["email", "phone"]);
let result = schema.validate(
&json!({
"email": "user@example.com",
"phone": "+1234567890"
}),
&JsonPath::root(),
);
assert!(result.is_success());
}
#[test]
fn test_at_least_one_of_all_missing() {
let schema = Schema::object()
.optional("email", Schema::string())
.optional("phone", Schema::string())
.at_least_one_of(["email", "phone"]);
let result = schema.validate(&json!({}), &JsonPath::root());
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert_eq!(errors.first().code, "at_least_one_required");
}
#[test]
fn test_equal_fields_matching() {
let schema = Schema::object()
.field("password", Schema::string())
.field("confirm_password", Schema::string())
.equal_fields("password", "confirm_password");
let result = schema.validate(
&json!({
"password": "secret123",
"confirm_password": "secret123"
}),
&JsonPath::root(),
);
assert!(result.is_success());
}
#[test]
fn test_equal_fields_not_matching() {
let schema = Schema::object()
.field("password", Schema::string())
.field("confirm_password", Schema::string())
.equal_fields("password", "confirm_password");
let result = schema.validate(
&json!({
"password": "secret123",
"confirm_password": "different"
}),
&JsonPath::root(),
);
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert_eq!(errors.first().code, "fields_not_equal");
}
#[test]
fn test_equal_fields_one_missing() {
let schema = Schema::object()
.optional("password", Schema::string())
.optional("confirm_password", Schema::string())
.equal_fields("password", "confirm_password");
let result = schema.validate(
&json!({
"password": "secret123"
}),
&JsonPath::root(),
);
assert!(result.is_success());
}
#[test]
fn test_field_less_than_numbers() {
let schema = Schema::object()
.field("min", Schema::integer())
.field("max", Schema::integer())
.field_less_than("min", "max");
let result = schema.validate(
&json!({
"min": 10,
"max": 20
}),
&JsonPath::root(),
);
assert!(result.is_success());
let result = schema.validate(
&json!({
"min": 20,
"max": 10
}),
&JsonPath::root(),
);
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert_eq!(errors.first().code, "field_not_less_than");
let result = schema.validate(
&json!({
"min": 10,
"max": 10
}),
&JsonPath::root(),
);
assert!(result.is_failure());
}
#[test]
fn test_field_less_than_strings() {
let schema = Schema::object()
.field("start_date", Schema::string())
.field("end_date", Schema::string())
.field_less_than("start_date", "end_date");
let result = schema.validate(
&json!({
"start_date": "2024-01-01",
"end_date": "2024-12-31"
}),
&JsonPath::root(),
);
assert!(result.is_success());
let result = schema.validate(
&json!({
"start_date": "2024-12-31",
"end_date": "2024-01-01"
}),
&JsonPath::root(),
);
assert!(result.is_failure());
}
#[test]
fn test_field_less_than_type_mismatch() {
let schema = Schema::object()
.field("start", Schema::integer())
.field("end", Schema::string())
.field_less_than("start", "end");
let result = schema.validate(
&json!({
"start": 100,
"end": "200"
}),
&JsonPath::root(),
);
assert!(result.is_success());
}
#[test]
fn test_field_less_or_equal_numbers() {
let schema = Schema::object()
.field("min", Schema::integer())
.field("max", Schema::integer())
.field_less_or_equal("min", "max");
let result = schema.validate(
&json!({
"min": 10,
"max": 20
}),
&JsonPath::root(),
);
assert!(result.is_success());
let result = schema.validate(
&json!({
"min": 10,
"max": 10
}),
&JsonPath::root(),
);
assert!(result.is_success());
let result = schema.validate(
&json!({
"min": 20,
"max": 10
}),
&JsonPath::root(),
);
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert_eq!(errors.first().code, "field_not_less_or_equal");
}
#[test]
fn test_multiple_cross_field_rules() {
let schema = Schema::object()
.field("method", Schema::string())
.optional("email", Schema::string())
.optional("phone", Schema::string())
.optional("card_number", Schema::string())
.require_if("method", |v| v == &json!("card"), "card_number")
.mutually_exclusive("email", "phone")
.at_least_one_of(["email", "phone"]);
let result = schema.validate(
&json!({
"method": "cash",
"email": "user@example.com"
}),
&JsonPath::root(),
);
assert!(result.is_success());
let result = schema.validate(
&json!({
"method": "card",
"email": "user@example.com",
"phone": "+1234567890"
}),
&JsonPath::root(),
);
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert!(errors.len() >= 2);
}
#[test]
fn test_skip_cross_field_on_errors_default() {
let schema = Schema::object()
.field("name", Schema::string().min_len(5))
.field("age", Schema::integer().positive())
.custom(|_obj, _path| {
panic!("Cross-field validator should not run");
});
let result = schema.validate(
&json!({
"name": "AB", "age": 30
}),
&JsonPath::root(),
);
assert!(result.is_failure());
}
#[test]
fn test_skip_cross_field_on_errors_disabled() {
let schema = Schema::object()
.field("name", Schema::string().min_len(5))
.field("age", Schema::integer().positive())
.skip_cross_field_on_errors(false)
.custom(move |_obj, _path| {
Validation::Success(())
});
let result = schema.validate(
&json!({
"name": "AB", "age": 30
}),
&JsonPath::root(),
);
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert!(!errors.with_code("min_length").is_empty());
}
#[test]
fn test_cross_field_error_accumulation() {
let schema = Schema::object()
.field("password", Schema::string())
.field("confirm", Schema::string())
.field("min", Schema::integer())
.field("max", Schema::integer())
.equal_fields("password", "confirm")
.field_less_than("min", "max");
let result = schema.validate(
&json!({
"password": "secret",
"confirm": "different",
"min": 100,
"max": 50
}),
&JsonPath::root(),
);
assert!(result.is_failure());
let errors = unwrap_failure(result);
assert_eq!(errors.len(), 2);
assert!(!errors.with_code("fields_not_equal").is_empty());
assert!(!errors.with_code("field_not_less_than").is_empty());
}
#[test]
fn test_validated_object_has_method() {
let schema = Schema::object()
.optional("email", Schema::string())
.optional("phone", Schema::string())
.custom(|obj, _path| {
assert!(!obj.has("email"));
assert!(!obj.has("phone"));
Validation::Success(())
});
let result = schema.validate(&json!({}), &JsonPath::root());
assert!(result.is_success());
}