use std::borrow::Cow;
use std::collections::BTreeMap;
use modelvault_core::error::DbError;
use modelvault_core::record::RowValue;
use modelvault_core::schema::{Constraint, FieldDef, FieldPath, Type};
use modelvault_core::validation::{
ensure_pk_type_primitive, validate_top_level_row, validate_value,
};
fn path_seg(s: &str) -> FieldPath {
FieldPath::new([Cow::Owned(s.to_string())]).unwrap()
}
#[test]
fn ensure_pk_rejects_optional() {
let e = ensure_pk_type_primitive(&Type::Optional(Box::new(Type::String))).unwrap_err();
assert!(matches!(e, DbError::Validation(_)));
}
#[test]
fn ensure_pk_accepts_primitive_types() {
for ty in [
Type::Bool,
Type::Int64,
Type::String,
Type::Bytes,
Type::Float64,
Type::Uint64,
Type::Uuid,
Type::Timestamp,
] {
ensure_pk_type_primitive(&ty).unwrap();
}
}
#[test]
fn ensure_pk_rejects_list_object_enum() {
for ty in [
Type::List(Box::new(Type::String)),
Type::Object(vec![]),
Type::Enum(vec!["a".into()]),
] {
assert!(matches!(
ensure_pk_type_primitive(&ty),
Err(DbError::Validation(_))
));
}
}
#[test]
fn validate_uuid_and_timestamp_values_ok() {
let mut p = vec!["u".into()];
validate_value(&mut p, &Type::Uuid, &[], &RowValue::Uuid([7u8; 16])).unwrap();
let mut p = vec!["t".into()];
validate_value(&mut p, &Type::Timestamp, &[], &RowValue::Timestamp(1)).unwrap();
}
#[test]
fn validate_bool_value_ok() {
let mut p = vec!["b".into()];
validate_value(&mut p, &Type::Bool, &[], &RowValue::Bool(true)).unwrap();
}
#[test]
fn validate_bool_uuid_timestamp_wrong_value_type() {
let mut p = vec!["p".into()];
for (ty, msg) in [
(Type::Bool, "expected bool"),
(Type::Uuid, "expected uuid"),
(Type::Timestamp, "expected timestamp"),
] {
let e = validate_value(&mut p, &ty, &[], &RowValue::Int64(1)).unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message == msg));
}
}
#[test]
fn validate_enum_requires_string_value() {
let mut p = vec!["e".into()];
let e = validate_value(
&mut p,
&Type::Enum(vec!["a".into()]),
&[],
&RowValue::Int64(1),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message == "expected string (enum)"));
}
#[test]
fn validate_optional_inner_then_constraint_on_wrapper() {
let mut p = vec!["o".into()];
validate_value(
&mut p,
&Type::Optional(Box::new(Type::String)),
&[Constraint::NonEmpty],
&RowValue::String("x".into()),
)
.unwrap();
}
#[test]
fn validate_int64_min_constraint() {
let mut p = vec!["n".into()];
let e = validate_value(
&mut p,
&Type::Int64,
&[Constraint::MinI64(10)],
&RowValue::Int64(3),
)
.unwrap_err();
match e {
DbError::Validation(v) => {
assert_eq!(v.path, vec!["n".to_string()]);
assert!(v.message.contains("below minimum"));
}
_ => panic!("expected Validation"),
}
}
#[test]
fn validate_object_optional_field_absent_is_ok() {
let fields = vec![FieldDef {
path: path_seg("x"),
ty: Type::Optional(Box::new(Type::String)),
constraints: vec![],
}];
let ty = Type::Object(fields);
let m = BTreeMap::new(); let mut p = vec!["obj".into()];
validate_value(&mut p, &ty, &[], &RowValue::Object(m)).unwrap();
}
#[test]
fn validate_constraints_violation_branches() {
let mut p = vec!["v".into()];
assert!(validate_value(
&mut p,
&Type::Int64,
&[Constraint::MaxI64(1)],
&RowValue::Int64(2),
)
.is_err());
assert!(validate_value(
&mut p,
&Type::Uint64,
&[Constraint::MinU64(5)],
&RowValue::Uint64(1),
)
.is_err());
assert!(validate_value(
&mut p,
&Type::Uint64,
&[Constraint::MaxU64(1)],
&RowValue::Uint64(2),
)
.is_err());
assert!(validate_value(
&mut p,
&Type::Float64,
&[Constraint::MinF64(5.0)],
&RowValue::Float64(1.0),
)
.is_err());
assert!(validate_value(
&mut p,
&Type::Float64,
&[Constraint::MaxF64(1.0)],
&RowValue::Float64(2.0),
)
.is_err());
assert!(validate_value(
&mut p,
&Type::String,
&[Constraint::MinLength(3)],
&RowValue::String("hi".into()),
)
.is_err());
assert!(validate_value(
&mut p,
&Type::Bytes,
&[Constraint::MinLength(2)],
&RowValue::Bytes(vec![1]),
)
.is_err());
assert!(validate_value(
&mut p,
&Type::List(Box::new(Type::Int64)),
&[Constraint::MinLength(2)],
&RowValue::List(vec![RowValue::Int64(1)]),
)
.is_err());
assert!(validate_value(
&mut p,
&Type::List(Box::new(Type::Int64)),
&[Constraint::MaxLength(1)],
&RowValue::List(vec![RowValue::Int64(1), RowValue::Int64(2)]),
)
.is_err());
}
#[test]
fn validate_string_regex_constraint_ok_and_fail() {
let mut p = vec!["s".into()];
validate_value(
&mut p,
&Type::String,
&[Constraint::Regex("^[a-z]+$".into())],
&RowValue::String("abc".into()),
)
.unwrap();
let e = validate_value(
&mut p,
&Type::String,
&[Constraint::Regex("^[a-z]+$".into())],
&RowValue::String("A".into()),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(_)));
}
#[test]
fn validate_object_rejects_unknown_key() {
let fields = vec![FieldDef {
path: path_seg("x"),
ty: Type::String,
constraints: vec![],
}];
let ty = Type::Object(fields);
let mut m = BTreeMap::new();
m.insert("x".into(), RowValue::String("a".into()));
m.insert("oops".into(), RowValue::String("b".into()));
let mut p = vec!["obj".into()];
let e = validate_value(&mut p, &ty, &[], &RowValue::Object(m)).unwrap_err();
match e {
DbError::Validation(v) => {
assert!(v.path.iter().any(|s| s == "oops"));
assert!(v.message.contains("unknown field"));
}
_ => panic!("expected Validation"),
}
}
#[test]
fn validate_enum_rejects_bad_variant() {
let mut p = vec!["e".into()];
let e = validate_value(
&mut p,
&Type::Enum(vec!["a".into(), "b".into()]),
&[],
&RowValue::String("c".into()),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(_)));
}
#[test]
fn validate_object_missing_field_path() {
let fields = vec![FieldDef {
path: path_seg("x"),
ty: Type::String,
constraints: vec![],
}];
let ty = Type::Object(fields);
let mut m = BTreeMap::new();
m.insert("y".into(), RowValue::String("nope".into()));
let mut p = vec!["obj".into()];
let e = validate_value(&mut p, &ty, &[], &RowValue::Object(m)).unwrap_err();
match e {
DbError::Validation(v) => {
assert!(v.path.contains(&"x".to_string()));
assert!(v.message.contains("missing"));
}
_ => panic!("expected Validation"),
}
}
#[test]
fn validate_constraint_type_mismatch_errors() {
let mut p = vec!["x".into()];
let e = validate_value(
&mut p,
&Type::String,
&[Constraint::MinI64(0)],
&RowValue::String("s".into()),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("requires int64")));
let e = validate_value(
&mut p,
&Type::Object(vec![]),
&[Constraint::NonEmpty],
&RowValue::Object(BTreeMap::new()),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("NonEmpty")));
let e = validate_value(
&mut p,
&Type::String,
&[Constraint::MaxI64(0)],
&RowValue::String("s".into()),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("requires int64")));
let e = validate_value(
&mut p,
&Type::String,
&[Constraint::MinU64(0)],
&RowValue::String("s".into()),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("requires uint64")));
let e = validate_value(
&mut p,
&Type::String,
&[Constraint::MaxU64(0)],
&RowValue::String("s".into()),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("requires uint64")));
let e = validate_value(
&mut p,
&Type::String,
&[Constraint::MinF64(0.0)],
&RowValue::String("s".into()),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("requires float64")));
let e = validate_value(
&mut p,
&Type::String,
&[Constraint::MaxF64(0.0)],
&RowValue::String("s".into()),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("requires float64")));
let e = validate_value(
&mut p,
&Type::Int64,
&[Constraint::MinLength(1)],
&RowValue::Int64(1),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("MinLength")));
let e = validate_value(
&mut p,
&Type::Int64,
&[Constraint::MaxLength(1)],
&RowValue::Int64(1),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("MaxLength")));
let e = validate_value(
&mut p,
&Type::Int64,
&[Constraint::Regex(".".into())],
&RowValue::Int64(1),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("Regex")));
let e = validate_value(
&mut p,
&Type::Int64,
&[Constraint::Email],
&RowValue::Int64(1),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("Email")));
let e = validate_value(
&mut p,
&Type::Int64,
&[Constraint::Url],
&RowValue::Int64(1),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message.contains("Url")));
}
#[test]
fn validate_top_level_unknown_field() {
let defs = vec![
FieldDef {
path: path_seg("id"),
ty: Type::String,
constraints: vec![],
},
FieldDef {
path: path_seg("y"),
ty: Type::Int64,
constraints: vec![],
},
];
let mut row = BTreeMap::new();
row.insert("id".into(), RowValue::String("k".into()));
row.insert("y".into(), RowValue::Int64(1));
row.insert("extra".into(), RowValue::Bool(true));
let e = validate_top_level_row(&defs, "id", &row).unwrap_err();
assert!(matches!(e, DbError::Validation(_)));
}
#[test]
fn validate_top_level_optional_omitted_is_ok() {
let defs = vec![
FieldDef {
path: path_seg("id"),
ty: Type::String,
constraints: vec![],
},
FieldDef {
path: path_seg("opt"),
ty: Type::Optional(Box::new(Type::String)),
constraints: vec![],
},
];
let mut row = BTreeMap::new();
row.insert("id".into(), RowValue::String("k".into()));
validate_top_level_row(&defs, "id", &row).unwrap();
}
#[test]
fn validate_top_level_required_null_rejected() {
let defs = vec![
FieldDef {
path: path_seg("id"),
ty: Type::String,
constraints: vec![],
},
FieldDef {
path: path_seg("y"),
ty: Type::Int64,
constraints: vec![],
},
];
let mut row = BTreeMap::new();
row.insert("id".into(), RowValue::String("k".into()));
row.insert("y".into(), RowValue::None);
let e = validate_top_level_row(&defs, "id", &row).unwrap_err();
assert!(matches!(e, DbError::Validation(_)));
}
#[test]
fn validate_list_and_object_expected_value_type() {
let mut p = vec!["l".into()];
let e = validate_value(
&mut p,
&Type::List(Box::new(Type::Int64)),
&[],
&RowValue::Object(BTreeMap::new()),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message == "expected list"));
let mut p = vec!["o".into()];
let e = validate_value(
&mut p,
&Type::Object(vec![FieldDef {
path: path_seg("a"),
ty: Type::String,
constraints: vec![],
}]),
&[],
&RowValue::List(vec![]),
)
.unwrap_err();
assert!(matches!(e, DbError::Validation(v) if v.message == "expected object"));
}
#[test]
fn validate_list_element_path_in_error() {
let mut p = vec!["items".into()];
let e = validate_value(
&mut p,
&Type::List(Box::new(Type::Int64)),
&[],
&RowValue::List(vec![RowValue::Int64(1), RowValue::String("bad".into())]),
)
.unwrap_err();
match e {
DbError::Validation(v) => {
assert!(
v.path == vec!["items".to_string(), "1".to_string()],
"path={:?}",
v.path
);
}
_ => panic!("expected Validation"),
}
}