@@ -354,6 +354,143 @@ [new RequiredAttribute()]),
354354 Assert . Contains ( "Maximum validation depth of 3 exceeded at 'Children[0].Parent.Children[0]'. This is likely caused by a circular reference in the object graph. Consider increasing the MaxDepth in ValidationOptions if deeper validation is required." , exception . Message ) ;
355355 }
356356
357+ [ Fact ]
358+ public async Task Validate_HandlesCustomValidationAttributes ( )
359+ {
360+ // Arrange
361+ var productType = new TestValidatableTypeInfo (
362+ typeof ( Product ) ,
363+ [
364+ CreatePropertyInfo ( typeof ( Product ) , typeof ( string ) , "SKU" , "SKU" , false , false , true , false , [ new RequiredAttribute ( ) , new CustomSkuValidationAttribute ( ) ] ) ,
365+ ] ,
366+ false
367+ ) ;
368+
369+ var context = new ValidatableContext
370+ {
371+ ValidationOptions = new TestValidationOptions ( new Dictionary < Type , ValidatableTypeInfo >
372+ {
373+ { typeof ( Product ) , productType }
374+ } )
375+ } ;
376+
377+ var product = new Product { SKU = "INVALID-SKU" } ;
378+ context . ValidationContext = new ValidationContext ( product ) ;
379+
380+ // Act
381+ await productType . Validate ( product , context ) ;
382+
383+ // Assert
384+ Assert . NotNull ( context . ValidationErrors ) ;
385+ var error = Assert . Single ( context . ValidationErrors ) ;
386+ Assert . Equal ( "SKU" , error . Key ) ;
387+ Assert . Equal ( "SKU must start with 'PROD-'." , error . Value . First ( ) ) ;
388+ }
389+
390+ [ Fact ]
391+ public async Task Validate_HandlesMultipleErrorsOnSameProperty ( )
392+ {
393+ // Arrange
394+ var userType = new TestValidatableTypeInfo (
395+ typeof ( User ) ,
396+ [
397+ CreatePropertyInfo ( typeof ( User ) , typeof ( string ) , "Password" , "Password" , false , false , true , false ,
398+ [
399+ new RequiredAttribute ( ) ,
400+ new MinLengthAttribute ( 8 ) { ErrorMessage = "Password must be at least 8 characters." } ,
401+ new PasswordComplexityAttribute ( )
402+ ] )
403+ ] ,
404+ false
405+ ) ;
406+
407+ var context = new ValidatableContext
408+ {
409+ ValidationOptions = new TestValidationOptions ( new Dictionary < Type , ValidatableTypeInfo >
410+ {
411+ { typeof ( User ) , userType }
412+ } )
413+ } ;
414+
415+ var user = new User { Password = "abc" } ; // Too short and not complex enough
416+ context . ValidationContext = new ValidationContext ( user ) ;
417+
418+ // Act
419+ await userType . Validate ( user , context ) ;
420+
421+ // Assert
422+ Assert . NotNull ( context . ValidationErrors ) ;
423+ Assert . Single ( context . ValidationErrors . Keys ) ; // Only the "Password" key
424+ Assert . Equal ( 2 , context . ValidationErrors [ "Password" ] . Length ) ; // But with 2 errors
425+ Assert . Contains ( "Password must be at least 8 characters." , context . ValidationErrors [ "Password" ] ) ;
426+ Assert . Contains ( "Password must contain at least one number and one special character." , context . ValidationErrors [ "Password" ] ) ;
427+ }
428+
429+ [ Fact ]
430+ public async Task Validate_HandlesMultiLevelInheritance ( )
431+ {
432+ // Arrange
433+ var baseType = new TestValidatableTypeInfo (
434+ typeof ( BaseEntity ) ,
435+ [
436+ CreatePropertyInfo ( typeof ( BaseEntity ) , typeof ( Guid ) , "Id" , "Id" , false , false , false , false , [ ] )
437+ ] ,
438+ false
439+ ) ;
440+
441+ var intermediateType = new TestValidatableTypeInfo (
442+ typeof ( IntermediateEntity ) ,
443+ [
444+ CreatePropertyInfo ( typeof ( IntermediateEntity ) , typeof ( DateTime ) , "CreatedAt" , "CreatedAt" , false , false , false , false , [ new PastDateAttribute ( ) ] )
445+ ] ,
446+ false ,
447+ [ typeof ( BaseEntity ) ]
448+ ) ;
449+
450+ var derivedType = new TestValidatableTypeInfo (
451+ typeof ( DerivedEntity ) ,
452+ [
453+ CreatePropertyInfo ( typeof ( DerivedEntity ) , typeof ( string ) , "Name" , "Name" , false , false , true , false , [ new RequiredAttribute ( ) ] )
454+ ] ,
455+ false ,
456+ [ typeof ( IntermediateEntity ) ]
457+ ) ;
458+
459+ var context = new ValidatableContext
460+ {
461+ ValidationOptions = new TestValidationOptions ( new Dictionary < Type , ValidatableTypeInfo >
462+ {
463+ { typeof ( BaseEntity ) , baseType } ,
464+ { typeof ( IntermediateEntity ) , intermediateType } ,
465+ { typeof ( DerivedEntity ) , derivedType }
466+ } )
467+ } ;
468+
469+ var entity = new DerivedEntity
470+ {
471+ Name = "" , // Invalid: required
472+ CreatedAt = DateTime . Now . AddDays ( 1 ) // Invalid: future date
473+ } ;
474+ context . ValidationContext = new ValidationContext ( entity ) ;
475+
476+ // Act
477+ await derivedType . Validate ( entity , context ) ;
478+
479+ // Assert
480+ Assert . NotNull ( context . ValidationErrors ) ;
481+ Assert . Collection ( context . ValidationErrors ,
482+ kvp =>
483+ {
484+ Assert . Equal ( "Name" , kvp . Key ) ;
485+ Assert . Equal ( "The Name field is required." , kvp . Value . First ( ) ) ;
486+ } ,
487+ kvp =>
488+ {
489+ Assert . Equal ( "CreatedAt" , kvp . Key ) ;
490+ Assert . Equal ( "Date must be in the past." , kvp . Value . First ( ) ) ;
491+ } ) ;
492+ }
493+
357494 private ValidatablePropertyInfo CreatePropertyInfo (
358495 Type containingType ,
359496 Type propertyType ,
@@ -436,6 +573,76 @@ private class TreeNode
436573 public List < TreeNode > Children { get ; set ; } = [ ] ;
437574 }
438575
576+ private class Product
577+ {
578+ public string SKU { get ; set ; } = string . Empty ;
579+ }
580+
581+ private class User
582+ {
583+ public string Password { get ; set ; } = string . Empty ;
584+ }
585+
586+ private class BaseEntity
587+ {
588+ public Guid Id { get ; set ; } = Guid . NewGuid ( ) ;
589+ }
590+
591+ private class IntermediateEntity : BaseEntity
592+ {
593+ public DateTime CreatedAt { get ; set ; }
594+ }
595+
596+ private class DerivedEntity : IntermediateEntity
597+ {
598+ public string Name { get ; set ; } = string . Empty ;
599+ }
600+
601+ private class PastDateAttribute : ValidationAttribute
602+ {
603+ protected override ValidationResult ? IsValid ( object ? value , ValidationContext validationContext )
604+ {
605+ if ( value is DateTime date && date > DateTime . Now )
606+ {
607+ return new ValidationResult ( "Date must be in the past." ) ;
608+ }
609+
610+ return ValidationResult . Success ;
611+ }
612+ }
613+
614+ private class CustomSkuValidationAttribute : ValidationAttribute
615+ {
616+ protected override ValidationResult ? IsValid ( object ? value , ValidationContext validationContext )
617+ {
618+ if ( value is string sku && ! sku . StartsWith ( "PROD-" , StringComparison . Ordinal ) )
619+ {
620+ return new ValidationResult ( "SKU must start with 'PROD-'." ) ;
621+ }
622+
623+ return ValidationResult . Success ;
624+ }
625+ }
626+
627+ private class PasswordComplexityAttribute : ValidationAttribute
628+ {
629+ protected override ValidationResult ? IsValid ( object ? value , ValidationContext validationContext )
630+ {
631+ if ( value is string password )
632+ {
633+ bool hasDigit = password . Any ( c => char . IsDigit ( c ) ) ;
634+ bool hasSpecial = password . Any ( c => ! char . IsLetterOrDigit ( c ) ) ;
635+
636+ if ( ! hasDigit || ! hasSpecial )
637+ {
638+ return new ValidationResult ( "Password must contain at least one number and one special character." ) ;
639+ }
640+ }
641+
642+ return ValidationResult . Success ;
643+ }
644+ }
645+
439646 // Test implementations
440647 private class TestValidatablePropertyInfo : ValidatablePropertyInfo
441648 {
0 commit comments