diff --git a/cot/src/form/fields.rs b/cot/src/form/fields.rs index b30c73c0..18a50e4f 100644 --- a/cot/src/form/fields.rs +++ b/cot/src/form/fields.rs @@ -31,6 +31,16 @@ use crate::db::{Auto, ForeignKey, LimitedString, Model}; use crate::form::{AsFormField, FormField, FormFieldOptions, FormFieldValidationError}; use crate::html::HtmlTag; +pub(crate) trait ValidateFormFieldOptions { + fn validate(&self); +} + +fn validate_min_max(min: Option<&T>, max: Option<&T>, message: &str) { + if let (Some(min), Some(max)) = (min, max) { + assert!(min <= max, "{message}"); + } +} + macro_rules! impl_form_field { ($field_type_name:ident, $field_options_type_name:ident, $purpose:literal $(, $generic_param:ident $(: $generic_param_bound:ident $(+ $generic_param_bound_more:ident)*)?)?) => { #[derive(Debug)] @@ -48,6 +58,7 @@ macro_rules! impl_form_field { options: $crate::form::FormFieldOptions, custom_options: Self::CustomOptions, ) -> Self { + $crate::form::fields::ValidateFormFieldOptions::validate(&custom_options); Self { options, custom_options, @@ -660,6 +671,10 @@ pub trait Integer: Sized + ToString + Send { /// assert_eq!(::MAX, Some(127)); /// ``` const MAX: Option; + #[doc(hidden)] + fn is_valid_range(_min: &Self, _max: &Self) -> bool { + true + } } macro_rules! impl_integer { @@ -667,6 +682,10 @@ macro_rules! impl_integer { impl Integer for $type { const MAX: Option = Some(Self::MAX); const MIN: Option = Some(Self::MIN); + + fn is_valid_range(min: &Self, max: &Self) -> bool { + min <= max + } } }; } @@ -1039,6 +1058,10 @@ pub trait Float: Sized + ToString + Send { /// assert_eq!(::MAX, Some(f32::MAX)); /// ``` const MAX: Option; + #[doc(hidden)] + fn is_valid_range(_min: &Self, _max: &Self) -> bool { + true + } } macro_rules! impl_float { @@ -1046,6 +1069,10 @@ macro_rules! impl_float { impl Float for $type { const MIN: Option = Some(Self::MIN); const MAX: Option = Some(Self::MAX); + + fn is_valid_range(min: &Self, max: &Self) -> bool { + min <= max + } } }; } @@ -1211,6 +1238,82 @@ impl AsFormField for Url { } } +macro_rules! impl_length_options_validation { + ($($options:ty),+ $(,)?) => { + $( + impl ValidateFormFieldOptions for $options { + fn validate(&self) { + validate_min_max( + self.min_length.as_ref(), + self.max_length.as_ref(), + "minimum length cannot exceed maximum length", + ); + } + } + )+ + }; +} + +impl_length_options_validation!( + StringFieldOptions, + PasswordFieldOptions, + EmailFieldOptions, + UrlFieldOptions, +); + +impl ValidateFormFieldOptions for IntegerFieldOptions { + fn validate(&self) { + if let (Some(min), Some(max)) = (&self.min, &self.max) { + assert!( + T::is_valid_range(min, max), + "minimum value cannot exceed maximum value" + ); + } + } +} + +impl ValidateFormFieldOptions for FloatFieldOptions { + fn validate(&self) { + if let (Some(min), Some(max)) = (&self.min, &self.max) { + assert!( + T::is_valid_range(min, max), + "minimum value cannot exceed maximum value" + ); + } + } +} + +macro_rules! impl_value_options_validation { + ($($options:ty),+ $(,)?) => { + $( + impl ValidateFormFieldOptions for $options { + fn validate(&self) { + validate_min_max( + self.min.as_ref(), + self.max.as_ref(), + "minimum value cannot exceed maximum value", + ); + } + } + )+ + }; +} + +impl_value_options_validation!( + DateTimeFieldOptions, + DateTimeWithTimezoneFieldOptions, + TimeFieldOptions, + DateFieldOptions, +); + +impl ValidateFormFieldOptions for BoolFieldOptions { + fn validate(&self) {} +} + +impl ValidateFormFieldOptions for SelectFieldOptions { + fn validate(&self) {} +} + impl_field_options_builder!( StringFieldOptions, StringFieldOptionsBuilder { @@ -1271,9 +1374,131 @@ impl_field_options_builder!(BoolFieldOptions, BoolFieldOptionsBuilder { must_be_ #[cfg(test)] mod tests { + use std::panic::{AssertUnwindSafe, catch_unwind}; + + use ::chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime}; + use super::*; use crate::form::FormFieldValue; + fn test_field_options() -> FormFieldOptions { + FormFieldOptions::builder() + .id("test".to_owned()) + .name("test".to_owned()) + .required(true) + .build() + } + fn assert_rejects_invalid_options(build_field: impl FnOnce()) { + assert!(catch_unwind(AssertUnwindSafe(build_field)).is_err()); + } + + #[test] + #[should_panic(expected = "minimum length cannot exceed maximum length")] + fn string_field_rejects_invalid_options() { + StringField::with_options( + test_field_options(), + StringFieldOptions::builder() + .min_length(10) + .max_length(5) + .build(), + ); + } + + #[test] + #[should_panic(expected = "minimum value cannot exceed maximum value")] + fn integer_field_rejects_invalid_options() { + IntegerField::::with_options( + test_field_options(), + IntegerFieldOptions::builder().min(10).max(5).build(), + ); + } + #[test] + fn every_constrained_field_rejects_invalid_options() { + assert_rejects_invalid_options(|| { + PasswordField::with_options( + test_field_options(), + PasswordFieldOptions::builder() + .min_length(10) + .max_length(5) + .build(), + ); + }); + assert_rejects_invalid_options(|| { + EmailField::with_options( + test_field_options(), + EmailFieldOptions::builder() + .min_length(10) + .max_length(5) + .build(), + ); + }); + assert_rejects_invalid_options(|| { + UrlField::with_options( + test_field_options(), + UrlFieldOptions::builder() + .min_length(10) + .max_length(5) + .build(), + ); + }); + assert_rejects_invalid_options(|| { + FloatField::::with_options( + test_field_options(), + FloatFieldOptions::builder().min(10.0).max(5.0).build(), + ); + }); + + let min_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let max_date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + assert_rejects_invalid_options(|| { + DateField::with_options( + test_field_options(), + DateFieldOptions::builder() + .min(min_date) + .max(max_date) + .build(), + ); + }); + + let min_time = NaiveTime::from_hms_opt(12, 0, 0).unwrap(); + let max_time = NaiveTime::from_hms_opt(11, 0, 0).unwrap(); + assert_rejects_invalid_options(|| { + TimeField::with_options( + test_field_options(), + TimeFieldOptions::builder() + .min(min_time) + .max(max_time) + .build(), + ); + }); + + let min_datetime = + NaiveDateTime::parse_from_str("2025-01-02 12:00", "%Y-%m-%d %H:%M").unwrap(); + let max_datetime = + NaiveDateTime::parse_from_str("2025-01-01 12:00", "%Y-%m-%d %H:%M").unwrap(); + assert_rejects_invalid_options(|| { + DateTimeField::with_options( + test_field_options(), + DateTimeFieldOptions::builder() + .min(min_datetime) + .max(max_datetime) + .build(), + ); + }); + + let min_datetime = DateTime::parse_from_rfc3339("2025-01-02T12:00:00+00:00").unwrap(); + let max_datetime = DateTime::parse_from_rfc3339("2025-01-01T12:00:00+00:00").unwrap(); + assert_rejects_invalid_options(|| { + DateTimeWithTimezoneField::with_options( + test_field_options(), + DateTimeWithTimezoneFieldOptions::builder() + .min(min_datetime) + .max(max_datetime) + .build(), + ); + }); + } + #[test] fn string_field_render() { let field = StringField::with_options( @@ -1524,33 +1749,6 @@ mod tests { )); } - #[cot::test] - async fn email_field_clean_invalid_length_options() { - let mut field = EmailField::with_options( - FormFieldOptions::builder() - .id("email_test".to_owned()) - .name("email_test".to_owned()) - .required(true) - .build(), - EmailFieldOptions::builder() - .max_length(10) - .min_length(50) - .build(), - ); - - field - .set_value(FormFieldValue::new_text("user@example.com")) - .await - .unwrap(); - let result = Email::clean_value(&field); - - assert!(result.is_err()); - if let Err(err) = result { - let msg = err.to_string(); - assert!(msg.contains("min_length") && msg.contains("exceeds max_length")); - } - } - #[test] fn integer_field_render() { let field = IntegerField::::with_options(