diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml index b513263..e2e24b6 100644 --- a/.github/workflows/ci-main.yml +++ b/.github/workflows/ci-main.yml @@ -7,7 +7,7 @@ on: types: [ published ] env: - NETCORE_VERSION: '7.0.x' + NETCORE_VERSION: '8.0.x' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true PROJECT_NAME: FluentValidation @@ -42,12 +42,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - + - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: dotnet-version: ${{ env.NETCORE_VERSION }} - + - name: Create Release NuGet package run: | arrTag=(${GITHUB_REF//\// }) @@ -56,17 +56,17 @@ jobs: VERSION="${VERSION:1}" echo Clean Version: $VERSION dotnet pack -v normal -c Release --include-symbols --include-source -p:PackageVersion=$VERSION -o nupkg src/Blazored.$PROJECT_NAME/Blazored.$PROJECT_NAME.csproj - + - name: Push to NuGet Feed run: dotnet nuget push ./nupkg/*.nupkg --source $NUGET_FEED --api-key $NUGET_KEY --skip-duplicate - + - name: Publish Sample Site run: dotnet publish -c Release samples/BlazorWebAssembly/BlazorWebAssembly.csproj - name: Rewrite base href uses: SteveSandersonMS/ghaction-rewrite-base-href@v1 with: - html_path: samples/BlazorWebAssembly/bin/Release/net7.0/publish/wwwroot/index.html + html_path: samples/BlazorWebAssembly/bin/Release/net8.0/publish/wwwroot/index.html base_href: /${{ env.PROJECT_NAME }}/ - name: Deploy to Github Pages @@ -75,5 +75,5 @@ jobs: ACCESS_TOKEN: $GITHUB_TOKEN BASE_BRANCH: main # The branch the action should deploy from. BRANCH: gh-pages # The branch the action should deploy to. - FOLDER: samples/BlazorWebAssembly/bin/Release/net7.0/publish/wwwroot # The folder the action should deploy. + FOLDER: samples/BlazorWebAssembly/bin/Release/net8.0/publish/wwwroot # The folder the action should deploy. SINGLE_COMMIT: true diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index f3b4f98..864f0cf 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -6,7 +6,7 @@ on: env: PROJECT_NAME: Blazored.FluentValidation - NETCORE_VERSION: '7.0.x' + NETCORE_VERSION: '8.0.x' jobs: build: diff --git a/Blazored.FluentValidation.sln b/Blazored.FluentValidation.sln index 5b8ce3e..bc1abab 100644 --- a/Blazored.FluentValidation.sln +++ b/Blazored.FluentValidation.sln @@ -20,6 +20,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorServer", "samples\Bla EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharedModels", "samples\Shared\SharedModels\SharedModels.csproj", "{42276235-5139-41D6-923D-18B7EB5E3E44}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DACAA0DB-2B93-4FE1-9D21-F45A4E63A640}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blazored.FluentValidation.Tests", "tests\Blazored.FluentValidation.Tests\Blazored.FluentValidation.Tests.csproj", "{C92DF59B-B760-4FCC-A34C-A4007529BCC5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -78,6 +82,18 @@ Global {42276235-5139-41D6-923D-18B7EB5E3E44}.Release|x64.Build.0 = Release|Any CPU {42276235-5139-41D6-923D-18B7EB5E3E44}.Release|x86.ActiveCfg = Release|Any CPU {42276235-5139-41D6-923D-18B7EB5E3E44}.Release|x86.Build.0 = Release|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Debug|x64.ActiveCfg = Debug|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Debug|x64.Build.0 = Debug|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Debug|x86.ActiveCfg = Debug|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Debug|x86.Build.0 = Debug|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Release|Any CPU.Build.0 = Release|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Release|x64.ActiveCfg = Release|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Release|x64.Build.0 = Release|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Release|x86.ActiveCfg = Release|Any CPU + {C92DF59B-B760-4FCC-A34C-A4007529BCC5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -86,6 +102,7 @@ Global {8BC1065A-A71E-4568-8A67-9C3AF039F73A} = {D5C6DCA9-C2BD-4117-BCCC-19E36E8406AB} {2459CF4B-6548-4031-B784-43E943E270A9} = {D5C6DCA9-C2BD-4117-BCCC-19E36E8406AB} {42276235-5139-41D6-923D-18B7EB5E3E44} = {D5C6DCA9-C2BD-4117-BCCC-19E36E8406AB} + {C92DF59B-B760-4FCC-A34C-A4007529BCC5} = {DACAA0DB-2B93-4FE1-9D21-F45A4E63A640} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {42B22D99-6E59-4B30-88AD-B9CC07E0DA49} diff --git a/README.md b/README.md index 2a95284..00f9ce5 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ The second is when manually validating the model using the `Validate` or `Valida ``` ## Access to full `ValidationFailure` -If you need details about the specifics of a validation result (e.g. its `Severity), you can access the result of the +If you need details about the specifics of a validation result (e.g. its `Severity`), you can access the result of the last validation by calling the `GetFailuresFromLastValidation` method on the `FluentValidationValidator` component. ```razor diff --git a/samples/BlazorServer/BlazorServer.csproj b/samples/BlazorServer/BlazorServer.csproj index 4b3ab49..5056724 100644 --- a/samples/BlazorServer/BlazorServer.csproj +++ b/samples/BlazorServer/BlazorServer.csproj @@ -1,7 +1,7 @@  - net7.0 + net6.0;net7.0;net8.0 enable enable diff --git a/samples/BlazorWebAssembly/BlazorWebAssembly.csproj b/samples/BlazorWebAssembly/BlazorWebAssembly.csproj index 8ee79b3..7c8cf58 100644 --- a/samples/BlazorWebAssembly/BlazorWebAssembly.csproj +++ b/samples/BlazorWebAssembly/BlazorWebAssembly.csproj @@ -1,16 +1,21 @@  - net7.0 + net7.0;net8.0 enable enable - + + + + + + diff --git a/samples/Shared/SharedModels/SharedModels.csproj b/samples/Shared/SharedModels/SharedModels.csproj index 0337b08..7492bff 100644 --- a/samples/Shared/SharedModels/SharedModels.csproj +++ b/samples/Shared/SharedModels/SharedModels.csproj @@ -1,13 +1,13 @@  - net7.0 + net6.0;net7.0;net8.0 enable enable - + diff --git a/src/Blazored.FluentValidation/Blazored.FluentValidation.csproj b/src/Blazored.FluentValidation/Blazored.FluentValidation.csproj index 90ebca6..8d700f4 100644 --- a/src/Blazored.FluentValidation/Blazored.FluentValidation.csproj +++ b/src/Blazored.FluentValidation/Blazored.FluentValidation.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0 + net6.0;net7.0;net8.0 enable enable @@ -31,14 +31,20 @@ + + + + - - + - - + + + + + diff --git a/src/Blazored.FluentValidation/EditContextFluentValidationExtensions.cs b/src/Blazored.FluentValidation/EditContextFluentValidationExtensions.cs index f879a7a..b217c43 100644 --- a/src/Blazored.FluentValidation/EditContextFluentValidationExtensions.cs +++ b/src/Blazored.FluentValidation/EditContextFluentValidationExtensions.cs @@ -1,8 +1,10 @@ using FluentValidation; using FluentValidation.Internal; using FluentValidation.Results; + using Microsoft.AspNetCore.Components.Forms; using Microsoft.Extensions.DependencyInjection; + using static FluentValidation.AssemblyScanner; namespace Blazored.FluentValidation; @@ -46,12 +48,12 @@ private static async Task ValidateModel(EditContext editContext, messages.Clear(); fluentValidationValidator.LastValidationResult = new Dictionary>(); - + foreach (var validationResult in validationResults.Errors) { var fieldIdentifier = ToFieldIdentifier(editContext, validationResult.PropertyName); messages.Add(fieldIdentifier, validationResult.ErrorMessage); - + if (fluentValidationValidator.LastValidationResult.TryGetValue(fieldIdentifier, out var failures)) { failures.Add(validationResult); @@ -188,7 +190,7 @@ private static FieldIdentifier ToFieldIdentifier(in EditContext editContext, in var obj = editContext.Model; var nextTokenEnd = propertyPath.IndexOfAny(Separators); - + // Optimize for a scenario when parsing isn't needed. if (nextTokenEnd < 0) { @@ -215,8 +217,8 @@ private static FieldIdentifier ToFieldIdentifier(in EditContext editContext, in // we've got an Item property var indexerType = prop.GetIndexParameters()[0].ParameterType; var indexerValue = Convert.ChangeType(nextToken.ToString(), indexerType); - - newObj = prop.GetValue(obj, new [] { indexerValue }); + + newObj = prop.GetValue(obj, new[] { indexerValue }); } else { @@ -261,7 +263,7 @@ private static FieldIdentifier ToFieldIdentifier(in EditContext editContext, in } obj = newObj; - + nextTokenEnd = propertyPathAsSpan.IndexOfAny(Separators); if (nextTokenEnd < 0) { diff --git a/src/Blazored.FluentValidation/FluentValidationsValidator.cs b/src/Blazored.FluentValidation/FluentValidationsValidator.cs index efb2d24..4cd5273 100644 --- a/src/Blazored.FluentValidation/FluentValidationsValidator.cs +++ b/src/Blazored.FluentValidation/FluentValidationsValidator.cs @@ -1,9 +1,10 @@ using System; using FluentValidation; using FluentValidation.Internal; +using FluentValidation.Results; + using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; -using FluentValidation.Results; namespace Blazored.FluentValidation; @@ -48,7 +49,7 @@ public async Task ValidateAsync(Action>? option { throw new NullReferenceException(nameof(CurrentEditContext)); } - + ValidateOptions = options; try @@ -61,7 +62,7 @@ public async Task ValidateAsync(Action>? option throw new InvalidOperationException("No pending ValidationResult found"); } - await (Task) asyncValidationTask; + await (Task)asyncValidationTask; return !CurrentEditContext.GetValidationMessages().Any(); } @@ -95,10 +96,10 @@ public ValidationFailure[] GetFailuresFromLastValidation(FieldIdentifier? fieldI if (fieldIdentifier is null) return LastValidationResult.Values.SelectMany(f => f).ToArray(); - + if (!LastValidationResult.TryGetValue(fieldIdentifier.Value, out var failures)) - return Array.Empty(); - + return Array.Empty(); + return failures.ToArray(); } } \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/AssemblyScanning/Component.razor b/tests/Blazored.FluentValidation.Tests/AssemblyScanning/Component.razor new file mode 100644 index 0000000..ca9d7a3 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/AssemblyScanning/Component.razor @@ -0,0 +1,33 @@ + + + @if (DisableAssemblyScanning is null) + { + + } + else + { + + } + + + +

+ + +

+ + +
+ +@code { + [Parameter] public bool? DisableAssemblyScanning { get; set; } + private readonly Person _person = new(); + + internal ValidationResultType Result { get; private set; } = ValidationResultType.Valid; + + private void ValidSubmit() => Result = ValidationResultType.Valid; + private void InvalidSubmit() => Result = ValidationResultType.Error; +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/AssemblyScanning/Readme.md b/tests/Blazored.FluentValidation.Tests/AssemblyScanning/Readme.md new file mode 100644 index 0000000..4540b8d --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/AssemblyScanning/Readme.md @@ -0,0 +1,9 @@ +### What does this test? +This test checks if the assembly scanning works. It leverages, that this test +assembly does not register any `AbstractValidator` by default. + + - Setting the `DisableAssemblyScanning` to `true` should not find any validators and ignore errors. + - Setting the `DisableAssemblyScanning` to `false` or not setting the attribute at all, should + find the validators in the assembly and validate normally. + - Setting the `DisableAssemblyScanning` to `true` and registering the validators manually should + find the validators and validate normally. diff --git a/tests/Blazored.FluentValidation.Tests/AssemblyScanning/Tests.cs b/tests/Blazored.FluentValidation.Tests/AssemblyScanning/Tests.cs new file mode 100644 index 0000000..3144750 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/AssemblyScanning/Tests.cs @@ -0,0 +1,80 @@ +using Blazored.FluentValidation.Tests.Model; + +namespace Blazored.FluentValidation.Tests.AssemblyScanning; + +public class Tests : TestContext +{ + private readonly Fixture _fixture = new(); + + [Fact] + public void DisableAssemblyScanning_SetToTrue_NoValidationHappens() + { + // Arrange + var cut = RenderComponent(p => p.Add(c => c.DisableAssemblyScanning, true)); + var person = _fixture.InvalidPerson(); + + // Act + cut.Find($"input[name={nameof(Person.FirstName)}]").Change(person.FirstName); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Valid); + } + + [Fact] + public void DisableAssemblyScanning_SetToFalse_ValidationHappens() + { + // Arrange + var cut = RenderComponent(p => p.Add(c => c.DisableAssemblyScanning, false)); + var person = _fixture.InvalidPerson(); + + // Act + cut.Find($"input[name={nameof(Person.FirstName)}]").Change(person.FirstName); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Error); + } + + [Fact] + public void DisableAssemblyScanning_NotSet_ValidationHappens() + { + // Arrange + var cut = RenderComponent(p => p.Add(c => c.DisableAssemblyScanning, null)); + var person = _fixture.InvalidPerson(); + + // Act + cut.Find($"input[name={nameof(Person.FirstName)}]").Change(person.FirstName); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Error); + } + + [Fact] + public void DisableAssemblyScanning_SetToTrueButValidatorsRegistered_ValidationHappens() + { + // Arrange + Services.AddTransient, PersonValidator>(); + var cut = RenderComponent(p => p.Add(c => c.DisableAssemblyScanning, null)); + var person = _fixture.InvalidPerson(); + + // Act + cut.Find($"input[name={nameof(Person.FirstName)}]").Change(person.FirstName); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Error); + } + + private class Fixture + { + public Person InvalidPerson() => new() + { + FirstName = "", + LastName = "Doe", + EmailAddress = "john.doe@blazored.org", + Age = 30 + }; + } +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/BasicValidation/Component.razor b/tests/Blazored.FluentValidation.Tests/BasicValidation/Component.razor new file mode 100644 index 0000000..17b2f0f --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/BasicValidation/Component.razor @@ -0,0 +1,43 @@ + + + + + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ + +
+ +@code { + private readonly Person _person = new() { Address = new() }; + internal ValidationResultType Result { get; private set; } = ValidationResultType.Valid; + + private void ValidSubmit() => Result = ValidationResultType.Valid; + private void InvalidSubmit() => Result = ValidationResultType.Error; +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/BasicValidation/Readme.md b/tests/Blazored.FluentValidation.Tests/BasicValidation/Readme.md new file mode 100644 index 0000000..4ab205e --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/BasicValidation/Readme.md @@ -0,0 +1,7 @@ +### What does this test? +This test checks if the basic validation works. + + - Does a valid model pass the validation? + - Do basic validation rules get picked up? + - Do validation errors get displayed correctly in the UI? + - Are nested rules validated correctly? diff --git a/tests/Blazored.FluentValidation.Tests/BasicValidation/Tests.cs b/tests/Blazored.FluentValidation.Tests/BasicValidation/Tests.cs new file mode 100644 index 0000000..69317f6 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/BasicValidation/Tests.cs @@ -0,0 +1,110 @@ +using Blazored.FluentValidation.Tests.Model; + +namespace Blazored.FluentValidation.Tests.BasicValidation; + +public class Tests : TestContext +{ + private readonly Fixture _fixture = new(); + + [Fact] + public void Validate_DataIsValid_ValidSubmit() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson(); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Valid); + } + + [Fact] + public void Validate_FirstNameMissing_InvalidSubmit() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson() with { FirstName = string.Empty }; + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Error); + } + + [Fact] + public void Validate_FirstNameMissing_ValidationErrorsPresent() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson() with { FirstName = string.Empty }; + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Find(".validation-errors>.validation-message").TextContent.Should().Contain(PersonValidator.FirstNameRequired); + cut.Find("li.validation-message").TextContent.Should().Contain(PersonValidator.FirstNameRequired); + } + + [Fact] + public void Validate_AgeTooOld_ValidationErrorsPresent() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson() with { Age = 250 }; + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Find(".validation-errors>.validation-message").TextContent.Should().Contain(PersonValidator.AgeMax); + } + + [Fact] + public void Validate_AddressLine1Missing_ValidationErrorsPresent() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson() with { Address = new() { Line1 = string.Empty } }; + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Find(".validation-errors>.validation-message").TextContent.Should().Contain(AddressValidator.Line1Required); + } + + private static void FillForm(IRenderedComponent cut, Person person) + { + cut.Find($"input[name={nameof(Person.FirstName)}]").Change(person.FirstName); + cut.Find($"input[name={nameof(Person.LastName)}]").Change(person.LastName); + cut.Find($"input[name={nameof(Person.EmailAddress)}]").Change(person.EmailAddress); + cut.Find($"input[name={nameof(Person.Age)}]").Change(person.Age.ToString()); + cut.Find($"input[name={nameof(Person.Address.Line1)}]").Change(person.Address!.Line1); + } + + private class Fixture + { + public Person ValidPerson() => new() + { + FirstName = "John", + LastName = "Doe", + EmailAddress = "john.doe@blazored.org", + Age = 30, + Address = new() + { + Line1 = "123 Main St", + Town = "Springfield", + Postcode = "12345" + } + }; + } +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/Blazored.FluentValidation.Tests.csproj b/tests/Blazored.FluentValidation.Tests/Blazored.FluentValidation.Tests.csproj new file mode 100644 index 0000000..c97d6b1 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/Blazored.FluentValidation.Tests.csproj @@ -0,0 +1,41 @@ + + + + net8.0 + enable + false + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + diff --git a/tests/Blazored.FluentValidation.Tests/DirectValidation/AsyncComponent.razor b/tests/Blazored.FluentValidation.Tests/DirectValidation/AsyncComponent.razor new file mode 100644 index 0000000..c0cafe7 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/DirectValidation/AsyncComponent.razor @@ -0,0 +1,39 @@ + + + + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ + +
+ +@code { + private readonly Person _person = new(); + private FluentValidationValidator? _fluentValidationValidator; + + public ValidationResultType? Result { get; private set; } + + private async Task SubmitAsync() + { + var result = await _fluentValidationValidator!.ValidateAsync(); + Result = result ? ValidationResultType.Valid : ValidationResultType.Error; + } +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/DirectValidation/AsyncTests.cs b/tests/Blazored.FluentValidation.Tests/DirectValidation/AsyncTests.cs new file mode 100644 index 0000000..634efe3 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/DirectValidation/AsyncTests.cs @@ -0,0 +1,76 @@ +using Blazored.FluentValidation.Tests.Model; + +namespace Blazored.FluentValidation.Tests.DirectValidation; + +public class AsyncTests : TestContext +{ + private readonly Fixture _fixture = new(); + + [Fact] + public void ValidateAsync_PersonIsValid_ResultIsValid() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson(); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + cut.WaitForState(() => cut.Instance.Result is not null); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Valid); + } + + [Fact] + public void ValidateAsync_AgeNegative_ResultIsError() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson() with { Age = -5 }; + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + cut.WaitForState(() => cut.Instance.Result is not null); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Error); + } + + [Fact] + public void ValidateAsync_AgeNegative_ValidationMessagesPresent() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson() with { Age = -5 }; + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + cut.WaitForState(() => cut.Instance.Result is not null); + + // Assert + cut.Find(".validation-errors>.validation-message").TextContent.Should().Contain(PersonValidator.AgeMin); + cut.Find("li.validation-message").TextContent.Should().Contain(PersonValidator.AgeMin); + } + + private void FillForm(IRenderedComponent cut, Person person) + { + cut.Find("input[name=FirstName]").Change(person.FirstName); + cut.Find("input[name=LastName]").Change(person.LastName); + cut.Find("input[name=Age]").Change(person.Age.ToString()); + cut.Find("input[name=EmailAddress]").Change(person.EmailAddress); + } + + private class Fixture + { + public Person ValidPerson() => new() + { + FirstName = "John", + LastName = "Doe", + Age = 30, + EmailAddress = "john.doe@blazored.com" + }; + } +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/DirectValidation/Readme.md b/tests/Blazored.FluentValidation.Tests/DirectValidation/Readme.md new file mode 100644 index 0000000..e1363a0 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/DirectValidation/Readme.md @@ -0,0 +1,4 @@ +### What does this test? +This test checks if the `ValidateAsync` method works correctly, +specifically that the `bool` returned is `false` when validation fails, +and `true` otherwise. \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/DirectValidation/SyncComponent.razor b/tests/Blazored.FluentValidation.Tests/DirectValidation/SyncComponent.razor new file mode 100644 index 0000000..00a73e9 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/DirectValidation/SyncComponent.razor @@ -0,0 +1,39 @@ + + + + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ + +
+ +@code { + private readonly Person _person = new(); + private FluentValidationValidator? _fluentValidationValidator; + + public ValidationResultType? Result { get; private set; } + + private void Submit() + { + var result = _fluentValidationValidator!.Validate(); + Result = result ? ValidationResultType.Valid : ValidationResultType.Error; + } +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/DirectValidation/SyncTests.cs b/tests/Blazored.FluentValidation.Tests/DirectValidation/SyncTests.cs new file mode 100644 index 0000000..cd48fcb --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/DirectValidation/SyncTests.cs @@ -0,0 +1,73 @@ +using Blazored.FluentValidation.Tests.Model; + +namespace Blazored.FluentValidation.Tests.DirectValidation; + +public class SyncTests : TestContext +{ + private readonly Fixture _fixture = new(); + + [Fact] + public void Validate_PersonIsValid_ResultIsValid() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson(); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Valid); + } + + [Fact] + public void Validate_AgeNegative_ResultIsError() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson() with { Age = -5 }; + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Error); + } + + [Fact] + public void Validate_AgeNegative_ValidationMessagesPresent() + { + // Arrange + var cut = RenderComponent(); + var person = _fixture.ValidPerson() with { Age = -5 }; + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Find(".validation-errors>.validation-message").TextContent.Should().Contain(PersonValidator.AgeMin); + cut.Find("li.validation-message").TextContent.Should().Contain(PersonValidator.AgeMin); + } + + private void FillForm(IRenderedComponent cut, Person person) + { + cut.Find("input[name=FirstName]").Change(person.FirstName); + cut.Find("input[name=LastName]").Change(person.LastName); + cut.Find("input[name=Age]").Change(person.Age.ToString()); + cut.Find("input[name=EmailAddress]").Change(person.EmailAddress); + } + + private class Fixture + { + public Person ValidPerson() => new() + { + FirstName = "John", + LastName = "Doe", + Age = 30, + EmailAddress = "john.doe@blazored.com" + }; + } +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/FullFailureAccess/AsyncComponent.razor b/tests/Blazored.FluentValidation.Tests/FullFailureAccess/AsyncComponent.razor new file mode 100644 index 0000000..144144b --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/FullFailureAccess/AsyncComponent.razor @@ -0,0 +1,55 @@ + + + + + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ + +
+ +@code { + private readonly Person _person = new(); + private FluentValidationValidator? _fluentValidationValidator; + + public ValidationResultType? Result { get; private set; } + + protected async Task Submit() + { + await _fluentValidationValidator!.ValidateAsync(); + var lastResult = _fluentValidationValidator!.GetFailuresFromLastValidation(); + if (!lastResult.Any()) + { + Result = ValidationResultType.Valid; + } + else if (lastResult.Any(failure => failure.Severity == Severity.Error)) + { + Result = ValidationResultType.Error; + } + else + { + Result = ValidationResultType.Warning; + } + } + +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/FullFailureAccess/AsyncTests.cs b/tests/Blazored.FluentValidation.Tests/FullFailureAccess/AsyncTests.cs new file mode 100644 index 0000000..aa6eb0a --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/FullFailureAccess/AsyncTests.cs @@ -0,0 +1,77 @@ +using System; +using Blazored.FluentValidation.Tests.Model; + +namespace Blazored.FluentValidation.Tests.FullFailureAccess; + +public class AsyncTests : TestContext +{ + private readonly Fixture _fixture = new(); + + [Fact] + public void GetFailuresFromLastValidation_PersonValid_ResultIsValid() + { + // Arrange + var person = _fixture.ValidPerson(); + var cut = RenderComponent(); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + cut.WaitForState(() => cut.Instance.Result is not null); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Valid); + } + + [Fact] + public void GetFailuresFromLastValidation_EmailInvalid_ResultIsError() + { + // Arrange + var person = _fixture.ValidPerson() with { EmailAddress = "invalid-email" }; + var cut = RenderComponent(); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + cut.WaitForState(() => cut.Instance.Result is not null); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Error); + } + + [Fact] + public void GetFailuresFromLastValidation_AgeSuspect_ResultIsWarning() + { + // Arrange + var person = _fixture.ValidPerson() with { Age = 69 }; + var cut = RenderComponent(); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + cut.WaitForState(() => cut.Instance.Result is not null); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Warning); + } + + + private static void FillForm(IRenderedComponent cut, Person person) + { + cut.Find($"input[name={nameof(Person.FirstName)}]").Change(person.FirstName); + cut.Find($"input[name={nameof(Person.LastName)}]").Change(person.LastName); + cut.Find($"input[name={nameof(Person.EmailAddress)}]").Change(person.EmailAddress); + cut.Find($"input[name={nameof(Person.Age)}]").Change(person.Age.ToString()); + } + + private class Fixture + { + public Person ValidPerson() => new() + { + FirstName = "John", + LastName = "Doe", + EmailAddress = "john.doe@blazored.com", + Age = 30 + }; + } +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/FullFailureAccess/Readme.md b/tests/Blazored.FluentValidation.Tests/FullFailureAccess/Readme.md new file mode 100644 index 0000000..aff880a --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/FullFailureAccess/Readme.md @@ -0,0 +1,5 @@ +### What does this test? +This test checks if the `GetFailuresFromLastValidation` method works correctly. It does so by both using `Validate` +and `ValidateAsync`. The failures given back are then checked for severity. + +To test warnings, the age of a person can be set to 69. diff --git a/tests/Blazored.FluentValidation.Tests/FullFailureAccess/SyncComponent.razor b/tests/Blazored.FluentValidation.Tests/FullFailureAccess/SyncComponent.razor new file mode 100644 index 0000000..4958393 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/FullFailureAccess/SyncComponent.razor @@ -0,0 +1,54 @@ + + + + + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ + +
+ +@code { + private readonly Person _person = new(); + private FluentValidationValidator? _fluentValidationValidator; + + public ValidationResultType? Result { get; private set; } + + protected void Submit() + { + _fluentValidationValidator!.Validate(); + var lastResult = _fluentValidationValidator!.GetFailuresFromLastValidation(); + if (!lastResult.Any()) + { + Result = ValidationResultType.Valid; + } + else if (lastResult.Any(failure => failure.Severity == Severity.Error)) + { + Result = ValidationResultType.Error; + } + else + { + Result = ValidationResultType.Warning; + } + } +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/FullFailureAccess/SyncTests.cs b/tests/Blazored.FluentValidation.Tests/FullFailureAccess/SyncTests.cs new file mode 100644 index 0000000..107708b --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/FullFailureAccess/SyncTests.cs @@ -0,0 +1,73 @@ +using Blazored.FluentValidation.Tests.Model; + +namespace Blazored.FluentValidation.Tests.FullFailureAccess; + +public class SyncTests : TestContext +{ + private readonly Fixture _fixture = new(); + + [Fact] + public void GetFailuresFromLastValidation_PersonValid_ResultIsValid() + { + // Arrange + var person = _fixture.ValidPerson(); + var cut = RenderComponent(); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Valid); + } + + [Fact] + public void GetFailuresFromLastValidation_EmailInvalid_ResultIsError() + { + // Arrange + var person = _fixture.ValidPerson() with { EmailAddress = "invalid-email" }; + var cut = RenderComponent(); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Error); + } + + [Fact] + public void GetFailuresFromLastValidation_AgeSuspect_ResultIsWarning() + { + // Arrange + var person = _fixture.ValidPerson() with { Age = 69 }; + var cut = RenderComponent(); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Warning); + } + + + private static void FillForm(IRenderedComponent cut, Person person) + { + cut.Find($"input[name={nameof(Person.FirstName)}]").Change(person.FirstName); + cut.Find($"input[name={nameof(Person.LastName)}]").Change(person.LastName); + cut.Find($"input[name={nameof(Person.EmailAddress)}]").Change(person.EmailAddress); + cut.Find($"input[name={nameof(Person.Age)}]").Change(person.Age.ToString()); + } + + private class Fixture + { + public Person ValidPerson() => new() + { + FirstName = "John", + LastName = "Doe", + EmailAddress = "john.doe@blazored.com", + Age = 30 + }; + } +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/GlobalUsings.cs b/tests/Blazored.FluentValidation.Tests/GlobalUsings.cs new file mode 100644 index 0000000..6b9b9b4 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using System.Threading.Tasks; +global using FluentAssertions; +global using FluentValidation; \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/Model/Address.cs b/tests/Blazored.FluentValidation.Tests/Model/Address.cs new file mode 100644 index 0000000..0eeebc2 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/Model/Address.cs @@ -0,0 +1,27 @@ +namespace Blazored.FluentValidation.Tests.Model +{ + public record Address + { + public string? Line1 { get; set; } + public string? Line2 { get; set; } + public string? Town { get; set; } + public string? County { get; set; } + public string? Postcode { get; set; } + } + + public class AddressValidator : AbstractValidator
+ { + public const string Line1Required = "You must enter Line 1"; + public const string TownRequired = "You must enter a town"; + public const string CountyRequired = "You must enter a county"; + public const string PostcodeRequired = "You must enter a postcode"; + + public AddressValidator() + { + RuleFor(p => p.Line1).NotEmpty().WithMessage(Line1Required); + RuleFor(p => p.Town).NotEmpty().WithMessage(TownRequired); + RuleFor(p => p.County).NotEmpty().WithMessage(CountyRequired); + RuleFor(p => p.Postcode).NotEmpty().WithMessage(PostcodeRequired); + } + } +} diff --git a/tests/Blazored.FluentValidation.Tests/Model/Person.cs b/tests/Blazored.FluentValidation.Tests/Model/Person.cs new file mode 100644 index 0000000..0ab6ca6 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/Model/Person.cs @@ -0,0 +1,66 @@ +namespace Blazored.FluentValidation.Tests.Model +{ + public record Person + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + public int? Age { get; set; } + public string? EmailAddress { get; set; } + public Address? Address { get; set; } + } + + public class PersonValidator : AbstractValidator + { + public const string FirstNameRequired = "You must enter your first name"; + public const string FirstNameMaxLength = "First name cannot be longer than 50 characters"; + public const string LastNameRequired = "You must enter your last name"; + public const string LastNameMaxLength = "Last name cannot be longer than 50 characters"; + public const string AgeRequired = "You must enter your age"; + public const string AgeMin = "Age must be greater than 0"; + public const string AgeMax = "Age cannot be greater than 150"; + public const string AgeSuspect = "Age is suspect. Troll?"; + public const string EmailRequired = "You must enter an email address"; + public const string EmailValid = "You must provide a valid email address"; + public const string EmailUnique = "Email address must be unique"; + public const string DuplicateEmail = "mail@my.com"; + + public PersonValidator() + { + RuleSet("Names", () => + { + RuleFor(p => p.FirstName) + .NotEmpty().WithMessage(FirstNameRequired) + .MaximumLength(50).WithMessage(FirstNameMaxLength); + + RuleFor(p => p.LastName) + .NotEmpty().WithMessage(LastNameRequired) + .MaximumLength(50).WithMessage(LastNameMaxLength); + }); + + RuleFor(p => p.Age) + .NotNull().WithMessage(AgeRequired) + .GreaterThanOrEqualTo(0).WithMessage(AgeMin) + .LessThan(150).WithMessage(AgeMax); + + RuleFor(p => p.EmailAddress) + .NotEmpty().WithMessage(EmailRequired) + .EmailAddress().WithMessage(EmailValid) + .MustAsync(async (email, _) => await IsUniqueAsync(email)).WithMessage(EmailUnique); + + RuleFor(p => p.Address!) + .SetValidator(new AddressValidator()) + .When(p => p.Address is not null); + + RuleFor(p => p.Age) + .NotEqual(69) + .WithMessage(AgeSuspect) + .WithSeverity(Severity.Warning); + } + + private static async Task IsUniqueAsync(string? email) + { + await Task.Delay(300); + return email?.ToLower() != DuplicateEmail; + } + } +} diff --git a/tests/Blazored.FluentValidation.Tests/Model/ValidationResultType.cs b/tests/Blazored.FluentValidation.Tests/Model/ValidationResultType.cs new file mode 100644 index 0000000..e35c667 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/Model/ValidationResultType.cs @@ -0,0 +1,8 @@ +namespace Blazored.FluentValidation.Tests.Model; + +public enum ValidationResultType +{ + Valid, + Warning, + Error +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/RuleSets/Component.razor b/tests/Blazored.FluentValidation.Tests/RuleSets/Component.razor new file mode 100644 index 0000000..fa209c7 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/RuleSets/Component.razor @@ -0,0 +1,53 @@ + + + @if (IncludeWithAttribute) + { + @*

Can't implement.. bugged?

*@ + } + else + { + + } + + +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ + +
+ +@code { + [Parameter] public bool IncludeWithAttribute { get; set; } + [Parameter] public bool IncludeWithCode { get; set; } + + private readonly Person _person = new(); + private FluentValidationValidator? _fluentValidationValidator; + + public ValidationResultType? Result { get; private set; } + + protected void Submit() + { + var result = _fluentValidationValidator!.Validate(o => o.IncludeRuleSets("Names")); + Result = result ? ValidationResultType.Valid : ValidationResultType.Error; + } + +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/RuleSets/Readme.md b/tests/Blazored.FluentValidation.Tests/RuleSets/Readme.md new file mode 100644 index 0000000..afc393f --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/RuleSets/Readme.md @@ -0,0 +1,17 @@ +### What does this test? +This test checks if using the `IncludeRuleSets..` method work, once by attribute + +```html + +``` + +and once by code + +```csharp +@code { + private FluentValidationValidator? _fluentValidationValidator; + + private void PartialValidate() + => _fluentValidationValidator?.Validate(options => options.IncludeRuleSets("Names")); +} +``` \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/RuleSets/Tests.cs b/tests/Blazored.FluentValidation.Tests/RuleSets/Tests.cs new file mode 100644 index 0000000..d0f7ab5 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/RuleSets/Tests.cs @@ -0,0 +1,79 @@ +using Blazored.FluentValidation.Tests.Model; + +namespace Blazored.FluentValidation.Tests.RuleSets; + +public class Tests : TestContext +{ + private readonly Fixture _fixture = new(); + + [Fact] + public void AddedByAttribute_PersonValid_ValidationPasses() + { + // Arrange + var person = _fixture.ValidPerson(); + var cut = RenderComponent(p => p.Add(c => c.IncludeWithCode, true)); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Valid); + } + + [Fact] + public void AddedByAttribute_PersonFirstNameTooLong_ValidationFails() + { + // Arrange + var person = _fixture.ValidPerson() with + { + FirstName = "This name is clearly longer than 50 characters and thus should fail." + }; + var cut = RenderComponent(p => p.Add(c => c.IncludeWithCode, true)); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Instance.Result.Should().Be(ValidationResultType.Error); + } + + [Fact] + public void AddedByAttribute_PersonFirstNameTooLong_ValidationMessagesPresent() + { + // Arrange + var person = _fixture.ValidPerson() with + { + FirstName = "This name is clearly longer than 50 characters and thus should fail." + }; + var cut = RenderComponent(p => p.Add(c => c.IncludeWithCode, true)); + + // Act + FillForm(cut, person); + cut.Find("button").Click(); + + // Assert + cut.Find(".validation-errors>.validation-message").TextContent.Should().Contain(PersonValidator.FirstNameMaxLength); + cut.Find("li.validation-message").TextContent.Should().Contain(PersonValidator.FirstNameMaxLength); + } + + private static void FillForm(IRenderedComponent cut, Person person) + { + cut.Find($"input[name={nameof(Person.FirstName)}]").Change(person.FirstName); + cut.Find($"input[name={nameof(Person.LastName)}]").Change(person.LastName); + cut.Find($"input[name={nameof(Person.EmailAddress)}]").Change(person.EmailAddress); + cut.Find($"input[name={nameof(Person.Age)}]").Change(person.Age.ToString()); + } + + private class Fixture + { + public Person ValidPerson() => new() + { + FirstName = "John", + LastName = "Doe", + EmailAddress = "john.doe@blazored.com", + Age = 30 + }; + } +} \ No newline at end of file diff --git a/tests/Blazored.FluentValidation.Tests/_Imports.razor b/tests/Blazored.FluentValidation.Tests/_Imports.razor new file mode 100644 index 0000000..b5e1119 --- /dev/null +++ b/tests/Blazored.FluentValidation.Tests/_Imports.razor @@ -0,0 +1,15 @@ +@using Blazored.FluentValidation +@using Blazored.FluentValidation.Tests.Model + +@using Bunit +@using Bunit.TestDoubles + +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.Extensions.DependencyInjection +@using Microsoft.JSInterop +@using System.Net.Http +@using System.Net.Http.Json + +@using Xunit \ No newline at end of file