diff --git a/.github/workflows/cloudshop-example.yml b/.github/workflows/cloudshop-example.yml new file mode 100644 index 0000000000..170bb18fcc --- /dev/null +++ b/.github/workflows/cloudshop-example.yml @@ -0,0 +1,38 @@ +name: CloudShop Example Tests + +on: + pull_request: + branches: ["main"] + push: + branches: ["main"] + workflow_dispatch: + +jobs: + integration-tests: + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Cache NuGet packages + uses: actions/cache@v5 + continue-on-error: true + with: + path: ~/.nuget/packages + key: nuget-cloudshop-${{ hashFiles('examples/CloudShop/**/*.csproj') }} + restore-keys: | + nuget-cloudshop- + + - name: Build + run: dotnet build examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj -c Release + + - name: Run integration tests + run: dotnet run --project examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj -c Release --no-build diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index 74dec370b9..d3bada1e50 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -1408,6 +1408,30 @@ public static WaitsForAssertion WaitsFor( return new WaitsForAssertion(source.Context, assertionBuilder, timeout, pollingInterval); } + /// + /// Alias for — asserts that an assertion passes within the specified timeout by polling repeatedly. + /// Reads more naturally for integration tests with eventually-consistent state. + /// Example: await Assert.That(getValue).Eventually(assert => assert.IsEqualTo(expected), timeout: TimeSpan.FromSeconds(10)); + /// + /// The type of value being asserted + /// The assertion source + /// A function that builds the assertion to be evaluated on each poll + /// The maximum time to wait for the assertion to pass + /// The interval between polling attempts (defaults to 10ms if not specified) + /// Captured expression for the timeout parameter + /// Captured expression for the polling interval parameter + /// An assertion that can be awaited or chained with And/Or + public static WaitsForAssertion Eventually( + this IAssertionSource source, + Func, Assertion> assertionBuilder, + TimeSpan timeout, + TimeSpan? pollingInterval = null, + [CallerArgumentExpression(nameof(timeout))] string? timeoutExpression = null, + [CallerArgumentExpression(nameof(pollingInterval))] string? pollingIntervalExpression = null) + { + return source.WaitsFor(assertionBuilder, timeout, pollingInterval, timeoutExpression, pollingIntervalExpression); + } + private static Action GetActionFromDelegate(DelegateAssertion source) { return source.Action; diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 091e8cfeb9..f24b083161 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -561,10 +561,10 @@ private static int GetMaxParallelism(ILogger logger, ICommandLineOptions command } } - // Default: 8x CPU cores (empirically optimized for async/IO-bound workloads) + // Default: 4x CPU cores (empirically optimized for async/IO-bound workloads) // Users can override via --maximum-parallel-tests or TUNIT_MAX_PARALLEL_TESTS - var defaultLimit = Environment.ProcessorCount * 8; - logger.LogDebug($"Maximum parallel tests limit defaulting to {defaultLimit} ({Environment.ProcessorCount} processors * 8)"); + var defaultLimit = Environment.ProcessorCount * 4; + logger.LogDebug($"Maximum parallel tests limit defaulting to {defaultLimit} ({Environment.ProcessorCount} processors * 4)"); return defaultLimit; } } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 4885566dad..b214b21021 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -2439,6 +2439,7 @@ namespace .Extensions public static ..DoesNotHaveSameValueAsAssertion DoesNotHaveSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } + public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..HasFlagAssertion HasFlag(this . source, TEnum expectedFlag, [.("expectedFlag")] string? expression = null) where TEnum : struct, { } [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" + diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 0a42120fdd..35a861522e 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -2418,6 +2418,7 @@ namespace .Extensions public static ..DoesNotHaveSameValueAsAssertion DoesNotHaveSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } + public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..HasFlagAssertion HasFlag(this . source, TEnum expectedFlag, [.("expectedFlag")] string? expression = null) where TEnum : struct, { } [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" + diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 7c8c1cb1fa..f068600f0e 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -2439,6 +2439,7 @@ namespace .Extensions public static ..DoesNotHaveSameValueAsAssertion DoesNotHaveSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } + public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..HasFlagAssertion HasFlag(this . source, TEnum expectedFlag, [.("expectedFlag")] string? expression = null) where TEnum : struct, { } [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" + diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index c995f5f34e..1f0b7c8a11 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -2186,6 +2186,7 @@ namespace .Extensions public static ..DoesNotHaveSameValueAsAssertion DoesNotHaveSameValueAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } + public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..HasFlagAssertion HasFlag(this . source, TEnum expectedFlag, [.("expectedFlag")] string? expression = null) where TEnum : struct, { } [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" + diff --git a/TUnit.sln b/TUnit.sln index 02b43bffdd..9f12b91c67 100644 --- a/TUnit.sln +++ b/TUnit.sln @@ -159,6 +159,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.SourceGenerator.Incre EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.SourceGenerator.Benchmarks", "TUnit.SourceGenerator.Benchmarks\TUnit.SourceGenerator.Benchmarks.csproj", "{F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CloudShop", "CloudShop", "{031DDE1B-FBE4-57DA-1BA5-5AC07861149E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.AppHost", "examples\CloudShop\CloudShop.AppHost\CloudShop.AppHost.csproj", "{6A44B84C-7397-491A-88A8-700DA4737D76}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.ApiService", "examples\CloudShop\CloudShop.ApiService\CloudShop.ApiService.csproj", "{8370E6E8-A925-485D-8823-5752AA9EDDD2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.ServiceDefaults", "examples\CloudShop\CloudShop.ServiceDefaults\CloudShop.ServiceDefaults.csproj", "{88D7BDBA-625F-497B-B172-5E050426BAD9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.Shared", "examples\CloudShop\CloudShop.Shared\CloudShop.Shared.csproj", "{4950126F-628A-4A64-9F11-DAB96F28C086}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.Web", "examples\CloudShop\CloudShop.Web\CloudShop.Web.csproj", "{F4FA4C54-2F73-4164-A5FA-015C7D6174D8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.Worker", "examples\CloudShop\CloudShop.Worker\CloudShop.Worker.csproj", "{F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.Tests", "examples\CloudShop\CloudShop.Tests\CloudShop.Tests.csproj", "{8C153BF6-87C4-468F-A7C0-954537F52BDA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -901,18 +917,6 @@ Global {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Release|x64.Build.0 = Release|Any CPU {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Release|x86.ActiveCfg = Release|Any CPU {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Release|x86.Build.0 = Release|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|x64.ActiveCfg = Debug|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|x64.Build.0 = Debug|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|x86.ActiveCfg = Debug|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|x86.Build.0 = Debug|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|Any CPU.Build.0 = Release|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x64.ActiveCfg = Release|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x64.Build.0 = Release|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x86.ActiveCfg = Release|Any CPU - {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x86.Build.0 = Release|Any CPU {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Debug|Any CPU.Build.0 = Debug|Any CPU {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -925,6 +929,102 @@ Global {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Release|x64.Build.0 = Release|Any CPU {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Release|x86.ActiveCfg = Release|Any CPU {93A728CE-CC78-4F9B-897B-AA6F72E870F2}.Release|x86.Build.0 = Release|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|x64.ActiveCfg = Debug|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|x64.Build.0 = Debug|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|x86.ActiveCfg = Debug|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Debug|x86.Build.0 = Debug|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|Any CPU.Build.0 = Release|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x64.ActiveCfg = Release|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x64.Build.0 = Release|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x86.ActiveCfg = Release|Any CPU + {F686AD4B-FC90-48B9-84C9-C7B16C2E13E5}.Release|x86.Build.0 = Release|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Debug|x64.ActiveCfg = Debug|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Debug|x64.Build.0 = Debug|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Debug|x86.ActiveCfg = Debug|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Debug|x86.Build.0 = Debug|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Release|Any CPU.Build.0 = Release|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Release|x64.ActiveCfg = Release|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Release|x64.Build.0 = Release|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Release|x86.ActiveCfg = Release|Any CPU + {6A44B84C-7397-491A-88A8-700DA4737D76}.Release|x86.Build.0 = Release|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Debug|x64.ActiveCfg = Debug|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Debug|x64.Build.0 = Debug|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Debug|x86.ActiveCfg = Debug|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Debug|x86.Build.0 = Debug|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Release|Any CPU.Build.0 = Release|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Release|x64.ActiveCfg = Release|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Release|x64.Build.0 = Release|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Release|x86.ActiveCfg = Release|Any CPU + {8370E6E8-A925-485D-8823-5752AA9EDDD2}.Release|x86.Build.0 = Release|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Debug|x64.ActiveCfg = Debug|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Debug|x64.Build.0 = Debug|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Debug|x86.ActiveCfg = Debug|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Debug|x86.Build.0 = Debug|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Release|Any CPU.Build.0 = Release|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Release|x64.ActiveCfg = Release|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Release|x64.Build.0 = Release|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Release|x86.ActiveCfg = Release|Any CPU + {88D7BDBA-625F-497B-B172-5E050426BAD9}.Release|x86.Build.0 = Release|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Debug|x64.ActiveCfg = Debug|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Debug|x64.Build.0 = Debug|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Debug|x86.ActiveCfg = Debug|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Debug|x86.Build.0 = Debug|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Release|Any CPU.Build.0 = Release|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Release|x64.ActiveCfg = Release|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Release|x64.Build.0 = Release|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Release|x86.ActiveCfg = Release|Any CPU + {4950126F-628A-4A64-9F11-DAB96F28C086}.Release|x86.Build.0 = Release|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Debug|x64.ActiveCfg = Debug|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Debug|x64.Build.0 = Debug|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Debug|x86.Build.0 = Debug|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Release|Any CPU.Build.0 = Release|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Release|x64.ActiveCfg = Release|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Release|x64.Build.0 = Release|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Release|x86.ActiveCfg = Release|Any CPU + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8}.Release|x86.Build.0 = Release|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Debug|x64.ActiveCfg = Debug|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Debug|x64.Build.0 = Debug|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Debug|x86.ActiveCfg = Debug|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Debug|x86.Build.0 = Debug|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Release|Any CPU.Build.0 = Release|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Release|x64.ActiveCfg = Release|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Release|x64.Build.0 = Release|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Release|x86.ActiveCfg = Release|Any CPU + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F}.Release|x86.Build.0 = Release|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Debug|x64.ActiveCfg = Debug|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Debug|x64.Build.0 = Debug|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Debug|x86.ActiveCfg = Debug|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Debug|x86.Build.0 = Debug|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Release|Any CPU.Build.0 = Release|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Release|x64.ActiveCfg = Release|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Release|x64.Build.0 = Release|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Release|x86.ActiveCfg = Release|Any CPU + {8C153BF6-87C4-468F-A7C0-954537F52BDA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -991,9 +1091,17 @@ Global {D5C70ADD-B960-4E6C-836C-6041938D04BE} = {503DA9FA-045D-4910-8AF6-905E6048B1F1} {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850} {6134813B-F928-443F-A629-F6726A1112F9} = {503DA9FA-045D-4910-8AF6-905E6048B1F1} - {3428D7AD-B362-4647-B1B0-72674CF3BC7C} = {0BA988BF-ADCE-4343-9098-B4EF65C43709} {6846A70E-2232-4BEF-9CE5-03F28A221335} = {1B56B580-4D59-4E83-9F80-467D58DADAC1} + {3428D7AD-B362-4647-B1B0-72674CF3BC7C} = {0BA988BF-ADCE-4343-9098-B4EF65C43709} {93A728CE-CC78-4F9B-897B-AA6F72E870F2} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850} + {031DDE1B-FBE4-57DA-1BA5-5AC07861149E} = {0BA988BF-ADCE-4343-9098-B4EF65C43709} + {6A44B84C-7397-491A-88A8-700DA4737D76} = {031DDE1B-FBE4-57DA-1BA5-5AC07861149E} + {8370E6E8-A925-485D-8823-5752AA9EDDD2} = {031DDE1B-FBE4-57DA-1BA5-5AC07861149E} + {88D7BDBA-625F-497B-B172-5E050426BAD9} = {031DDE1B-FBE4-57DA-1BA5-5AC07861149E} + {4950126F-628A-4A64-9F11-DAB96F28C086} = {031DDE1B-FBE4-57DA-1BA5-5AC07861149E} + {F4FA4C54-2F73-4164-A5FA-015C7D6174D8} = {031DDE1B-FBE4-57DA-1BA5-5AC07861149E} + {F22711E6-B8B7-4BB6-A957-3CAE94FCC98F} = {031DDE1B-FBE4-57DA-1BA5-5AC07861149E} + {8C153BF6-87C4-468F-A7C0-954537F52BDA} = {031DDE1B-FBE4-57DA-1BA5-5AC07861149E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {109D285A-36B3-4503-BCDF-8E26FB0E2C5B} diff --git a/examples/CloudShop/CloudShop.ApiService/CloudShop.ApiService.csproj b/examples/CloudShop/CloudShop.ApiService/CloudShop.ApiService.csproj new file mode 100644 index 0000000000..14472dec23 --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/CloudShop.ApiService.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + + + + + + + + + + + + + + + + diff --git a/examples/CloudShop/CloudShop.ApiService/CloudShop.ApiService.http b/examples/CloudShop/CloudShop.ApiService/CloudShop.ApiService.http new file mode 100644 index 0000000000..4aa8536f9e --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/CloudShop.ApiService.http @@ -0,0 +1,6 @@ +@ApiService_HostAddress = http://localhost:5547 + +GET {{ApiService_HostAddress}}/weatherforecast/ +Accept: application/json + +### \ No newline at end of file diff --git a/examples/CloudShop/CloudShop.ApiService/Data/AppDbContext.cs b/examples/CloudShop/CloudShop.ApiService/Data/AppDbContext.cs new file mode 100644 index 0000000000..f5289d9b40 --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/Data/AppDbContext.cs @@ -0,0 +1,73 @@ +using CloudShop.Shared.Models; +using Microsoft.EntityFrameworkCore; + +namespace CloudShop.ApiService.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Products => Set(); + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(200); + entity.Property(e => e.Category).IsRequired().HasMaxLength(100); + entity.Property(e => e.Price).HasPrecision(18, 2); + entity.HasIndex(e => e.Category); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.CustomerEmail).IsRequired().HasMaxLength(200); + entity.Property(e => e.TotalAmount).HasPrecision(18, 2); + entity.HasMany(e => e.Items).WithOne().HasForeignKey(e => e.OrderId); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.ProductName).IsRequired().HasMaxLength(200); + entity.Property(e => e.UnitPrice).HasPrecision(18, 2); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Email).IsRequired().HasMaxLength(200); + entity.HasIndex(e => e.Email).IsUnique(); + }); + + // Seed default users + modelBuilder.Entity().HasData( + new User { Id = 1, Email = "admin@cloudshop.test", PasswordHash = BCryptHash("Admin123!"), Role = "admin", Name = "Admin User" }, + new User { Id = 2, Email = "customer@cloudshop.test", PasswordHash = BCryptHash("Customer123!"), Role = "customer", Name = "Test Customer" } + ); + + // Seed some products + modelBuilder.Entity().HasData( + new Product { Id = 1, Name = "Wireless Headphones", Category = "electronics", Price = 79.99m, Description = "Bluetooth over-ear headphones with noise cancellation", StockQuantity = 150 }, + new Product { Id = 2, Name = "USB-C Hub", Category = "electronics", Price = 49.99m, Description = "7-in-1 USB-C dock with HDMI, USB-A, and SD card reader", StockQuantity = 200 }, + new Product { Id = 3, Name = "Mechanical Keyboard", Category = "electronics", Price = 129.99m, Description = "RGB mechanical keyboard with Cherry MX switches", StockQuantity = 75 }, + new Product { Id = 4, Name = "Running Shoes", Category = "clothing", Price = 89.99m, Description = "Lightweight running shoes with cushioned sole", StockQuantity = 300 }, + new Product { Id = 5, Name = "Winter Jacket", Category = "clothing", Price = 149.99m, Description = "Waterproof insulated winter jacket", StockQuantity = 100 }, + new Product { Id = 6, Name = "Cotton T-Shirt", Category = "clothing", Price = 24.99m, Description = "Premium cotton crew neck t-shirt", StockQuantity = 500 }, + new Product { Id = 7, Name = "Clean Code", Category = "books", Price = 39.99m, Description = "A Handbook of Agile Software Craftsmanship by Robert C. Martin", StockQuantity = 50 }, + new Product { Id = 8, Name = "Design Patterns", Category = "books", Price = 44.99m, Description = "Elements of Reusable Object-Oriented Software", StockQuantity = 45 }, + new Product { Id = 9, Name = "The Pragmatic Programmer", Category = "books", Price = 49.99m, Description = "Your Journey To Mastery, 20th Anniversary Edition", StockQuantity = 60 } + ); + } + + // Simple password hashing for demo purposes (NOT production-safe) + private static string BCryptHash(string password) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + var bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(password + "cloudshop-salt")); + return Convert.ToBase64String(bytes); + } +} diff --git a/examples/CloudShop/CloudShop.ApiService/Endpoints/AuthEndpoints.cs b/examples/CloudShop/CloudShop.ApiService/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000000..dacec99b69 --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/Endpoints/AuthEndpoints.cs @@ -0,0 +1,28 @@ +using CloudShop.ApiService.Services; +using CloudShop.Shared.Contracts; + +namespace CloudShop.ApiService.Endpoints; + +public static class AuthEndpoints +{ + public static void MapAuthEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/auth").WithTags("Authentication"); + + group.MapPost("/login", async (LoginRequest request, AuthService authService) => + { + var result = await authService.AuthenticateAsync(request); + return result is not null + ? Results.Ok(result) + : Results.Unauthorized(); + }); + + group.MapPost("/register", async (RegisterRequest request, AuthService authService) => + { + var result = await authService.RegisterAsync(request); + return result is not null + ? Results.Ok(result) + : Results.Conflict(new ErrorResponse("Email already registered")); + }); + } +} diff --git a/examples/CloudShop/CloudShop.ApiService/Endpoints/OrderEndpoints.cs b/examples/CloudShop/CloudShop.ApiService/Endpoints/OrderEndpoints.cs new file mode 100644 index 0000000000..eecc51f6c0 --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/Endpoints/OrderEndpoints.cs @@ -0,0 +1,144 @@ +using System.Security.Claims; +using CloudShop.ApiService.Data; +using CloudShop.ApiService.Services; +using CloudShop.Shared.Contracts; +using CloudShop.Shared.Events; +using CloudShop.Shared.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace CloudShop.ApiService.Endpoints; + +public static class OrderEndpoints +{ + public static void MapOrderEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/orders").WithTags("Orders").RequireAuthorization(); + + group.MapGet("/mine", async (ClaimsPrincipal user, AppDbContext db, [FromQuery] int page = 1, [FromQuery] int pageSize = 25) => + { + var email = user.FindFirstValue(ClaimTypes.Email)!; + + var query = db.Orders + .Where(o => o.CustomerEmail == email) + .OrderByDescending(o => o.CreatedAt); + + var totalCount = await query.CountAsync(); + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Include(o => o.Items) + .Select(o => new OrderResponse( + o.Id, o.CustomerEmail, o.Status, o.PaymentMethod, o.ShippingOption, + o.TotalAmount, o.Items.Select(i => new OrderItemResponse(i.ProductId, i.ProductName, i.UnitPrice, i.Quantity)).ToList(), + o.CreatedAt, o.FulfilledAt)) + .ToListAsync(); + + return Results.Ok(new PagedResult(items, totalCount, page, pageSize)); + }); + + group.MapGet("/{id:int}", async (int id, ClaimsPrincipal user, AppDbContext db) => + { + var email = user.FindFirstValue(ClaimTypes.Email)!; + var role = user.FindFirstValue(ClaimTypes.Role); + + var order = await db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id); + if (order is null) return Results.NotFound(); + + // Customers can only see their own orders + if (role != "admin" && order.CustomerEmail != email) + return Results.Forbid(); + + return Results.Ok(new OrderResponse( + order.Id, order.CustomerEmail, order.Status, order.PaymentMethod, order.ShippingOption, + order.TotalAmount, order.Items.Select(i => new OrderItemResponse(i.ProductId, i.ProductName, i.UnitPrice, i.Quantity)).ToList(), + order.CreatedAt, order.FulfilledAt)); + }); + + group.MapPost("/", async (CreateOrderRequest request, ClaimsPrincipal user, AppDbContext db, OrderEventPublisher publisher) => + { + if (request.Items.Count == 0) + return Results.BadRequest(new ErrorResponse("Order must contain at least one item")); + + var email = user.FindFirstValue(ClaimTypes.Email)!; + var order = new Order + { + CustomerEmail = email, + PaymentMethod = request.PaymentMethod, + ShippingOption = request.ShippingOption, + Items = [] + }; + + foreach (var item in request.Items) + { + if (item.Quantity <= 0) + return Results.BadRequest(new ErrorResponse("Item quantity must be positive")); + + if (item.Quantity > 10000) + return Results.BadRequest(new ErrorResponse("Item quantity exceeds maximum of 10000")); + + var product = await db.Products.FindAsync(item.ProductId); + if (product is null) + return Results.BadRequest(new ErrorResponse($"Product {item.ProductId} not found")); + + if (product.StockQuantity < item.Quantity) + return Results.BadRequest(new ErrorResponse($"Insufficient stock for {product.Name}")); + + product.StockQuantity -= item.Quantity; + + order.Items.Add(new OrderItem + { + ProductId = product.Id, + ProductName = product.Name, + UnitPrice = product.Price, + Quantity = item.Quantity + }); + } + + order.TotalAmount = order.Items.Sum(i => i.UnitPrice * i.Quantity); + + db.Orders.Add(order); + await db.SaveChangesAsync(); + + try + { + await publisher.PublishOrderCreatedAsync(new OrderCreatedEvent(order.Id, email, order.TotalAmount, order.CreatedAt)); + } + catch + { + // Don't fail the order if messaging is down + } + + var response = new OrderResponse( + order.Id, order.CustomerEmail, order.Status, order.PaymentMethod, order.ShippingOption, + order.TotalAmount, order.Items.Select(i => new OrderItemResponse(i.ProductId, i.ProductName, i.UnitPrice, i.Quantity)).ToList(), + order.CreatedAt, order.FulfilledAt); + + return Results.Created($"/api/orders/{order.Id}", response); + }); + + group.MapPost("/{id:int}/pay", async (int id, ProcessPaymentRequest request, ClaimsPrincipal user, AppDbContext db, OrderEventPublisher publisher) => + { + var email = user.FindFirstValue(ClaimTypes.Email)!; + var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id && o.CustomerEmail == email); + if (order is null) return Results.NotFound(); + + if (order.Status != OrderStatus.Pending) + return Results.BadRequest(new ErrorResponse("Order is not in pending status")); + + order.Status = OrderStatus.PaymentProcessed; + await db.SaveChangesAsync(); + + try + { + await publisher.PublishPaymentProcessedAsync(new OrderPaymentProcessedEvent(order.Id, request.PaymentMethod, DateTime.UtcNow)); + } + catch + { + // Don't fail payment if messaging is down + } + + return Results.Ok(new { order.Id, order.Status }); + }); + } +} diff --git a/examples/CloudShop/CloudShop.ApiService/Endpoints/ProductEndpoints.cs b/examples/CloudShop/CloudShop.ApiService/Endpoints/ProductEndpoints.cs new file mode 100644 index 0000000000..a3e2fa5b22 --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/Endpoints/ProductEndpoints.cs @@ -0,0 +1,110 @@ +using CloudShop.ApiService.Data; +using CloudShop.ApiService.Services; +using CloudShop.Shared.Contracts; +using CloudShop.Shared.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace CloudShop.ApiService.Endpoints; + +public static class ProductEndpoints +{ + public static void MapProductEndpoints(this WebApplication app) + { + var group = app.MapGroup("/api/products").WithTags("Products"); + + group.MapGet("/", async ( + AppDbContext db, + ProductCacheService cache, + [FromQuery] string? category, + [FromQuery] string sort = "name", + [FromQuery] int page = 1, + [FromQuery] int pageSize = 25) => + { + var query = db.Products.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(category)) + query = query.Where(p => p.Category.ToLower() == category.ToLower()); + + query = sort switch + { + "price_asc" => query.OrderBy(p => p.Price), + "price_desc" => query.OrderByDescending(p => p.Price), + "name" or "name_asc" => query.OrderBy(p => p.Name), + _ => query.OrderBy(p => p.Name) + }; + + var totalCount = await query.CountAsync(); + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(p => new ProductResponse(p.Id, p.Name, p.Category, p.Price, p.Description, p.StockQuantity, p.CreatedAt)) + .ToListAsync(); + + return Results.Ok(new PagedResult(items, totalCount, page, pageSize)); + }); + + group.MapGet("/{id:int}", async (int id, AppDbContext db, ProductCacheService cache) => + { + // Try cache first + var cached = await cache.GetAsync(id); + if (cached is not null) + return Results.Ok(new ProductResponse(cached.Id, cached.Name, cached.Category, cached.Price, cached.Description, cached.StockQuantity, cached.CreatedAt)); + + var product = await db.Products.FindAsync(id); + if (product is null) return Results.NotFound(); + + // Cache for next time + await cache.SetAsync(product); + + return Results.Ok(new ProductResponse(product.Id, product.Name, product.Category, product.Price, product.Description, product.StockQuantity, product.CreatedAt)); + }); + + group.MapPost("/", async (CreateProductRequest request, AppDbContext db, ProductCacheService cache) => + { + var product = new Product + { + Name = request.Name, + Category = request.Category, + Price = request.Price, + Description = request.Description, + StockQuantity = request.StockQuantity + }; + + db.Products.Add(product); + await db.SaveChangesAsync(); + + var response = new ProductResponse(product.Id, product.Name, product.Category, product.Price, product.Description, product.StockQuantity, product.CreatedAt); + return Results.Created($"/api/products/{product.Id}", response); + }).RequireAuthorization("admin"); + + group.MapPut("/{id:int}", async (int id, UpdateProductRequest request, AppDbContext db, ProductCacheService cache) => + { + var product = await db.Products.FindAsync(id); + if (product is null) return Results.NotFound(); + + if (request.Name is not null) product.Name = request.Name; + if (request.Category is not null) product.Category = request.Category; + if (request.Price.HasValue) product.Price = request.Price.Value; + if (request.Description is not null) product.Description = request.Description; + if (request.StockQuantity.HasValue) product.StockQuantity = request.StockQuantity.Value; + + await db.SaveChangesAsync(); + await cache.InvalidateAsync(id); + + return Results.Ok(new ProductResponse(product.Id, product.Name, product.Category, product.Price, product.Description, product.StockQuantity, product.CreatedAt)); + }).RequireAuthorization("admin"); + + group.MapDelete("/{id:int}", async (int id, AppDbContext db, ProductCacheService cache) => + { + var product = await db.Products.FindAsync(id); + if (product is null) return Results.NotFound(); + + db.Products.Remove(product); + await db.SaveChangesAsync(); + await cache.InvalidateAsync(id); + + return Results.NoContent(); + }).RequireAuthorization("admin"); + } +} diff --git a/examples/CloudShop/CloudShop.ApiService/Program.cs b/examples/CloudShop/CloudShop.ApiService/Program.cs new file mode 100644 index 0000000000..27111159a3 --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/Program.cs @@ -0,0 +1,76 @@ +using System.Text; +using CloudShop.ApiService.Data; +using CloudShop.ApiService.Endpoints; +using CloudShop.ApiService.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +// Add Aspire service defaults (telemetry, health checks, resilience) +builder.AddServiceDefaults(); + +// Add Aspire-managed services +builder.AddNpgsqlDbContext("postgresdb"); +builder.AddRedisClient("redis"); +builder.AddRabbitMQClient("rabbitmq"); + +// Application services +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Authentication +var jwtKey = builder.Configuration["Jwt:Key"] ?? "cloudshop-super-secret-key-for-testing-only-1234567890"; +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = "cloudshop", + ValidAudience = "cloudshop", + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) + }; + }); + +builder.Services.AddAuthorizationBuilder() + .AddPolicy("admin", policy => policy.RequireRole("admin")); + +builder.Services.AddProblemDetails(); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Create database schema and seed data on startup +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); +} + +app.UseExceptionHandler(); +app.UseAuthentication(); +app.UseAuthorization(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +// Map endpoints +app.MapAuthEndpoints(); +app.MapProductEndpoints(); +app.MapOrderEndpoints(); + +app.MapGet("/", () => "CloudShop API is running"); +app.MapDefaultEndpoints(); + +app.Run(); + +// Make Program class accessible for testing +public partial class Program; diff --git a/examples/CloudShop/CloudShop.ApiService/Properties/launchSettings.json b/examples/CloudShop/CloudShop.ApiService/Properties/launchSettings.json new file mode 100644 index 0000000000..2a1b80939a --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5547", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/CloudShop/CloudShop.ApiService/Services/AuthService.cs b/examples/CloudShop/CloudShop.ApiService/Services/AuthService.cs new file mode 100644 index 0000000000..05768eb4a4 --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/Services/AuthService.cs @@ -0,0 +1,79 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using CloudShop.ApiService.Data; +using CloudShop.Shared.Contracts; +using CloudShop.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; + +namespace CloudShop.ApiService.Services; + +public class AuthService(AppDbContext db, IConfiguration config) +{ + public async Task AuthenticateAsync(LoginRequest request) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.Email == request.Email); + if (user is null) return null; + + var hash = HashPassword(request.Password); + if (user.PasswordHash != hash) return null; + + return GenerateToken(user); + } + + public async Task RegisterAsync(RegisterRequest request) + { + if (await db.Users.AnyAsync(u => u.Email == request.Email)) + return null; + + var user = new User + { + Email = request.Email, + PasswordHash = HashPassword(request.Password), + Role = "customer", + Name = request.Name + }; + + db.Users.Add(user); + await db.SaveChangesAsync(); + + return GenerateToken(user); + } + + private TokenResponse GenerateToken(User user) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes( + config["Jwt:Key"] ?? "cloudshop-super-secret-key-for-testing-only-1234567890")); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expires = DateTime.UtcNow.AddHours(24); + + var claims = new[] + { + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Role, user.Role), + new Claim(ClaimTypes.Name, user.Name), + new Claim("sub", user.Id.ToString()) + }; + + var token = new JwtSecurityToken( + issuer: "cloudshop", + audience: "cloudshop", + claims: claims, + expires: expires, + signingCredentials: credentials); + + return new TokenResponse( + new JwtSecurityTokenHandler().WriteToken(token), + user.Email, + user.Role, + expires); + } + + private static string HashPassword(string password) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(password + "cloudshop-salt")); + return Convert.ToBase64String(bytes); + } +} diff --git a/examples/CloudShop/CloudShop.ApiService/Services/OrderEventPublisher.cs b/examples/CloudShop/CloudShop.ApiService/Services/OrderEventPublisher.cs new file mode 100644 index 0000000000..4ad4ebfb29 --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/Services/OrderEventPublisher.cs @@ -0,0 +1,30 @@ +using System.Text; +using System.Text.Json; +using CloudShop.Shared.Events; +using RabbitMQ.Client; + +namespace CloudShop.ApiService.Services; + +public class OrderEventPublisher(IConnection rabbitConnection) +{ + public async Task PublishOrderCreatedAsync(OrderCreatedEvent orderEvent) + { + await PublishAsync("order-events", "order.created", orderEvent); + } + + public async Task PublishPaymentProcessedAsync(OrderPaymentProcessedEvent paymentEvent) + { + await PublishAsync("order-events", "order.payment-processed", paymentEvent); + } + + private async Task PublishAsync(string exchange, string routingKey, T message) + { + await using var channel = await rabbitConnection.CreateChannelAsync(); + await channel.ExchangeDeclareAsync(exchange, ExchangeType.Topic, durable: true); + + var json = JsonSerializer.Serialize(message); + var body = Encoding.UTF8.GetBytes(json); + + await channel.BasicPublishAsync(exchange, routingKey, body); + } +} diff --git a/examples/CloudShop/CloudShop.ApiService/Services/ProductCacheService.cs b/examples/CloudShop/CloudShop.ApiService/Services/ProductCacheService.cs new file mode 100644 index 0000000000..d6c5780722 --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/Services/ProductCacheService.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using CloudShop.Shared.Models; +using StackExchange.Redis; + +namespace CloudShop.ApiService.Services; + +public class ProductCacheService(IConnectionMultiplexer redis) +{ + private readonly IDatabase _db = redis.GetDatabase(); + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + + public async Task GetAsync(int id) + { + var cached = await _db.StringGetAsync($"product:{id}"); + return cached.HasValue ? JsonSerializer.Deserialize(cached.ToString()) : null; + } + + public async Task SetAsync(Product product) + { + var json = JsonSerializer.Serialize(product); + await _db.StringSetAsync($"product:{product.Id}", json, CacheDuration); + } + + public async Task InvalidateAsync(int id) + { + await _db.KeyDeleteAsync($"product:{id}"); + } +} diff --git a/examples/CloudShop/CloudShop.ApiService/appsettings.Development.json b/examples/CloudShop/CloudShop.ApiService/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/examples/CloudShop/CloudShop.ApiService/appsettings.json b/examples/CloudShop/CloudShop.ApiService/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/examples/CloudShop/CloudShop.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/CloudShop/CloudShop.AppHost/AppHost.cs b/examples/CloudShop/CloudShop.AppHost/AppHost.cs new file mode 100644 index 0000000000..2a22da532b --- /dev/null +++ b/examples/CloudShop/CloudShop.AppHost/AppHost.cs @@ -0,0 +1,42 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// Infrastructure +var postgres = builder.AddPostgres("postgres") + .WithPgAdmin() + .AddDatabase("postgresdb"); + +var redis = builder.AddRedis("redis") + .WithRedisInsight(); + +var rabbitmq = builder.AddRabbitMQ("rabbitmq") + .WithManagementPlugin(); + +// API Service +var apiService = builder.AddProject("apiservice") + .WithReference(postgres) + .WithReference(redis) + .WithReference(rabbitmq) + .WaitFor(postgres) + .WaitFor(redis) + .WaitFor(rabbitmq) + .WithHttpHealthCheck("/health") + .WithoutHttpsCertificate(); + +// Worker Service +builder.AddProject("worker") + .WithReference(postgres) + .WithReference(rabbitmq) + .WaitFor(postgres) + .WaitFor(rabbitmq) + .WaitFor(apiService) + .WithoutHttpsCertificate(); + +// Web Frontend +builder.AddProject("webfrontend") + .WithExternalHttpEndpoints() + .WithReference(apiService) + .WaitFor(apiService) + .WithHttpHealthCheck("/health") + .WithoutHttpsCertificate(); + +builder.Build().Run(); diff --git a/examples/CloudShop/CloudShop.AppHost/CloudShop.AppHost.csproj b/examples/CloudShop/CloudShop.AppHost/CloudShop.AppHost.csproj new file mode 100644 index 0000000000..fbc8cd884e --- /dev/null +++ b/examples/CloudShop/CloudShop.AppHost/CloudShop.AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + $(NoWarn);ASPIRECERTIFICATES001 + + + + + + + + + + + + + + + diff --git a/examples/CloudShop/CloudShop.AppHost/Properties/launchSettings.json b/examples/CloudShop/CloudShop.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..704ef638d5 --- /dev/null +++ b/examples/CloudShop/CloudShop.AppHost/Properties/launchSettings.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15106", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19155", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18225", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20008" + } + } + } +} diff --git a/examples/CloudShop/CloudShop.AppHost/appsettings.Development.json b/examples/CloudShop/CloudShop.AppHost/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/examples/CloudShop/CloudShop.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/examples/CloudShop/CloudShop.AppHost/appsettings.json b/examples/CloudShop/CloudShop.AppHost/appsettings.json new file mode 100644 index 0000000000..31c092aa45 --- /dev/null +++ b/examples/CloudShop/CloudShop.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/examples/CloudShop/CloudShop.ServiceDefaults/CloudShop.ServiceDefaults.csproj b/examples/CloudShop/CloudShop.ServiceDefaults/CloudShop.ServiceDefaults.csproj new file mode 100644 index 0000000000..eeb71e38f4 --- /dev/null +++ b/examples/CloudShop/CloudShop.ServiceDefaults/CloudShop.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/examples/CloudShop/CloudShop.ServiceDefaults/Extensions.cs b/examples/CloudShop/CloudShop.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000000..b72c8753c8 --- /dev/null +++ b/examples/CloudShop/CloudShop.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +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/examples/CloudShop/CloudShop.Shared/CloudShop.Shared.csproj b/examples/CloudShop/CloudShop.Shared/CloudShop.Shared.csproj new file mode 100644 index 0000000000..b760144708 --- /dev/null +++ b/examples/CloudShop/CloudShop.Shared/CloudShop.Shared.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/examples/CloudShop/CloudShop.Shared/Contracts/Requests.cs b/examples/CloudShop/CloudShop.Shared/Contracts/Requests.cs new file mode 100644 index 0000000000..8e3cf991d3 --- /dev/null +++ b/examples/CloudShop/CloudShop.Shared/Contracts/Requests.cs @@ -0,0 +1,11 @@ +namespace CloudShop.Shared.Contracts; + +public record CreateProductRequest(string Name, string Category, decimal Price, string? Description = null, int StockQuantity = 100); +public record UpdateProductRequest(string? Name = null, string? Category = null, decimal? Price = null, string? Description = null, int? StockQuantity = null); + +public record CreateOrderRequest(List Items, string PaymentMethod = "credit_card", string ShippingOption = "standard"); +public record OrderItemRequest(int ProductId, int Quantity); +public record ProcessPaymentRequest(string PaymentMethod, string PaymentToken); + +public record LoginRequest(string Email, string Password); +public record RegisterRequest(string Email, string Password, string Name); diff --git a/examples/CloudShop/CloudShop.Shared/Contracts/Responses.cs b/examples/CloudShop/CloudShop.Shared/Contracts/Responses.cs new file mode 100644 index 0000000000..7329bb0aa4 --- /dev/null +++ b/examples/CloudShop/CloudShop.Shared/Contracts/Responses.cs @@ -0,0 +1,16 @@ +using CloudShop.Shared.Models; + +namespace CloudShop.Shared.Contracts; + +public record ProductResponse(int Id, string Name, string Category, decimal Price, string? Description, int StockQuantity, DateTime CreatedAt); +public record OrderResponse(int Id, string CustomerEmail, OrderStatus Status, string PaymentMethod, string ShippingOption, decimal TotalAmount, List Items, DateTime CreatedAt, DateTime? FulfilledAt); +public record OrderItemResponse(int ProductId, string ProductName, decimal UnitPrice, int Quantity); + +public record PagedResult(List Items, int TotalCount, int Page, int PageSize) +{ + public bool HasNextPage => Page * PageSize < TotalCount; +} + +public record TokenResponse(string AccessToken, string Email, string Role, DateTime ExpiresAt); + +public record ErrorResponse(string Message, Dictionary? Errors = null); diff --git a/examples/CloudShop/CloudShop.Shared/Events/OrderEvents.cs b/examples/CloudShop/CloudShop.Shared/Events/OrderEvents.cs new file mode 100644 index 0000000000..8fc3ef5169 --- /dev/null +++ b/examples/CloudShop/CloudShop.Shared/Events/OrderEvents.cs @@ -0,0 +1,5 @@ +namespace CloudShop.Shared.Events; + +public record OrderCreatedEvent(int OrderId, string CustomerEmail, decimal TotalAmount, DateTime CreatedAt); +public record OrderPaymentProcessedEvent(int OrderId, string PaymentMethod, DateTime ProcessedAt); +public record OrderFulfilledEvent(int OrderId, DateTime FulfilledAt); diff --git a/examples/CloudShop/CloudShop.Shared/Models/Order.cs b/examples/CloudShop/CloudShop.Shared/Models/Order.cs new file mode 100644 index 0000000000..63229e9509 --- /dev/null +++ b/examples/CloudShop/CloudShop.Shared/Models/Order.cs @@ -0,0 +1,34 @@ +namespace CloudShop.Shared.Models; + +public class Order +{ + public int Id { get; set; } + public required string CustomerEmail { get; set; } + public OrderStatus Status { get; set; } = OrderStatus.Pending; + public required string PaymentMethod { get; set; } + public required string ShippingOption { get; set; } + public decimal TotalAmount { get; set; } + public List Items { get; set; } = []; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? FulfilledAt { get; set; } +} + +public class OrderItem +{ + public int Id { get; set; } + public int OrderId { get; set; } + public int ProductId { get; set; } + public required string ProductName { get; set; } + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } +} + +public enum OrderStatus +{ + Pending, + PaymentProcessed, + Fulfilled, + Shipped, + Delivered, + Cancelled +} diff --git a/examples/CloudShop/CloudShop.Shared/Models/Product.cs b/examples/CloudShop/CloudShop.Shared/Models/Product.cs new file mode 100644 index 0000000000..7682ec7bac --- /dev/null +++ b/examples/CloudShop/CloudShop.Shared/Models/Product.cs @@ -0,0 +1,12 @@ +namespace CloudShop.Shared.Models; + +public class Product +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string Category { get; set; } + public string? Description { get; set; } + public decimal Price { get; set; } + public int StockQuantity { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/examples/CloudShop/CloudShop.Shared/Models/User.cs b/examples/CloudShop/CloudShop.Shared/Models/User.cs new file mode 100644 index 0000000000..1709d35e6b --- /dev/null +++ b/examples/CloudShop/CloudShop.Shared/Models/User.cs @@ -0,0 +1,11 @@ +namespace CloudShop.Shared.Models; + +public class User +{ + public int Id { get; set; } + public required string Email { get; set; } + public required string PasswordHash { get; set; } + public required string Role { get; set; } // "admin" or "customer" + public required string Name { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/examples/CloudShop/CloudShop.Tests/Assertions/HttpAssertionExtensions.cs b/examples/CloudShop/CloudShop.Tests/Assertions/HttpAssertionExtensions.cs new file mode 100644 index 0000000000..cf64bf42dc --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Assertions/HttpAssertionExtensions.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Net.Http; +using TUnit.Assertions.Attributes; + +namespace CloudShop.Tests.Assertions; + +/// +/// Custom HTTP assertion extensions for CloudShop API testing. +/// These complement the built-in TUnit HttpResponseMessage assertions. +/// +public static partial class HttpAssertionExtensions +{ + /// + /// Asserts that the HTTP response has a JSON content type. + /// Usage: await Assert.That(response).HasJsonContent(); + /// + [GenerateAssertion(ExpectationMessage = "have JSON content type")] + public static bool HasJsonContent(this HttpResponseMessage response) + => response.Content.Headers.ContentType?.MediaType == "application/json"; + + /// + /// Asserts that the HTTP response has the expected status code. + /// Usage: await Assert.That(response).HasStatusCode(HttpStatusCode.Created); + /// + [GenerateAssertion(ExpectationMessage = "have status code {expected}")] + public static bool HasStatusCode(this HttpResponseMessage response, HttpStatusCode expected) + => response.StatusCode == expected; + + /// + /// Asserts that the HTTP response has a 201 Created status code. + /// Usage: await Assert.That(response).IsCreated(); + /// + [GenerateAssertion(ExpectationMessage = "have status code 201 Created")] + public static bool IsCreated(this HttpResponseMessage response) + => response.StatusCode == HttpStatusCode.Created; + + /// + /// Asserts that the HTTP response has a 404 Not Found status code. + /// Usage: await Assert.That(response).IsNotFound(); + /// + [GenerateAssertion(ExpectationMessage = "have status code 404 Not Found")] + public static bool IsNotFound(this HttpResponseMessage response) + => response.StatusCode == HttpStatusCode.NotFound; + + /// + /// Asserts that the HTTP response has a 400 Bad Request status code. + /// Usage: await Assert.That(response).IsBadRequest(); + /// + [GenerateAssertion(ExpectationMessage = "have status code 400 Bad Request")] + public static bool IsBadRequest(this HttpResponseMessage response) + => response.StatusCode == HttpStatusCode.BadRequest; + + /// + /// Asserts that the HTTP response has a 401 Unauthorized status code. + /// Usage: await Assert.That(response).IsUnauthorized(); + /// + [GenerateAssertion(ExpectationMessage = "have status code 401 Unauthorized")] + public static bool IsUnauthorized(this HttpResponseMessage response) + => response.StatusCode == HttpStatusCode.Unauthorized; + + /// + /// Asserts that the HTTP response has a 403 Forbidden status code. + /// Usage: await Assert.That(response).IsForbidden(); + /// + [GenerateAssertion(ExpectationMessage = "have status code 403 Forbidden")] + public static bool IsForbidden(this HttpResponseMessage response) + => response.StatusCode == HttpStatusCode.Forbidden; +} diff --git a/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj b/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj new file mode 100644 index 0000000000..bfd0c31fa4 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/CloudShop.Tests.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + Exe + + + + + + + + + + + + + + + + diff --git a/examples/CloudShop/CloudShop.Tests/DataSources/OrderDataSources.cs b/examples/CloudShop/CloudShop.Tests/DataSources/OrderDataSources.cs new file mode 100644 index 0000000000..f142c6aed3 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/DataSources/OrderDataSources.cs @@ -0,0 +1,73 @@ +using System.Net; +using CloudShop.Shared.Contracts; + +namespace CloudShop.Tests.DataSources; + +/// +/// Provides test data for order-related tests using [MethodDataSource]. +/// Returns Func<T> for reference types to ensure proper test isolation (TUnit best practice). +/// +public static class OrderDataSources +{ + /// + /// Valid order creation requests with different item combinations. + /// Usage: [MethodDataSource(typeof(OrderDataSources), nameof(ValidOrders))] + /// + public static IEnumerable> ValidOrders() + { + // Single item order + yield return () => new([new(1, 1)], "credit_card", "standard"); + + // Multi-item order + yield return () => new([new(1, 2), new(2, 1)], "paypal", "express"); + + // Large quantity order + yield return () => new([new(3, 5)], "bank_transfer", "overnight"); + + // Multiple different items + yield return () => new([new(1, 1), new(2, 2), new(3, 3)], "credit_card", "standard"); + } + + /// + /// Invalid order requests that should be rejected, with expected error messages. + /// Usage: [MethodDataSource(typeof(OrderDataSources), nameof(InvalidOrders))] + /// + public static IEnumerable> InvalidOrders() + { + // Empty order + yield return () => new(new([], "credit_card", "standard"), "at least one item"); + + // Invalid product ID + yield return () => new(new([new(-1, 1)], "credit_card", "standard"), "not found"); + + // Zero quantity + yield return () => new(new([new(1, 0)], "credit_card", "standard"), "quantity"); + + // Negative quantity + yield return () => new(new([new(1, -5)], "credit_card", "standard"), "quantity"); + } + + /// + /// Protected API endpoints with expected access results per role. + /// Used for authorization matrix testing. + /// + public static IEnumerable ProtectedEndpoints() + { + yield return new(HttpMethod.Post, "/api/products", HttpStatusCode.Forbidden); + yield return new(HttpMethod.Delete, "/api/products/1", HttpStatusCode.Forbidden); + yield return new(HttpMethod.Put, "/api/products/1", HttpStatusCode.Forbidden); + yield return new(HttpMethod.Get, "/api/products", HttpStatusCode.OK); + yield return new(HttpMethod.Get, "/api/orders/mine", HttpStatusCode.OK); + } +} + +/// +/// Describes an invalid order scenario with the expected error message. +/// Uses a concrete type instead of a tuple for AOT compatibility. +/// +public record InvalidOrderScenario(CreateOrderRequest Request, string ExpectedError); + +/// +/// Describes an API endpoint with its expected status code for non-admin users. +/// +public record EndpointScenario(HttpMethod Method, string Path, HttpStatusCode ExpectedForCustomer); diff --git a/examples/CloudShop/CloudShop.Tests/DataSources/ProductDataSources.cs b/examples/CloudShop/CloudShop.Tests/DataSources/ProductDataSources.cs new file mode 100644 index 0000000000..f40f8eea88 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/DataSources/ProductDataSources.cs @@ -0,0 +1,47 @@ +using CloudShop.Shared.Contracts; + +namespace CloudShop.Tests.DataSources; + +/// +/// Provides test data for product-related tests using [MethodDataSource]. +/// Returns Func<T> for reference types to ensure proper test isolation (TUnit best practice). +/// +public static class ProductDataSources +{ + /// + /// Valid product creation requests for parameterized tests. + /// Usage: [MethodDataSource(typeof(ProductDataSources), nameof(ValidProducts))] + /// + public static IEnumerable> ValidProducts() + { + yield return () => new("Bluetooth Speaker", "electronics", 39.99m, "Portable wireless speaker", 200); + yield return () => new("Yoga Mat", "clothing", 29.99m, "Non-slip exercise mat", 150); + yield return () => new("Cooking Guide", "books", 19.99m, "100 easy recipes", 300); + yield return () => new("Budget Item", "electronics", 0.99m, "Cheapest item", 1); + yield return () => new("Premium Watch", "electronics", 999.99m, "Luxury smartwatch", 10); + } + + /// + /// All product categories used in the test seed data. + /// + public static IEnumerable Categories() + { + yield return "electronics"; + yield return "clothing"; + yield return "books"; + } + + /// + /// Valid product update requests for testing partial updates. + /// Usage: [MethodDataSource(typeof(ProductDataSources), nameof(ValidUpdates))] + /// + public static IEnumerable> ValidUpdates() + { + yield return () => new(Name: "Updated Name"); + yield return () => new(Price: 99.99m); + yield return () => new(Category: "sports"); + yield return () => new(StockQuantity: 500); + yield return () => new(Description: "Updated description"); + yield return () => new(Name: "Full Update", Category: "electronics", Price: 49.99m, Description: "All fields", StockQuantity: 100); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/DataSources/UserDataSources.cs b/examples/CloudShop/CloudShop.Tests/DataSources/UserDataSources.cs new file mode 100644 index 0000000000..6c4a379b22 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/DataSources/UserDataSources.cs @@ -0,0 +1,46 @@ +using CloudShop.Shared.Contracts; + +namespace CloudShop.Tests.DataSources; + +/// +/// Provides test data for user/auth-related tests using [MethodDataSource]. +/// Returns Func<T> for reference types to ensure proper test isolation (TUnit best practice). +/// +public static class UserDataSources +{ + private static int _counter; + + /// + /// Valid registration requests with unique emails. + /// Usage: [MethodDataSource(typeof(UserDataSources), nameof(NewUsers))] + /// + public static IEnumerable> NewUsers() + { + yield return () => + { + var id = Interlocked.Increment(ref _counter); + return new($"testuser-{id}@cloudshop.test", "TestPass123!", $"Test User {id}"); + }; + yield return () => + { + var id = Interlocked.Increment(ref _counter); + return new($"testuser-{id}@cloudshop.test", "TestPass456!", $"Test User {id}"); + }; + yield return () => + { + var id = Interlocked.Increment(ref _counter); + return new($"testuser-{id}@cloudshop.test", "TestPass789!", $"Test User {id}"); + }; + } + + /// + /// Invalid login credentials that should fail authentication. + /// + public static IEnumerable> InvalidCredentials() + { + yield return () => new("nonexistent@cloudshop.test", "password"); + yield return () => new("admin@cloudshop.test", "wrong-password"); + yield return () => new("", ""); + yield return () => new("invalid-email", "password"); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Hooks/TestHooks.cs b/examples/CloudShop/CloudShop.Tests/Hooks/TestHooks.cs new file mode 100644 index 0000000000..336d1eef8b --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Hooks/TestHooks.cs @@ -0,0 +1,46 @@ +using TUnit.Core; + +namespace CloudShop.Tests.Hooks; + +/// +/// Assembly-level and per-test hooks for test infrastructure management. +/// +/// Showcases: +/// - [Before(HookType.Assembly)] for one-time setup +/// - [AfterEvery(HookType.Test)] for per-test logging +/// - TestContext for accessing test metadata and results +/// +public static class TestHooks +{ + [Before(HookType.Assembly)] + public static void LogTestRunStart() + { + Console.WriteLine("========================================"); + Console.WriteLine(" CloudShop Integration Tests Starting"); + Console.WriteLine($" {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}"); + Console.WriteLine("========================================"); + } + + [AfterEvery(HookType.Test)] + public static void LogTestResult(TestContext context) + { + var status = context.Execution.Result?.State switch + { + TestState.Passed => "PASS", + TestState.Failed => "FAIL", + TestState.Skipped => "SKIP", + _ => "UNKNOWN" + }; + + Console.WriteLine($"[{status}] {context.Metadata.DisplayName} ({context.Execution.Result?.Duration?.TotalMilliseconds:F0}ms)"); + } + + [After(HookType.Assembly)] + public static void LogTestRunEnd() + { + Console.WriteLine("========================================"); + Console.WriteLine(" CloudShop Integration Tests Complete"); + Console.WriteLine($" {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss UTC}"); + Console.WriteLine("========================================"); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Infrastructure/ApiClientFixture.cs b/examples/CloudShop/CloudShop.Tests/Infrastructure/ApiClientFixture.cs new file mode 100644 index 0000000000..ae7c0d6b16 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Infrastructure/ApiClientFixture.cs @@ -0,0 +1,25 @@ +using System.Net.Http.Headers; +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace CloudShop.Tests.Infrastructure; + +/// +/// Base unauthenticated API client fixture. +/// Nested dependency: injects DistributedAppFixture to create HTTP clients. +/// +public class ApiClientFixture : IAsyncInitializer +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required DistributedAppFixture App { get; init; } + + public HttpClient Client { get; private set; } = null!; + + public Task InitializeAsync() + { + Client = App.CreateHttpClient("apiservice"); + Client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + return Task.CompletedTask; + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Infrastructure/AuthenticatedApiClient.cs b/examples/CloudShop/CloudShop.Tests/Infrastructure/AuthenticatedApiClient.cs new file mode 100644 index 0000000000..b1952ef0dd --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Infrastructure/AuthenticatedApiClient.cs @@ -0,0 +1,65 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace CloudShop.Tests.Infrastructure; + +/// +/// Base class for authenticated API clients. +/// Nested dependency: injects DistributedAppFixture to create HTTP clients and authenticate. +/// Subclasses define the specific user credentials. +/// +public abstract class AuthenticatedApiClient : IAsyncInitializer +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required DistributedAppFixture App { get; init; } + + public HttpClient Client { get; private set; } = null!; + public string Email => UserEmail; + public string Role => UserRole; + + protected abstract string UserEmail { get; } + protected abstract string UserPassword { get; } + protected abstract string UserRole { get; } + + public async Task InitializeAsync() + { + Client = App.CreateHttpClient("apiservice"); + Client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + + // Authenticate and set bearer token + var response = await Client.PostAsJsonAsync("/api/auth/login", + new LoginRequest(UserEmail, UserPassword)); + + response.EnsureSuccessStatusCode(); + + var token = await response.Content.ReadFromJsonAsync(); + Client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token!.AccessToken); + } +} + +/// +/// Admin API client with full permissions. +/// Shared per test session - all admin tests reuse the same authenticated client. +/// +public class AdminApiClient : AuthenticatedApiClient +{ + protected override string UserEmail => "admin@cloudshop.test"; + protected override string UserPassword => "Admin123!"; + protected override string UserRole => "admin"; +} + +/// +/// Customer API client with limited permissions. +/// Shared per test session - all customer tests reuse the same authenticated client. +/// +public class CustomerApiClient : AuthenticatedApiClient +{ + protected override string UserEmail => "customer@cloudshop.test"; + protected override string UserPassword => "Customer123!"; + protected override string UserRole => "customer"; +} diff --git a/examples/CloudShop/CloudShop.Tests/Infrastructure/DatabaseFixture.cs b/examples/CloudShop/CloudShop.Tests/Infrastructure/DatabaseFixture.cs new file mode 100644 index 0000000000..0f52302681 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Infrastructure/DatabaseFixture.cs @@ -0,0 +1,58 @@ +using Npgsql; +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace CloudShop.Tests.Infrastructure; + +/// +/// Provides direct database access for test verification. +/// Nested dependency: injects DistributedAppFixture to get the connection string. +/// +public class DatabaseFixture : IAsyncInitializer, IAsyncDisposable +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required DistributedAppFixture App { get; init; } + + private NpgsqlDataSource? _dataSource; + + public NpgsqlDataSource DataSource => _dataSource + ?? throw new InvalidOperationException("Database not initialized"); + + public async Task InitializeAsync() + { + var connectionString = await App.GetConnectionStringAsync("postgresdb"); + var builder = new NpgsqlConnectionStringBuilder(connectionString) + { + MaxPoolSize = 5 // Limit test connections to avoid exhausting PostgreSQL's max_connections + }; + _dataSource = NpgsqlDataSource.Create(builder.ConnectionString); + + // Verify connectivity + await using var connection = await _dataSource.OpenConnectionAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT 1"; + await cmd.ExecuteScalarAsync(); + } + + public async Task QuerySingleAsync(string sql, params (string name, object value)[] parameters) + { + await using var connection = await DataSource.OpenConnectionAsync(); + await using var cmd = connection.CreateCommand(); + cmd.CommandText = sql; + foreach (var (name, value) in parameters) + { + var param = cmd.CreateParameter(); + param.ParameterName = name; + param.Value = value; + cmd.Parameters.Add(param); + } + var result = await cmd.ExecuteScalarAsync(); + return result is DBNull or null ? default : (T)result; + } + + public async ValueTask DisposeAsync() + { + if (_dataSource is not null) + await _dataSource.DisposeAsync(); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs b/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs new file mode 100644 index 0000000000..0a44944632 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Infrastructure/DistributedAppFixture.cs @@ -0,0 +1,56 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Testing; +using TUnit.Core.Interfaces; + +namespace CloudShop.Tests.Infrastructure; + +/// +/// Root fixture that starts the entire Aspire distributed application. +/// Shared across all tests in the session - the app is started once and reused. +/// +public class DistributedAppFixture : IAsyncInitializer, IAsyncDisposable +{ + private DistributedApplication? _app; + + public DistributedApplication App => _app ?? throw new InvalidOperationException("App not initialized"); + + public async Task InitializeAsync() + { + // Allow HTTP transport so DCP doesn't require trusted dev certificates. + // This is necessary in CI/test environments where certificates may not be trusted. + Environment.SetEnvironmentVariable("ASPIRE_ALLOW_UNSECURED_TRANSPORT", "true"); + + var builder = await DistributedApplicationTestingBuilder + .CreateAsync(); + + _app = await builder.BuildAsync(); + + await _app.StartAsync(); + + // The AppHost defines WaitFor dependencies: + // apiservice waits for postgres, redis, rabbitmq + // worker waits for postgres, rabbitmq, apiservice + // So waiting for the leaf services ensures all infrastructure is ready too. + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + + await _app.ResourceNotifications.WaitForResourceAsync("apiservice", KnownResourceStates.Running, cts.Token); + await _app.ResourceNotifications.WaitForResourceAsync("worker", KnownResourceStates.Running, cts.Token); + } + + public HttpClient CreateHttpClient(string resourceName) + => App.CreateHttpClient(resourceName); + + public async Task GetConnectionStringAsync(string resourceName) + => await App.GetConnectionStringAsync(resourceName) + ?? throw new InvalidOperationException($"No connection string for '{resourceName}'"); + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Infrastructure/RabbitMqFixture.cs b/examples/CloudShop/CloudShop.Tests/Infrastructure/RabbitMqFixture.cs new file mode 100644 index 0000000000..f7023aaba7 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Infrastructure/RabbitMqFixture.cs @@ -0,0 +1,86 @@ +using System.Text; +using System.Text.Json; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace CloudShop.Tests.Infrastructure; + +/// +/// Provides direct RabbitMQ access for messaging verification tests. +/// Nested dependency: injects DistributedAppFixture to get the connection string. +/// +public class RabbitMqFixture : IAsyncInitializer, IAsyncDisposable +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required DistributedAppFixture App { get; init; } + + private IConnection? _connection; + + public IConnection Connection => _connection + ?? throw new InvalidOperationException("RabbitMQ not initialized"); + + public async Task InitializeAsync() + { + var connectionString = await App.GetConnectionStringAsync("rabbitmq"); + var factory = new ConnectionFactory { Uri = new Uri(connectionString) }; + _connection = await factory.CreateConnectionAsync(); + } + + /// + /// Subscribe to a RabbitMQ exchange and collect messages of type T. + /// Returns a MessageCollector that can be awaited for messages. + /// + public async Task> SubscribeAsync(string exchange, string routingKeyPattern) + { + var channel = await Connection.CreateChannelAsync(); + await channel.ExchangeDeclareAsync(exchange, ExchangeType.Topic, durable: true); + var queue = await channel.QueueDeclareAsync(exclusive: true); + await channel.QueueBindAsync(queue.QueueName, exchange, routingKeyPattern); + + var collector = new MessageCollector(); + var consumer = new AsyncEventingBasicConsumer(channel); + consumer.ReceivedAsync += (_, ea) => + { + var json = Encoding.UTF8.GetString(ea.Body.ToArray()); + var message = JsonSerializer.Deserialize(json); + if (message is not null) + collector.Add(message); + return Task.CompletedTask; + }; + + await channel.BasicConsumeAsync(queue.QueueName, autoAck: true, consumer: consumer); + return collector; + } + + public async ValueTask DisposeAsync() + { + if (_connection is not null) + await _connection.DisposeAsync(); + } +} + +/// +/// Collects messages from a RabbitMQ subscription and allows waiting for them. +/// +public class MessageCollector +{ + private readonly List _messages = []; + private readonly SemaphoreSlim _semaphore = new(0); + + public IReadOnlyList Messages => _messages; + + public void Add(T message) + { + lock (_messages) _messages.Add(message); + _semaphore.Release(); + } + + public async Task WaitForFirstAsync(TimeSpan timeout) + { + if (!await _semaphore.WaitAsync(timeout)) + throw new TimeoutException($"No message received within {timeout}"); + lock (_messages) return _messages[^1]; + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Infrastructure/RedisFixture.cs b/examples/CloudShop/CloudShop.Tests/Infrastructure/RedisFixture.cs new file mode 100644 index 0000000000..7a8a4d49d3 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Infrastructure/RedisFixture.cs @@ -0,0 +1,30 @@ +using StackExchange.Redis; +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace CloudShop.Tests.Infrastructure; + +/// +/// Provides direct Redis access for cache verification tests. +/// Nested dependency: injects DistributedAppFixture to get the connection string. +/// +public class RedisFixture : IAsyncInitializer, IAsyncDisposable +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required DistributedAppFixture App { get; init; } + + public IConnectionMultiplexer Connection { get; private set; } = null!; + public IDatabase Database => Connection.GetDatabase(); + + public async Task InitializeAsync() + { + var connectionString = await App.GetConnectionStringAsync("redis"); + Connection = await ConnectionMultiplexer.ConnectAsync(connectionString); + } + + public async ValueTask DisposeAsync() + { + if (Connection is not null) + await Connection.DisposeAsync(); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Infrastructure/SeededDatabaseFixture.cs b/examples/CloudShop/CloudShop.Tests/Infrastructure/SeededDatabaseFixture.cs new file mode 100644 index 0000000000..fb3bfc52e7 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Infrastructure/SeededDatabaseFixture.cs @@ -0,0 +1,42 @@ +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace CloudShop.Tests.Infrastructure; + +/// +/// Seeds additional test data for specific test classes. +/// Shared per class - each test class gets its own seeded data. +/// Nested dependency: injects AdminApiClient to create data via the API. +/// +public class SeededDatabaseFixture : IAsyncInitializer +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AdminApiClient Admin { get; init; } + + public List SeededProducts { get; } = []; + + public async Task InitializeAsync() + { + // Seed extra test products via the API + var testProducts = new[] + { + new CreateProductRequest("Test Widget Alpha", "electronics", 19.99m, "Alpha test product", 50), + new CreateProductRequest("Test Widget Beta", "electronics", 29.99m, "Beta test product", 75), + new CreateProductRequest("Test Book Gamma", "books", 12.99m, "Gamma test book", 100), + new CreateProductRequest("Test Shirt Delta", "clothing", 34.99m, "Delta test shirt", 200), + }; + + foreach (var product in testProducts) + { + var response = await Admin.Client.PostAsJsonAsync("/api/products", product); + if (response.IsSuccessStatusCode) + { + var created = await response.Content.ReadFromJsonAsync(); + if (created is not null) + SeededProducts.Add(created); + } + } + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Infrastructure/TestHelpers.cs b/examples/CloudShop/CloudShop.Tests/Infrastructure/TestHelpers.cs new file mode 100644 index 0000000000..a9be3843c4 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Infrastructure/TestHelpers.cs @@ -0,0 +1,34 @@ +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; + +namespace CloudShop.Tests.Infrastructure; + +/// +/// Helpers for creating isolated test resources. +/// Each test creates its own products/data so tests never share mutable state. +/// This enables full parallelism with zero flakiness. +/// +public static class TestHelpers +{ + /// + /// Creates a unique product via the admin API, isolated to the calling test. + /// Uses high stock quantity so concurrent orders never exhaust it. + /// + public static async Task CreateTestProductAsync( + this HttpClient adminClient, + decimal price = 99.99m, + int stockQuantity = 10000) + { + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + var request = new CreateProductRequest( + $"Test Product {uniqueId}", + "electronics", + price, + "Isolated test product", + stockQuantity); + + var response = await adminClient.PostAsJsonAsync("/api/products", request); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Auth/AuthenticationTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Auth/AuthenticationTests.cs new file mode 100644 index 0000000000..8361b93895 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Auth/AuthenticationTests.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Tests.Assertions; +using CloudShop.Tests.DataSources; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Auth; + +/// +/// Tests authentication endpoints (login, register). +/// +/// Showcases: +/// - [ClassDataSource] for unauthenticated API client (no auth needed for login/register) +/// - [MethodDataSource] for various credential scenarios +/// - [Category] for test filtering +/// +[Category("Integration"), Category("Auth")] +public class AuthenticationTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required ApiClientFixture Api { get; init; } + + [Test] + public async Task Valid_Login_Returns_Token() + { + var response = await Api.Client.PostAsJsonAsync("/api/auth/login", + new LoginRequest("admin@cloudshop.test", "Admin123!")); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + + var token = await response.Content.ReadFromJsonAsync(); + await Assert.That(token).IsNotNull(); + await Assert.That(token!.AccessToken).IsNotNull(); + await Assert.That(token.Email).IsEqualTo("admin@cloudshop.test"); + await Assert.That(token.Role).IsEqualTo("admin"); + await Assert.That(token.ExpiresAt).IsGreaterThan(DateTime.UtcNow); + } + + [Test] + [MethodDataSource(typeof(UserDataSources), nameof(UserDataSources.InvalidCredentials))] + public async Task Invalid_Login_Returns_Unauthorized(LoginRequest credentials) + { + var response = await Api.Client.PostAsJsonAsync("/api/auth/login", credentials); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + [MethodDataSource(typeof(UserDataSources), nameof(UserDataSources.NewUsers))] + public async Task Can_Register_New_User(RegisterRequest registration) + { + var response = await Api.Client.PostAsJsonAsync("/api/auth/register", registration); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + + var token = await response.Content.ReadFromJsonAsync(); + await Assert.That(token).IsNotNull(); + await Assert.That(token!.Email).IsEqualTo(registration.Email); + await Assert.That(token.Role).IsEqualTo("customer"); // New users are always customers + } + + [Test] + public async Task Duplicate_Registration_Returns_Conflict() + { + // Try to register with an existing email + var response = await Api.Client.PostAsJsonAsync("/api/auth/register", + new RegisterRequest("admin@cloudshop.test", "NewPassword123!", "Duplicate User")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Auth/AuthorizationTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Auth/AuthorizationTests.cs new file mode 100644 index 0000000000..ee4a918828 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Auth/AuthorizationTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using CloudShop.Tests.DataSources; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Auth; + +/// +/// Tests role-based access control across API endpoints. +/// +/// Showcases: +/// - [CombinedDataSources] creating Cartesian product of different data source types +/// - Mixing [ClassDataSource] (authenticated clients) with [MethodDataSource] (endpoint scenarios) +/// - Tests admin vs customer access for 5 endpoints × 2 roles = 10 test cases +/// +[Category("Integration"), Category("Authorization")] +public class AuthorizationTests +{ + [Test] + [CombinedDataSources] + public async Task Admin_Has_Full_Access( + [ClassDataSource(Shared = SharedType.PerTestSession)] + AdminApiClient admin, + [MethodDataSource(typeof(OrderDataSources), nameof(OrderDataSources.ProtectedEndpoints))] + EndpointScenario endpoint) + { + var request = new HttpRequestMessage(endpoint.Method, endpoint.Path); + // Add a body for POST/PUT requests to avoid 400 errors + if (endpoint.Method == HttpMethod.Post || endpoint.Method == HttpMethod.Put) + { + request.Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json"); + } + + var response = await admin.Client.SendAsync(request); + + // Admin should never get Forbidden + await Assert.That(response.StatusCode).IsNotEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + [CombinedDataSources] + public async Task Customer_Has_Limited_Access( + [ClassDataSource(Shared = SharedType.PerTestSession)] + CustomerApiClient customer, + [MethodDataSource(typeof(OrderDataSources), nameof(OrderDataSources.ProtectedEndpoints))] + EndpointScenario endpoint) + { + var request = new HttpRequestMessage(endpoint.Method, endpoint.Path); + if (endpoint.Method == HttpMethod.Post || endpoint.Method == HttpMethod.Put) + { + request.Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json"); + } + + var response = await customer.Client.SendAsync(request); + + await Assert.That(response.StatusCode).IsEqualTo(endpoint.ExpectedForCustomer); + } + + [Test] + [CombinedDataSources] + public async Task Unauthenticated_Requests_Are_Rejected( + [ClassDataSource(Shared = SharedType.PerTestSession)] + ApiClientFixture unauthenticated) + { + var response = await unauthenticated.Client.GetAsync("/api/orders/mine"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Messaging/OrderEventTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Messaging/OrderEventTests.cs new file mode 100644 index 0000000000..c2af45c0ef --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Messaging/OrderEventTests.cs @@ -0,0 +1,84 @@ +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Shared.Events; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Messaging; + +/// +/// Tests RabbitMQ event publishing and consumption. +/// +/// Showcases: +/// - [ClassDataSource] for RabbitMQ fixture (nested: App → RabbitMq) +/// - [Retry] for eventually-consistent scenarios +/// - MessageCollector pattern for awaiting async messages +/// - Direct infrastructure testing (verifying messages on the bus) +/// - Test isolation: each test creates its own products and uses exclusive queues +/// +[Category("Integration"), Category("Messaging")] +[NotInParallel("MessagingTests")] +public class OrderEventTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CustomerApiClient Customer { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AdminApiClient Admin { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required RabbitMqFixture RabbitMq { get; init; } + + [Test, Retry(2)] + public async Task Order_Creation_Publishes_Event() + { + // Create an isolated product for this test + var product = await Admin.Client.CreateTestProductAsync(); + + // Subscribe to order events before creating the order (exclusive queue = isolated) + var collector = await RabbitMq.SubscribeAsync( + "order-events", "order.created"); + + // Create an order using our isolated product + var response = await Customer.Client.PostAsJsonAsync("/api/orders", + new CreateOrderRequest([new OrderItemRequest(product.Id, 1)])); + var order = await response.Content.ReadFromJsonAsync(); + + // Wait for the event + var orderEvent = await collector.WaitForFirstAsync(TimeSpan.FromSeconds(10)); + + await Assert.That(orderEvent).IsNotNull(); + await Assert.That(orderEvent.OrderId).IsEqualTo(order!.Id); + await Assert.That(orderEvent.CustomerEmail).IsEqualTo(Customer.Email); + await Assert.That(orderEvent.TotalAmount).IsGreaterThan(0); + } + + [Test, Retry(2)] + public async Task Payment_Processing_Publishes_Event() + { + // Create an isolated product for this test + var product = await Admin.Client.CreateTestProductAsync(); + + // Subscribe to payment events (exclusive queue = isolated) + var collector = await RabbitMq.SubscribeAsync( + "order-events", "order.payment-processed"); + + // Create and pay an order using our isolated product + var createResponse = await Customer.Client.PostAsJsonAsync("/api/orders", + new CreateOrderRequest([new OrderItemRequest(product.Id, 1)])); + var order = await createResponse.Content.ReadFromJsonAsync(); + + await Customer.Client.PostAsJsonAsync( + $"/api/orders/{order!.Id}/pay", + new ProcessPaymentRequest("credit_card", "tok_test")); + + // Wait for the payment event + var paymentEvent = await collector.WaitForFirstAsync(TimeSpan.FromSeconds(10)); + + await Assert.That(paymentEvent).IsNotNull(); + await Assert.That(paymentEvent.OrderId).IsEqualTo(order.Id); + await Assert.That(paymentEvent.PaymentMethod).IsEqualTo("credit_card"); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Orders/OrderPlacementTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Orders/OrderPlacementTests.cs new file mode 100644 index 0000000000..c269c1c29c --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Orders/OrderPlacementTests.cs @@ -0,0 +1,97 @@ +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Shared.Models; +using CloudShop.Tests.Assertions; +using CloudShop.Tests.DataSources; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Orders; + +/// +/// Tests order placement with various payment and shipping combinations. +/// +/// Showcases: +/// - [MatrixDataSource] with [Matrix] for combinatorial testing +/// - 9 test cases from 3 payment methods × 3 shipping options +/// - [MethodDataSource] for valid and invalid order scenarios +/// - Test isolation: each test creates its own product so parallel tests never conflict +/// +[Category("Integration"), Category("Orders")] +public class OrderPlacementTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CustomerApiClient Customer { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AdminApiClient Admin { get; init; } + + [Test] + [MatrixDataSource] + public async Task Can_Place_Order_With_Various_Options( + [Matrix("credit_card", "paypal", "bank_transfer")] string paymentMethod, + [Matrix("standard", "express", "overnight")] string shippingOption) + { + // Each test creates its own product - no shared mutable state + var product = await Admin.Client.CreateTestProductAsync(); + + var request = new CreateOrderRequest( + [new OrderItemRequest(product.Id, 1)], + paymentMethod, + shippingOption); + + var response = await Customer.Client.PostAsJsonAsync("/api/orders", request); + + await Assert.That(response).IsCreated(); + + var order = await response.Content.ReadFromJsonAsync(); + await Assert.That(order).IsNotNull(); + await Assert.That(order!.Status).IsEqualTo(OrderStatus.Pending); + await Assert.That(order.PaymentMethod).IsEqualTo(paymentMethod); + await Assert.That(order.ShippingOption).IsEqualTo(shippingOption); + await Assert.That(order.Items).IsNotNull(); + await Assert.That(order.Items.Count).IsGreaterThanOrEqualTo(1); + } + + [Test] + [MethodDataSource(typeof(OrderDataSources), nameof(OrderDataSources.ValidOrders))] + public async Task Valid_Orders_Are_Accepted(CreateOrderRequest template) + { + // Create isolated products - one per unique product ID in the template + var productMap = new Dictionary(); + foreach (var item in template.Items.Where(i => !productMap.ContainsKey(i.ProductId))) + { + var product = await Admin.Client.CreateTestProductAsync(); + productMap[item.ProductId] = product.Id; + } + + // Substitute template product IDs with our isolated products + var request = template with + { + Items = template.Items.Select(item => + new OrderItemRequest(productMap[item.ProductId], item.Quantity)).ToList() + }; + + var response = await Customer.Client.PostAsJsonAsync("/api/orders", request); + + await Assert.That(response).IsCreated(); + + var order = await response.Content.ReadFromJsonAsync(); + await Assert.That(order).IsNotNull(); + await Assert.That(order!.TotalAmount).IsGreaterThan(0); + } + + [Test] + [MethodDataSource(typeof(OrderDataSources), nameof(OrderDataSources.InvalidOrders))] + public async Task Invalid_Orders_Are_Rejected(InvalidOrderScenario scenario) + { + var response = await Customer.Client.PostAsJsonAsync("/api/orders", scenario.Request); + + await Assert.That(response).IsBadRequest(); + + var error = await response.Content.ReadAsStringAsync(); + await Assert.That(error.ToLowerInvariant()).Contains(scenario.ExpectedError.ToLowerInvariant()); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Orders/OrderValidationTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Orders/OrderValidationTests.cs new file mode 100644 index 0000000000..b22d13a222 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Orders/OrderValidationTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Orders; + +/// +/// Tests order validation and business rules. +/// +/// Showcases: +/// - Multiple targeted assertions on responses +/// - Testing error responses and status codes +/// - Negative/edge case testing +/// - Test isolation: each test creates its own products and orders +/// +[Category("Integration"), Category("Orders")] +public class OrderValidationTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CustomerApiClient Customer { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AdminApiClient Admin { get; init; } + + [Test] + public async Task Cannot_Pay_For_Already_Paid_Order() + { + // Create an isolated product and order + var product = await Admin.Client.CreateTestProductAsync(); + + var createResponse = await Customer.Client.PostAsJsonAsync("/api/orders", + new CreateOrderRequest([new OrderItemRequest(product.Id, 1)])); + var order = await createResponse.Content.ReadFromJsonAsync(); + + await Customer.Client.PostAsJsonAsync( + $"/api/orders/{order!.Id}/pay", + new ProcessPaymentRequest("credit_card", "tok_test")); + + // Try to pay again + var secondPayment = await Customer.Client.PostAsJsonAsync( + $"/api/orders/{order.Id}/pay", + new ProcessPaymentRequest("credit_card", "tok_test_2")); + + await Assert.That(secondPayment.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Cannot_Pay_For_Nonexistent_Order() + { + var response = await Customer.Client.PostAsJsonAsync( + "/api/orders/999999/pay", + new ProcessPaymentRequest("credit_card", "tok_test")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Customer_Can_Only_See_Own_Orders() + { + // Create an isolated product and place an order + var product = await Admin.Client.CreateTestProductAsync(); + + await Customer.Client.PostAsJsonAsync("/api/orders", + new CreateOrderRequest([new OrderItemRequest(product.Id, 1)])); + + // Verify it appears in their order list + var listResponse = await Customer.Client.GetFromJsonAsync>( + "/api/orders/mine"); + + await Assert.That(listResponse).IsNotNull(); + await Assert.That(listResponse!.TotalCount).IsGreaterThanOrEqualTo(1); + } + + [Test] + public async Task Order_Total_Is_Calculated_Correctly() + { + // Create an isolated product with a known price + var product = await Admin.Client.CreateTestProductAsync(price: 79.99m); + + var response = await Customer.Client.PostAsJsonAsync("/api/orders", + new CreateOrderRequest( + [new OrderItemRequest(product.Id, 3)], // 3x our product + "credit_card", "standard")); + + var order = await response.Content.ReadFromJsonAsync(); + await Assert.That(order).IsNotNull(); + + // Total should be quantity * unit price + var expectedTotal = order!.Items.Sum(i => i.UnitPrice * i.Quantity); + await Assert.That(order.TotalAmount).IsEqualTo(expectedTotal); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Orders/OrderWorkflowTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Orders/OrderWorkflowTests.cs new file mode 100644 index 0000000000..b1c6b53c4c --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Orders/OrderWorkflowTests.cs @@ -0,0 +1,119 @@ +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Shared.Models; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Orders; + +/// +/// Tests the complete order lifecycle using DependsOn for sequential execution. +/// +/// Showcases: +/// - [DependsOn] for test dependency chains: Create → Pay → Verify Fulfillment +/// - TestContext.StateBag for passing data between dependent tests (no static fields) +/// - [NotInParallel] to prevent interference with other order tests +/// - WaitsFor() polling assertion for eventually-consistent state +/// - Multiple [ClassDataSource] properties accessing different fixtures +/// - Database fixture for direct DB verification alongside API checks +/// - Test isolation: creates its own products so it never conflicts with other tests +/// +[Category("E2E"), Category("Orders")] +[NotInParallel("OrderWorkflow")] +public class OrderWorkflowTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CustomerApiClient Customer { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AdminApiClient Admin { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required DatabaseFixture Database { get; init; } + + [Test] + public async Task Step1_Place_Order() + { + // Create isolated products for this workflow + var product1 = await Admin.Client.CreateTestProductAsync(price: 79.99m); + var product2 = await Admin.Client.CreateTestProductAsync(price: 49.99m); + + var response = await Customer.Client.PostAsJsonAsync("/api/orders", + new CreateOrderRequest( + [new OrderItemRequest(product1.Id, 2), new OrderItemRequest(product2.Id, 1)], + "credit_card", + "express")); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + + var order = await response.Content.ReadFromJsonAsync(); + await Assert.That(order).IsNotNull(); + await Assert.That(order!.Status).IsEqualTo(OrderStatus.Pending); + await Assert.That(order.Items.Count).IsEqualTo(2); + + // Store the order ID in the StateBag so dependent tests can access it + TestContext.Current!.StateBag.Items["OrderId"] = order.Id; + } + + [Test, DependsOn(nameof(Step1_Place_Order))] + public async Task Step2_Process_Payment() + { + // Retrieve the order ID from the dependency's StateBag + var orderId = (int)TestContext.Current!.Dependencies + .GetTests(nameof(Step1_Place_Order))[0].StateBag.Items["OrderId"]!; + + var response = await Customer.Client.PostAsJsonAsync( + $"/api/orders/{orderId}/pay", + new ProcessPaymentRequest("credit_card", "tok_test_123")); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + + // Verify via API + var orderResponse = await Customer.Client.GetFromJsonAsync( + $"/api/orders/{orderId}"); + await Assert.That(orderResponse!.Status).IsEqualTo(OrderStatus.PaymentProcessed); + + // Pass the order ID forward for downstream tests + TestContext.Current.StateBag.Items["OrderId"] = orderId; + } + + [Test, DependsOn(nameof(Step2_Process_Payment))] + public async Task Step3_Worker_Fulfills_Order() + { + var orderId = (int)TestContext.Current!.Dependencies + .GetTests(nameof(Step2_Process_Payment))[0].StateBag.Items["OrderId"]!; + + // The Worker service listens for PaymentProcessed events on RabbitMQ + // and updates the order status to Fulfilled. + // WaitsFor polls repeatedly until the assertion passes or the timeout expires. + var order = await Assert.That(async () => + await Customer.Client.GetFromJsonAsync($"/api/orders/{orderId}")) + .WaitsFor( + assert => assert.Satisfies(o => o?.Status == OrderStatus.Fulfilled), + timeout: TimeSpan.FromSeconds(25), + pollingInterval: TimeSpan.FromMilliseconds(500)); + + await Assert.That(order).IsNotNull(); + await Assert.That(order!.FulfilledAt).IsNotNull(); + + // Pass the order ID forward for the final verification step + TestContext.Current.StateBag.Items["OrderId"] = orderId; + } + + [Test, DependsOn(nameof(Step3_Worker_Fulfills_Order))] + public async Task Step4_Verify_In_Database() + { + var orderId = (int)TestContext.Current!.Dependencies + .GetTests(nameof(Step3_Worker_Fulfills_Order))[0].StateBag.Items["OrderId"]!; + + // Direct database verification using the DatabaseFixture + var status = await Database.QuerySingleAsync( + "SELECT \"Status\" FROM \"Orders\" WHERE \"Id\" = @id", + ("id", orderId)); + + // OrderStatus.Fulfilled = 2 + await Assert.That(status).IsEqualTo((int)OrderStatus.Fulfilled); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Performance/LoadTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Performance/LoadTests.cs new file mode 100644 index 0000000000..182f43ab39 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Performance/LoadTests.cs @@ -0,0 +1,73 @@ +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Tests.Assertions; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Core.Interfaces; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Performance; + +/// +/// Load and concurrency tests using Repeat and ParallelLimiter. +/// +/// Showcases: +/// - [Repeat(N)] to run the same test N times +/// - [ParallelLimiter<T>] to control max concurrent test execution +/// - Custom IParallelLimit implementation +/// - [Category("Performance")] for selective test runs +/// +[Category("Performance")] +public class LoadTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CustomerApiClient Customer { get; init; } + + [Test, Repeat(50), ParallelLimiter] + public async Task Product_Listing_Handles_Concurrent_Load() + { + var response = await Customer.Client.GetAsync("/api/products?pageSize=10"); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + + var result = await response.Content.ReadFromJsonAsync>(); + await Assert.That(result).IsNotNull(); + } + + [Test, Repeat(20), ParallelLimiter] + public async Task Product_Search_Handles_Concurrent_Load() + { + var categories = new[] { "electronics", "clothing", "books" }; + var category = categories[Random.Shared.Next(categories.Length)]; + + var response = await Customer.Client.GetAsync( + $"/api/products?category={category}&pageSize=5"); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + } + + [Test, Repeat(30), ParallelLimiter] + public async Task Order_History_Handles_Concurrent_Load() + { + var response = await Customer.Client.GetAsync("/api/orders/mine?pageSize=5"); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + } +} + +/// +/// Custom parallel limit: max 20 concurrent tests. +/// +public class TwentyConcurrentLimit : IParallelLimit +{ + public int Limit => 20; +} + +/// +/// Custom parallel limit: max 10 concurrent tests. +/// +public class TenConcurrentLimit : IParallelLimit +{ + public int Limit => 10; +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Performance/ResilienceTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Performance/ResilienceTests.cs new file mode 100644 index 0000000000..111e0c0484 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Performance/ResilienceTests.cs @@ -0,0 +1,63 @@ +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Performance; + +/// +/// Tests for resilience and eventual consistency scenarios. +/// +/// Showcases: +/// - [Retry(N)] for tests that may fail due to timing +/// - Testing eventually-consistent behavior (worker processing) +/// - Test isolation: each retry creates a fresh product and order +/// +[Category("Resilience")] +public class ResilienceTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CustomerApiClient Customer { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AdminApiClient Admin { get; init; } + + [Test, Retry(3)] + public async Task Order_Is_Eventually_Fulfilled_After_Payment() + { + // Create an isolated product for this test + var product = await Admin.Client.CreateTestProductAsync(); + + // Create and immediately pay + var createResponse = await Customer.Client.PostAsJsonAsync("/api/orders", + new CreateOrderRequest([new OrderItemRequest(product.Id, 1)])); + var order = await createResponse.Content.ReadFromJsonAsync(); + + await Customer.Client.PostAsJsonAsync( + $"/api/orders/{order!.Id}/pay", + new ProcessPaymentRequest("credit_card", "tok_test")); + + // WaitsFor polls for fulfillment (worker processes asynchronously) + var latestOrder = await Assert.That(async () => + await Customer.Client.GetFromJsonAsync($"/api/orders/{order.Id}")) + .WaitsFor( + assert => assert.Satisfies(o => o?.Status == CloudShop.Shared.Models.OrderStatus.Fulfilled), + timeout: TimeSpan.FromSeconds(15), + pollingInterval: TimeSpan.FromMilliseconds(500)); + + await Assert.That(latestOrder).IsNotNull(); + } + + [Test] + public async Task Health_Endpoint_Responds_Quickly() + { + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var response = await Customer.Client.GetAsync("/health"); + stopwatch.Stop(); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + await Assert.That(stopwatch.ElapsedMilliseconds).IsLessThan(2000); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Products/ProductCacheTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Products/ProductCacheTests.cs new file mode 100644 index 0000000000..e972dd2488 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Products/ProductCacheTests.cs @@ -0,0 +1,86 @@ +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Products; + +/// +/// Tests Redis caching behavior for products. +/// +/// Showcases: +/// - Multiple [ClassDataSource] properties on one class (Admin, Customer, Redis) +/// - Three levels of nested fixtures (App → Admin/Customer/Redis) +/// - Direct infrastructure verification (checking Redis directly) +/// - [NotInParallel] to avoid cache interference between tests +/// +[Category("Integration"), Category("Cache")] +[NotInParallel("CacheTests")] +public class ProductCacheTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AdminApiClient Admin { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CustomerApiClient Customer { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required RedisFixture Redis { get; init; } + + [Test] + public async Task Product_Is_Cached_After_First_Fetch() + { + // Create a product via admin + var createResponse = await Admin.Client.PostAsJsonAsync("/api/products", + new CreateProductRequest("Cache Test Product", "electronics", 99.99m)); + var product = await createResponse.Content.ReadFromJsonAsync(); + + // Fetch via customer (triggers caching) + await Customer.Client.GetAsync($"/api/products/{product!.Id}"); + + // Verify Redis has the cached entry + var cached = await Redis.Database.StringGetAsync($"product:{product.Id}"); + await Assert.That(cached.HasValue).IsTrue(); + } + + [Test] + public async Task Cache_Is_Invalidated_On_Update() + { + // Create and cache a product + var createResponse = await Admin.Client.PostAsJsonAsync("/api/products", + new CreateProductRequest("Cache Invalidation Test", "books", 25.00m)); + var product = await createResponse.Content.ReadFromJsonAsync(); + await Customer.Client.GetAsync($"/api/products/{product!.Id}"); // Cache it + + // Verify it's cached + var cachedBefore = await Redis.Database.StringGetAsync($"product:{product.Id}"); + await Assert.That(cachedBefore.HasValue).IsTrue(); + + // Update the product (should invalidate cache) + await Admin.Client.PutAsJsonAsync($"/api/products/{product.Id}", + new UpdateProductRequest(Price: 30.00m)); + + // Verify cache is cleared + var cachedAfter = await Redis.Database.StringGetAsync($"product:{product.Id}"); + await Assert.That(cachedAfter.HasValue).IsFalse(); + } + + [Test] + public async Task Cache_Is_Invalidated_On_Delete() + { + // Create and cache a product + var createResponse = await Admin.Client.PostAsJsonAsync("/api/products", + new CreateProductRequest("Cache Delete Test", "clothing", 45.00m)); + var product = await createResponse.Content.ReadFromJsonAsync(); + await Customer.Client.GetAsync($"/api/products/{product!.Id}"); // Cache it + + // Delete the product + await Admin.Client.DeleteAsync($"/api/products/{product.Id}"); + + // Verify cache is cleared + var cachedAfter = await Redis.Database.StringGetAsync($"product:{product.Id}"); + await Assert.That(cachedAfter.HasValue).IsFalse(); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Products/ProductCrudTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Products/ProductCrudTests.cs new file mode 100644 index 0000000000..fcc0cc34a7 --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Products/ProductCrudTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Tests.Assertions; +using CloudShop.Tests.DataSources; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Products; + +/// +/// Tests product CRUD operations using ClassDataSource for API clients +/// and MethodDataSource for test data. +/// +/// Showcases: +/// - [ClassDataSource] with SharedType.PerTestSession for fixture sharing +/// - [MethodDataSource] for parameterized test data +/// - [Category] for test filtering +/// - Custom [GenerateAssertion] assertions +/// +[Category("Integration"), Category("Products")] +public class ProductCrudTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required AdminApiClient Admin { get; init; } + + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CustomerApiClient Customer { get; init; } + + [Test] + [MethodDataSource(typeof(ProductDataSources), nameof(ProductDataSources.ValidProducts))] + public async Task Admin_Can_Create_Product(CreateProductRequest product) + { + var response = await Admin.Client.PostAsJsonAsync("/api/products", product); + + await Assert.That(response).IsCreated(); + + var created = await response.Content.ReadFromJsonAsync(); + await Assert.That(created).IsNotNull(); + await Assert.That(created!.Name).IsEqualTo(product.Name); + await Assert.That(created.Category).IsEqualTo(product.Category); + await Assert.That(created.Price).IsEqualTo(product.Price); + } + + [Test] + [MethodDataSource(typeof(ProductDataSources), nameof(ProductDataSources.ValidProducts))] + public async Task Customer_Cannot_Create_Product(CreateProductRequest product) + { + var response = await Customer.Client.PostAsJsonAsync("/api/products", product); + + await Assert.That(response).IsForbidden(); + } + + [Test] + public async Task Can_Get_Product_By_Id() + { + // Create a product first + var createResponse = await Admin.Client.PostAsJsonAsync("/api/products", + new CreateProductRequest("GetById Test Product", "electronics", 15.99m)); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Fetch it + var response = await Customer.Client.GetAsync($"/api/products/{created!.Id}"); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + var product = await response.Content.ReadFromJsonAsync(); + await Assert.That(product!.Name).IsEqualTo("GetById Test Product"); + } + + [Test] + [MethodDataSource(typeof(ProductDataSources), nameof(ProductDataSources.ValidUpdates))] + public async Task Admin_Can_Update_Product(UpdateProductRequest update) + { + // Create a product first + var createResponse = await Admin.Client.PostAsJsonAsync("/api/products", + new CreateProductRequest("Update Test", "books", 10.00m)); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Update it + var response = await Admin.Client.PutAsJsonAsync($"/api/products/{created!.Id}", update); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + var updated = await response.Content.ReadFromJsonAsync(); + await Assert.That(updated).IsNotNull(); + + // Verify only specified fields changed + if (update.Name is not null) + await Assert.That(updated!.Name).IsEqualTo(update.Name); + if (update.Price.HasValue) + await Assert.That(updated!.Price).IsEqualTo(update.Price.Value); + } + + [Test] + public async Task Admin_Can_Delete_Product() + { + // Create a product + var createResponse = await Admin.Client.PostAsJsonAsync("/api/products", + new CreateProductRequest("Delete Me", "books", 5.00m)); + var created = await createResponse.Content.ReadFromJsonAsync(); + + // Delete it + var deleteResponse = await Admin.Client.DeleteAsync($"/api/products/{created!.Id}"); + await Assert.That(deleteResponse.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + + // Verify it's gone + var getResponse = await Customer.Client.GetAsync($"/api/products/{created.Id}"); + await Assert.That(getResponse).IsNotFound(); + } + + [Test] + public async Task Customer_Cannot_Delete_Product() + { + var response = await Customer.Client.DeleteAsync("/api/products/1"); + await Assert.That(response).IsForbidden(); + } +} diff --git a/examples/CloudShop/CloudShop.Tests/Tests/Products/ProductSearchTests.cs b/examples/CloudShop/CloudShop.Tests/Tests/Products/ProductSearchTests.cs new file mode 100644 index 0000000000..28429648eb --- /dev/null +++ b/examples/CloudShop/CloudShop.Tests/Tests/Products/ProductSearchTests.cs @@ -0,0 +1,59 @@ +using System.Net.Http.Json; +using CloudShop.Shared.Contracts; +using CloudShop.Tests.Infrastructure; +using TUnit.Core; +using TUnit.Assertions; +using TUnit.Assertions.Extensions; + +namespace CloudShop.Tests.Tests.Products; + +/// +/// Tests product search with combinatorial parameters using MatrixDataSource. +/// +/// Showcases: +/// - [MatrixDataSource] generating all combinations automatically +/// - [Matrix] attribute on parameters for discrete values +/// - 27 test cases generated from just 3 parameters (3 × 3 × 3) +/// - [Category] for test organization +/// +[Category("Integration"), Category("Search")] +public class ProductSearchTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CustomerApiClient Client { get; init; } + + [Test] + [MatrixDataSource] + public async Task Search_Returns_Correct_Filtered_Results( + [Matrix("electronics", "clothing", "books")] string category, + [Matrix("price_asc", "price_desc", "name")] string sortBy, + [Matrix(10, 25, 50)] int pageSize) + { + var response = await Client.Client.GetFromJsonAsync>( + $"/api/products?category={category}&sort={sortBy}&pageSize={pageSize}"); + + await Assert.That(response).IsNotNull(); + await Assert.That(response!.Items.Count).IsLessThanOrEqualTo(pageSize); + await Assert.That(response.PageSize).IsEqualTo(pageSize); + + // All returned items should match the category filter + foreach (var item in response.Items) + { + await Assert.That(item.Category.ToLowerInvariant()).IsEqualTo(category); + } + } + + [Test] + [MatrixDataSource] + public async Task Pagination_Works_Correctly( + [Matrix(1, 2, 3)] int page, + [Matrix(2, 5)] int pageSize) + { + var response = await Client.Client.GetFromJsonAsync>( + $"/api/products?page={page}&pageSize={pageSize}"); + + await Assert.That(response).IsNotNull(); + await Assert.That(response!.Items.Count).IsLessThanOrEqualTo(pageSize); + await Assert.That(response.Page).IsEqualTo(page); + } +} diff --git a/examples/CloudShop/CloudShop.Web/CloudShop.Web.csproj b/examples/CloudShop/CloudShop.Web/CloudShop.Web.csproj new file mode 100644 index 0000000000..e3affa7c64 --- /dev/null +++ b/examples/CloudShop/CloudShop.Web/CloudShop.Web.csproj @@ -0,0 +1,12 @@ + + + + net10.0 + + + + + + + + diff --git a/examples/CloudShop/CloudShop.Web/Program.cs b/examples/CloudShop/CloudShop.Web/Program.cs new file mode 100644 index 0000000000..295d5ac0d2 --- /dev/null +++ b/examples/CloudShop/CloudShop.Web/Program.cs @@ -0,0 +1,47 @@ +using System.Net; +using CloudShop.Shared.Contracts; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.Services.AddHttpClient("api", client => client.BaseAddress = new Uri("http://apiservice")); + +var app = builder.Build(); + +app.UseExceptionHandler("/error"); + +app.MapGet("/", () => Results.Content(""" + + CloudShop + +

