From 1fb7a4d455d108e8bef4e2f88c895f3b49f9f22a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gr=C3=A9gory=20C=C3=A9let?=
<1457422+gcelet@users.noreply.github.com>
Date: Sun, 30 Nov 2025 23:02:59 +0100
Subject: [PATCH] :white_check_mark: test: add end to end test suite on all
supported .net versions (6, 8, 9, 10)
---
Directory.Packages.props | 16 +-
ProtectedNumbers.sln | 70 ++++-
build/Build.cs | 27 +-
...isableChildValidationForProtectedNumber.cs | 24 ++
.../ProtectedNumbersConfigureMvcOptions.cs | 6 +
.../AppHost.cs | 10 +
.../Properties/launchSettings.json | 31 ++
...ectedNumbers.Tests.EndToEnd.AppHost.csproj | 19 ++
.../appsettings.Development.json | 8 +
.../appsettings.json | 9 +
.../Extensions.cs | 129 +++++++++
...bers.Tests.EndToEnd.ServiceDefaults.csproj | 23 ++
.../Controllers/SampleObjectController.cs | 100 +++++++
.../SampleObjects/GetByIdEndpoint.cs | 47 +++
.../SampleObjectGetAllEndpoint.cs | 35 +++
.../Endpoints/SampleObjects/SaveEndpoint.cs | 49 ++++
.../Endpoints/SampleObjects/SearchEndpoint.cs | 37 +++
.../Extensions.cs | 66 +++++
.../MinimalApi/Extensions.cs | 54 ++++
.../MinimalApi/HttpResultsShims.cs | 237 +++++++++++++++
.../MinimalApi/SampleObjectEndpoints.cs | 166 +++++++++++
.../Models/Inputs/GetByIdInput.cs | 11 +
.../Models/SampleObject.cs | 10 +
.../Models/SampleObjectSearch.cs | 45 +++
...tectedNumbers.Tests.EndToEnd.Shared.csproj | 32 +++
.../Repositories/SampleObjectRepository.cs | 122 ++++++++
.../Validators/Extensions.cs | 42 +++
.../Inputs/GetByIdInputValidator.cs | 22 ++
.../Validators/SampleObjectSearchValidator.cs | 25 ++
.../Validators/SampleObjectValidator.cs | 24 ++
.../Program.cs | 11 +
.../Properties/launchSettings.json | 23 ++
...Numbers.Tests.EndToEnd.WebApi.Net10.csproj | 19 ++
.../appsettings.Development.json | 8 +
.../appsettings.json | 9 +
.../Program.cs | 11 +
.../Properties/launchSettings.json | 23 ++
...dNumbers.Tests.EndToEnd.WebApi.Net6.csproj | 19 ++
.../appsettings.Development.json | 8 +
.../appsettings.json | 9 +
.../Program.cs | 11 +
.../Properties/launchSettings.json | 23 ++
...dNumbers.Tests.EndToEnd.WebApi.Net8.csproj | 19 ++
.../appsettings.Development.json | 8 +
.../appsettings.json | 9 +
.../Program.cs | 11 +
.../Properties/launchSettings.json | 23 ++
...dNumbers.Tests.EndToEnd.WebApi.Net9.csproj | 19 ++
.../appsettings.Development.json | 8 +
.../appsettings.json | 9 +
.../CheckAllEndpointBindingsTests.cs | 66 +++++
.../EndpointStack.cs | 32 +++
.../ProtectedNumbers.Tests.EndToEnd.csproj | 46 +++
.../ScenarioData.cs | 14 +
.../StepBuilderExtensions.cs | 270 ++++++++++++++++++
tst/ProtectedNumbers.Tests.EndToEnd/WebApi.cs | 87 ++++++
...ApiAccessorWithEndpointStackFixtureData.cs | 19 ++
.../WebApiProvider.cs | 171 +++++++++++
58 files changed, 2467 insertions(+), 14 deletions(-)
create mode 100644 src/ProtectedNumbers/Internal/DisableChildValidationForProtectedNumber.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.AppHost/AppHost.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.AppHost/Properties/launchSettings.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.AppHost/ProtectedNumbers.Tests.EndToEnd.AppHost.csproj
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.Development.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/Extensions.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults.csproj
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Controllers/SampleObjectController.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/GetByIdEndpoint.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SampleObjectGetAllEndpoint.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SaveEndpoint.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SearchEndpoint.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Extensions.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/Extensions.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/HttpResultsShims.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/SampleObjectEndpoints.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/Inputs/GetByIdInput.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObject.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObjectSearch.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/ProtectedNumbers.Tests.EndToEnd.Shared.csproj
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Repositories/SampleObjectRepository.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Extensions.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Inputs/GetByIdInputValidator.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectSearchValidator.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectValidator.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Program.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Properties/launchSettings.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10.csproj
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.Development.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Program.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Properties/launchSettings.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6.csproj
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.Development.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Program.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Properties/launchSettings.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8.csproj
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.Development.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Program.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Properties/launchSettings.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9.csproj
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.Development.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.json
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/CheckAllEndpointBindingsTests.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/EndpointStack.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/ProtectedNumbers.Tests.EndToEnd.csproj
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/ScenarioData.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/StepBuilderExtensions.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/WebApi.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/WebApiAccessorWithEndpointStackFixtureData.cs
create mode 100644 tst/ProtectedNumbers.Tests.EndToEnd/WebApiProvider.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index bb8db7f..c588efb 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,12 +4,14 @@
+
-
+
+
@@ -17,6 +19,11 @@
+
+
+
+
+
@@ -25,15 +32,22 @@
+
+
+
+
+
+
+
diff --git a/ProtectedNumbers.sln b/ProtectedNumbers.sln
index f492bf2..55f6968 100644
--- a/ProtectedNumbers.sln
+++ b/ProtectedNumbers.sln
@@ -39,6 +39,30 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "03-samples", "03-samples",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Samples", "samples\ProtectedNumbers.Samples\ProtectedNumbers.Samples.csproj", "{49437ECB-B026-4059-B235-C828D7578A40}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01-unit", "01-unit", "{07399998-14DD-4670-BB5D-7DE7A0F5CAD9}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "02-end-to-end", "02-end-to-end", "{CF69D319-F7B4-4A0F-8931-7C926716CE53}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.Shared", "tst\ProtectedNumbers.Tests.EndToEnd.Shared\ProtectedNumbers.Tests.EndToEnd.Shared.csproj", "{3A5781BC-DF65-4933-BA11-3C5EEC7D06E6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.WebApi.Net6", "tst\ProtectedNumbers.Tests.EndToEnd.WebApi.Net6\ProtectedNumbers.Tests.EndToEnd.WebApi.Net6.csproj", "{16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.WebApi.Net8", "tst\ProtectedNumbers.Tests.EndToEnd.WebApi.Net8\ProtectedNumbers.Tests.EndToEnd.WebApi.Net8.csproj", "{4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.WebApi.Net9", "tst\ProtectedNumbers.Tests.EndToEnd.WebApi.Net9\ProtectedNumbers.Tests.EndToEnd.WebApi.Net9.csproj", "{3F156108-2C1B-4840-868E-BB927A9EBBDC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.WebApi.Net10", "tst\ProtectedNumbers.Tests.EndToEnd.WebApi.Net10\ProtectedNumbers.Tests.EndToEnd.WebApi.Net10.csproj", "{6597682B-5D0E-430D-A317-E63961443F89}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.AppHost", "tst\ProtectedNumbers.Tests.EndToEnd.AppHost\ProtectedNumbers.Tests.EndToEnd.AppHost.csproj", "{B7F72066-5054-44DA-8839-D01A89844381}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd.ServiceDefaults", "tst\ProtectedNumbers.Tests.EndToEnd.ServiceDefaults\ProtectedNumbers.Tests.EndToEnd.ServiceDefaults.csproj", "{AA7C47F8-5F0C-4440-A829-C7767E528BBD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProtectedNumbers.Tests.EndToEnd", "tst\ProtectedNumbers.Tests.EndToEnd\ProtectedNumbers.Tests.EndToEnd.csproj", "{E5CFD612-400B-46C4-90EE-2318C43BAB8F}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01-aspire", "01-aspire", "{8B2CB553-CF4A-41DB-BFB2-6438150CBB16}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "02-webapi", "02-webapi", "{DC5DF0BA-2F47-432B-941B-6A1DC0921E24}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -59,6 +83,38 @@ Global
{49437ECB-B026-4059-B235-C828D7578A40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{49437ECB-B026-4059-B235-C828D7578A40}.Release|Any CPU.ActiveCfg = Release|Any CPU
{49437ECB-B026-4059-B235-C828D7578A40}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3A5781BC-DF65-4933-BA11-3C5EEC7D06E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3A5781BC-DF65-4933-BA11-3C5EEC7D06E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3A5781BC-DF65-4933-BA11-3C5EEC7D06E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3A5781BC-DF65-4933-BA11-3C5EEC7D06E6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3F156108-2C1B-4840-868E-BB927A9EBBDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3F156108-2C1B-4840-868E-BB927A9EBBDC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3F156108-2C1B-4840-868E-BB927A9EBBDC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3F156108-2C1B-4840-868E-BB927A9EBBDC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6597682B-5D0E-430D-A317-E63961443F89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6597682B-5D0E-430D-A317-E63961443F89}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6597682B-5D0E-430D-A317-E63961443F89}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6597682B-5D0E-430D-A317-E63961443F89}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B7F72066-5054-44DA-8839-D01A89844381}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B7F72066-5054-44DA-8839-D01A89844381}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B7F72066-5054-44DA-8839-D01A89844381}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B7F72066-5054-44DA-8839-D01A89844381}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AA7C47F8-5F0C-4440-A829-C7767E528BBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AA7C47F8-5F0C-4440-A829-C7767E528BBD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AA7C47F8-5F0C-4440-A829-C7767E528BBD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AA7C47F8-5F0C-4440-A829-C7767E528BBD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E5CFD612-400B-46C4-90EE-2318C43BAB8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E5CFD612-400B-46C4-90EE-2318C43BAB8F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E5CFD612-400B-46C4-90EE-2318C43BAB8F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E5CFD612-400B-46C4-90EE-2318C43BAB8F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -68,7 +124,19 @@ Global
{54E9DA71-884C-47CB-8234-397722D49CBA} = {28E1E0C8-E214-4118-B592-AE443DB7DFD9}
{DF741A65-666B-4814-9FF1-361E29BDCC75} = {CFD2F704-18DC-4F75-9F91-DD4A58559AAA}
{66B0C8CE-E238-470F-ABF5-0896D5367564} = {499B92F7-0036-4B5C-AE09-BA6DE023EC76}
- {029D532A-57A2-4E35-803E-A2071598C043} = {3A3D618C-D90D-4050-BF3D-68A67B0A75D7}
{49437ECB-B026-4059-B235-C828D7578A40} = {FCDBD854-E336-4DAD-9893-0DCD353FCA21}
+ {07399998-14DD-4670-BB5D-7DE7A0F5CAD9} = {3A3D618C-D90D-4050-BF3D-68A67B0A75D7}
+ {029D532A-57A2-4E35-803E-A2071598C043} = {07399998-14DD-4670-BB5D-7DE7A0F5CAD9}
+ {CF69D319-F7B4-4A0F-8931-7C926716CE53} = {3A3D618C-D90D-4050-BF3D-68A67B0A75D7}
+ {E5CFD612-400B-46C4-90EE-2318C43BAB8F} = {CF69D319-F7B4-4A0F-8931-7C926716CE53}
+ {8B2CB553-CF4A-41DB-BFB2-6438150CBB16} = {CF69D319-F7B4-4A0F-8931-7C926716CE53}
+ {B7F72066-5054-44DA-8839-D01A89844381} = {8B2CB553-CF4A-41DB-BFB2-6438150CBB16}
+ {AA7C47F8-5F0C-4440-A829-C7767E528BBD} = {8B2CB553-CF4A-41DB-BFB2-6438150CBB16}
+ {DC5DF0BA-2F47-432B-941B-6A1DC0921E24} = {CF69D319-F7B4-4A0F-8931-7C926716CE53}
+ {16A25C6F-4AA6-4CAA-9A3A-A7C08DB98450} = {DC5DF0BA-2F47-432B-941B-6A1DC0921E24}
+ {4AE35AEB-0668-4DD7-B8EF-72CFB62C3F33} = {DC5DF0BA-2F47-432B-941B-6A1DC0921E24}
+ {3F156108-2C1B-4840-868E-BB927A9EBBDC} = {DC5DF0BA-2F47-432B-941B-6A1DC0921E24}
+ {6597682B-5D0E-430D-A317-E63961443F89} = {DC5DF0BA-2F47-432B-941B-6A1DC0921E24}
+ {3A5781BC-DF65-4933-BA11-3C5EEC7D06E6} = {DC5DF0BA-2F47-432B-941B-6A1DC0921E24}
EndGlobalSection
EndGlobal
diff --git a/build/Build.cs b/build/Build.cs
index 9f71717..8891c47 100644
--- a/build/Build.cs
+++ b/build/Build.cs
@@ -204,18 +204,21 @@ class Build : NukeBuild
.OnlyWhenDynamic(() => !SkipTests)
.Executes(() =>
{
- string dataCollector = EnableCoverage ? "Code Coverage;Format=cobertura" : null;
-
- DotNetTest(_ => _
- .SetProjectFile(Solution)
- .SetConfiguration(Configuration)
- .EnableNoRestore()
- .EnableNoBuild()
- .When(TestResultsDirectory.DirectoryExists(), _ => _
- .SetResultsDirectory(TestResultsDirectory)
- )
- .When(!string.IsNullOrEmpty(dataCollector), _ => _
- .SetDataCollector(dataCollector)
+ string dataCollector = EnableCoverage ? "XPlat Code Coverage" : null; // coverlet.collector
+
+ DotNetTest(_ => _
+ .SetProjectFile(Solution)
+ .SetConfiguration(Configuration)
+ .EnableNoRestore()
+ .EnableNoBuild()
+ .When(TestResultsDirectory.DirectoryExists(), _ => _.SetResultsDirectory(TestResultsDirectory))
+ .When(!string.IsNullOrEmpty(dataCollector), t => t
+ .SetDataCollector(dataCollector)
+ .SetProcessAdditionalArguments(
+ "-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura",
+ "-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include=\"[ProtectedNumbers]*\"",
+ "-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude=\"[*.Tests]*\""
+ )
)
);
diff --git a/src/ProtectedNumbers/Internal/DisableChildValidationForProtectedNumber.cs b/src/ProtectedNumbers/Internal/DisableChildValidationForProtectedNumber.cs
new file mode 100644
index 0000000..9684d9e
--- /dev/null
+++ b/src/ProtectedNumbers/Internal/DisableChildValidationForProtectedNumber.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+#if NET6_0
+namespace ProtectedNumbers.Internal;
+
+using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
+
+using ProtectedNumbers;
+
+///
+/// Disables child validation for the value object on .NET 6 MVC,
+/// preventing the validator from invoking its getters during traversal.
+///
+internal sealed class DisableChildValidationForProtectedNumber : IValidationMetadataProvider
+{
+ public void CreateValidationMetadata(ValidationMetadataProviderContext context)
+ {
+ if (context.Key.ModelType == typeof(ProtectedNumber))
+ {
+ context.ValidationMetadata.ValidateChildren = false;
+ }
+ }
+}
+#endif
diff --git a/src/ProtectedNumbers/Internal/ProtectedNumbersConfigureMvcOptions.cs b/src/ProtectedNumbers/Internal/ProtectedNumbersConfigureMvcOptions.cs
index 77c38be..f319743 100644
--- a/src/ProtectedNumbers/Internal/ProtectedNumbersConfigureMvcOptions.cs
+++ b/src/ProtectedNumbers/Internal/ProtectedNumbersConfigureMvcOptions.cs
@@ -53,5 +53,11 @@ public void Configure(Microsoft.AspNetCore.Mvc.MvcOptions options)
{
options.ModelBinderProviders.Add(new ProtectedNumberModelBinderProvider());
}
+
+#if NET6_0
+ // Workaround for .NET 6 MVC validation eagerly traversing value objects and invoking getters.
+ // Suppress child validation for ProtectedNumber so MVC doesn't access Value/ProtectedValue during validation.
+ options.ModelMetadataDetailsProviders.Add(new DisableChildValidationForProtectedNumber());
+#endif
}
}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/AppHost.cs b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/AppHost.cs
new file mode 100644
index 0000000..1e7af9a
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/AppHost.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
+
+builder.AddProject("webapi-dotnet6");
+builder.AddProject("webapi-dotnet8");
+builder.AddProject("webapi-dotnet9");
+builder.AddProject("webapi-dotnet10");
+
+builder.Build().Run();
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/Properties/launchSettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/Properties/launchSettings.json
new file mode 100644
index 0000000..7327a5a
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/Properties/launchSettings.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17241;http://localhost:15273",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21076",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23075",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22080"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15273",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19014",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18058",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20043"
+ }
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/ProtectedNumbers.Tests.EndToEnd.AppHost.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/ProtectedNumbers.Tests.EndToEnd.AppHost.csproj
new file mode 100644
index 0000000..0391a4d
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/ProtectedNumbers.Tests.EndToEnd.AppHost.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ ProtectedNumbers.Tests.EndToEnd
+
+
+
+
+
+
+
+
+
+
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.Development.json b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.json
new file mode 100644
index 0000000..31c092a
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/Extensions.cs b/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/Extensions.cs
new file mode 100644
index 0000000..c7abe93
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/Extensions.cs
@@ -0,0 +1,129 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.ServiceDiscovery;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
+// This project should be referenced by each service project in your solution.
+// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
+public static class Extensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
+
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
+
+ // Uncomment the following to restrict the allowed schemes for service discovery.
+ // builder.Services.Configure(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation(tracing =>
+ // Exclude health check requests from tracing
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
+ )
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
+ //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
+ //{
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+ //}
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ // Add a default liveness check to ensure app is responsive
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ // Adding health checks endpoints to applications in non-development environments has security implications.
+ // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
+ if (app.Environment.IsDevelopment())
+ {
+ // All health checks must pass for app to be considered ready to accept traffic after starting
+ app.MapHealthChecks(HealthEndpointPath);
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults.csproj
new file mode 100644
index 0000000..8a40fd9
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults/ProtectedNumbers.Tests.EndToEnd.ServiceDefaults.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0;net9.0;net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Controllers/SampleObjectController.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Controllers/SampleObjectController.cs
new file mode 100644
index 0000000..12c0838
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Controllers/SampleObjectController.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Controllers;
+
+#if NET6_0
+using FluentValidation;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Validators;
+#endif
+
+using Microsoft.AspNetCore.Mvc;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories;
+
+[ApiController]
+[Route("mvc/samples-objects")]
+public class SampleObjectController : ControllerBase
+{
+ [HttpGet("")]
+ public IActionResult GetAll([FromServices] SampleObjectRepository repository)
+ {
+ IEnumerable allSampleObjects = repository.GetAll();
+
+ return Ok(allSampleObjects);
+ }
+
+ [HttpGet("{id}")]
+ public IActionResult GetById(
+ [FromServices] SampleObjectRepository repository,
+ [FromRoute] ProtectedNumber id)
+ {
+ SampleObject? sampleObject = repository.GetById(id);
+
+ if (sampleObject == null)
+ {
+ return NotFound();
+ }
+
+ return Ok(sampleObject);
+ }
+
+ [HttpPut("")]
+ public IActionResult Save([FromServices] SampleObjectRepository repository,
+#if NET6_0
+ [FromServices] IValidator validator,
+#endif
+ [FromBody] SampleObject sampleObject)
+ {
+#if NET6_0
+ // NOTE: Manual validation since FluentValidation AutoValidation is not available for MVC in .NET 6
+ var validationResult = validator.Validate(sampleObject);
+ if (!validationResult.IsValid)
+ {
+ ValidationProblemDetails validationProblemDetails = validationResult.ToValidationProblemDetails();
+ return BadRequest(validationProblemDetails);
+ }
+#endif
+
+ ProtectedNumber? id = sampleObject.Id;
+ SampleObject? saved = repository.Save(id, s =>
+ {
+ s.Name = sampleObject.Name;
+ });
+
+ if (saved is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(saved);
+ }
+
+ [HttpGet("search")]
+ public IActionResult Search([FromServices] SampleObjectRepository repository,
+ [FromQuery] ProtectedNumber? id, [FromQuery] ProtectedNumber[]? ids)
+ {
+ SampleObjectSearch search = new()
+ {
+ Id = id,
+ Ids = ids,
+ };
+
+ IEnumerable result = repository.Search(search);
+
+ return Ok(result);
+ }
+
+ [HttpGet("search/object")]
+ public IActionResult SearchByObject([FromServices] SampleObjectRepository repository,
+ #if NET6_0
+ [FromQuery]
+ #endif
+ SampleObjectSearch search)
+ {
+ IEnumerable result = repository.Search(search);
+
+ return Ok(result);
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/GetByIdEndpoint.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/GetByIdEndpoint.cs
new file mode 100644
index 0000000..98d0951
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/GetByIdEndpoint.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Endpoints.SampleObjects;
+
+using FastEndpoints;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models.Inputs;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Validators.Inputs;
+
+public class GetByIdEndpoint : Endpoint
+{
+ public GetByIdEndpoint(SampleObjectRepository repository)
+ {
+ Repository = repository;
+ }
+
+ private SampleObjectRepository Repository { get; }
+
+ public override void Configure()
+ {
+ AllowAnonymous();
+ Validator();
+ Get("/samples-objects/{id}");
+ }
+
+ public override Task HandleAsync(GetByIdInput input, CancellationToken cancellationToken)
+ {
+ SampleObject? sampleObject = Repository.GetById(input.Id.GetValueOrDefault(ProtectedNumber.Empty));
+
+ if (sampleObject == null)
+ {
+#if NET6_0
+ return SendNotFoundAsync(cancellationToken);
+#elif NET8_0_OR_GREATER
+ return Send.NotFoundAsync(cancellationToken);
+#endif
+ }
+
+#if NET6_0
+ return SendOkAsync(sampleObject, cancellationToken);
+#elif NET8_0_OR_GREATER
+ return Send.OkAsync(sampleObject, cancellationToken);
+#endif
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SampleObjectGetAllEndpoint.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SampleObjectGetAllEndpoint.cs
new file mode 100644
index 0000000..0da2e2c
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SampleObjectGetAllEndpoint.cs
@@ -0,0 +1,35 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Endpoints.SampleObjects;
+
+using FastEndpoints;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories;
+
+public class GetAllEndpoint : EndpointWithoutRequest>
+{
+ public GetAllEndpoint(SampleObjectRepository repository)
+ {
+ Repository = repository;
+ }
+
+ private SampleObjectRepository Repository { get; }
+
+ public override void Configure()
+ {
+ AllowAnonymous();
+ Get("/samples-objects");
+ }
+
+ public override Task HandleAsync(CancellationToken cancellationToken)
+ {
+ IEnumerable all = Repository.GetAll();
+
+#if NET6_0
+ return SendOkAsync(all, cancellationToken);
+#elif NET8_0_OR_GREATER
+ return Send.OkAsync(all, cancellationToken);
+#endif
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SaveEndpoint.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SaveEndpoint.cs
new file mode 100644
index 0000000..4602216
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SaveEndpoint.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Endpoints.SampleObjects;
+
+using FastEndpoints;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Validators;
+
+public class SaveEndpoint : Endpoint
+{
+ public SaveEndpoint(SampleObjectRepository repository)
+ {
+ Repository = repository;
+ }
+
+ private SampleObjectRepository Repository { get; }
+
+ public override void Configure()
+ {
+ AllowAnonymous();
+ Validator();
+ Put("/samples-objects");
+ }
+
+ public override Task HandleAsync(SampleObject input, CancellationToken cancellationToken)
+ {
+ SampleObject? saved = Repository.Save(input.Id, s =>
+ {
+ s.Name = input.Name;
+ });
+
+ if (saved == null)
+ {
+#if NET6_0
+ return SendNotFoundAsync(cancellationToken);
+#elif NET8_0_OR_GREATER
+ return Send.NotFoundAsync(cancellationToken);
+#endif
+ }
+
+#if NET6_0
+ return SendOkAsync(saved, cancellationToken);
+#elif NET8_0_OR_GREATER
+ return Send.OkAsync(saved, cancellationToken);
+#endif
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SearchEndpoint.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SearchEndpoint.cs
new file mode 100644
index 0000000..de74e53
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Endpoints/SampleObjects/SearchEndpoint.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Endpoints.SampleObjects;
+
+using FastEndpoints;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Validators;
+
+public class SearchEndpoint : Endpoint>
+{
+ public SearchEndpoint(SampleObjectRepository repository)
+ {
+ Repository = repository;
+ }
+
+ private SampleObjectRepository Repository { get; }
+
+ public override void Configure()
+ {
+ AllowAnonymous();
+ Validator();
+ Get("/samples-objects/search", "/samples-objects/search/object");
+ }
+
+ public override Task HandleAsync(SampleObjectSearch input, CancellationToken cancellationToken)
+ {
+ IEnumerable result = Repository.Search(input);
+
+#if NET6_0
+ return SendOkAsync(result, cancellationToken);
+#elif NET8_0_OR_GREATER
+ return Send.OkAsync(result, cancellationToken);
+#endif
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Extensions.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Extensions.cs
new file mode 100644
index 0000000..57370a9
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Extensions.cs
@@ -0,0 +1,66 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared;
+
+using FastEndpoints;
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.DependencyInjection;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.MinimalApi;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Validators;
+
+#if NET8_0_OR_GREATER
+using SharpGrip.FluentValidation.AutoValidation.Endpoints.Configuration;
+using SharpGrip.FluentValidation.AutoValidation.Endpoints.Extensions;
+using SharpGrip.FluentValidation.AutoValidation.Mvc.Configuration;
+using SharpGrip.FluentValidation.AutoValidation.Mvc.Enums;
+using SharpGrip.FluentValidation.AutoValidation.Mvc.Extensions;
+#endif
+
+public static class Extensions
+{
+ public static void AddProtectedNumbersEndToEnd(this WebApplicationBuilder builder, string webAppName)
+ {
+ // Required for ProtectedNumbers to work correctly
+ builder.Services.AddHttpContextAccessor();
+ builder.Services.AddDataProtection(opts =>
+ {
+ opts.ApplicationDiscriminator = $"ProtectedNumbers.Tests.EndToEnd.{webAppName}";
+ })
+ // .PersistKeysToFileSystem(new DirectoryInfo("./DataProtectionKeys"))
+ ;
+
+ builder.Services.AddProtectedNumbers();
+
+ builder.Services.AddControllers();
+ builder.Services.AddFastEndpoints();
+
+#if NET6_0
+#elif NET8_0_OR_GREATER
+ builder.Services.AddFluentValidationAutoValidation((AutoValidationMvcConfiguration cfg) =>
+ {
+ cfg.ValidationStrategy = ValidationStrategy.All;
+ });
+
+ builder.Services.AddFluentValidationAutoValidation((AutoValidationEndpointsConfiguration _) =>
+ {
+ });
+#endif
+
+ builder.Services.AddSingleton();
+ builder.Services.AddValidators();
+ }
+
+ public static void UseProtectedNumbersEndToEnd(this WebApplication app)
+ {
+ app.RegisterMinimalApiEndpoints();
+ app.MapControllers();
+ app.UseFastEndpoints(cfg =>
+ {
+ cfg.Endpoints.RoutePrefix = "fast-endpoints";
+ });
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/Extensions.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/Extensions.cs
new file mode 100644
index 0000000..2cbed44
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/Extensions.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.MinimalApi;
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+#if NET6_0
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Primitives;
+
+using ProtectedNumbers.Protection;
+
+#elif NET8_0_OR_GREATER
+using Microsoft.AspNetCore.Routing;
+#endif
+
+public static class Extensions
+{
+ public static void RegisterMinimalApiEndpoints(this WebApplication app)
+ {
+ const string minimalApiPrefix = "/minimal-api";
+#if NET6_0
+ app.RegisterSampleObjectEndpoints(minimalApiPrefix);
+#elif NET8_0_OR_GREATER
+ RouteGroupBuilder minimalApi = app.MapGroup(minimalApiPrefix);
+
+ minimalApi.RegisterSampleObjectEndpoints();
+#endif
+ }
+
+#if NET6_0
+ public static ProtectedNumber[]? BindProtectedNumberCollection(this HttpContext httpContext, string parameterName)
+ {
+ if (!httpContext.Request.Query.TryGetValue(parameterName, out StringValues stringValues))
+ {
+ return null;
+ }
+
+ IApplicationDataProtector? protector = httpContext.RequestServices.GetService();
+ string?[] protectedNumberRaws = stringValues.ToArray();
+
+ // HACK: Minimal api on .net 6 don't handle correctly collection binding: we must unprotect manually collections only on .net 6 (it's done automatically/naturally on newer .net versions)
+ ProtectedNumber[]? protectedNumbers =
+ protectedNumberRaws.Select(s => ProtectedNumber.From(null, s)) // capture protected string
+ .Select(pn => protector != null && protector.TryUnprotect(pn, out var unp) // attempt unprotect
+ ? unp!.Value
+ : pn) // keep as-is if unprotect fails
+ .ToArray();
+
+ return protectedNumbers;
+ }
+#endif
+
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/HttpResultsShims.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/HttpResultsShims.cs
new file mode 100644
index 0000000..5bae22d
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/HttpResultsShims.cs
@@ -0,0 +1,237 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+#if NET6_0
+#pragma warning disable CS9113 // Parameter is unread - we may only need to store some values for parity
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Net;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Primitives;
+
+// This namespace must match newer ASP.NET Core typed results
+namespace Microsoft.AspNetCore.Http.HttpResults
+{
+ // --------- Primitive typed results ---------
+ public readonly struct Ok : IResult
+ {
+ private readonly T _value;
+ public Ok(T value) => _value = value;
+ public Task ExecuteAsync(HttpContext httpContext) => Results.Ok(_value).ExecuteAsync(httpContext);
+ }
+
+ public readonly struct NotFound : IResult
+ {
+ public Task ExecuteAsync(HttpContext httpContext) => Results.NotFound().ExecuteAsync(httpContext);
+ }
+
+ public readonly struct NoContent : IResult
+ {
+ public Task ExecuteAsync(HttpContext httpContext) => Results.NoContent().ExecuteAsync(httpContext);
+ }
+
+ public readonly struct BadRequest : IResult
+ {
+ public BadRequest(){}
+ public Task ExecuteAsync(HttpContext httpContext)
+ => Results.BadRequest().ExecuteAsync(httpContext);
+ }
+
+ public readonly struct BadRequest : IResult
+ {
+ private readonly T? _error;
+ public BadRequest(T? error = default) => _error = error;
+ public Task ExecuteAsync(HttpContext httpContext)
+ => (_error is null ? Results.BadRequest() : Results.BadRequest(_error)).ExecuteAsync(httpContext);
+ }
+
+ public readonly struct Created : IResult
+ {
+ private readonly string _uri;
+ private readonly T _value;
+ public Created(string uri, T value) { _uri = uri; _value = value; }
+ public Task ExecuteAsync(HttpContext httpContext)
+ => Results.Created(_uri, _value).ExecuteAsync(httpContext);
+ }
+
+ public readonly struct CreatedAtRoute : IResult
+ {
+ private readonly string? _routeName;
+ private readonly object? _routeValues;
+ private readonly T _value;
+ public CreatedAtRoute(string? routeName, object? routeValues, T value)
+ { _routeName = routeName; _routeValues = routeValues; _value = value; }
+ public Task ExecuteAsync(HttpContext httpContext)
+ => Results.CreatedAtRoute(_routeName, _routeValues, _value).ExecuteAsync(httpContext);
+ }
+
+ public readonly struct ValidationProblem : IResult
+ {
+ private readonly IDictionary _errors;
+ private readonly int? _statusCode;
+ private readonly string? _title;
+ private readonly string? _detail;
+ private readonly string? _instance;
+ private readonly string? _type;
+ private readonly IDictionary? _extensions;
+
+ public ValidationProblem(
+ IDictionary errors,
+ int? statusCode = null,
+ string? title = null,
+ string? type = null,
+ string? detail = null,
+ string? instance = null,
+ IDictionary? extensions = null)
+ {
+ _errors = errors;
+ _statusCode = statusCode;
+ _title = title; _detail = detail; _instance = instance; _type = type;
+ _extensions = extensions;
+ }
+
+ public Task ExecuteAsync(HttpContext httpContext)
+ => Results.ValidationProblem(_errors,
+ statusCode: _statusCode,
+ title: _title,
+ type: _type,
+ detail: _detail,
+ instance: _instance,
+ extensions: _extensions)
+ .ExecuteAsync(httpContext);
+ }
+
+ public readonly struct ProblemHttpResult : IResult
+ {
+ private readonly string? _detail;
+ private readonly string? _instance;
+ private readonly int? _statusCode;
+ private readonly string? _title;
+ private readonly string? _type;
+ private readonly IDictionary? _extensions;
+
+ public ProblemHttpResult(
+ string? detail = null,
+ string? instance = null,
+ int? statusCode = null,
+ string? title = null,
+ string? type = null,
+ IDictionary? extensions = null)
+ {
+ _detail = detail; _instance = instance; _statusCode = statusCode; _title = title; _type = type; _extensions = extensions;
+ }
+
+ public Task ExecuteAsync(HttpContext httpContext)
+ => Results.Problem(
+ detail: _detail,
+ instance: _instance,
+ statusCode: _statusCode,
+ title: _title,
+ type: _type,
+ extensions: _extensions)
+ .ExecuteAsync(httpContext);
+ }
+
+ // --------- Union wrappers (Results) ---------
+ public readonly struct Results : IResult
+ where T1 : struct, IResult
+ where T2 : struct, IResult
+ {
+ private readonly IResult _inner;
+ private Results(IResult inner) => _inner = inner;
+ public static implicit operator Results(T1 value) => new(value);
+ public static implicit operator Results(T2 value) => new(value);
+ public Task ExecuteAsync(HttpContext httpContext) => _inner.ExecuteAsync(httpContext);
+ }
+
+ public readonly struct Results : IResult
+ where T1 : struct, IResult
+ where T2 : struct, IResult
+ where T3 : struct, IResult
+ {
+ private readonly IResult _inner;
+ private Results(IResult inner) => _inner = inner;
+ public static implicit operator Results(T1 value) => new(value);
+ public static implicit operator Results(T2 value) => new(value);
+ public static implicit operator Results(T3 value) => new(value);
+ public Task ExecuteAsync(HttpContext httpContext) => _inner.ExecuteAsync(httpContext);
+ }
+
+ public readonly struct Results : IResult
+ where T1 : struct, IResult
+ where T2 : struct, IResult
+ where T3 : struct, IResult
+ where T4 : struct, IResult
+ {
+ private readonly IResult _inner;
+ private Results(IResult inner) => _inner = inner;
+ public static implicit operator Results(T1 value) => new(value);
+ public static implicit operator Results(T2 value) => new(value);
+ public static implicit operator Results(T3 value) => new(value);
+ public static implicit operator Results(T4 value) => new(value);
+ public Task ExecuteAsync(HttpContext httpContext) => _inner.ExecuteAsync(httpContext);
+ }
+
+ public readonly struct Results : IResult
+ where T1 : struct, IResult
+ where T2 : struct, IResult
+ where T3 : struct, IResult
+ where T4 : struct, IResult
+ where T5 : struct, IResult
+ {
+ private readonly IResult _inner;
+ private Results(IResult inner) => _inner = inner;
+ public static implicit operator Results(T1 value) => new(value);
+ public static implicit operator Results(T2 value) => new(value);
+ public static implicit operator Results(T3 value) => new(value);
+ public static implicit operator Results(T4 value) => new(value);
+ public static implicit operator Results(T5 value) => new(value);
+ public Task ExecuteAsync(HttpContext httpContext) => _inner.ExecuteAsync(httpContext);
+ }
+
+ // --------- Factory methods (TypedResults) ---------
+ public static class TypedResults
+ {
+ // 200
+ public static Ok Ok(T value) => new(value);
+
+ // 201
+ public static Created Created(string uri, T value) => new(uri, value);
+ public static CreatedAtRoute CreatedAtRoute(string? routeName, object? routeValues, T value)
+ => new(routeName, routeValues, value);
+
+ // 204
+ public static NoContent NoContent() => new();
+
+ // 400
+ public static BadRequest BadRequest() => new();
+ public static BadRequest BadRequest(T? error) => new(error);
+
+ // 404
+ public static NotFound NotFound() => new();
+
+ // 422 / 400-like (depends on config)
+ public static ValidationProblem ValidationProblem(
+ IDictionary errors,
+ int? statusCode = null,
+ string? title = null,
+ string? type = null,
+ string? detail = null,
+ string? instance = null,
+ IDictionary? extensions = null)
+ => new(errors, statusCode, title, type, detail, instance, extensions);
+
+ // RFC7807 Problem
+ public static ProblemHttpResult Problem(
+ string? detail = null,
+ string? instance = null,
+ int? statusCode = null,
+ string? title = null,
+ string? type = null,
+ IDictionary? extensions = null)
+ => new(detail, instance, statusCode, title, type, extensions);
+ }
+}
+#pragma warning restore CS9113
+#endif
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/SampleObjectEndpoints.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/SampleObjectEndpoints.cs
new file mode 100644
index 0000000..1631513
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/MinimalApi/SampleObjectEndpoints.cs
@@ -0,0 +1,166 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.MinimalApi;
+
+using FluentValidation;
+using FluentValidation.Results;
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.AspNetCore.Mvc;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Repositories;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Validators;
+
+#if NET8_0_OR_GREATER
+using Microsoft.AspNetCore.Routing;
+
+using SharpGrip.FluentValidation.AutoValidation.Endpoints.Extensions;
+#endif
+
+public static class SampleObjectEndpoints
+{
+#if NET6_0
+ public static void RegisterSampleObjectEndpoints(this WebApplication app, string prefix)
+ {
+ string groupPath = $"{prefix}/samples-objects";
+
+ app.MapGet($"{groupPath}/", GetAll);
+ app.MapGet(groupPath + "/{id}", GetById);
+ app.MapGet($"{groupPath}/search", Search);
+ app.MapGet($"{groupPath}/search/object", SearchByObject);
+ app.MapPut($"{groupPath}/", Save);
+ }
+#elif NET8_0_OR_GREATER
+ public static void RegisterSampleObjectEndpoints(this RouteGroupBuilder minimalApi)
+ {
+ RouteGroupBuilder sampleObjectEndpoints = minimalApi.MapGroup("/samples-objects")
+ .AddFluentValidationAutoValidation()
+ ;
+
+ sampleObjectEndpoints.MapGet("/", GetAll);
+ sampleObjectEndpoints.MapGet("/{id}", GetById);
+ sampleObjectEndpoints.MapGet("/search", Search);
+ sampleObjectEndpoints.MapGet("/search/object", SearchByObject);
+ sampleObjectEndpoints.MapPut("/", Save);
+ }
+#endif
+
+ private static Ok> GetAll(SampleObjectRepository repository)
+ {
+ IEnumerable all = repository.GetAll();
+
+ return TypedResults.Ok(all);
+ }
+
+ private static Results, BadRequest, NotFound> GetById(
+ SampleObjectRepository repository,
+ ProtectedNumber id)
+ {
+ if (!id.HaveProtectedValueAndValue())
+ {
+ return TypedResults.BadRequest();
+ }
+
+ SampleObject? item = repository.GetById(id);
+
+ if (item is null)
+ {
+ return TypedResults.NotFound();
+ }
+
+ return TypedResults.Ok(item);
+ }
+
+ private static Results, NotFound, BadRequest> Save(
+ SampleObjectRepository repository,
+#if NET6_0
+ [FromServices] IValidator validator,
+#endif
+ [FromBody] SampleObject sampleObject)
+ {
+#if NET6_0
+ // NOTE: Manual validation since FluentValidation AutoValidation is not available for Minimal APIs in .NET 6
+ var validationResult = validator.Validate(sampleObject);
+ if (!validationResult.IsValid)
+ {
+ ValidationProblemDetails problemDetails = validationResult.ToValidationProblemDetails();
+ return TypedResults.BadRequest(problemDetails);
+ }
+#endif
+
+ ProtectedNumber? id = sampleObject.Id;
+ SampleObject? saved = repository.Save(id, s =>
+ {
+ s.Name = sampleObject.Name;
+ });
+
+ if (saved is null)
+ {
+ return TypedResults.NotFound();
+ }
+
+ return TypedResults.Ok(saved);
+ }
+
+ private static Results>, BadRequest> Search(
+#if NET6_0
+ HttpContext httpContext,
+#endif
+ [FromServices] SampleObjectRepository repository,
+ [FromServices] IValidator validator,
+ [FromQuery] ProtectedNumber? id
+#if NET8_0_OR_GREATER
+ , [FromQuery] ProtectedNumber[]? ids
+#endif
+ )
+ {
+#if NET6_0
+ ProtectedNumber[]? ids = httpContext.BindProtectedNumberCollection("ids");
+#endif
+ SampleObjectSearch search = new()
+ {
+ Id = id,
+ Ids = ids,
+ };
+
+ // NOTE: Manual validation since independant parameters binding does not support FluentValidation AutoValidation
+ ValidationResult? validationResult = validator.Validate(search);
+ if (!validationResult.IsValid)
+ {
+ ValidationProblemDetails problemDetails = validationResult.ToValidationProblemDetails();
+
+ return TypedResults.BadRequest(problemDetails);
+ }
+
+ IEnumerable result = repository.Search(search);
+ return TypedResults.Ok(result);
+ }
+
+ private static Results>, BadRequest> SearchByObject(
+ [FromServices] SampleObjectRepository repository,
+#if NET6_0
+ [FromServices] IValidator validator,
+#endif
+#if NET8_0_OR_GREATER
+ [AsParameters]
+#endif
+ SampleObjectSearch search)
+ {
+#if NET6_0
+ // NOTE: Manual validation since FluentValidation AutoValidation is not available for Minimal APIs in .NET 6
+ var validationResult = validator.Validate(search);
+ if (!validationResult.IsValid)
+ {
+ ValidationProblemDetails problemDetails = validationResult.ToValidationProblemDetails();
+ return TypedResults.BadRequest(problemDetails);
+ }
+#endif
+
+ IEnumerable result = repository.Search(search);
+
+ return TypedResults.Ok(result);
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/Inputs/GetByIdInput.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/Inputs/GetByIdInput.cs
new file mode 100644
index 0000000..e1e9b13
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/Inputs/GetByIdInput.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Models.Inputs;
+
+using Microsoft.AspNetCore.Mvc;
+
+public class GetByIdInput
+{
+ [FromRoute]
+ public ProtectedNumber? Id { get; set; }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObject.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObject.cs
new file mode 100644
index 0000000..81adb65
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObject.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+
+public class SampleObject
+{
+ public ProtectedNumber? Id { get; set; }
+
+ public string? Name { get; set; }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObjectSearch.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObjectSearch.cs
new file mode 100644
index 0000000..2b8ec43
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Models/SampleObjectSearch.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+
+using Microsoft.AspNetCore.Mvc;
+
+#if NET6_0
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using ProtectedNumbers.Protection;
+using ProtectedNumbers.Tests.EndToEnd.Shared.MinimalApi;
+#endif
+
+
+public class SampleObjectSearch
+{
+ [FromQuery]
+ public ProtectedNumber? Id { get; set; }
+
+ [FromQuery]
+ public ProtectedNumber[]? Ids { get; set; }
+
+#if NET6_0
+ // Minimal APIs in .NET 6 will use this instead of inferring body binding
+ public static ValueTask BindAsync(HttpContext httpContext)
+ {
+ // Optional: resolve services if you need to unprotect ids
+ IApplicationDataProtector? protector = httpContext.RequestServices.GetService();
+ ProtectedNumber? id = null;
+
+ if (httpContext.Request.Query.TryGetValue("id", out var idVals) && idVals.Count > 0)
+ {
+ id = ProtectedNumber.From(null, idVals[0]);
+ if (protector is not null && protector.TryUnprotect(id.Value, out var unp))
+ {
+ id = unp!.Value;
+ }
+ }
+
+ ProtectedNumber[]? ids = httpContext.BindProtectedNumberCollection("ids");
+
+ return ValueTask.FromResult(new SampleObjectSearch { Id = id, Ids = ids });
+ }
+#endif
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/ProtectedNumbers.Tests.EndToEnd.Shared.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/ProtectedNumbers.Tests.EndToEnd.Shared.csproj
new file mode 100644
index 0000000..8736abb
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/ProtectedNumbers.Tests.EndToEnd.Shared.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net6.0;net8.0;net9.0;net10.0
+ latest
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Repositories/SampleObjectRepository.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Repositories/SampleObjectRepository.cs
new file mode 100644
index 0000000..30a1258
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Repositories/SampleObjectRepository.cs
@@ -0,0 +1,122 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Repositories;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+
+public class SampleObjectRepository
+{
+ public SampleObjectRepository()
+ {
+ Db =
+ [
+ new SampleObject
+ {
+ Id = ProtectedNumber.From(3268149285222967951L, null),
+ Name = "3268149285222967951"
+ },
+ new SampleObject
+ {
+ Id = ProtectedNumber.From(4370645650355927116L, null),
+ Name = "4370645650355927116"
+ },
+ new SampleObject
+ {
+ Id = ProtectedNumber.From(2849208538696462814L, null),
+ Name = "2849208538696462814"
+ },
+ new SampleObject
+ {
+ Id = ProtectedNumber.From(2737488684378130375L, null),
+ Name = "2737488684378130375"
+ },
+ new SampleObject
+ {
+ Id = ProtectedNumber.From(3165521510852862138L, null),
+ Name = "3165521510852862138"
+ },
+ new SampleObject
+ {
+ Id = ProtectedNumber.From(3367344455419562669L, null),
+ Name = "3367344455419562669"
+ },
+ new SampleObject
+ {
+ Id = ProtectedNumber.From(4046037197419659517L, null),
+ Name = "4046037197419659517"
+ },
+ new SampleObject
+ {
+ Id = ProtectedNumber.From(4605951185251650790L, null),
+ Name = "4605951185251650790"
+ },
+ new SampleObject
+ {
+ Id = ProtectedNumber.From(2831023411971416963L, null),
+ Name = "2831023411971416963"
+ },
+ new SampleObject
+ {
+ Id = ProtectedNumber.From(4018378694012108869L, null),
+ Name = "4018378694012108869"
+ },
+ ];
+ }
+
+ private List Db { get; }
+
+ public IEnumerable GetAll() => Db;
+
+ public SampleObject? GetById(ProtectedNumber protectedNumber)
+ {
+ SampleObject? sampleObject = Db.FirstOrDefault(i => protectedNumber.Equals(i.Id));
+
+ return sampleObject;
+ }
+
+ public IEnumerable Search(SampleObjectSearch search)
+ {
+ IQueryable query = Db.AsQueryable();
+
+ if (search.Id.HasValue)
+ {
+ query = query.Where(s => s.Id.HasValue && s.Id.Value.Equals(search.Id.Value));
+ }
+
+ if (search.Ids is { Length: > 0 })
+ {
+ query = query.Where(s => s.Id.HasValue && search.Ids.Any(id => id.Equals(s.Id.Value)));
+ }
+
+ List results = query.ToList();
+
+ return results;
+ }
+
+ public SampleObject? Save(ProtectedNumber? id, Action changeAction)
+ {
+ SampleObject? sampleObject = null;
+
+ if (id.HasValue)
+ {
+ sampleObject = GetById(id.Value);
+ }
+ else
+ {
+ sampleObject = new()
+ {
+ Id = ProtectedNumber.From(Random.Shared.NextInt64(100000000000000000L, 999999999999999999L), null)
+ };
+ Db.Add(sampleObject);
+ }
+
+ if (sampleObject == null)
+ {
+ return null;
+ }
+
+ changeAction.Invoke(sampleObject);
+
+ return sampleObject;
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Extensions.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Extensions.cs
new file mode 100644
index 0000000..8bf5adb
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Extensions.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Validators;
+
+using FluentValidation;
+using FluentValidation.Results;
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models.Inputs;
+using ProtectedNumbers.Tests.EndToEnd.Shared.Validators.Inputs;
+
+public static class Extensions
+{
+ public static void AddValidators(this IServiceCollection services)
+ {
+ services.AddScoped, SampleObjectValidator>();
+ services.AddScoped, SampleObjectSearchValidator>();
+
+ services.AddScoped, GetByIdInputValidator>();
+ }
+
+ public static bool HaveProtectedValueAndValue(this ProtectedNumber protectedNumber)
+ {
+ return protectedNumber.IsInitialized() &&
+ protectedNumber is { HasProtectedValue: true, HasValue: true };
+ }
+
+ public static ValidationProblemDetails ToValidationProblemDetails(this ValidationResult validationResult)
+ {
+ ValidationProblemDetails validationProblemDetails = new(validationResult.ToDictionary())
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Title = "One or more validation errors occurred.",
+ };
+
+ return validationProblemDetails;
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Inputs/GetByIdInputValidator.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Inputs/GetByIdInputValidator.cs
new file mode 100644
index 0000000..95f29cf
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/Inputs/GetByIdInputValidator.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Validators.Inputs;
+
+using FluentValidation;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models.Inputs;
+
+public class GetByIdInputValidator : AbstractValidator
+{
+ public GetByIdInputValidator()
+ {
+ RuleFor(e => e.Id)
+ .NotNull()
+ ;
+ RuleFor(e => e.Id)
+ .Must(e => e.HasValue && e.Value.HaveProtectedValueAndValue())
+ .When(e => e.Id.HasValue)
+ .WithMessage("id must be valid")
+ ;
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectSearchValidator.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectSearchValidator.cs
new file mode 100644
index 0000000..c612715
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectSearchValidator.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Validators;
+
+using FluentValidation;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+
+public class SampleObjectSearchValidator : AbstractValidator
+{
+ public SampleObjectSearchValidator()
+ {
+ RuleFor(i => i.Id)
+ .Must(i => i.HasValue && i.Value.HaveProtectedValueAndValue())
+ .When(i => i.Id.HasValue)
+ .WithMessage("id must be valid")
+ ;
+
+ RuleForEach(i => i.Ids)
+ .Must(i => i.HaveProtectedValueAndValue())
+ .When(i => i.Ids != null && i.Ids.Length > 0)
+ .WithMessage("ids must be valid")
+ ;
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectValidator.cs b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectValidator.cs
new file mode 100644
index 0000000..e31ff5f
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.Shared/Validators/SampleObjectValidator.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd.Shared.Validators;
+
+using FluentValidation;
+
+using ProtectedNumbers.Tests.EndToEnd.Shared.Models;
+
+public class SampleObjectValidator : AbstractValidator
+{
+ public SampleObjectValidator()
+ {
+ RuleFor(e => e.Id)
+ .Must(e => e.HasValue && e.Value.HaveProtectedValueAndValue())
+ .When(e => e.Id.HasValue)
+ .WithMessage("id must be valid")
+ ;
+
+ RuleFor(e => e.Name)
+ .NotEmpty()
+ .WithMessage("name is required")
+ ;
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Program.cs b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Program.cs
new file mode 100644
index 0000000..b48266f
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Program.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.AddProtectedNumbersEndToEnd("WebApi.Net10");
+
+WebApplication app = builder.Build();
+
+app.UseProtectedNumbersEndToEnd();
+app.Run();
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Properties/launchSettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Properties/launchSettings.json
new file mode 100644
index 0000000..402dfca
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:10000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:10001;http://localhost:10000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10.csproj
new file mode 100644
index 0000000..c004194
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.Development.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net10/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Program.cs b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Program.cs
new file mode 100644
index 0000000..4a32b35
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Program.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+//builder.AddServiceDefaults();
+builder.AddProtectedNumbersEndToEnd("WebApi.Net6");
+
+WebApplication app = builder.Build();
+
+app.UseProtectedNumbersEndToEnd();
+app.Run();
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Properties/launchSettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Properties/launchSettings.json
new file mode 100644
index 0000000..6c400e6
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:6000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:6001;http://localhost:6000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6.csproj
new file mode 100644
index 0000000..0e83dd4
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net6.0
+ enable
+ enable
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.Development.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net6/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Program.cs b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Program.cs
new file mode 100644
index 0000000..ce4b215
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Program.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.AddProtectedNumbersEndToEnd("WebApi.Net8");
+
+WebApplication app = builder.Build();
+
+app.UseProtectedNumbersEndToEnd();
+app.Run();
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Properties/launchSettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Properties/launchSettings.json
new file mode 100644
index 0000000..0e4767b
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:8000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:8001;http://localhost:8000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8.csproj
new file mode 100644
index 0000000..baa4ef2
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.Development.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net8/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Program.cs b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Program.cs
new file mode 100644
index 0000000..2d7ebf9
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Program.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.AddProtectedNumbersEndToEnd("WebApi.Net9");
+
+WebApplication app = builder.Build();
+
+app.UseProtectedNumbersEndToEnd();
+app.Run();
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Properties/launchSettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Properties/launchSettings.json
new file mode 100644
index 0000000..7749f4e
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:9000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:9001;http://localhost:9000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9.csproj b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9.csproj
new file mode 100644
index 0000000..9e3ae26
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.Development.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.json b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd.WebApi.Net9/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/CheckAllEndpointBindingsTests.cs b/tst/ProtectedNumbers.Tests.EndToEnd/CheckAllEndpointBindingsTests.cs
new file mode 100644
index 0000000..1738ef8
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd/CheckAllEndpointBindingsTests.cs
@@ -0,0 +1,66 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd;
+
+using ConfirmSteps.Steps.Http;
+
+using NUnit.Framework.Internal;
+
+using static StepBuilderExtensions;
+
+[TestFixtureSource(typeof(WebApiWithEndpointStackFixtureData), nameof(WebApiWithEndpointStackFixtureData.FixtureParams))]
+public class CheckAllEndpointBindingsTests
+{
+ public CheckAllEndpointBindingsTests(WebApi webApi, EndpointStack endpointStack)
+ {
+ WebApi = webApi;
+ EndpointStack = endpointStack;
+ }
+
+ private EndpointStack EndpointStack { get; }
+
+ private WebApi WebApi { get; }
+
+ [Test]
+ public async Task AllSteps_Should_Returns_Correctly()
+ {
+ // Arrange
+ HttpClient? httpClient = WebApiProvider.GetHttpClient(WebApi);
+
+ if (httpClient == null)
+ {
+ throw new NUnitException();
+ }
+
+ Scenario scenario =
+ Scenario.New("[All-Endpoints-Returns-Correctly]")
+ .WithServices(s => s.AddExternalHttpClient(httpClient))
+ .WithGlobals(b => b
+ .UseConst(STEP_PATH_PREFIX, EndpointStack.PathPrefix)
+ )
+ .WithSteps(s => s
+ .GetAll()
+ .GetByProtectedId()
+ .SearchByProtectedId()
+ .SearchObjectByProtectedId()
+ .SaveByProtectedId()
+ .GetByUnprotectedId()
+ .SearchByUnprotectedId()
+ .SearchObjectByUnprotectedId()
+ .SaveByUnprotectedId()
+ )
+ .Build()
+ ;
+ ScenarioData data = new();
+ // Act
+ using CancellationTokenSource cts = new();
+ ConfirmStepResult confirmResult =
+ await scenario.ConfirmSteps(data, cts.Token);
+ // Assert
+ confirmResult.ShouldSatisfyAllConditions($"should be a successful confirm result without exception thrown on [{WebApi.Name}-{EndpointStack.Name}]",
+ r => r.Status.ShouldBe(ConfirmStatus.Success),
+ r => r.Exception.ShouldBeNull(),
+ r => r.StepResults.ShouldAllBe(sr => sr.State == StepState.Done && sr.Status == ConfirmStatus.Success)
+ );
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/EndpointStack.cs b/tst/ProtectedNumbers.Tests.EndToEnd/EndpointStack.cs
new file mode 100644
index 0000000..d42bada
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd/EndpointStack.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd;
+
+public class EndpointStack
+{
+ private EndpointStack(string name, string pathPrefix)
+ {
+ Name = name;
+ PathPrefix = pathPrefix;
+ }
+
+ public static EndpointStack FastEndpoints { get; } = new("FastEndpoints", "fast-endpoints");
+
+ public static EndpointStack MinimalApi { get; } = new("Minimal Api", "minimal-api");
+
+ public static EndpointStack Mvc { get; } = new("MVC", "mvc");
+
+ public static IEnumerable EnumerateStacks()
+ {
+ yield return Mvc;
+ yield return MinimalApi;
+ yield return FastEndpoints;
+ }
+
+ public string Name { get; }
+
+ public string PathPrefix { get; }
+
+ ///
+ public override string ToString() => Name;
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/ProtectedNumbers.Tests.EndToEnd.csproj b/tst/ProtectedNumbers.Tests.EndToEnd/ProtectedNumbers.Tests.EndToEnd.csproj
new file mode 100644
index 0000000..36b9d12
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd/ProtectedNumbers.Tests.EndToEnd.csproj
@@ -0,0 +1,46 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ PreserveNewest
+ PreserveNewest
+
+
+
+
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/ScenarioData.cs b/tst/ProtectedNumbers.Tests.EndToEnd/ScenarioData.cs
new file mode 100644
index 0000000..1d632d8
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd/ScenarioData.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd;
+
+public class ScenarioData
+{
+ public string? DefaultName { get; set; }
+
+ public string? DefaultProtectedId { get; set; }
+
+ public string[]? Names { get; set; }
+
+ public string[]? ProtectedIds { get; set; }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/StepBuilderExtensions.cs b/tst/ProtectedNumbers.Tests.EndToEnd/StepBuilderExtensions.cs
new file mode 100644
index 0000000..b576209
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd/StepBuilderExtensions.cs
@@ -0,0 +1,270 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+
+namespace ProtectedNumbers.Tests.EndToEnd;
+
+using System.Text.Json;
+
+using JsonCons.JsonPath;
+
+using Microsoft.Net.Http.Headers;
+
+public static class StepBuilderExtensions
+{
+ public const string SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE = "{{" + SAMPLE_OBJECT_PROTECTED_ID + "}}";
+
+ public const string SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE = "{{" + SAMPLE_OBJECT_UNPROTECTED_ID + "}}";
+
+ public const string STEP_PATH_PREFIX = "PATH_PREFIX";
+
+ private const string SAMPLE_OBJECT_PROTECTED_ID = "SAMPLE_OBJECT_PROTECTED_ID";
+
+ private const string SAMPLE_OBJECT_UNPROTECTED_ID = "SAMPLE_OBJECT_UNPROTECTED_ID";
+
+ private const string SAMPLE_OBJECTS_PATH = "samples-objects";
+
+ private const string STEP_PATH_PREFIX_TEMPLATE = "{{" + STEP_PATH_PREFIX + "}}";
+
+ public static IStepBuilderAppender GetAll(
+ this IStepBuilderAppender stepBuilderAppender) =>
+ stepBuilderAppender
+ .HttpStep("GET-All",
+ () => RequestBuilder.Get()
+ .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH)
+ .WithHeaders(h => h.AppendDefaultHeaders()),
+ step => step
+ .VerifyJson((response, stepContext) =>
+ {
+ response.ShouldSatisfyAllConditions("should be an OK JSON response",
+ r => r.StatusCode.ShouldBe(HttpStatusCode.OK),
+ r => r.Response.ShouldNotBeNull()
+ );
+
+ JsonElement rootElement = response.Response!.RootElement;
+
+ rootElement.ShouldSatisfyAllConditions("should be a JSON array with at least one item",
+ e => e.ValueKind.ShouldBe(JsonValueKind.Array),
+ e => e.GetArrayLength().ShouldBeGreaterThan(0)
+ );
+
+ IList idJsonElements = JsonSelector.Select(response.Response.RootElement, "$[*].id");
+ IList nameJsonElements = JsonSelector.Select(response.Response.RootElement, "$[*].name");
+
+ idJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string");
+ nameJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string");
+
+ // TODO: Should be in Extract instead of VerifyJson: need to add feature in ConfirmSteps.Net
+ stepContext.ScenarioContext.Data.ProtectedIds = idJsonElements
+ .Select(elt => elt.GetString())
+ .Where(s => !string.IsNullOrEmpty(s))
+ .ToArray()!;
+
+ stepContext.ScenarioContext.Data.DefaultProtectedId = stepContext.ScenarioContext.Data.ProtectedIds.FirstOrDefault();
+ stepContext.ScenarioContext.Data.Names = nameJsonElements
+ .Select(elt => elt.GetString())
+ .Where(s => !string.IsNullOrEmpty(s))
+ .ToArray()!;
+
+ stepContext.ScenarioContext.Data.DefaultName = stepContext.ScenarioContext.Data.Names.FirstOrDefault();
+
+ stepContext.Vars[SAMPLE_OBJECT_PROTECTED_ID] = stepContext.ScenarioContext.Data.DefaultProtectedId ?? string.Empty;
+ stepContext.Vars[SAMPLE_OBJECT_UNPROTECTED_ID] = stepContext.ScenarioContext.Data.DefaultName ?? string.Empty;
+ })
+ );
+
+ public static IStepBuilderAppender GetByProtectedId(
+ this IStepBuilderAppender stepBuilderAppender) =>
+ stepBuilderAppender
+ .HttpStep("GET-By-ProtectedId",
+ () => RequestBuilder.Get()
+ .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE)
+ .WithHeaders(h => h.AppendDefaultHeaders()),
+ step => step
+ .VerifyJson((response, _) =>
+ {
+ response.ShouldSatisfyAllConditions("should be an OK JSON response",
+ r => r.StatusCode.ShouldBe(HttpStatusCode.OK),
+ r => r.Response.ShouldNotBeNull()
+ );
+
+ JsonElement rootElement = response.Response!.RootElement;
+
+ rootElement.ShouldSatisfyAllConditions("should be a JSON object",
+ e => e.ValueKind.ShouldBe(JsonValueKind.Object)
+ );
+
+ IList idJsonElements = JsonSelector.Select(response.Response.RootElement, "$.id");
+
+ idJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string");
+ })
+ );
+
+ public static IStepBuilderAppender GetByUnprotectedId(
+ this IStepBuilderAppender stepBuilderAppender) =>
+ stepBuilderAppender
+ .HttpStep("GET-By-UnprotectedId",
+ () => RequestBuilder.Get()
+ .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE)
+ .WithHeaders(h => h.AppendDefaultHeaders()),
+ step => step
+ .Verify((response, _) =>
+ {
+ response.ShouldSatisfyAllConditions("should be a BadRequest response",
+ r => r.StatusCode.ShouldBe(HttpStatusCode.BadRequest)
+ );
+ })
+ );
+
+ public static IStepBuilderAppender SaveByProtectedId(
+ this IStepBuilderAppender stepBuilderAppender) =>
+ stepBuilderAppender
+ .HttpStep("PUT-Save-By-ProtectedId",
+ () => RequestBuilder.Put()
+ .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH)
+ .WithHeaders(h => h.AppendDefaultHeaders())
+ .WithBody(@$"{{ ""id"": ""{SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE}"", ""name"": ""E2E Update""}}"),
+ step => step
+ .VerifyJson((response, _) =>
+ {
+ response.ShouldSatisfyAllConditions("should be an OK JSON response",
+ r => r.StatusCode.ShouldBe(HttpStatusCode.OK),
+ r => r.Response.ShouldNotBeNull()
+ );
+
+ JsonElement rootElement = response.Response!.RootElement;
+
+ rootElement.ShouldSatisfyAllConditions("should be a JSON object",
+ e => e.ValueKind.ShouldBe(JsonValueKind.Object)
+ );
+
+ IList idJsonElements = JsonSelector.Select(response.Response.RootElement, "$.id");
+
+ idJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string");
+ })
+ );
+
+ public static IStepBuilderAppender SaveByUnprotectedId(
+ this IStepBuilderAppender stepBuilderAppender) =>
+ stepBuilderAppender
+ .HttpStep("PUT-Save-By-UnprotectedId",
+ () => RequestBuilder.Put()
+ .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH)
+ .WithHeaders(h => h.AppendDefaultHeaders())
+ .WithBody(@$"{{ ""id"": ""{SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE}"", ""name"": ""E2E Update""}}"),
+ step => step
+ .Verify((response, _) =>
+ {
+ response.ShouldSatisfyAllConditions("should be a BadRequest response",
+ r => r.StatusCode.ShouldBe(HttpStatusCode.BadRequest)
+ );
+ })
+ );
+
+ public static IStepBuilderAppender SearchByProtectedId(
+ this IStepBuilderAppender stepBuilderAppender) =>
+ stepBuilderAppender
+ .HttpStep("GET-Search-By-ProtectedId",
+ () => RequestBuilder.Get()
+ .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, "search")
+ .WithHeaders(h => h.AppendDefaultHeaders())
+ .WithQueryString(q => q
+ .Append("id", SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE)
+ .Append("ids", SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE)
+ ),
+ step => step
+ .VerifyJson((response, _) =>
+ {
+ response.ShouldSatisfyAllConditions("should be an OK JSON response",
+ r => r.StatusCode.ShouldBe(HttpStatusCode.OK),
+ r => r.Response.ShouldNotBeNull()
+ );
+
+ JsonElement rootElement = response.Response!.RootElement;
+
+ rootElement.ShouldSatisfyAllConditions("should be a JSON array with at least one item",
+ e => e.ValueKind.ShouldBe(JsonValueKind.Array),
+ e => e.GetArrayLength().ShouldBe(1)
+ );
+
+ IList idJsonElements = JsonSelector.Select(response.Response.RootElement, "$[*].id");
+
+ idJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string");
+ })
+ );
+
+ public static IStepBuilderAppender SearchByUnprotectedId(
+ this IStepBuilderAppender stepBuilderAppender) =>
+ stepBuilderAppender
+ .HttpStep("GET-Search-By-UnprotectedId",
+ () => RequestBuilder.Get()
+ .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, "search")
+ .WithHeaders(h => h.AppendDefaultHeaders())
+ .WithQueryString(q => q
+ .Append("id", SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE)
+ .Append("ids", SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE)
+ ),
+ step => step
+ .Verify((response, _) =>
+ {
+ response.ShouldSatisfyAllConditions("should be a BadRequest response",
+ r => r.StatusCode.ShouldBe(HttpStatusCode.BadRequest)
+ );
+ })
+ );
+
+ public static IStepBuilderAppender SearchObjectByProtectedId(
+ this IStepBuilderAppender stepBuilderAppender) =>
+ stepBuilderAppender
+ .HttpStep("GET-SearchObject-By-ProtectedId",
+ () => RequestBuilder.Get()
+ .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, "search", "object")
+ .WithHeaders(h => h.AppendDefaultHeaders())
+ .WithQueryString(q => q
+ .Append("id", SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE)
+ .Append("ids", SAMPLE_OBJECT_PROTECTED_ID_TEMPLATE)
+ ),
+ step => step
+ .VerifyJson((response, _) =>
+ {
+ response.ShouldSatisfyAllConditions("should be an OK JSON response",
+ r => r.StatusCode.ShouldBe(HttpStatusCode.OK),
+ r => r.Response.ShouldNotBeNull()
+ );
+
+ JsonElement rootElement = response.Response!.RootElement;
+
+ rootElement.ShouldSatisfyAllConditions("should be a JSON array with at least one item",
+ e => e.ValueKind.ShouldBe(JsonValueKind.Array),
+ e => e.GetArrayLength().ShouldBe(1)
+ );
+
+ IList idJsonElements = JsonSelector.Select(response.Response.RootElement, "$[*].id");
+
+ idJsonElements.ShouldAllBe(elt => elt.ValueKind == JsonValueKind.String, "should all be JSON string");
+ })
+ );
+
+ public static IStepBuilderAppender SearchObjectByUnprotectedId(
+ this IStepBuilderAppender stepBuilderAppender) =>
+ stepBuilderAppender
+ .HttpStep("GET-SearchObject-By-UnprotectedId",
+ () => RequestBuilder.Get()
+ .AppendPathSegments(STEP_PATH_PREFIX_TEMPLATE, SAMPLE_OBJECTS_PATH, "search", "object")
+ .WithHeaders(h => h.AppendDefaultHeaders())
+ .WithQueryString(q => q
+ .Append("id", SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE)
+ .Append("ids", SAMPLE_OBJECT_UNPROTECTED_ID_TEMPLATE)
+ ),
+ step => step
+ .Verify((response, _) =>
+ {
+ response.ShouldSatisfyAllConditions("should be a BadRequest response",
+ r => r.StatusCode.ShouldBe(HttpStatusCode.BadRequest)
+ );
+ })
+ );
+
+ private static HeaderBuilder AppendDefaultHeaders(this HeaderBuilder headerBuilder) =>
+ headerBuilder
+ .Header(HeaderNames.ContentType, "application/json");
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/WebApi.cs b/tst/ProtectedNumbers.Tests.EndToEnd/WebApi.cs
new file mode 100644
index 0000000..049f6bc
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd/WebApi.cs
@@ -0,0 +1,87 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd;
+
+public class WebApi : IEquatable
+{
+ private WebApi(string name, string resourceName)
+ {
+ Name = name;
+ ResourceName = resourceName;
+ }
+
+ public static IEnumerable EnumerateWebApi()
+ {
+ string[] dotnetVersions = [
+ "dotnet6",
+ "dotnet8",
+ "dotnet9",
+ "dotnet10"
+ ];
+
+ foreach (string dotnetVersion in dotnetVersions)
+ {
+ string resourceName = $"webapi-{dotnetVersion}";
+ WebApi webApi = new(dotnetVersion, resourceName);
+
+ yield return webApi;
+ }
+ }
+
+ public static bool operator ==(WebApi? left, WebApi? right)
+ {
+ return Equals(left, right);
+ }
+
+ public static bool operator !=(WebApi? left, WebApi? right)
+ {
+ return !Equals(left, right);
+ }
+
+ public string Name { get; }
+
+ public string ResourceName { get; }
+
+ ///
+ public bool Equals(WebApi? other)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ return Name == other.Name;
+ }
+
+ ///
+ public override bool Equals(object? obj)
+ {
+ if (obj is null)
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, obj))
+ {
+ return true;
+ }
+
+ if (obj.GetType() != GetType())
+ {
+ return false;
+ }
+
+ return Equals((WebApi)obj);
+ }
+
+ ///
+ public override int GetHashCode() => Name.GetHashCode();
+
+ ///
+ public override string ToString() => Name;
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/WebApiAccessorWithEndpointStackFixtureData.cs b/tst/ProtectedNumbers.Tests.EndToEnd/WebApiAccessorWithEndpointStackFixtureData.cs
new file mode 100644
index 0000000..5531d91
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd/WebApiAccessorWithEndpointStackFixtureData.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd;
+
+using System.Collections;
+
+public static class WebApiWithEndpointStackFixtureData
+{
+ public static IEnumerable FixtureParams()
+ {
+ foreach (WebApi webApi in WebApi.EnumerateWebApi())
+ {
+ foreach (EndpointStack endpointStack in EndpointStack.EnumerateStacks())
+ {
+ yield return new TestFixtureData(webApi, endpointStack);
+ }
+ }
+ }
+}
diff --git a/tst/ProtectedNumbers.Tests.EndToEnd/WebApiProvider.cs b/tst/ProtectedNumbers.Tests.EndToEnd/WebApiProvider.cs
new file mode 100644
index 0000000..ada47b0
--- /dev/null
+++ b/tst/ProtectedNumbers.Tests.EndToEnd/WebApiProvider.cs
@@ -0,0 +1,171 @@
+// Copyright (c) Grégory Célet. All Rights Reserved.
+// Licensed under the MIT License. See LICENSE in the project root for license information.
+namespace ProtectedNumbers.Tests.EndToEnd;
+
+using Aspire.Hosting;
+
+using Microsoft.Extensions.Logging;
+
+using NUnit.Framework.Internal;
+
+[SetUpFixture]
+public static class WebApiProvider
+{
+ private const int InfrastructureTimeout = 30_000;
+
+ private static IDistributedApplicationTestingBuilder? AppHost { get; set; }
+
+ private static DistributedApplication? DistributedApplication { get; set; }
+
+ private static TimeSpan WaitingTimeout { get; } = TimeSpan.FromMilliseconds(InfrastructureTimeout - 500);
+
+ private static Dictionary WebApiAccessors { get; } = new();
+
+ public static HttpClient? GetHttpClient(WebApi webApi) => WebApiAccessors.GetValueOrDefault(webApi);
+
+ [OneTimeSetUp]
+ public static async Task OneTimeSetup()
+ {
+ using CancellationTokenSource cancellationTokenSource = new();
+
+ cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(InfrastructureTimeout));
+
+ CancellationToken cancellationToken = cancellationTokenSource.Token;
+
+ await BuildAppHost(cancellationToken);
+ await BuildDistributedApplication(cancellationToken);
+ await BuildWebApiAccessors(cancellationToken);
+ }
+
+ [OneTimeTearDown]
+ public static async Task OneTimeTearDown()
+ {
+ using CancellationTokenSource cancellationTokenSource = new();
+
+ cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(InfrastructureTimeout));
+
+ CancellationToken cancellationToken = cancellationTokenSource.Token;
+
+ DisposeWebApiAccessors();
+ await DisposeDistributedApplication(cancellationToken);
+ await DisposeAppHost();
+ }
+
+ private static async Task BuildAppHost(CancellationToken cancellationToken)
+ {
+ await DisposeAppHost();
+
+ AppHost =
+ await DistributedApplicationTestingBuilder.CreateAsync(cancellationToken);
+
+ AppHost.Services.AddLogging(logging =>
+ {
+ logging.SetMinimumLevel(LogLevel.Debug);
+ // Override the logging filters from the app's configuration
+ logging.AddFilter(AppHost.Environment.ApplicationName, LogLevel.Debug);
+ logging.AddFilter("Aspire.", LogLevel.Debug);
+ });
+
+ AppHost.Services.ConfigureHttpClientDefaults(clientBuilder =>
+ {
+ clientBuilder.AddStandardResilienceHandler();
+ });
+ }
+
+ private static async Task BuildDistributedApplication(CancellationToken cancellationToken)
+ {
+ if (AppHost == null)
+ {
+ throw new NUnitException($"Can't build '{nameof(DistributedApplication)}' when '{nameof(AppHost)}' is null");
+ }
+
+ await DisposeDistributedApplication(cancellationToken);
+
+ DistributedApplication = await AppHost.BuildAsync(cancellationToken)
+ .WaitAsync(WaitingTimeout, cancellationToken)
+ ;
+
+ await DistributedApplication.StartAsync(cancellationToken)
+ .WaitAsync(WaitingTimeout, cancellationToken)
+ ;
+ }
+
+ private static async Task BuildWebApiAccessors(CancellationToken cancellationToken)
+ {
+ if (DistributedApplication == null)
+ {
+ throw new NUnitException($"Can't build '{nameof(WebApiAccessors)}' when '{nameof(DistributedApplication)}' is null");
+ }
+
+ DisposeWebApiAccessors();
+
+ Dictionary webApiAccessors = new();
+ List resourceWaitTasks = new();
+
+ foreach (WebApi webApi in WebApi.EnumerateWebApi())
+ {
+ HttpClient httpClient = DistributedApplication.CreateHttpClient(webApi.ResourceName);
+ Task resourceWaitTask = DistributedApplication.ResourceNotifications
+ .WaitForResourceHealthyAsync(webApi.ResourceName, cancellationToken)
+ .WaitAsync(WaitingTimeout, cancellationToken)
+ ;
+
+ webApiAccessors[webApi] = httpClient;
+ resourceWaitTasks.Add(resourceWaitTask);
+ }
+
+ if (resourceWaitTasks.Count > 0)
+ {
+ await Task.WhenAll(resourceWaitTasks);
+ }
+
+ if (webApiAccessors.Count > 0)
+ {
+ foreach (KeyValuePair kvp in webApiAccessors)
+ {
+ WebApiAccessors[kvp.Key] = kvp.Value;
+ }
+ }
+ }
+
+ private static async Task DisposeAppHost()
+ {
+ if (AppHost == null)
+ {
+ return;
+ }
+
+ await AppHost.DisposeAsync();
+ AppHost = null;
+ }
+
+ private static async Task DisposeDistributedApplication(CancellationToken cancellationToken)
+ {
+ if (DistributedApplication == null)
+ {
+ return;
+ }
+
+ await DistributedApplication.StopAsync(cancellationToken);
+ await DistributedApplication.DisposeAsync();
+ DistributedApplication = null;
+ }
+
+ private static void DisposeWebApiAccessors()
+ {
+ if (WebApiAccessors.Count == 0)
+ {
+ return;
+ }
+
+ List webApiAccessors = WebApiAccessors.Values.ToList();
+
+ WebApiAccessors.Clear();
+
+ foreach (HttpClient httpClient in webApiAccessors)
+ {
+ httpClient.CancelPendingRequests();
+ httpClient.Dispose();
+ }
+ }
+}