diff --git a/src/Http/Routing/src/Constraints/NotRouteConstraint.cs b/src/Http/Routing/src/Constraints/NotRouteConstraint.cs new file mode 100644 index 000000000000..aff2accde757 --- /dev/null +++ b/src/Http/Routing/src/Constraints/NotRouteConstraint.cs @@ -0,0 +1,314 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !COMPONENTS +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +#else +using Microsoft.AspNetCore.Components.Routing; +#endif + +namespace Microsoft.AspNetCore.Routing.Constraints; + +/// +/// A route constraint that negates one or more inner constraints. The constraint matches +/// when none of the inner constraints match the route value. +/// +/// +/// +/// The implements logical negation for route constraints. +/// It takes a semicolon-separated list of constraint names and returns true only +/// when none of the specified constraints match the route value. +/// +/// +/// Supported Features: +/// +/// +/// +/// Basic type constraints: int, bool, guid, datetime, decimal, double, float, long +/// +/// +/// String constraints: alpha, length(n), minlength(n), maxlength(n) +/// +/// +/// Numeric constraints: min(n), max(n), range(min,max) +/// +/// +/// File constraints: file, nonfile +/// +/// +/// Special constraints: required +/// +/// +/// Multiple constraints with semicolon separation (logical AND of negations) +/// +/// +/// Nested negation patterns (e.g., not(not(int))) - fully supported as recursive constraint evaluation +/// +/// +/// +/// Examples: +/// +/// +/// +/// not(int) +/// Matches any value that is NOT an integer (e.g., "abc", "12.5", "true") +/// +/// +/// not(int;bool) +/// Matches values that are neither integers nor booleans (e.g., "abc", "12.5") +/// +/// +/// not(not(int)) +/// Double negation - matches integers (equivalent to just using int constraint) +/// +/// +/// not(min(18)) +/// Matches integer values less than 18 or non-integer values +/// +/// +/// not(alpha) +/// Matches non-alphabetic values (e.g., "123", "test123") +/// +/// +/// not(file) +/// Matches values that don't contain file extensions +/// +/// +/// +/// Important Notes: +/// +/// +/// +/// Unknown constraint names are ignored and always treated as non-matching, resulting in negation returning true +/// +/// +/// Nested negation patterns are fully supported and work recursively (e.g., not(not(int)) = double negation) +/// +/// +/// Multiple constraints are combined with logical AND - ALL inner constraints must fail for the negation to succeed +/// +/// +/// Works with both route matching and literal parameter matching scenarios +/// +/// +/// +#if !COMPONENTS +public class NotRouteConstraint : IRouteConstraint, IParameterLiteralNodeMatchingPolicy +#else +internal class NotRouteConstraint : IRouteConstraint +#endif +{ + /// + /// Gets the array of inner constraint names to be negated. + /// + private string[] _inner { get; } + + /// + /// Cached constraint map to avoid repeated reflection-based lookups. + /// + private static IDictionary? _cachedConstraintMap; + + /// + /// Initializes a new instance of the class + /// with the specified inner constraints. + /// + /// + /// A semicolon-separated string containing the names of constraints to negate. + /// Can be a single constraint name (e.g., "int") or multiple constraints (e.g., "int;bool;guid"). + /// Parameterized constraints are supported (e.g., "min(18);length(5)"). + /// Unknown constraint names are treated as non-matching constraints. + /// + /// + /// The constraints string is split by semicolons to create individual constraint checks. + /// Examples of valid constraint strings: + /// + /// "int" - Single type constraint + /// "int;bool" - Multiple type constraints + /// "min(18)" - Parameterized constraint + /// "length(5);alpha" - Mixed constraint types + /// "" - Empty string (always returns true) + /// + /// + public NotRouteConstraint(string constraints) + { + _inner = constraints.Split(";"); + } + + private static IDictionary GetConstraintMap() + { + // Use cached map or fall back to default constraint map + return _cachedConstraintMap ??= GetDefaultConstraintMap(); + } + + private static Dictionary GetDefaultConstraintMap() + { + // FIXME: I'm not sure if this is a good thing to do because + // it requires weak spreading between the ConstraintMap and + // RouteOptions. It doesn't seem appropriate to create two + // identical variables for this... + + var defaults = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // Type-specific constraints + ["int"] = typeof(IntRouteConstraint), + ["bool"] = typeof(BoolRouteConstraint), + ["datetime"] = typeof(DateTimeRouteConstraint), + ["decimal"] = typeof(DecimalRouteConstraint), + ["double"] = typeof(DoubleRouteConstraint), + ["float"] = typeof(FloatRouteConstraint), + ["guid"] = typeof(GuidRouteConstraint), + ["long"] = typeof(LongRouteConstraint), + + // Length constraints + ["minlength"] = typeof(MinLengthRouteConstraint), + ["maxlength"] = typeof(MaxLengthRouteConstraint), + ["length"] = typeof(LengthRouteConstraint), + + // Min/Max value constraints + ["min"] = typeof(MinRouteConstraint), + ["max"] = typeof(MaxRouteConstraint), + ["range"] = typeof(RangeRouteConstraint), + + // Alpha constraint + ["alpha"] = typeof(AlphaRouteConstraint), + +#if !COMPONENTS + ["required"] = typeof(RequiredRouteConstraint), +#endif + + // File constraints + ["file"] = typeof(FileNameRouteConstraint), + ["nonfile"] = typeof(NonFileNameRouteConstraint), + + // Not constraint + ["not"] = typeof(NotRouteConstraint) + }; + + return defaults; + } + + /// + /// + /// + /// This method implements the core negation logic by: + /// + /// + /// Resolving each inner constraint name to its corresponding implementation + /// Testing each resolved constraint against the route value + /// Returning false immediately if any constraint matches (short-circuit evaluation) + /// Returning true only if no constraints match + /// + /// + /// The method attempts to use the constraint map from if available via + /// the HTTP context's service provider, falling back to the default constraint map if needed. + /// + /// + /// Unknown constraint names are ignored (treated as non-matching), which means they don't affect + /// the negation result. + /// + /// + public bool Match( +#if !COMPONENTS + HttpContext? httpContext, + IRouter? route, + string routeKey, + RouteValueDictionary values, + RouteDirection routeDirection) +#else + string routeKey, + RouteValueDictionary values) +#endif + { + ArgumentNullException.ThrowIfNull(routeKey); + ArgumentNullException.ThrowIfNull(values); + + // Try to get constraint map from HttpContext first, fallback to default map + IDictionary constraintMap; + IServiceProvider? serviceProvider = null; + +#if !COMPONENTS + if (httpContext?.RequestServices != null) + { + try + { + var routeOptions = httpContext.RequestServices.GetService>(); + if (routeOptions != null) + { + constraintMap = routeOptions.Value.TrimmerSafeConstraintMap; + serviceProvider = httpContext.RequestServices; + } + else + { + constraintMap = GetConstraintMap(); + } + } + catch + { + constraintMap = GetConstraintMap(); + } + } + else + { + constraintMap = GetConstraintMap(); + } +#else + constraintMap = GetConstraintMap(); +#endif + + foreach (var constraintText in _inner) + { + var resolvedConstraint = ParameterPolicyActivator.ResolveParameterPolicy( + constraintMap, + serviceProvider, + constraintText, + out _); + + if (resolvedConstraint != null) + { + // If any inner constraint matches, return false (negation logic) +#if !COMPONENTS + if (resolvedConstraint.Match(httpContext, route, routeKey, values, routeDirection)) +#else + if (resolvedConstraint.Match(routeKey, values)) +#endif + { + return false; + } + } + } + + // If no inner constraints matched, return true (all constraints were negated) + return true; + } + +#if !COMPONENTS + bool IParameterLiteralNodeMatchingPolicy.MatchesLiteral(string parameterName, string literal) + { + var constraintMap = GetConstraintMap(); + + foreach (var constraintText in _inner) + { + var resolvedConstraint = ParameterPolicyActivator.ResolveParameterPolicy( + constraintMap, + null, + constraintText, + out _); + + if (resolvedConstraint is IParameterLiteralNodeMatchingPolicy literalPolicy) + { + // If any inner constraint matches the literal, return false (negation logic) + if (literalPolicy.MatchesLiteral(parameterName, literal)) + { + return false; + } + } + } + + // If no inner constraints matched the literal, return true + return true; + } +#endif +} diff --git a/src/Http/Routing/src/PublicAPI.Unshipped.txt b/src/Http/Routing/src/PublicAPI.Unshipped.txt index 0612dc9ff2b0..c3782771b7c8 100644 --- a/src/Http/Routing/src/PublicAPI.Unshipped.txt +++ b/src/Http/Routing/src/PublicAPI.Unshipped.txt @@ -1,3 +1,6 @@ #nullable enable Microsoft.AspNetCore.Builder.ValidationEndpointConventionBuilderExtensions +Microsoft.AspNetCore.Routing.Constraints.NotRouteConstraint +Microsoft.AspNetCore.Routing.Constraints.NotRouteConstraint.Match(Microsoft.AspNetCore.Http.HttpContext? httpContext, Microsoft.AspNetCore.Routing.IRouter? route, string! routeKey, Microsoft.AspNetCore.Routing.RouteValueDictionary! values, Microsoft.AspNetCore.Routing.RouteDirection routeDirection) -> bool +Microsoft.AspNetCore.Routing.Constraints.NotRouteConstraint.NotRouteConstraint(string! constraints) -> void static Microsoft.AspNetCore.Builder.ValidationEndpointConventionBuilderExtensions.DisableValidation(this TBuilder builder) -> TBuilder diff --git a/src/Http/Routing/src/RouteOptions.cs b/src/Http/Routing/src/RouteOptions.cs index 098583357514..1be30c41d7db 100644 --- a/src/Http/Routing/src/RouteOptions.cs +++ b/src/Http/Routing/src/RouteOptions.cs @@ -141,6 +141,8 @@ private static IDictionary GetDefaultConstraintMap() AddConstraint(defaults, "file"); AddConstraint(defaults, "nonfile"); + // Not constraint + AddConstraint(defaults, "not"); return defaults; } diff --git a/src/Http/Routing/test/UnitTests/Constraints/NotRouteConstraintTests.cs b/src/Http/Routing/test/UnitTests/Constraints/NotRouteConstraintTests.cs new file mode 100644 index 000000000000..000d2cf608ef --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Constraints/NotRouteConstraintTests.cs @@ -0,0 +1,507 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing.Matching; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.AspNetCore.Routing.Constraints; + +public class NotRouteConstraintTests +{ + [Fact] + public void Constructor_WithSingleConstraint_ParsesCorrectly() + { + // Arrange & Act + var constraint = new NotRouteConstraint("int"); + + // Assert + Assert.NotNull(constraint); + } + + [Fact] + public void Constructor_WithMultipleConstraints_ParsesCorrectly() + { + // Arrange & Act + var constraint = new NotRouteConstraint("int;bool;guid"); + + // Assert + Assert.NotNull(constraint); + } + + [Fact] + public void Constructor_WithEmptyString_CreatesConstraint() + { + // Arrange & Act + var constraint = new NotRouteConstraint(""); + + // Assert + Assert.NotNull(constraint); + } + + [Theory] + [InlineData("int", "123", false)] // int constraint matches, so NOT should return false + [InlineData("int", "abc", true)] // int constraint doesn't match, so NOT should return true + [InlineData("bool", "true", false)] // bool constraint matches, so NOT should return false + [InlineData("bool", "abc", true)] // bool constraint doesn't match, so NOT should return true + [InlineData("guid", "550e8400-e29b-41d4-a716-446655440000", false)] // guid matches, NOT returns false + [InlineData("guid", "not-a-guid", true)] // guid doesn't match, NOT returns true + public void Match_WithSingleConstraint_ReturnsExpectedResult(string constraintName, string value, bool expected) + { + // Arrange + var constraint = new NotRouteConstraint(constraintName); + var values = new RouteValueDictionary { { "test", value } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("int;bool", "123", false)] // int matches, so overall result is false + [InlineData("int;bool", "true", false)] // bool matches, so overall result is false + [InlineData("int;bool", "abc", true)] // neither matches, so overall result is true + [InlineData("min(5);max(10)", "7", false)] // value is between 5 and 10, both constraints match, so false + [InlineData("min(15);max(3)", "7", true)] // value is less than 15 and greater than 3, neither matches completely, so true + [InlineData("min(5);max(3)", "7", false)] // value is greater than 5, min matches, so false + public void Match_WithMultipleConstraints_ReturnsExpectedResult(string constraints, string value, bool expected) + { + // Arrange + var constraint = new NotRouteConstraint(constraints); + var values = new RouteValueDictionary { { "test", value } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void Match_WithNullHttpContext_UsesDefaultConstraintMap() + { + // Arrange + var constraint = new NotRouteConstraint("int"); + var values = new RouteValueDictionary { { "test", "123" } }; + + // Act + var result = constraint.Match(null, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.False(result); // int constraint should match "123", so NOT returns false + } + + [Fact] + public void Match_WithHttpContextButNoRouteOptions_UsesDefaultConstraintMap() + { + // Arrange + var constraint = new NotRouteConstraint("int"); + var values = new RouteValueDictionary { { "test", "123" } }; + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + var httpContext = new DefaultHttpContext { RequestServices = serviceProvider }; + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.False(result); // int constraint should match "123", so NOT returns false + } + + [Fact] + public void Match_WithCustomRouteOptions_UsesCustomConstraintMap() + { + // Arrange + var constraint = new NotRouteConstraint("custom"); + var values = new RouteValueDictionary { { "test", "value" } }; + + var customConstraintMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["custom"] = typeof(AlwaysTrueConstraint) + }; + + var routeOptions = new RouteOptions(); + typeof(RouteOptions).GetField("_constraintTypeMap", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?.SetValue(routeOptions, customConstraintMap); + + var services = new ServiceCollection(); + services.AddSingleton(Options.Create(routeOptions)); + var serviceProvider = services.BuildServiceProvider(); + var httpContext = new DefaultHttpContext { RequestServices = serviceProvider }; + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.False(result); + } + + [Fact] + public void Match_WithServiceResolutionException_FallsBackToDefaultMap() + { + // Arrange + var constraint = new NotRouteConstraint("int"); + var values = new RouteValueDictionary { { "test", "123" } }; + + var mockServiceProvider = new Mock(); + mockServiceProvider.Setup(sp => sp.GetService(It.IsAny())) + .Throws(new InvalidOperationException("Service resolution failed")); + + var httpContext = new DefaultHttpContext { RequestServices = mockServiceProvider.Object }; + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.False(result); // Should fall back to default map and int constraint should match + } + + [Fact] + public void Match_ThrowsArgumentNullException_WhenRouteKeyIsNull() + { + // Arrange + var constraint = new NotRouteConstraint("int"); + var values = new RouteValueDictionary { { "test", "123" } }; + var httpContext = CreateHttpContext(); + + // Act & Assert + Assert.Throws(() => + constraint.Match(httpContext, null, null!, values, RouteDirection.IncomingRequest)); + } + + [Fact] + public void Match_ThrowsArgumentNullException_WhenValuesIsNull() + { + // Arrange + var constraint = new NotRouteConstraint("int"); + var httpContext = CreateHttpContext(); + + // Act & Assert + Assert.Throws(() => + constraint.Match(httpContext, null, "test", null!, RouteDirection.IncomingRequest)); + } + + [Theory] + [InlineData("int", "123", false)] // int constraint matches literal, NOT returns false + [InlineData("int", "abc", true)] // int constraint doesn't match literal, NOT returns true + [InlineData("bool", "true", false)] // bool constraint matches literal, NOT returns false + [InlineData("bool", "abc", true)] // bool constraint doesn't match literal, NOT returns true + public void MatchesLiteral_WithSingleConstraint_ReturnsExpectedResult(string constraintName, string literal, bool expected) + { + // Arrange + var constraint = new NotRouteConstraint(constraintName); + + // Act + var result = ((IParameterLiteralNodeMatchingPolicy)constraint).MatchesLiteral("test", literal); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("int;bool", "123", false)] // int matches literal, so overall result is false + [InlineData("int;bool", "true", false)] // bool matches literal, so overall result is false + [InlineData("int;bool", "abc", true)] // neither matches literal, so overall result is true + public void MatchesLiteral_WithMultipleConstraints_ReturnsExpectedResult(string constraints, string literal, bool expected) + { + // Arrange + var constraint = new NotRouteConstraint(constraints); + + // Act + var result = ((IParameterLiteralNodeMatchingPolicy)constraint).MatchesLiteral("test", literal); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void MatchesLiteral_WithUnknownConstraint_ReturnsTrue() + { + // Arrange + var constraint = new NotRouteConstraint("unknownconstraint"); + + // Act + var result = ((IParameterLiteralNodeMatchingPolicy)constraint).MatchesLiteral("test", "value"); + + // Assert + Assert.True(result); + } + + [Fact] + public void MatchesLiteral_WithConstraintThatDoesNotImplementLiteralPolicy_ReturnsTrue() + { + // Arrange + var constraint = new NotRouteConstraint("required"); // RequiredRouteConstraint doesn't implement IParameterLiteralNodeMatchingPolicy + + // Act + var result = ((IParameterLiteralNodeMatchingPolicy)constraint).MatchesLiteral("test", "value"); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("")] + [InlineData("single")] + [InlineData("multiple;constraints;here")] + [InlineData("int;bool;guid;datetime")] + public void Constructor_WithVariousConstraintStrings_DoesNotThrow(string constraints) + { + // Arrange & Act + var exception = Record.Exception(() => new NotRouteConstraint(constraints)); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void Match_WithComplexConstraints_HandlesCorrectly() + { + // Arrange + var constraint = new NotRouteConstraint("min(10);max(5)"); + var values = new RouteValueDictionary { { "test", "7" } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.True(result); // Neither constraint should match, so NOT returns true + } + + [Fact] + public void Match_WithParameterizedConstraints_HandlesCorrectly() + { + // Arrange + var constraint = new NotRouteConstraint("length(5);minlength(3)"); + var values = new RouteValueDictionary { { "test", "hello" } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.False(result); + } + + [Fact] + public void Match_WithEmptyConstraintString_ReturnsTrue() + { + // Arrange + var constraint = new NotRouteConstraint(""); + var values = new RouteValueDictionary { { "test", "anyvalue" } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.True(result); + } + + [Fact] + public void Match_WithNonExistentRouteKey_ReturnsTrue() + { + // Arrange + var constraint = new NotRouteConstraint("int"); + var values = new RouteValueDictionary { { "other", "123" } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.True(result); + } + + [Fact] + public void Match_WithNullRouteValue_ReturnsTrue() + { + // Arrange + var constraint = new NotRouteConstraint("int"); + var values = new RouteValueDictionary { { "test", null } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("alpha", "abc", false)] // alpha matches letters, NOT returns false + [InlineData("alpha", "123", true)] // alpha doesn't match numbers, NOT returns true + public void Match_WithAlphaConstraints_ReturnsExpectedResult(string constraintName, string value, bool expected) + { + // Arrange + var constraint = new NotRouteConstraint(constraintName); + var values = new RouteValueDictionary { { "test", value } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void Match_WithRequiredConstraint_HandlesCorrectly() + { + // Arrange + var constraint = new NotRouteConstraint("required"); + var values = new RouteValueDictionary { { "test", "" } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("file", "test.txt", false)] // file constraint matches, NOT returns false + [InlineData("file", "test", true)] // file constraint doesn't match, NOT returns true + [InlineData("nonfile", "test", false)] // nonfile constraint matches, NOT returns false + [InlineData("nonfile", "test.txt", true)] // nonfile constraint doesn't match, NOT returns true + public void Match_WithFileConstraints_ReturnsExpectedResult(string constraintName, string value, bool expected) + { + // Arrange + var constraint = new NotRouteConstraint(constraintName); + var values = new RouteValueDictionary { { "test", value } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("unknownconstraint", "any", true)] + [InlineData("faketype", "value", true)] + public void Match_WithUnknownConstraints_ReturnsTrue(string constraintName, string value, bool expected) + { + // Arrange + var constraint = new NotRouteConstraint(constraintName); + var values = new RouteValueDictionary { { "test", value } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(RouteDirection.IncomingRequest)] + [InlineData(RouteDirection.UrlGeneration)] + public void Match_WithDifferentRouteDirections_WorksCorrectly(RouteDirection direction) + { + // Arrange + var constraint = new NotRouteConstraint("int"); + var values = new RouteValueDictionary { { "test", "123" } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, direction); + + // Assert + Assert.False(result); + } + + [Fact] + public void MatchesLiteral_WithComplexConstraints_HandlesCorrectly() + { + // Arrange + var constraint = new NotRouteConstraint("min(10);length(3)"); + + // Act & Assert + Assert.True(((IParameterLiteralNodeMatchingPolicy)constraint).MatchesLiteral("test", "5")); + Assert.False(((IParameterLiteralNodeMatchingPolicy)constraint).MatchesLiteral("test", "15")); + Assert.False(((IParameterLiteralNodeMatchingPolicy)constraint).MatchesLiteral("test", "abc")); + } + + [Theory] + [InlineData("not(int)", "123", true)] // Double negation: not(not(int)) with int value should return true + [InlineData("not(int)", "abc", false)] // Double negation: not(not(int)) with non-int value should return false + [InlineData("not(bool)", "true", true)] // Double negation: not(not(bool)) with bool value should return true + [InlineData("not(bool)", "abc", false)] // Triple negation: not(not(bool)) with non-bool value should return false + public void Match_WithDoubleNegationPattern_ReturnsExpectedResult(string constraintPattern, string value, bool expected) + { + // Arrange + var constraint = new NotRouteConstraint(constraintPattern); + var values = new RouteValueDictionary { { "test", value } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("not(not(int))", "123", false)] // Triple negation: not(not(not(int))) with int value should return false + [InlineData("not(not(int))", "abc", true)] // Triple negation: not(not(not(int))) with non-int value should return true + [InlineData("not(not(bool))", "true", false)] // Triple negation: not(not(not(bool))) with bool value should return false + [InlineData("not(not(bool))", "abc", true)] // Triple negation: not(not(not(bool))) with non-bool value should return true + public void Match_WithTripleNegationPattern_ReturnsExpectedResult(string constraintPattern, string value, bool expected) + { + // Arrange + var constraint = new NotRouteConstraint(constraintPattern); + var values = new RouteValueDictionary { { "test", value } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("not(not(not(int)))", "123", true)] // Fourth negation: not(not(not(not(int)))) with int value should return true + [InlineData("not(not(not(int)))", "abc", false)] // Fourth negation: not(not(not(int))) with non-int value should return false + [InlineData("not(not(not(bool)))", "true", true)] // Fourth negation: not(not(not(bool))) with bool value should return true + [InlineData("not(not(not(bool)))", "abc", false)] // Fourth negation: not(not(not(bool))) with non-bool value should return false + public void Match_WithFourthNegationPattern_ReturnsExpectedResult(string constraintPattern, string value, bool expected) + { + // Arrange + var constraint = new NotRouteConstraint(constraintPattern); + var values = new RouteValueDictionary { { "test", value } }; + var httpContext = CreateHttpContext(); + + // Act + var result = constraint.Match(httpContext, null, "test", values, RouteDirection.IncomingRequest); + + // Assert + Assert.Equal(expected, result); + } + + private static DefaultHttpContext CreateHttpContext() + { + var services = new ServiceCollection(); + services.Configure(options => { }); + var serviceProvider = services.BuildServiceProvider(); + return new DefaultHttpContext { RequestServices = serviceProvider }; + } + + private class AlwaysTrueConstraint : IRouteConstraint + { + public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) + { + return true; + } + } +} \ No newline at end of file diff --git a/src/Http/startvscode.sh b/src/Http/startvscode.sh old mode 100644 new mode 100755