CloudShop

+

Welcome to CloudShop! This is a demo application for testing with TUnit + Aspire.

+ + + """, "text/html")); + +app.MapGet("/products", async (IHttpClientFactory httpClientFactory) => +{ + var client = httpClientFactory.CreateClient("api"); + var products = await client.GetFromJsonAsync>("/api/products"); + + var html = "Products - CloudShop"; + html += "

Products

    "; + if (products?.Items is not null) + { + foreach (var p in products.Items) + { + html += $"
  • {WebUtility.HtmlEncode(p.Name)} ({WebUtility.HtmlEncode(p.Category)}) - ${p.Price:F2}
  • "; + } + } + html += "
Home"; + + return Results.Content(html, "text/html"); +}); + +app.MapGet("/error", () => Results.Problem("An error occurred")); + +app.MapDefaultEndpoints(); +app.Run(); diff --git a/examples/CloudShop/CloudShop.Web/Properties/launchSettings.json b/examples/CloudShop/CloudShop.Web/Properties/launchSettings.json new file mode 100644 index 0000000000..d97d7bd254 --- /dev/null +++ b/examples/CloudShop/CloudShop.Web/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/CloudShop/CloudShop.Web/appsettings.Development.json b/examples/CloudShop/CloudShop.Web/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/examples/CloudShop/CloudShop.Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/examples/CloudShop/CloudShop.Web/appsettings.json b/examples/CloudShop/CloudShop.Web/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/examples/CloudShop/CloudShop.Web/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/CloudShop/CloudShop.Worker/CloudShop.Worker.csproj b/examples/CloudShop/CloudShop.Worker/CloudShop.Worker.csproj new file mode 100644 index 0000000000..305f53d9ea --- /dev/null +++ b/examples/CloudShop/CloudShop.Worker/CloudShop.Worker.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + + + + + + + + + + + + + diff --git a/examples/CloudShop/CloudShop.Worker/OrderProcessingWorker.cs b/examples/CloudShop/CloudShop.Worker/OrderProcessingWorker.cs new file mode 100644 index 0000000000..a98a695ee0 --- /dev/null +++ b/examples/CloudShop/CloudShop.Worker/OrderProcessingWorker.cs @@ -0,0 +1,72 @@ +using System.Text; +using System.Text.Json; +using CloudShop.Shared.Events; +using CloudShop.Shared.Models; +using Microsoft.EntityFrameworkCore; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace CloudShop.Worker; + +public class OrderProcessingWorker( + IServiceProvider serviceProvider, + IConnection rabbitConnection, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var channel = await rabbitConnection.CreateChannelAsync(cancellationToken: stoppingToken); + + await channel.ExchangeDeclareAsync("order-events", ExchangeType.Topic, durable: true, + cancellationToken: stoppingToken); + var queueDeclare = await channel.QueueDeclareAsync("order-processing", durable: true, exclusive: false, + autoDelete: false, cancellationToken: stoppingToken); + await channel.QueueBindAsync(queueDeclare.QueueName, "order-events", "order.payment-processed", + cancellationToken: stoppingToken); + + var consumer = new AsyncEventingBasicConsumer(channel); + consumer.ReceivedAsync += async (_, ea) => + { + try + { + var json = Encoding.UTF8.GetString(ea.Body.ToArray()); + var paymentEvent = JsonSerializer.Deserialize(json); + + if (paymentEvent is not null) + { + await FulfillOrderAsync(paymentEvent.OrderId); + logger.LogInformation("Fulfilled order {OrderId}", paymentEvent.OrderId); + } + + await channel.BasicAckAsync(ea.DeliveryTag, multiple: false); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing message"); + await channel.BasicNackAsync(ea.DeliveryTag, multiple: false, requeue: true); + } + }; + + await channel.BasicConsumeAsync(queueDeclare.QueueName, autoAck: false, consumer: consumer, + cancellationToken: stoppingToken); + + // Keep the worker alive + await Task.Delay(Timeout.Infinite, stoppingToken); + } + + private async Task FulfillOrderAsync(int orderId) + { + using var scope = serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId); + if (order is null || order.Status != OrderStatus.PaymentProcessed) return; + + // Simulate some processing time + await Task.Delay(500); + + order.Status = OrderStatus.Fulfilled; + order.FulfilledAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + } +} diff --git a/examples/CloudShop/CloudShop.Worker/Program.cs b/examples/CloudShop/CloudShop.Worker/Program.cs new file mode 100644 index 0000000000..b53fe6761e --- /dev/null +++ b/examples/CloudShop/CloudShop.Worker/Program.cs @@ -0,0 +1,12 @@ +using CloudShop.Worker; + +var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); +builder.AddNpgsqlDbContext("postgresdb"); +builder.AddRabbitMQClient("rabbitmq"); + +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); diff --git a/examples/CloudShop/CloudShop.Worker/Properties/launchSettings.json b/examples/CloudShop/CloudShop.Worker/Properties/launchSettings.json new file mode 100644 index 0000000000..129681f5f6 --- /dev/null +++ b/examples/CloudShop/CloudShop.Worker/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "CloudShop.Worker": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/CloudShop/CloudShop.Worker/WorkerDbContext.cs b/examples/CloudShop/CloudShop.Worker/WorkerDbContext.cs new file mode 100644 index 0000000000..e9a5eeec11 --- /dev/null +++ b/examples/CloudShop/CloudShop.Worker/WorkerDbContext.cs @@ -0,0 +1,28 @@ +using CloudShop.Shared.Models; +using Microsoft.EntityFrameworkCore; + +namespace CloudShop.Worker; + +public class WorkerDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.CustomerEmail).IsRequired().HasMaxLength(200); + entity.Property(e => e.TotalAmount).HasPrecision(18, 2); + entity.HasMany(e => e.Items).WithOne().HasForeignKey(e => e.OrderId); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.ProductName).IsRequired().HasMaxLength(200); + entity.Property(e => e.UnitPrice).HasPrecision(18, 2); + }); + } +} diff --git a/examples/CloudShop/CloudShop.Worker/appsettings.Development.json b/examples/CloudShop/CloudShop.Worker/appsettings.Development.json new file mode 100644 index 0000000000..b2dcdb6742 --- /dev/null +++ b/examples/CloudShop/CloudShop.Worker/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/examples/CloudShop/CloudShop.Worker/appsettings.json b/examples/CloudShop/CloudShop.Worker/appsettings.json new file mode 100644 index 0000000000..b2dcdb6742 --- /dev/null +++ b/examples/CloudShop/CloudShop.Worker/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/examples/CloudShop/CloudShop.sln b/examples/CloudShop/CloudShop.sln new file mode 100644 index 0000000000..b297385a38 --- /dev/null +++ b/examples/CloudShop/CloudShop.sln @@ -0,0 +1,121 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.0.0 +MinimumVisualStudioVersion = 17.8.0.0 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudShop.AppHost", "CloudShop.AppHost\CloudShop.AppHost.csproj", "{71D83770-664A-4185-96A7-81FC39DF2B48}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudShop.ServiceDefaults", "CloudShop.ServiceDefaults\CloudShop.ServiceDefaults.csproj", "{4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudShop.ApiService", "CloudShop.ApiService\CloudShop.ApiService.csproj", "{FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudShop.Web", "CloudShop.Web\CloudShop.Web.csproj", "{6128B50C-E792-4C46-9B9B-96F51D4C0C16}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.Shared", "CloudShop.Shared\CloudShop.Shared.csproj", "{5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.Worker", "CloudShop.Worker\CloudShop.Worker.csproj", "{83D5B21F-5DBB-4C28-AADD-B4647B17F020}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudShop.Tests", "CloudShop.Tests\CloudShop.Tests.csproj", "{B39BED70-DA27-447B-87B4-57CA4596302A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {71D83770-664A-4185-96A7-81FC39DF2B48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Debug|x64.ActiveCfg = Debug|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Debug|x64.Build.0 = Debug|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Debug|x86.ActiveCfg = Debug|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Debug|x86.Build.0 = Debug|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Release|Any CPU.Build.0 = Release|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Release|x64.ActiveCfg = Release|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Release|x64.Build.0 = Release|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Release|x86.ActiveCfg = Release|Any CPU + {71D83770-664A-4185-96A7-81FC39DF2B48}.Release|x86.Build.0 = Release|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Debug|x64.Build.0 = Debug|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Debug|x86.ActiveCfg = Debug|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Debug|x86.Build.0 = Debug|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Release|Any CPU.Build.0 = Release|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Release|x64.ActiveCfg = Release|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Release|x64.Build.0 = Release|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Release|x86.ActiveCfg = Release|Any CPU + {4D93DAF3-6F5B-4F82-A670-896CD3FF0D25}.Release|x86.Build.0 = Release|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Debug|x64.ActiveCfg = Debug|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Debug|x64.Build.0 = Debug|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Debug|x86.ActiveCfg = Debug|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Debug|x86.Build.0 = Debug|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Release|Any CPU.Build.0 = Release|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Release|x64.ActiveCfg = Release|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Release|x64.Build.0 = Release|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Release|x86.ActiveCfg = Release|Any CPU + {FAD76ACA-3F4B-498F-8C63-43813FA5E0A5}.Release|x86.Build.0 = Release|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Debug|x64.ActiveCfg = Debug|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Debug|x64.Build.0 = Debug|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Debug|x86.ActiveCfg = Debug|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Debug|x86.Build.0 = Debug|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Release|Any CPU.Build.0 = Release|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Release|x64.ActiveCfg = Release|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Release|x64.Build.0 = Release|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Release|x86.ActiveCfg = Release|Any CPU + {6128B50C-E792-4C46-9B9B-96F51D4C0C16}.Release|x86.Build.0 = Release|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Debug|x64.ActiveCfg = Debug|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Debug|x64.Build.0 = Debug|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Debug|x86.ActiveCfg = Debug|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Debug|x86.Build.0 = Debug|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Release|Any CPU.Build.0 = Release|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Release|x64.ActiveCfg = Release|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Release|x64.Build.0 = Release|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Release|x86.ActiveCfg = Release|Any CPU + {5FCF0DF7-24C1-47E3-9F30-D2FEDD4DAC1B}.Release|x86.Build.0 = Release|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Debug|x64.ActiveCfg = Debug|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Debug|x64.Build.0 = Debug|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Debug|x86.ActiveCfg = Debug|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Debug|x86.Build.0 = Debug|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Release|Any CPU.Build.0 = Release|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Release|x64.ActiveCfg = Release|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Release|x64.Build.0 = Release|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Release|x86.ActiveCfg = Release|Any CPU + {83D5B21F-5DBB-4C28-AADD-B4647B17F020}.Release|x86.Build.0 = Release|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Debug|x64.ActiveCfg = Debug|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Debug|x64.Build.0 = Debug|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Debug|x86.ActiveCfg = Debug|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Debug|x86.Build.0 = Debug|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Release|Any CPU.Build.0 = Release|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Release|x64.ActiveCfg = Release|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Release|x64.Build.0 = Release|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Release|x86.ActiveCfg = Release|Any CPU + {B39BED70-DA27-447B-87B4-57CA4596302A}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {17B277DF-F107-4BDD-A9EF-9D195B60AB8C} + EndGlobalSection +EndGlobal diff --git a/examples/CloudShop/Directory.Build.props b/examples/CloudShop/Directory.Build.props new file mode 100644 index 0000000000..0453980426 --- /dev/null +++ b/examples/CloudShop/Directory.Build.props @@ -0,0 +1,8 @@ + + + net10.0 + enable + enable + preview + + diff --git a/examples/CloudShop/Directory.Build.targets b/examples/CloudShop/Directory.Build.targets new file mode 100644 index 0000000000..5b0d0f2088 --- /dev/null +++ b/examples/CloudShop/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/examples/CloudShop/Directory.Packages.props b/examples/CloudShop/Directory.Packages.props new file mode 100644 index 0000000000..5f9708a97f --- /dev/null +++ b/examples/CloudShop/Directory.Packages.props @@ -0,0 +1,5 @@ + + + false + +