diff --git a/.csharpierignore b/.csharpierignore new file mode 100644 index 00000000..37415740 --- /dev/null +++ b/.csharpierignore @@ -0,0 +1 @@ +src/AwsLambda.Host.Testing/HostFactoryResolver.cs \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index b3a8c7ed..1693a54e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,4 +13,8 @@ insert_final_newline = true trim_trailing_whitespace = true [*.xml] -indent_size = 4 \ No newline at end of file +indent_size = 4 + +[src/AwsLambda.Host.Testing/HostFactoryResolver.cs] +ij_formatter_enabled = false +resharper_disable_formatter = true \ No newline at end of file diff --git a/.github/workflows/main-build.yaml b/.github/workflows/main-build.yaml index d3b1fe17..2e35c609 100644 --- a/.github/workflows/main-build.yaml +++ b/.github/workflows/main-build.yaml @@ -75,7 +75,7 @@ jobs: ~/.sonar/scanner/dotnet-sonarscanner begin \ /k:"j-d-ha_minimal-lambda" \ /o:"j-d-ha" \ - /d:sonar.exclusions="**/bin/**,**/obj/**,tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/**,**/coverage/**,**/.nuget/**,**/packages/**" \ + /d:sonar.exclusions="**/bin/**,**/obj/**,tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/**,**/coverage/**,**/.nuget/**,**/packages/**,src/MinimalLambda.Testing/HostFactoryResolver.cs" \ /d:sonar.coverage.exclusions="**/*" \ /d:sonar.token="$SONAR_TOKEN" dotnet build --no-restore --configuration Release /p:TreatWarningsAsErrors=true diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml index 7188ff70..5e3c503f 100644 --- a/.github/workflows/pr-build.yaml +++ b/.github/workflows/pr-build.yaml @@ -99,7 +99,7 @@ jobs: ~/.sonar/scanner/dotnet-sonarscanner begin \ /k:"j-d-ha_minimal-lambda" \ /o:"j-d-ha" \ - /d:sonar.exclusions="**/bin/**,**/obj/**,tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/**,**/coverage/**,**/.nuget/**,**/packages/**" \ + /d:sonar.exclusions="**/bin/**,**/obj/**,tests/MinimalLambda.SourceGenerators.UnitTests/Snapshots/**,**/coverage/**,**/.nuget/**,**/packages/**,src/MinimalLambda.Testing/HostFactoryResolver.cs" \ /d:sonar.coverage.exclusions="**/*" \ /d:sonar.token="$SONAR_TOKEN" diff --git a/.github/workflows/validate-pr-title.yaml b/.github/workflows/validate-pr-title.yaml index 38fae0aa..ff364d96 100644 --- a/.github/workflows/validate-pr-title.yaml +++ b/.github/workflows/validate-pr-title.yaml @@ -45,6 +45,7 @@ jobs: github core docs + testing requireScope: false subjectPattern: ^[a-z].* subjectPatternError: Subject must start with lowercase letter diff --git a/Directory.Packages.props b/Directory.Packages.props index 65004283..67464db4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,9 +12,13 @@ + + + + diff --git a/MinimalLambda.sln b/MinimalLambda.sln index cd134e3f..8413ae0f 100644 --- a/MinimalLambda.sln +++ b/MinimalLambda.sln @@ -20,6 +20,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Directory.Packages.props = Directory.Packages.props THIRD-PARTY-LICENSES.txt = THIRD-PARTY-LICENSES.txt mkdocs.yml = mkdocs.yml + .editorconfig = .editorconfig + .csharpierignore = .csharpierignore EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" @@ -71,6 +73,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Envelopes.Kaf EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Envelopes.CloudWatchLogs", "src\Envelopes\MinimalLambda.Envelopes.CloudWatchLogs\MinimalLambda.Envelopes.CloudWatchLogs.csproj", "{6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing", "src\MinimalLambda.Testing\MinimalLambda.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests", "tests\MinimalLambda.Testing.UnitTests\Tests\MinimalLambda.Testing.UnitTests\MinimalLambda.Testing.UnitTests.csproj", "{A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MinimalLambda.Testing.UnitTests", "MinimalLambda.Testing.UnitTests", "{47DD369E-341A-4E8F-A227-B1B1E04CB9D6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests.SimpleLambda", "tests\MinimalLambda.Testing.UnitTests\Lambdas\MinimalLambda.Testing.UnitTests.SimpleLambda\MinimalLambda.Testing.UnitTests.SimpleLambda.csproj", "{7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests.DiLambda", "tests\MinimalLambda.Testing.UnitTests\Lambdas\MinimalLambda.Testing.UnitTests.DiLambda\MinimalLambda.Testing.UnitTests.DiLambda.csproj", "{DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests.NoEventLambda", "tests\MinimalLambda.Testing.UnitTests\Lambdas\MinimalLambda.Testing.UnitTests.NoEventLambda\MinimalLambda.Testing.UnitTests.NoEventLambda.csproj", "{7350482B-DFDE-4DCD-A0C5-899D5EA00F74}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing.UnitTests.NoResponseLambda", "tests\MinimalLambda.Testing.UnitTests\Lambdas\MinimalLambda.Testing.UnitTests.NoResponseLambda\MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj", "{381F49C0-297F-4B61-8A82-E9E502E523AD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Lambdas", "Lambdas", "{D9109C8A-AFA8-49C8-A19C-381500902B4D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -345,6 +363,78 @@ Global {6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7}.Release|x64.Build.0 = Release|Any CPU {6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7}.Release|x86.ActiveCfg = Release|Any CPU {6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7}.Release|x86.Build.0 = Release|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|x64.ActiveCfg = Debug|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|x64.Build.0 = Debug|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|x86.ActiveCfg = Debug|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Debug|x86.Build.0 = Debug|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|Any CPU.Build.0 = Release|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x64.ActiveCfg = Release|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x64.Build.0 = Release|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x86.ActiveCfg = Release|Any CPU + {9FA188D7-CF5F-4F87-B292-2AF69994FF12}.Release|x86.Build.0 = Release|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|x64.ActiveCfg = Debug|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|x64.Build.0 = Debug|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|x86.ActiveCfg = Debug|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Debug|x86.Build.0 = Debug|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|Any CPU.Build.0 = Release|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|x64.ActiveCfg = Release|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|x64.Build.0 = Release|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|x86.ActiveCfg = Release|Any CPU + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF}.Release|x86.Build.0 = Release|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|x64.Build.0 = Debug|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Debug|x86.Build.0 = Debug|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|Any CPU.Build.0 = Release|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|x64.ActiveCfg = Release|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|x64.Build.0 = Release|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|x86.ActiveCfg = Release|Any CPU + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A}.Release|x86.Build.0 = Release|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|x64.ActiveCfg = Debug|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|x64.Build.0 = Debug|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Debug|x86.Build.0 = Debug|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|Any CPU.Build.0 = Release|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|x64.ActiveCfg = Release|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|x64.Build.0 = Release|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|x86.ActiveCfg = Release|Any CPU + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7}.Release|x86.Build.0 = Release|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|x64.ActiveCfg = Debug|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|x64.Build.0 = Debug|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|x86.ActiveCfg = Debug|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Debug|x86.Build.0 = Debug|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|Any CPU.Build.0 = Release|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|x64.ActiveCfg = Release|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|x64.Build.0 = Release|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|x86.ActiveCfg = Release|Any CPU + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74}.Release|x86.Build.0 = Release|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|x64.ActiveCfg = Debug|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|x64.Build.0 = Debug|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|x86.ActiveCfg = Debug|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Debug|x86.Build.0 = Debug|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|Any CPU.Build.0 = Release|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|x64.ActiveCfg = Release|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|x64.Build.0 = Release|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|x86.ActiveCfg = Release|Any CPU + {381F49C0-297F-4B61-8A82-E9E502E523AD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -373,5 +463,13 @@ Global {71EB7C3D-6EFE-4189-BE50-DC7C83D666F5} = {1C3C52D9-2936-4A0C-A9C8-F330F22B8359} {DA647025-1B7B-425A-9405-8E015F6DA723} = {1C3C52D9-2936-4A0C-A9C8-F330F22B8359} {6D40345B-6CCF-4A6C-8FA2-5BE6C693E3F7} = {1C3C52D9-2936-4A0C-A9C8-F330F22B8359} + {9FA188D7-CF5F-4F87-B292-2AF69994FF12} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {47DD369E-341A-4E8F-A227-B1B1E04CB9D6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF} = {47DD369E-341A-4E8F-A227-B1B1E04CB9D6} + {D9109C8A-AFA8-49C8-A19C-381500902B4D} = {47DD369E-341A-4E8F-A227-B1B1E04CB9D6} + {DA971B0E-E028-43E9-9FCE-6ADCD2535AC7} = {D9109C8A-AFA8-49C8-A19C-381500902B4D} + {7350482B-DFDE-4DCD-A0C5-899D5EA00F74} = {D9109C8A-AFA8-49C8-A19C-381500902B4D} + {381F49C0-297F-4B61-8A82-E9E502E523AD} = {D9109C8A-AFA8-49C8-A19C-381500902B4D} + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A} = {D9109C8A-AFA8-49C8-A19C-381500902B4D} EndGlobalSection EndGlobal diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index 8373afe4..34bca1ed 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -306,4 +306,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ + +5. runtime +-------------------------------------------------------------------------------- +Source: https://github.com/dotnet/runtime +Version: v10.0.0 +License: MIT License + +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ \ No newline at end of file diff --git a/codecov.yml b/codecov.yml index 853b288d..c8d7fbc2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -17,4 +17,6 @@ ignore: - "tests/**" - "examples/**" - "benchmarks/**" - - "**/Program.cs" \ No newline at end of file + - "**/Program.cs" + - "**/Program.cs" + - "src/MinimalLambda.Testing/HostFactoryResolver.cs" diff --git a/scripts/validate-version.sh b/scripts/validate-version.sh index 33cf0463..4147cfe8 100644 --- a/scripts/validate-version.sh +++ b/scripts/validate-version.sh @@ -12,10 +12,10 @@ cd "$DIR" || exit 1 PACKAGE_ID=$(grep -o '[^<]*' *.csproj | head -1 | sed 's/\(.*\)<\/PackageId>/\1/') # Extract version from Directory.Build.props (BSD grep compatible) -VERSION=$(grep -o '[^<]*' ../../Directory.Build.props | head -1 | sed 's/\(.*\)<\/VersionPrefix>/\1/') +VERSION=$(grep -o '[^<]*' ../../Directory.Build.props | head -1 | sed 's/\(.*\)<\/Version>/\1/') if [[ -z "$PACKAGE_ID" ]] || [[ -z "$VERSION" ]]; then - echo "Error: Could not extract PackageId or VersionPrefix" >&2 + echo "Error: Could not extract PackageId or Version" >&2 exit 1 fi diff --git a/src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs b/src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs new file mode 100644 index 00000000..b30c5b8f --- /dev/null +++ b/src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Amazon.Lambda.Serialization.SystemTextJson; +using Amazon.Lambda.Serialization.SystemTextJson.Converters; + +namespace MinimalLambda.Options; + +/// Provides the default JSON serializer options used by AWS Lambda. +public static class DefaultLambdaJsonSerializerOptions +{ + /// + /// Creates a instance that matches the defaults used by + /// . + /// + /// + /// Configures null-value ignoring, case-insensitive property names, and the AWS naming policy. + /// Adds the AWS-provided converters for dates, memory streams, constant classes, and byte arrays. + /// + /// Configured JSON serializer options suitable for AWS Lambda payloads. + public static JsonSerializerOptions Create() + { + var options = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new AwsNamingPolicy(), + }; + options.Converters.Add(new DateTimeConverter()); + options.Converters.Add(new MemoryStreamConverter()); + options.Converters.Add(new ConstantClassConverter()); + options.Converters.Add(new ByteArrayConverter()); + + return options; + } +} diff --git a/src/MinimalLambda.Abstractions/Options/EnvelopeOptions.cs b/src/MinimalLambda.Abstractions/Options/EnvelopeOptions.cs index c25fba1a..6d2645d1 100644 --- a/src/MinimalLambda.Abstractions/Options/EnvelopeOptions.cs +++ b/src/MinimalLambda.Abstractions/Options/EnvelopeOptions.cs @@ -1,8 +1,6 @@ using System.Text.Json; -using System.Text.Json.Serialization; using System.Xml; using Amazon.Lambda.Serialization.SystemTextJson; -using Amazon.Lambda.Serialization.SystemTextJson.Converters; using MinimalLambda.Envelopes; namespace MinimalLambda.Options; @@ -57,28 +55,8 @@ public class EnvelopeOptions /// not been explicitly configured. /// /// - public JsonSerializerOptions LambdaDefaultJsonOptions - { - get - { - if (field is null) - { - field = new JsonSerializerOptions - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = new AwsNamingPolicy(), - }; - field.Converters.Add(new DateTimeConverter()); - field.Converters.Add(new MemoryStreamConverter()); - field.Converters.Add(new ConstantClassConverter()); - field.Converters.Add(new ByteArrayConverter()); - } - - return field; - } - set; - } + public JsonSerializerOptions LambdaDefaultJsonOptions { get; set; } = + DefaultLambdaJsonSerializerOptions.Create(); /// Gets or sets the XML reader settings used when deserializing Lambda event payloads. /// diff --git a/src/MinimalLambda.Testing/DeferredHostBuilder.cs b/src/MinimalLambda.Testing/DeferredHostBuilder.cs new file mode 100644 index 00000000..4b26d5aa --- /dev/null +++ b/src/MinimalLambda.Testing/DeferredHostBuilder.cs @@ -0,0 +1,184 @@ +// Portions of this file are derived from aspnetcore +// Source: +// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/DeferredHostBuilder.cs +// Copyright (c) .NET Foundation and Contributors +// Licensed under the MIT License +// See THIRD-PARTY-LICENSES.txt file in the project root or visit +// https://github.com/dotnet/aspnetcore/blob/v10.0.0/LICENSE.txt + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MinimalLambda.Testing; + +// This host builder captures calls to the IHostBuilder then replays them in the call to +// ConfigureHostBuilder +internal sealed class DeferredHostBuilder : IHostBuilder +{ + private readonly TaskCompletionSource _entryPointCompletionTcs = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + private readonly ConfigurationManager _hostConfiguration = new(); + + // This task represents a call to IHost.Start, we create it here preemptively in case the + // application + // exits due to an exception or because it didn't wait for the shutdown signal + private readonly TaskCompletionSource _hostStartTcs = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + private Action _configure; + private Func? _hostFactory; + + public DeferredHostBuilder() => + _configure = b => + { + // Copy the properties from this builder into the builder + // that we're going to receive + foreach (var pair in Properties) + b.Properties[pair.Key] = pair.Value; + }; + + public Task EntryPointCompletion => _entryPointCompletionTcs.Task; + + public IDictionary Properties { get; } = new Dictionary(); + + public IHost Build() + { + // Hosting configuration is being provided by args so that + // we can impact WebApplicationBuilder based applications. + var args = new List(); + + // Transform the host configuration into command line arguments + foreach (var (key, value) in _hostConfiguration.AsEnumerable()) + args.Add($"--{key}={value}"); + + // This will never be null if the case where Build is being called + var host = (IHost)_hostFactory!(args.ToArray()); + + var applicationLifetime = host.Services.GetRequiredService(); + + // We can't return the host directly since we need to defer the call to StartAsync + return new DeferredHost(host, _hostStartTcs, applicationLifetime); + } + + public IHostBuilder ConfigureAppConfiguration( + Action configureDelegate + ) + { + _configure += b => b.ConfigureAppConfiguration(configureDelegate); + return this; + } + + public IHostBuilder ConfigureContainer( + Action configureDelegate + ) + { + _configure += b => b.ConfigureContainer(configureDelegate); + return this; + } + + public IHostBuilder ConfigureHostConfiguration(Action configureDelegate) + { + // Run this immediately so that we can capture the host configuration + // before we pass it to the application. We can do this for app configuration + // as well if it becomes necessary. + configureDelegate(_hostConfiguration); + return this; + } + + public IHostBuilder ConfigureServices( + Action configureDelegate + ) + { + _configure += b => b.ConfigureServices(configureDelegate); + return this; + } + + public IHostBuilder UseServiceProviderFactory( + IServiceProviderFactory factory + ) + where TContainerBuilder : notnull + { + _configure += b => b.UseServiceProviderFactory(factory); + return this; + } + + public IHostBuilder UseServiceProviderFactory( + Func> factory + ) + where TContainerBuilder : notnull + { + _configure += b => b.UseServiceProviderFactory(factory); + return this; + } + + public void ConfigureHostBuilder(object hostBuilder) => _configure((IHostBuilder)hostBuilder); + + public void EntryPointCompleted(Exception? exception) + { + // If the entry point completed we'll set the tcs just in case the application doesn't call + // IHost.Start/StartAsync. + if (exception is not null) + { + _hostStartTcs.TrySetException(exception); + } + else + { + _hostStartTcs.TrySetResult(); + } + + _entryPointCompletionTcs.TrySetResult(exception); + } + + public void SetHostFactory(Func hostFactory) => _hostFactory = hostFactory; + + private sealed class DeferredHost( + IHost host, + TaskCompletionSource hostStartedTcs, + IHostApplicationLifetime applicationLifetime + ) : IHost, IAsyncDisposable + { + public async ValueTask DisposeAsync() + { + if (host is IAsyncDisposable disposable) + { + await disposable.DisposeAsync().ConfigureAwait(false); + return; + } + + Dispose(); + } + + public IServiceProvider Services => host.Services; + + public void Dispose() => host.Dispose(); + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + // Wait on the existing host to start running and have this call wait on that. This + // avoids starting the actual host too early and + // leaves the application in charge of calling start. + + await using var reg = cancellationToken.UnsafeRegister( + _ => hostStartedTcs.TrySetCanceled(), + null + ); + + // Wait on the existing host to start running and have this call wait on that. This + // avoids starting the actual host too early and + // leaves the application in charge of calling start. + await using var reg2 = applicationLifetime.ApplicationStarted.UnsafeRegister( + _ => hostStartedTcs.TrySetResult(), + null + ); + + await hostStartedTcs.Task.ConfigureAwait(false); + } + + public Task StopAsync(CancellationToken cancellationToken = default) => + host.StopAsync(cancellationToken); + } +} diff --git a/src/MinimalLambda.Testing/DictionaryExtensions.cs b/src/MinimalLambda.Testing/DictionaryExtensions.cs new file mode 100644 index 00000000..ea887fc8 --- /dev/null +++ b/src/MinimalLambda.Testing/DictionaryExtensions.cs @@ -0,0 +1,19 @@ +namespace MinimalLambda.Testing; + +internal static class DictionaryExtensions +{ + extension(IDictionary dictionary) + { + internal void AddRequired(TKey key, TValue value) + { + if (!dictionary.TryAdd(key, value)) + throw new InvalidOperationException($"Key '{key}' already exists."); + } + + internal void GetRequired(TKey? key, out TValue value) + { + if (key is null || !dictionary.TryGetValue(key, out value!)) + throw new InvalidOperationException($"Key '{key}' is null or does not exist."); + } + } +} diff --git a/src/MinimalLambda.Testing/HostFactoryResolver.cs b/src/MinimalLambda.Testing/HostFactoryResolver.cs new file mode 100644 index 00000000..ff6a46ed --- /dev/null +++ b/src/MinimalLambda.Testing/HostFactoryResolver.cs @@ -0,0 +1,365 @@ +// This file is copied from runtime +// Source: +// https://github.com/dotnet/runtime/blob/v10.0.0/src/libraries/Microsoft.Extensions.HostFactoryResolver/src/HostFactoryResolver.cs +// Copyright (c) .NET Foundation and Contributors +// Licensed under the MIT License +// See THIRD-PARTY-LICENSES.txt file in the project root or visit +// https://github.com/dotnet/aspnetcore/blob/v10.0.0/LICENSE.txt + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Reflection; + +namespace Microsoft.Extensions.Hosting; + +internal sealed class HostFactoryResolver +{ + private const BindingFlags DeclaredOnlyLookup = + BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.DeclaredOnly; + + public const string BuildWebHost = nameof(BuildWebHost); + public const string CreateWebHostBuilder = nameof(CreateWebHostBuilder); + public const string CreateHostBuilder = nameof(CreateHostBuilder); + + private const string TimeoutEnvironmentKey = + "DOTNET_HOST_FACTORY_RESOLVER_DEFAULT_TIMEOUT_IN_SECONDS"; + + // The amount of time we wait for the diagnostic source events to fire + private static readonly TimeSpan s_defaultWaitTimeout = SetupDefaultTimeout(); + + private static TimeSpan SetupDefaultTimeout() + { + if (Debugger.IsAttached) + return Timeout.InfiniteTimeSpan; + + if ( + uint.TryParse( + Environment.GetEnvironmentVariable(TimeoutEnvironmentKey), + out var timeoutInSeconds + ) + ) + return TimeSpan.FromSeconds((int)timeoutInSeconds); + + return TimeSpan.FromMinutes(5); + } + + public static Func? ResolveWebHostFactory(Assembly assembly) => + ResolveFactory(assembly, BuildWebHost); + + public static Func? ResolveWebHostBuilderFactory( + Assembly assembly + ) => ResolveFactory(assembly, CreateWebHostBuilder); + + public static Func? ResolveHostBuilderFactory( + Assembly assembly + ) => ResolveFactory(assembly, CreateHostBuilder); + + // This helpers encapsulates all of the complex logic required to: + // 1. Execute the entry point of the specified assembly in a different thread. + // 2. Wait for the diagnostic source events to fire + // 3. Give the caller a chance to execute logic to mutate the IHostBuilder + // 4. Resolve the instance of the applications's IHost + // 5. Allow the caller to determine if the entry point has completed + public static Func? ResolveHostFactory( + Assembly assembly, + TimeSpan? waitTimeout = null, + bool stopApplication = true, + Action? configureHostBuilder = null, + Action? entrypointCompleted = null + ) + { + if (assembly.EntryPoint is null) + return null; + + try + { + // Attempt to load hosting and check the version to make sure the events + // even have a chance of firing (they were added in .NET >= 6) + var hostingAssembly = Assembly.Load("Microsoft.Extensions.Hosting"); + if (hostingAssembly.GetName().Version is Version version && version.Major < 6) + return null; + + // We're using a version >= 6 so the events can fire. If they don't fire + // then it's because the application isn't using the hosting APIs + } + catch + { + // There was an error loading the extensions assembly, return null. + return null; + } + + return args => + new HostingListener( + args, + assembly.EntryPoint, + waitTimeout ?? s_defaultWaitTimeout, + stopApplication, + configureHostBuilder, + entrypointCompleted + ).CreateHost(); + } + + private static Func? ResolveFactory(Assembly assembly, string name) + { + var programType = assembly?.EntryPoint?.DeclaringType; + if (programType == null) + return null; + + var factory = programType.GetMethod(name, DeclaredOnlyLookup); + if (!IsFactory(factory)) + return null; + + return args => (T)factory!.Invoke(null, new object[] { args })!; + } + + // TReturn Factory(string[] args); + private static bool IsFactory(MethodInfo? factory) => + factory != null + && typeof(TReturn).IsAssignableFrom(factory.ReturnType) + && factory.GetParameters().Length == 1 + && typeof(string[]).Equals(factory.GetParameters()[0].ParameterType); + + // Used by EF tooling without any Hosting references. Looses some return type safety checks. + public static Func? ResolveServiceProviderFactory( + Assembly assembly, + TimeSpan? waitTimeout = null + ) + { + // Prefer the older patterns by default for back compat. + var webHostFactory = ResolveWebHostFactory(assembly); + if (webHostFactory != null) + return args => + { + var webHost = webHostFactory(args); + return GetServiceProvider(webHost); + }; + + var webHostBuilderFactory = ResolveWebHostBuilderFactory(assembly); + if (webHostBuilderFactory != null) + return args => + { + var webHostBuilder = webHostBuilderFactory(args); + var webHost = Build(webHostBuilder); + return GetServiceProvider(webHost); + }; + + var hostBuilderFactory = ResolveHostBuilderFactory(assembly); + if (hostBuilderFactory != null) + return args => + { + var hostBuilder = hostBuilderFactory(args); + var host = Build(hostBuilder); + return GetServiceProvider(host); + }; + + var hostFactory = ResolveHostFactory(assembly, waitTimeout); + if (hostFactory != null) + return args => + { + static bool IsApplicationNameArg(string arg) => + arg.Equals("--applicationName", StringComparison.OrdinalIgnoreCase) + || arg.Equals("/applicationName", StringComparison.OrdinalIgnoreCase); + + if ( + !args.Any(arg => IsApplicationNameArg(arg)) + && assembly.GetName().Name is string assemblyName + ) + args = args.Concat(new[] { "--applicationName", assemblyName }).ToArray(); + + var host = hostFactory(args); + return GetServiceProvider(host); + }; + + return null; + } + + private static object? Build(object builder) + { + var buildMethod = builder.GetType().GetMethod("Build"); + return buildMethod?.Invoke(builder, Array.Empty()); + } + + private static IServiceProvider? GetServiceProvider(object? host) + { + if (host == null) + return null; + var hostType = host.GetType(); + var servicesProperty = hostType.GetProperty("Services", DeclaredOnlyLookup); + return (IServiceProvider?)servicesProperty?.GetValue(host); + } + + private sealed class HostingListener + : IObserver, + IObserver> + { + private static readonly AsyncLocal _currentListener = new(); + private readonly string[] _args; + private readonly Action? _configure; + private readonly MethodInfo _entryPoint; + private readonly Action? _entrypointCompleted; + + private readonly TaskCompletionSource _hostTcs = new(); + private readonly bool _stopApplication; + private readonly TimeSpan _waitTimeout; + private IDisposable? _disposable; + + public HostingListener( + string[] args, + MethodInfo entryPoint, + TimeSpan waitTimeout, + bool stopApplication, + Action? configure, + Action? entrypointCompleted + ) + { + _args = args; + _entryPoint = entryPoint; + _waitTimeout = waitTimeout; + _stopApplication = stopApplication; + _configure = configure; + _entrypointCompleted = entrypointCompleted; + } + + public void OnCompleted() => _disposable?.Dispose(); + + public void OnError(Exception error) { } + + public void OnNext(DiagnosticListener value) + { + if (_currentListener.Value != this) + // Ignore events that aren't for this listener + return; + + if (value.Name == "Microsoft.Extensions.Hosting") + _disposable = value.Subscribe(this); + } + + public void OnNext(KeyValuePair value) + { + if (_currentListener.Value != this) + // Ignore events that aren't for this listener + return; + + if (value.Key == "HostBuilding") + _configure?.Invoke(value.Value!); + + if (value.Key == "HostBuilt") + { + _hostTcs.TrySetResult(value.Value!); + + if (_stopApplication) + // Stop the host from running further + ThrowHostAborted(); + } + } + + public object CreateHost() + { + using var subscription = DiagnosticListener.AllListeners.Subscribe(this); + + // Kick off the entry point on a new thread so we don't block the current one + // in case we need to timeout the execution + var thread = new Thread(() => + { + Exception? exception = null; + + try + { + // Set the async local to the instance of the HostingListener so we can + // filter events that + // aren't scoped to this execution of the entry point. + _currentListener.Value = this; + + var parameters = _entryPoint.GetParameters(); + if (parameters.Length == 0) + _entryPoint.Invoke(null, Array.Empty()); + else + _entryPoint.Invoke(null, new object[] { _args }); + + // Try to set an exception if the entry point returns gracefully, this + // will force + // build to throw + _hostTcs.TrySetException( + new InvalidOperationException( + "The entry point exited without ever building an IHost." + ) + ); + } + catch (TargetInvocationException tie) + when (tie.InnerException?.GetType().Name == "HostAbortedException") + { + // The host was stopped by our own logic + } + catch (TargetInvocationException tie) + { + exception = tie.InnerException ?? tie; + + // Another exception happened, propagate that to the caller + _hostTcs.TrySetException(exception); + } + catch (Exception ex) + { + exception = ex; + + // Another exception happened, propagate that to the caller + _hostTcs.TrySetException(ex); + } + finally + { + // Signal that the entry point is completed + _entrypointCompleted?.Invoke(exception); + } + }) + { + // Make sure this doesn't hang the process + IsBackground = true, + }; + + // Start the thread + thread.Start(); + + try + { + // Wait before throwing an exception + if (!_hostTcs.Task.Wait(_waitTimeout)) + throw new InvalidOperationException( + $"Timed out waiting for the entry point to build the IHost after {s_defaultWaitTimeout}. This timeout can be modified using the '{TimeoutEnvironmentKey}' environment variable." + ); + } + catch (AggregateException) when (_hostTcs.Task.IsCompleted) + { + // Lets this propagate out of the call to GetAwaiter().GetResult() + } + + Debug.Assert(_hostTcs.Task.IsCompleted); + + return _hostTcs.Task.GetAwaiter().GetResult(); + } + + // HostFactoryResolver is used by tools that explicitly don't want to reference + // Microsoft.Extensions.Hosting assemblies. + // So don't depend on the public HostAbortedException directly. Instead, load the exception + // type dynamically if it can + // be found. If it can't (possibly because the app is using an older version), throw a + // private exception with the same name. + private void ThrowHostAborted() + { + var publicHostAbortedExceptionType = Type.GetType( + "Microsoft.Extensions.Hosting.HostAbortedException, Microsoft.Extensions.Hosting.Abstractions", + false + ); + if (publicHostAbortedExceptionType != null) + throw (Exception)Activator.CreateInstance(publicHostAbortedExceptionType)!; + + throw new HostAbortedException(); + } + + private sealed class HostAbortedException : Exception { } + } +} diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs new file mode 100644 index 00000000..79d7f337 --- /dev/null +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -0,0 +1,531 @@ +// Portions of this file are derived from aspnetcore +// Source: +// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/LambdaApplicationFactory.cs +// Copyright (c) .NET Foundation and Contributors +// Licensed under the MIT License +// See THIRD-PARTY-LICENSES.txt file in the project root or visit +// https://github.com/dotnet/aspnetcore/blob/v10.0.0/LICENSE.txt + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyModel; +using Microsoft.Extensions.Hosting; +using MinimalLambda.Builder.Extensions; +using MinimalLambda.Options; + +namespace MinimalLambda.Testing; + +/// +/// Factory for bootstrapping an application in memory for functional end to end tests. +/// +/// A type in the entry point assembly of the application. +/// Typically the Startup or Program classes can be used. +public class LambdaApplicationFactory : IDisposable, IAsyncDisposable + where TEntryPoint : class +{ + private readonly List> _derivedFactories = []; + private Action _configuration; + private bool _disposed; + private bool _disposedAsync; + private IHost? _host; + private LambdaTestServer? _server; + private CancellationToken? _stoppingToken; + + /// + /// + /// Creates an instance of . This factory can be used to + /// create a instance for testing Lambda applications defined by + /// in-memory without deploying to AWS. + /// The will find the entry point class of + /// assembly and initialize the application by calling IHostBuilder CreateHostBuilder(string[] args) + /// on . + /// + /// + /// This constructor will infer the application content root path by searching for a + /// on the assembly containing the functional tests with + /// a key equal to the assembly . + /// In case an attribute with the right key can't be found, + /// will fall back to searching for a solution file (*.sln) and then appending assembly name + /// to the solution directory. The application root directory will be used to discover views and content files. + /// + /// + /// The application assemblies will be loaded from the dependency context of the assembly containing + /// . This means that project dependencies of the assembly containing + /// will be loaded as application assemblies. + /// + /// + public LambdaApplicationFactory() => _configuration = ConfigureWebHost; + + /// + /// Gets the of factories created from this factory + /// by further customizing the when calling + /// . + /// + public IReadOnlyList> Factories => + _derivedFactories.AsReadOnly(); + + /// + /// Gets the used to configure the . + /// These options control Lambda-specific testing behavior such as function timeout, ARN, and custom headers + /// included in Lambda runtime HTTP responses. + /// + public LambdaServerOptions ServerOptions { get; private init; } = new(); + + /// + /// Gets the created by the server associated with this . + /// + public virtual IServiceProvider Services => TestServer.Services; + + /// + /// Gets the created by this . + /// + public LambdaTestServer TestServer + { + get + { + EnsureServer(); + return _server; + } + } + + /// + public virtual async ValueTask DisposeAsync() + { + if (_disposed) + return; + + if (_disposedAsync) + return; + + foreach (var factory in _derivedFactories) + await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); + + // TestServer handles disposing both processor and host + if (_server != null) + await _server.DisposeAsync().ConfigureAwait(false); + + _host?.Dispose(); + + _disposedAsync = true; + + Dispose(true); + + GC.SuppressFinalize(this); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Configures a cancellation token to propagate test cancellation signals to the Lambda test server + /// and its underlying components. + /// + /// + /// A that will be propagated to the test server, allowing it to + /// respond more efficiently to cancellation signals during test execution. + /// + /// + /// The current instance for method chaining. + /// + /// + /// This method allows test frameworks to signal cancellation to the Lambda test infrastructure, + /// enabling graceful shutdown and faster test cleanup when tests are cancelled or time out. + /// The cancellation token is passed to the during creation. + /// + public LambdaApplicationFactory WithCancellationToken( + CancellationToken cancellationToken + ) + { + _stoppingToken = cancellationToken; + return this; + } + + /// + /// Finalizes an instance of the class. + /// + ~LambdaApplicationFactory() => Dispose(false); + + /// + /// Creates a new with a + /// that is further customized by . + /// + /// + /// An to configure the . + /// + /// A new . + public LambdaApplicationFactory WithHostBuilder( + Action configuration + ) => WithHostBuilderCore(configuration); + + /// + /// Core implementation of that creates a derived factory with additional configuration. + /// This method creates a that chains the parent factory's configuration + /// with the new configuration provided in . The derived factory is tracked in the + /// list for proper disposal. + /// + /// + /// An to configure the . + /// This configuration will be applied after the parent factory's configuration. + /// + /// + /// A new that applies both the parent factory's + /// configuration and the additional configuration specified in . + /// + /// + /// This method is to allow derived classes to customize the factory creation behavior. + /// + protected virtual LambdaApplicationFactory WithHostBuilderCore( + Action configuration + ) + { + var factory = new DelegatedLambdaApplicationFactory( + ServerOptions, + CreateHost, + GetTestAssemblies, + builder => + { + _configuration(builder); + configuration(builder); + } + ); + + _derivedFactories.Add(factory); + + return factory; + } + + [MemberNotNull(nameof(_server))] + private void EnsureServer() + { + if (_server != null) + return; + + EnsureDepsFile(); + + var deferredHostBuilder = new DeferredHostBuilder(); + deferredHostBuilder.UseEnvironment(Environments.Development); + // There's no helper for UseApplicationName, but we need to + // set the application name to the target entry point + // assembly name. + deferredHostBuilder.ConfigureHostConfiguration(config => + { + config.AddInMemoryCollection( + new Dictionary + { + { + HostDefaults.ApplicationKey, + typeof(TEntryPoint).Assembly.GetName().Name ?? string.Empty + }, + } + ); + }); + // This helper call does the hard work to determine if we can fallback to diagnostic + // source events to get the host instance + var factory = HostFactoryResolver.ResolveHostFactory( + typeof(TEntryPoint).Assembly, + stopApplication: false, + configureHostBuilder: deferredHostBuilder.ConfigureHostBuilder, + entrypointCompleted: deferredHostBuilder.EntryPointCompleted + ); + + if (factory is not null) + { + // If we have a valid factory it means the specified entry point's assembly can + // potentially resolve the IHost + // so we set the factory on the DeferredHostBuilder so we can invoke it on the call + // to IHostBuilder.Build. + deferredHostBuilder.SetHostFactory(factory); + + ConfigureHostBuilder(deferredHostBuilder, deferredHostBuilder.EntryPointCompletion); + return; + } + + throw new InvalidOperationException("Unable to create IHostBuilder instance."); + } + + [MemberNotNull(nameof(_server))] + private void ConfigureHostBuilder( + IHostBuilder hostBuilder, + Task entryPointCompletion + ) + { + SetContentRoot(hostBuilder); + _configuration(hostBuilder); + + _server = new LambdaTestServer( + entryPointCompletion, + ServerOptions, + _stoppingToken ?? CancellationToken.None + ); + + // set Lambda Bootstrap Http Client + hostBuilder.ConfigureServices(services => + { + services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateHandler())); + + services.PostConfigure(options => + { + if (string.IsNullOrEmpty(options.BootstrapOptions.RuntimeApiEndpoint)) + options.BootstrapOptions.RuntimeApiEndpoint = "localhost"; + }); + }); + + // Build the host but DON'T start it - server will start it + _host = CreateHost(hostBuilder); + _server.SetHost(_host); + } + + private void SetContentRoot(IHostBuilder builder) + { + var contentRoot = GetContentRootFromAssembly(); + + if (contentRoot != null) + builder.UseContentRoot(contentRoot); + else + UseSolutionRelativeContentRoot(builder, typeof(TEntryPoint).Assembly.GetName().Name!); + } + + private static void UseSolutionRelativeContentRoot( + IHostBuilder builder, + string solutionRelativePath + ) + { + ArgumentNullException.ThrowIfNull(solutionRelativePath); + + var applicationBasePath = AppContext.BaseDirectory; + string[] solutionNames = ["*.sln", "*.slnx"]; + + var directoryInfo = new DirectoryInfo(applicationBasePath); + do + { + foreach (var solutionName in solutionNames) + { + var solutionPath = Directory + .EnumerateFiles(directoryInfo.FullName, solutionName) + .FirstOrDefault(); + if (solutionPath != null) + { + builder.UseContentRoot( + Path.GetFullPath(Path.Combine(directoryInfo.FullName, solutionRelativePath)) + ); + return; + } + } + + directoryInfo = directoryInfo.Parent; + } while (directoryInfo is not null); + + throw new InvalidOperationException( + $"Solution root could not be located using application root {applicationBasePath}." + ); + } + + private string? GetContentRootFromAssembly() + { + var metadataAttributes = GetContentRootMetadataAttributes( + typeof(TEntryPoint).Assembly.FullName!, + typeof(TEntryPoint).Assembly.GetName().Name! + ); + + string? contentRoot = null; + foreach (var contentRootAttribute in metadataAttributes) + { + var contentRootCandidate = Path.Combine( + AppContext.BaseDirectory, + contentRootAttribute.ContentRootPath + ); + + var contentRootMarker = Path.Combine( + contentRootCandidate, + Path.GetFileName(contentRootAttribute.ContentRootTest) + ); + + if (File.Exists(contentRootMarker)) + { + contentRoot = contentRootCandidate; + break; + } + } + + return contentRoot; + } + + private LambdaApplicationFactoryContentRootAttribute[] GetContentRootMetadataAttributes( + string tEntryPointAssemblyFullName, + string tEntryPointAssemblyName + ) + { + var testAssembly = GetTestAssemblies(); + var metadataAttributes = testAssembly + .SelectMany(a => a.GetCustomAttributes()) + .Where(a => + string.Equals( + a.Key, + tEntryPointAssemblyFullName, + StringComparison.OrdinalIgnoreCase + ) + || string.Equals(a.Key, tEntryPointAssemblyName, StringComparison.OrdinalIgnoreCase) + ) + .OrderBy(a => a.Priority) + .ToArray(); + + return metadataAttributes; + } + + /// + /// Gets the assemblies containing the functional tests. The + /// applied to these + /// assemblies defines the content root to use for the given + /// . + /// + /// The list of containing tests. + protected virtual IEnumerable GetTestAssemblies() + { + try + { + // The default dependency context will be populated in .net core applications. + var context = DependencyContext.Default; + if (context == null || context.CompileLibraries.Count == 0) + // The app domain friendly name will be populated in full framework. + return [Assembly.Load(AppDomain.CurrentDomain.FriendlyName)]; + + var runtimeProjectLibraries = context.RuntimeLibraries.ToDictionary( + r => r.Name, + r => r, + StringComparer.Ordinal + ); + + // Find the list of projects + _ = context.CompileLibraries.Where(l => l.Type == "project"); + + var entryPointAssemblyName = typeof(TEntryPoint).Assembly.GetName().Name; + + // Find the list of projects referencing TEntryPoint. + var candidates = context.CompileLibraries.Where(library => + library.Dependencies.Any(d => + string.Equals(d.Name, entryPointAssemblyName, StringComparison.Ordinal) + ) + ); + + var testAssemblies = new List(); + foreach (var candidate in candidates) + if (runtimeProjectLibraries.TryGetValue(candidate.Name, out var runtimeLibrary)) + { + var runtimeAssemblies = runtimeLibrary.GetDefaultAssemblyNames(context); + testAssemblies.AddRange(runtimeAssemblies.Select(Assembly.Load)); + } + + return testAssemblies; + } + catch (Exception) + { + // Ignore + } + + return []; + } + + private static void EnsureDepsFile() + { + if (typeof(TEntryPoint).Assembly.EntryPoint == null) + throw new InvalidOperationException( + $"Invalid assembly entry point: {typeof(TEntryPoint).Assembly.FullName}" + ); + + var depsFileName = $"{typeof(TEntryPoint).Assembly.GetName().Name}.deps.json"; + var depsFile = new FileInfo(Path.Combine(AppContext.BaseDirectory, depsFileName)); + if (!depsFile.Exists) + throw new InvalidOperationException($"Missing deps file: {depsFile.FullName}"); + } + + /// + /// Creates the with the bootstrapped application in . + /// The host is built but not started. The will start the host + /// when is called. + /// + /// The used to create the host. + /// The with the bootstrapped application. + protected virtual IHost CreateHost(IHostBuilder builder) => + // Build the host but DON'T start it - LambdaTestServer.StartAsync() will start it + builder.Build(); + + /// + /// Gives a fixture an opportunity to configure the application before it gets built. + /// + /// The for the application. + protected virtual void ConfigureWebHost(IHostBuilder builder) { } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + /// + /// to release both managed and unmanaged resources; + /// to release only unmanaged resources. + /// + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing && !_disposedAsync) + DisposeAsync().AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + + _disposed = true; + } + + /// + /// Internal implementation of that delegates virtual method calls + /// to callbacks provided at construction. This class is used by to create derived + /// factories with customized behavior without creating additional subclasses. + /// + /// + /// This class implements the delegation pattern to allow runtime customization of virtual methods. + /// Each virtual method (, , + /// and ) delegates to a callback function provided in the constructor, + /// enabling configuration chaining while reusing the base factory infrastructure. + /// + private sealed class DelegatedLambdaApplicationFactory : LambdaApplicationFactory + { + private readonly Func _createHost; + private readonly Func> _getTestAssemblies; + + public DelegatedLambdaApplicationFactory( + LambdaServerOptions options, + Func createHost, + Func> getTestAssemblies, + Action configureWebHost + ) + { + ServerOptions = options; + _createHost = createHost; + _getTestAssemblies = getTestAssemblies; + _configuration = configureWebHost; + } + + protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder); + + protected override IEnumerable GetTestAssemblies() => _getTestAssemblies(); + + protected override void ConfigureWebHost(IHostBuilder builder) => _configuration(builder); + + protected override LambdaApplicationFactory WithHostBuilderCore( + Action configuration + ) => + new DelegatedLambdaApplicationFactory( + ServerOptions, + _createHost, + _getTestAssemblies, + builder => + { + _configuration(builder); + configuration(builder); + } + ); + } +} diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactoryContentRootAttribute.cs b/src/MinimalLambda.Testing/LambdaApplicationFactoryContentRootAttribute.cs new file mode 100644 index 00000000..b79a883f --- /dev/null +++ b/src/MinimalLambda.Testing/LambdaApplicationFactoryContentRootAttribute.cs @@ -0,0 +1,91 @@ +// Portions of this file are derived from aspnetcore +// Source: +// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/LambdaApplicationFactoryContentRootAttribute.cs +// Copyright (c) .NET Foundation and Contributors +// Licensed under the MIT License +// See THIRD-PARTY-LICENSES.txt file in the project root or visit +// https://github.com/dotnet/aspnetcore/blob/v10.0.0/LICENSE.txt + +using System.Globalization; +using System.Reflection; + +namespace MinimalLambda.Testing; + +/// +/// Metadata that uses to find out the content +/// root for the web application represented by TEntryPoint. +/// will iterate over all the instances of +/// , filter the instances whose +/// is equal to TEntryPoint , +/// order them by in ascending order. +/// will check for the existence of the marker +/// in Path.Combine(, Path.GetFileName())" +/// and if the file exists it will set the content root to . +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class LambdaApplicationFactoryContentRootAttribute : Attribute +{ + /// + /// Initializes a new instance of . + /// + /// + /// The key of this . This + /// key is used by to determine what of the + /// instances on the test assembly should be used + /// to match a given TEntryPoint class. + /// + /// The path to the content root. This path can be either relative or absolute. + /// In case the path is relative, the path will be combined with + /// + /// + /// A file that will be use as a marker to determine that the content root path for the given context is correct. + /// + /// + /// The priority of this content root attribute compared to other attributes. When + /// multiple instances are applied for the + /// same key, they are processed with , ordered in ascending order and applied + /// in priority until a match is found. + /// + public LambdaApplicationFactoryContentRootAttribute( + string key, + string contentRootPath, + string contentRootTest, + string priority + ) + { + Key = key; + ContentRootPath = contentRootPath; + ContentRootTest = contentRootTest; + if ( + int.TryParse( + priority, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var parsedPriority + ) + ) + Priority = parsedPriority; + } + + /// + /// Gets the content root path for a given project. This content root can be relative or absolute. If it is a + /// relative path, it will be combined with . + /// + public string ContentRootPath { get; } + + /// + /// A marker file used to ensure that the path the content root is being set to is correct. + /// + public string ContentRootTest { get; } + + /// + /// Gets the key for the content root associated with this project. Typically . + /// + public string Key { get; } + + /// + /// Gets a number for determining the probing order when multiple + /// instances with the same key are present on the test . + /// + public int Priority { get; } +} diff --git a/src/MinimalLambda.Testing/LambdaHttpHandler.cs b/src/MinimalLambda.Testing/LambdaHttpHandler.cs new file mode 100644 index 00000000..b01b73c4 --- /dev/null +++ b/src/MinimalLambda.Testing/LambdaHttpHandler.cs @@ -0,0 +1,63 @@ +using System.Threading.Channels; + +namespace MinimalLambda.Testing; + +/// +/// HTTP message handler that intercepts Lambda Bootstrap HTTP calls and +/// routes them through the test server via transactions. +/// +internal class LambdaTestingHttpHandler( + Channel transactionChannel, + CancellationToken stoppingToken +) : HttpMessageHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + stoppingToken + ); + + // Buffer the content to make it re-readable for downstream consumers + if (request.Content != null) + { + var originalContent = request.Content; + var bytes = await originalContent.ReadAsByteArrayAsync(cts.Token); + var bufferedContent = new ByteArrayContent(bytes); + + foreach (var header in originalContent.Headers) + bufferedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + + request.Content = bufferedContent; + originalContent.Dispose(); + } + + // Create transaction with request and completion mechanism + var transaction = LambdaHttpTransaction.Create(request); + + // Register cancellation to cancel the transaction TCS + await using var registration = cts.Token.Register(() => transaction.Cancel()); + + // Send transaction to server + try + { + await transactionChannel.Writer.WriteAsync(transaction, cts.Token); + } + catch (ChannelClosedException) + { + // TestServer is shutting down; propagate cancellation to caller + var canceled = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + canceled.TrySetCanceled(cts.Token); + return await canceled.Task; + } + + // Wait for server to complete the transaction + var response = await transaction.ResponseTcs.Task; + return response; + } +} diff --git a/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs b/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs new file mode 100644 index 00000000..8d474122 --- /dev/null +++ b/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs @@ -0,0 +1,84 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Template; + +namespace MinimalLambda.Testing; + +internal static class LambdaRuntimeRouteManager +{ + private static readonly RouteTemplate[] Routes = + [ + new() + { + Type = RequestType.GetNextInvocation, + Method = HttpMethod.Get.Method, + Matcher = new TemplateMatcher( + TemplateParser.Parse("{version}/runtime/invocation/next"), + new RouteValueDictionary() + ), + }, + new() + { + Type = RequestType.PostInitError, + Method = HttpMethod.Post.Method, + Matcher = new TemplateMatcher( + TemplateParser.Parse("{version}/runtime/init/error"), + new RouteValueDictionary() + ), + }, + new() + { + Type = RequestType.PostResponse, + Method = HttpMethod.Post.Method, + Matcher = new TemplateMatcher( + TemplateParser.Parse("{version}/runtime/invocation/{requestId}/response"), + new RouteValueDictionary() + ), + }, + new() + { + Type = RequestType.PostError, + Method = HttpMethod.Post.Method, + Matcher = new TemplateMatcher( + TemplateParser.Parse("{version}/runtime/invocation/{requestId}/error"), + new RouteValueDictionary() + ), + }, + ]; + + internal static bool TryMatch( + HttpRequestMessage request, + [NotNullWhen(true)] out RequestType? routeType, + [NotNullWhen(true)] out RouteValueDictionary? values + ) + { + routeType = null; + values = null; + + var method = request.Method.Method; + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + + foreach (var route in Routes) + { + if (route.Method != method) + continue; + + var routeValues = new RouteValueDictionary(); + if (route.Matcher.TryMatch(path, routeValues)) + { + routeType = route.Type; + values = routeValues; + return true; + } + } + + return false; + } + + private sealed class RouteTemplate + { + internal required TemplateMatcher Matcher { get; init; } + internal required string Method { get; init; } + internal required RequestType Type { get; init; } + } +} diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs new file mode 100644 index 00000000..a6dec8fd --- /dev/null +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -0,0 +1,679 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MinimalLambda.Options; + +namespace MinimalLambda.Testing; + +/// +/// Provides an in-memory test server that simulates the AWS Lambda runtime environment for testing Lambda functions +/// without deploying to AWS. This server intercepts HTTP requests from the Lambda bootstrap client, manages the +/// invocation lifecycle, and provides a public API for test scenarios. +/// +/// +/// +/// The follows a strict state machine lifecycle: +/// Created → Starting → Running → Stopping → Stopped → Disposed. +/// +/// +/// This class is typically created and managed by +/// rather than being instantiated directly. The server handles invocation queuing, response handling, +/// timeout enforcement, and Lambda runtime API protocol compliance. +/// +/// +public class LambdaTestServer : IAsyncDisposable +{ + /// + /// Task that represents the running Host application that has been captured. + /// + private readonly Task _entryPointCompletion; + + /// + /// TCS used to signal the startup has completed + /// + private readonly TaskCompletionSource _initCompletionTcs = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + /// + /// JSON serializer options used to serialize/deserilize Lambda events and responses. + /// + private readonly JsonSerializerOptions _jsonSerializerOptions; + + /// + /// Channel used to queue pending invocations in a FIFO manner. + /// + private readonly Channel _pendingInvocationIds = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } + ); + + /// + /// Dictionary to track all invocations that have been sent to Lambda. + /// + private readonly ConcurrentDictionary _pendingInvocations = new(); + + /// + /// Options used to configure how the server interacts with the Lambda. + /// + private readonly LambdaServerOptions _serverOptions; + + /// + /// CTS used to signal shutdown of the server and cancellation of pending tasks. + /// + private readonly CancellationTokenSource _shutdownCts; + + private readonly SemaphoreSlim _startSemaphore = new(1, 1); + + /// + /// Channel used to by the Lambda to send events to the server. + /// + private readonly Channel _transactionChannel = + Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } + ); + + /// + /// Host application lifetime used to signal shutdown to the captioned Host. + /// + private IHostApplicationLifetime? _applicationLifetime; + + /// + /// Indicates whether the server has been disposed. + /// + private bool _disposed; + + /// + /// The captured Host instance. + /// + private IHost? _host; + + /// + /// Task that is running the background processing loop to handle incoming requests from Lambda. + /// + private Task? _processingTask; + + /// + /// Counter used to generate unique request IDs. + /// + private int _requestCounter; + + internal LambdaTestServer( + Task? entryPointCompletion, + LambdaServerOptions serverOptions, + CancellationToken shutdownToken = default + ) + { + ArgumentNullException.ThrowIfNull(entryPointCompletion); + + _entryPointCompletion = entryPointCompletion; + _shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(shutdownToken); + State = ServerState.Created; + + _jsonSerializerOptions = DefaultLambdaJsonSerializerOptions.Create(); + _serverOptions = serverOptions; + } + + /// + /// Gets the from the underlying instance, + /// providing access to the dependency injection container for the Lambda application. + /// + /// + /// The service provider from the captured host instance. + /// + /// + /// This property allows tests to resolve services from the Lambda application's DI container + /// for setup, assertion, or inspection purposes. + /// + /// + /// Thrown if accessed before the host has been set via . + /// + public IServiceProvider Services => _host!.Services; + + /// + /// Current state of the server used to enforce lifecycle rules. + /// + public ServerState State { get; private set; } + + /// + /// Asynchronously releases all resources used by the . + /// + /// A representing the asynchronous dispose operation. + /// + /// + /// This method performs the following cleanup operations: + /// + /// + /// Stops the server if it is currently running + /// Completes the transaction channel to prevent new requests + /// Cancels the shutdown token to signal background tasks + /// Disposes the underlying instance + /// Transitions the server state to + /// + /// + /// This method is safe to call multiple times. Subsequent calls after the first will return immediately + /// without performing any operations. + /// + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + // Complete both channels to prevent new items + _transactionChannel.Writer.TryComplete(); + _pendingInvocationIds.Writer.TryComplete(); + + // Cancel the shutdown token + await _shutdownCts.CancelAsync(); + + if (State == ServerState.Running) + await StopAsync().ConfigureAwait(false); + + // Dispose the CancellationTokenSource + _shutdownCts.Dispose(); + + State = ServerState.Disposed; + _disposed = true; + + GC.SuppressFinalize(this); + } + + internal void SetHost(IHost host) + { + ArgumentNullException.ThrowIfNull(host); + _host = host; + } + + internal HttpMessageHandler CreateHandler() => + new LambdaTestingHttpHandler(_transactionChannel, _shutdownCts.Token); + + // ┌──────────────────────────────────────────────────────────┐ + // │ Public API │ + // └──────────────────────────────────────────────────────────┘ + + /// + /// Initializes and starts the Lambda test server, beginning the application host and preparing + /// it to accept invocations. + /// + /// + /// A to observe while waiting for the server to start. + /// + /// + /// A that completes with an indicating + /// whether initialization succeeded, failed with an error, or the host exited prematurely. + /// + /// + /// Thrown if the server has already been started, if the host is not set, or if initialization + /// fails without an error or completion signal. + /// + /// + /// + /// This method can only be called once per server instance. The server must be in the + /// state when this method is called. + /// + /// + /// The method starts the underlying , begins background processing of + /// Lambda runtime HTTP transactions, and waits for the Lambda bootstrap to complete its + /// initialization phase by requesting the first invocation. + /// + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + var semaphoreAcquired = false; + try + { + await _startSemaphore.WaitAsync(cancellationToken); + semaphoreAcquired = true; + + if (State == ServerState.Running) + return new InitResponse { InitStatus = InitStatus.InitAlreadyCompleted }; + else if (State != ServerState.Created) + throw new InvalidOperationException( + $"{nameof(LambdaTestServer)} has already been started and cannot be restarted." + ); + + if (_host is null) + throw new InvalidOperationException("Host is not set."); + + using var cts = LinkedCts(cancellationToken); + + State = ServerState.Starting; + + _applicationLifetime = _host.Services.GetRequiredService(); + + // Start the host + await _host.StartAsync(cts.Token); + + // Start background processing + _processingTask = Task.Run(ProcessTransactionsAsync, cts.Token); + + await TaskHelpers + .WhenAny(_processingTask, _entryPointCompletion, _initCompletionTcs.Task) + .UnwrapAndThrow("Exception(s) encountered while running StartAsync"); + + if (_entryPointCompletion.IsCompleted) + { + State = ServerState.Stopped; + return new InitResponse { InitStatus = InitStatus.HostExited }; + } + + if (_initCompletionTcs.Task.IsCompleted) + { + if (_initCompletionTcs.Task.Result.InitStatus == InitStatus.InitCompleted) + State = ServerState.Running; + else + await StopAsync(CancellationToken.None); + + return _initCompletionTcs.Task.Result; + } + + throw new InvalidOperationException( + $"{nameof(LambdaTestServer)} initialization failed with neither an error nor completion." + ); + } + finally + { + if (semaphoreAcquired) + _startSemaphore.Release(); + } + } + + /// + /// Invokes the Lambda function with the specified event and waits for the response or error. + /// + /// The expected type of the Lambda function's response. + /// The type of the Lambda event to send to the function. + /// The event object to pass to the Lambda function. + /// + /// Set to when the handler does not return a response body (for example, + /// stream writers) to skip reading/deserializing the response. + /// + /// + /// The AWS X-Ray trace ID to use for this invocation. If , a new GUID will be generated and + /// surfaced on the `Lambda-Runtime-Trace-Id` header. + /// + /// + /// A to observe while waiting for the invocation to complete. + /// + /// + /// A that completes with an + /// containing either the successful response or error information from the Lambda function. + /// + /// + /// Thrown if the server is not in the state, or if the + /// invocation cannot be enqueued. + /// + /// + /// Thrown if the invocation times out based on + /// or if the is cancelled. + /// + /// + /// + /// This method simulates the AWS Lambda invocation protocol by: + /// + /// + /// Generating a unique request ID + /// Creating an HTTP response with Lambda runtime headers + /// Queuing the invocation for the Lambda bootstrap to retrieve + /// Waiting for the Lambda function to respond or report an error + /// Deserializing the response or error information + /// + /// + /// The invocation will timeout based on the setting, + /// which defaults to AWS Lambda's standard timeout behavior. + /// + /// + public async Task> InvokeAsync( + TEvent? invokeEvent, + bool noResponse, + string? traceId = null, + CancellationToken cancellationToken = default + ) + { + // inorder to allow + if (State != ServerState.Running) + { + var initResponse = await StartAsync(cancellationToken); + if ( + initResponse.InitStatus + is not (InitStatus.InitCompleted or InitStatus.InitAlreadyCompleted) + ) + throw new InvalidOperationException( + $"{nameof(LambdaTestServer)} failed to initialize and returned a status of {initResponse.InitStatus.ToString()}." + ); + } + + if (State != ServerState.Running) + throw new InvalidOperationException( + $"{nameof(LambdaTestServer)} is in state {State} and cannot invoke an event. Expected state: Running." + ); + + using var cts = LinkedCtsWithInvocationDeadline(cancellationToken); + + traceId ??= Guid.NewGuid().ToString(); + + // Generate unique request ID + var requestId = GetRequestId(); + + // Create the event response with Lambda headers + var eventResponse = CreateEventResponse(invokeEvent, requestId, traceId); + var deadlineUtc = DateTimeOffset.UtcNow.Add(_serverOptions.FunctionTimeout); + + var pending = PendingInvocation.Create(requestId, eventResponse, deadlineUtc); + + _pendingInvocations.AddRequired(requestId, pending); + + if (!_pendingInvocationIds.Writer.TryWrite(requestId)) + throw new InvalidOperationException("Failed to enqueue pending invocation"); + + var completion = await pending.ResponseTcs.Task.WaitAsync(cts.Token); + + var responseMessage = completion.Request; + var wasSuccess = completion.RequestType == RequestType.PostResponse; + + var response = + wasSuccess && !noResponse + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cts.Token + ) ?? Task.FromResult(default) + ) + : default; + + var error = !wasSuccess + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cts.Token + ) ?? Task.FromResult(null) + ) + : null; + + _pendingInvocations.TryRemove(requestId, out _); + + return new InvocationResponse + { + WasSuccess = wasSuccess, + Response = response, + Error = error, + }; + } + + /// + /// Gracefully stops the running test server, shutting down the Lambda application host and + /// completing all background processing. + /// + /// + /// A to observe while waiting for the server to stop. + /// + /// A representing the asynchronous stop operation. + /// + /// Thrown if the server is not currently in the state. + /// + /// + /// + /// This method performs the following shutdown sequence: + /// + /// + /// Transitions the server state to + /// Cancels the internal shutdown token to signal background tasks + /// Stops the application host via + /// Waits for the entry point and processing tasks to complete + /// Transitions the server state to + /// + /// + /// After stopping, the server cannot be restarted. A new instance + /// must be created for subsequent test runs. + /// + /// + public async Task StopAsync(CancellationToken cancellationToken = default) + { + if (State is <= ServerState.Created or >= ServerState.Stopping) + throw new InvalidOperationException( + $"{nameof(LambdaTestServer)} cannot be stopped in state {State}." + ); + + State = ServerState.Stopping; + + await _shutdownCts.CancelAsync(); + + _applicationLifetime?.StopApplication(); + + await TaskHelpers + .WhenAll(_entryPointCompletion, _processingTask ?? Task.CompletedTask) + .UnwrapAndThrow("Exception(s) encountered while running StopAsync") + .WaitAsync(cancellationToken); + + State = ServerState.Stopped; + } + + // ┌──────────────────────────────────────────────────────────┐ + // │ Internal TestServer Logic │ + // └──────────────────────────────────────────────────────────┘ + + private async Task ProcessTransactionsAsync() + { + try + { + await foreach ( + var transaction in _transactionChannel.Reader.ReadAllAsync(_shutdownCts.Token) + ) + { + if ( + !LambdaRuntimeRouteManager.TryMatch( + transaction.Request, + out var requestType, + out var routeValues + ) + ) + throw new InvalidOperationException( + $"Unexpected request received from the Lambda HTTP handler: {transaction.Request.Method} {transaction.Request.RequestUri}" + ); + + switch (requestType.Value) + { + case RequestType.GetNextInvocation: + await HandleGetNextInvocationAsync(transaction); + break; + + case RequestType.PostResponse: + await HandlePostResponseAsync(transaction, routeValues); + break; + + case RequestType.PostError: + await HandlePostErrorAsync(transaction, routeValues); + break; + + case RequestType.PostInitError: + await HandlePostInitErrorAsync(transaction); + break; + + default: + throw new InvalidOperationException( + $"Unexpected request type {requestType} for {transaction.Request.RequestUri}" + ); + } + } + } + catch (OperationCanceledException) when (_shutdownCts.IsCancellationRequested) + { + // Expected when task is canceled + } + } + + private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transaction) + { + if (State == ServerState.Starting) + _initCompletionTcs.SetResult( + new InitResponse { InitStatus = InitStatus.InitCompleted } + ); + + if (await _pendingInvocationIds.Reader.WaitToReadAsync(_shutdownCts.Token)) + { + var requestId = await _pendingInvocationIds.Reader.ReadAsync(_shutdownCts.Token); + _pendingInvocations.GetRequired(requestId, out var pendingInvocation); + transaction.ResponseTcs.SetResult(pendingInvocation.EventResponse); + } + } + + private async Task HandlePostResponseAsync( + LambdaHttpTransaction transaction, + RouteValueDictionary routeValues + ) + { + _pendingInvocations.GetRequired(routeValues["requestId"]?.ToString(), out var pending); + + // Acknowledge to Bootstrap + transaction.Respond(CreateSuccessResponse()); + + pending.ResponseTcs.SetResult( + await CreateCompletionAsync(RequestType.PostResponse, transaction.Request) + ); + } + + private async Task HandlePostErrorAsync( + LambdaHttpTransaction transaction, + RouteValueDictionary routeValues + ) + { + _pendingInvocations.GetRequired(routeValues["requestId"]?.ToString(), out var pending); + + // Acknowledge to Bootstrap + transaction.Respond(CreateSuccessResponse()); + + pending.ResponseTcs.SetResult( + await CreateCompletionAsync(RequestType.PostError, transaction.Request) + ); + } + + private async Task HandlePostInitErrorAsync(LambdaHttpTransaction transaction) + { + if (State == ServerState.Starting) + { + _initCompletionTcs.SetResult( + new InitResponse + { + Error = await ( + transaction.Request.Content?.ReadFromJsonAsync( + _jsonSerializerOptions + ) ?? Task.FromResult(null) + ), + InitStatus = InitStatus.InitError, + } + ); + return; + } + + throw new InvalidOperationException( + $"{nameof(LambdaTestServer)} is already started and as such an initialization error cannot be reported." + ); + } + + private HttpResponseMessage CreateEventResponse( + TEvent? invokeEvent, + string requestId, + string traceId + ) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Version = Version.Parse("1.1"), + }; + + if (invokeEvent is not null) + response.Content = new StringContent( + JsonSerializer.Serialize(invokeEvent, _jsonSerializerOptions), + Encoding.UTF8, + "application/json" + ); + + // Add standard HTTP headers + response.Headers.Date = new DateTimeOffset(DateTime.UtcNow, TimeSpan.Zero); + response.Headers.TransferEncodingChunked = true; + + // Add custom Lambda runtime headers + var deadlineMs = DateTimeOffset + .UtcNow.Add(_serverOptions.FunctionTimeout) + .ToUnixTimeMilliseconds(); + response.Headers.Add("Lambda-Runtime-Deadline-Ms", deadlineMs.ToString()); + response.Headers.Add("Lambda-Runtime-Aws-Request-Id", requestId); + response.Headers.Add("Lambda-Runtime-Trace-Id", traceId); + response.Headers.Add("Lambda-Runtime-Invoked-Function-Arn", _serverOptions.FunctionArn); + + // Add any additional custom headers + foreach (var header in _serverOptions.AdditionalHeaders) + response.Headers.Add(header.Key, header.Value); + + return response; + } + + private string GetRequestId() => + Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); + + private static async Task CreateCompletionAsync( + RequestType requestType, + HttpRequestMessage sourceRequest + ) + { + var clonedRequest = new HttpRequestMessage(sourceRequest.Method, sourceRequest.RequestUri) + { + Version = sourceRequest.Version, + VersionPolicy = sourceRequest.VersionPolicy, + }; + + foreach (var header in sourceRequest.Headers) + clonedRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + + foreach (var option in sourceRequest.Options) + clonedRequest.Options.TryAdd(option.Key, option.Value); + + if (sourceRequest.Content != null) + { + var contentBytes = await sourceRequest.Content.ReadAsByteArrayAsync(); + var clonedContent = new ByteArrayContent(contentBytes); + + foreach (var header in sourceRequest.Content.Headers) + clonedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + + clonedRequest.Content = clonedContent; + } + + return new InvocationCompletion { Request = clonedRequest, RequestType = requestType }; + } + + private static HttpResponseMessage CreateSuccessResponse() => + new(HttpStatusCode.Accepted) + { + Content = new StringContent( + """ + {"status":"success"} + """, + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + }; + + private CancellationTokenSource LinkedCtsWithInvocationDeadline( + CancellationToken cancellationTokens + ) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationTokens, + _shutdownCts.Token + ); + cts.CancelAfter(_serverOptions.FunctionTimeout); + + return cts; + } + + private CancellationTokenSource LinkedCts(CancellationToken cancellationTokens) => + CancellationTokenSource.CreateLinkedTokenSource(cancellationTokens, _shutdownCts.Token); +} diff --git a/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs new file mode 100644 index 00000000..37e31c26 --- /dev/null +++ b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs @@ -0,0 +1,124 @@ +namespace MinimalLambda.Testing; + +/// +/// Provides convenience extension methods for to simplify common invocation patterns. +/// +/// +/// +/// These extension methods wrap the core method, +/// providing simplified overloads for common scenarios such as invocations without events or without response bodies. +/// +/// +public static class LambdaTestServerExtensions +{ + extension(LambdaTestServer server) + { + /// + /// Invokes the Lambda function with the specified event and waits for the response. + /// + /// The type of the Lambda event to send to the function. + /// The expected type of the Lambda function's response. + /// The event object to pass to the Lambda function. + /// + /// A to observe while waiting for the invocation to complete. + /// + /// + /// A that completes with an + /// containing either the successful response or error information from the Lambda function. + /// + /// + /// Thrown if the server is not in a valid state to accept invocations. + /// + /// + /// Thrown if the invocation times out or the is cancelled. + /// + /// + /// This is a convenience method that invokes the Lambda function with an event and expects a response body. + /// It is equivalent to calling with + /// noResponse set to . + /// + public Task> InvokeAsync( + TEvent invokeEvent, + CancellationToken cancellationToken = default + ) => + server.InvokeAsync( + invokeEvent, + false, + cancellationToken: cancellationToken + ); + + /// + /// Invokes the Lambda function without an event and waits for the response. + /// + /// The expected type of the Lambda function's response. + /// + /// A to observe while waiting for the invocation to complete. + /// + /// + /// A that completes with an + /// containing either the successful response or error information from the Lambda function. + /// + /// + /// Thrown if the server is not in a valid state to accept invocations. + /// + /// + /// Thrown if the invocation times out or the is cancelled. + /// + /// + /// + /// This is a convenience method for invoking Lambda functions that do not require an event payload, + /// such as handlers that perform scheduled tasks or generate data without input. + /// + /// + /// It is equivalent to calling with + /// a event and noResponse set to . + /// + /// + public Task> InvokeNoEventAsync( + CancellationToken cancellationToken = default + ) => + server.InvokeAsync( + null, + false, + cancellationToken: cancellationToken + ); + + /// + /// Invokes the Lambda function with the specified event but does not expect or deserialize a response body. + /// + /// The type of the Lambda event to send to the function. + /// The event object to pass to the Lambda function. + /// + /// A to observe while waiting for the invocation to complete. + /// + /// + /// A that completes with an + /// containing either success status or error information from the Lambda function. + /// + /// + /// Thrown if the server is not in a valid state to accept invocations. + /// + /// + /// Thrown if the invocation times out or the is cancelled. + /// + /// + /// + /// This is a convenience method for invoking Lambda functions that do not return a response body, + /// such as handlers that write directly to streams or perform side effects without returning data. + /// + /// + /// It is equivalent to calling with + /// noResponse set to . + /// + /// + public async Task InvokeNoResponseAsync( + TEvent invokeEvent, + CancellationToken cancellationToken = default + ) => + await server.InvokeAsync( + invokeEvent, + true, + cancellationToken: cancellationToken + ); + } +} diff --git a/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj b/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj new file mode 100644 index 00000000..90a3e785 --- /dev/null +++ b/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj @@ -0,0 +1,27 @@ + + + net8.0;net9.0;net10.0 + preview + enable + enable + true + true + true + + MinimalLambda.Testing + In-memory testing harness for MinimalLambda + README.md + + + + + + + + + + + + + + diff --git a/src/MinimalLambda.Testing/Models/ErrorResponse.cs b/src/MinimalLambda.Testing/Models/ErrorResponse.cs new file mode 100644 index 00000000..dd0c433d --- /dev/null +++ b/src/MinimalLambda.Testing/Models/ErrorResponse.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace MinimalLambda.Testing; + +/// +/// Represents an error response with type, message, stack trace, and optional cause. +/// +public class ErrorResponse +{ + /// + /// The underlying cause of this error, if any. + /// + [JsonPropertyName("cause")] + public ErrorCause? Cause { get; set; } + + /// + /// The underlying causes of this error, if any. + /// + [JsonPropertyName("causes")] + public List? Causes { get; set; } + + /// + /// The error message describing what went wrong. + /// + [JsonPropertyName("errorMessage")] + public required string ErrorMessage { get; set; } + + /// + /// The type of error that occurred. + /// + [JsonPropertyName("errorType")] + public required string ErrorType { get; set; } + + /// + /// The stack trace showing where the error occurred. + /// + [JsonPropertyName("stackTrace")] + public List StackTrace { get; set; } = []; + + /// + /// Represents the cause of an error, which can have its own nested cause. + /// + public class ErrorCause + { + /// + /// The underlying cause of this error, if any. + /// + [JsonPropertyName("cause")] + public ErrorCause? Cause { get; set; } + + /// + /// The error message describing what went wrong. + /// + [JsonPropertyName("errorMessage")] + public required string ErrorMessage { get; set; } + + /// + /// The type of error that occurred. + /// + [JsonPropertyName("errorType")] + public required string ErrorType { get; set; } + + /// + /// The stack trace showing where the error occurred. + /// + [JsonPropertyName("stackTrace")] + public List StackTrace { get; set; } = []; + } +} diff --git a/src/MinimalLambda.Testing/Models/InitResponse.cs b/src/MinimalLambda.Testing/Models/InitResponse.cs new file mode 100644 index 00000000..823f9947 --- /dev/null +++ b/src/MinimalLambda.Testing/Models/InitResponse.cs @@ -0,0 +1,17 @@ +namespace MinimalLambda.Testing; + +/// +/// Represents the result of a Lambda function initialization attempt. +/// +public class InitResponse +{ + /// + /// Gets the error information if initialization failed, or null if initialization succeeded. + /// + public ErrorResponse? Error { get; internal init; } + + /// + /// Gets the status of the initialization attempt. + /// + public InitStatus InitStatus { get; internal init; } +} diff --git a/src/MinimalLambda.Testing/Models/InitStatus.cs b/src/MinimalLambda.Testing/Models/InitStatus.cs new file mode 100644 index 00000000..ae8f0f92 --- /dev/null +++ b/src/MinimalLambda.Testing/Models/InitStatus.cs @@ -0,0 +1,27 @@ +namespace MinimalLambda.Testing; + +/// +/// An enumeration of possible statuses for Lambda initialization. +/// +public enum InitStatus +{ + /// + /// Initialization of the Lambda completed successfully. + /// + InitCompleted, + + /// + /// Initialization of the Lambda failed, and the Lambda returned an error. + /// + InitError, + + /// + /// Initialization of the Lambda failed, and the Host process exited. + /// + HostExited, + + /// + /// Initialization has already been completed and cannot be repeated. + /// + InitAlreadyCompleted, +} diff --git a/src/MinimalLambda.Testing/Models/InvocationCompletion.cs b/src/MinimalLambda.Testing/Models/InvocationCompletion.cs new file mode 100644 index 00000000..0737b98a --- /dev/null +++ b/src/MinimalLambda.Testing/Models/InvocationCompletion.cs @@ -0,0 +1,10 @@ +namespace MinimalLambda.Testing; + +/// +/// Represents the completion of a Lambda invocation with metadata about the outcome. +/// +internal class InvocationCompletion +{ + internal required HttpRequestMessage Request { get; init; } + internal required RequestType RequestType { get; init; } +} diff --git a/src/MinimalLambda.Testing/Models/InvocationResponse.cs b/src/MinimalLambda.Testing/Models/InvocationResponse.cs new file mode 100644 index 00000000..354e2802 --- /dev/null +++ b/src/MinimalLambda.Testing/Models/InvocationResponse.cs @@ -0,0 +1,48 @@ +namespace MinimalLambda.Testing; + +/// +/// Represents the base result of a Lambda function invocation, containing success status and +/// error information. +/// +/// +/// +/// This class serves as the non-generic base for , +/// providing common properties for all invocation results regardless of the response type. +/// It contains the flag and information that +/// are shared across all invocation responses. +/// +/// +/// For invocations that return typed response data, use the generic +/// class instead. +/// +/// +public class InvocationResponse +{ + /// + /// Gets the error information if the Lambda function invocation failed, or + /// if the invocation succeeded. + /// + /// + /// An containing error details, or for successful invocations. + /// + /// + /// This property is populated when the Lambda function reports an error via the runtime API's + /// error endpoint, or when the invocation times out or encounters other failures. + /// + public ErrorResponse? Error { get; internal init; } + + /// + /// Gets a value indicating whether the Lambda function invocation completed successfully. + /// + /// + /// if the invocation succeeded and response data is available (in + /// for generic invocations); + /// if the invocation failed and contains error information. + /// + /// + /// Use this property to determine whether the invocation succeeded or failed, which indicates + /// whether response data or information contains meaningful data for the + /// invocation result. + /// + public bool WasSuccess { get; internal init; } +} diff --git a/src/MinimalLambda.Testing/Models/InvocationResponseT.cs b/src/MinimalLambda.Testing/Models/InvocationResponseT.cs new file mode 100644 index 00000000..e4d1e58b --- /dev/null +++ b/src/MinimalLambda.Testing/Models/InvocationResponseT.cs @@ -0,0 +1,31 @@ +namespace MinimalLambda.Testing; + +/// +/// Represents the result of a Lambda function invocation with a typed response, containing either +/// a successful response or error information. +/// +/// The expected type of the successful Lambda response. +/// +/// +/// This generic class extends to provide strongly-typed access +/// to the Lambda function's response data. Use the +/// property to determine whether the invocation succeeded or failed. If successful, +/// will contain the deserialized response data. If failed, +/// will contain details about the error. +/// +/// +public class InvocationResponse : InvocationResponse +{ + /// + /// Gets the Lambda function's response data if the invocation succeeded, or the default value + /// of if the invocation failed. + /// + /// + /// The deserialized response from the Lambda function, or for failed invocations. + /// + /// + /// This property is populated when the Lambda function successfully completes and returns a response + /// via the runtime API's response endpoint. + /// + public TResponse? Response { get; internal init; } +} diff --git a/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs b/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs new file mode 100644 index 00000000..cb3a6015 --- /dev/null +++ b/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs @@ -0,0 +1,41 @@ +namespace MinimalLambda.Testing; + +/// +/// Represents a single HTTP transaction from the Lambda Bootstrap. +/// Bundles the request with its response completion mechanism for automatic correlation. +/// +internal class LambdaHttpTransaction +{ + /// + /// The HTTP request from Lambda Bootstrap. + /// + internal required HttpRequestMessage Request { get; init; } + + /// + /// Task completion source for the HTTP response. + /// Completing this sends the response back to Bootstrap. + /// + internal required TaskCompletionSource ResponseTcs { get; init; } + + /// + /// Creates a new transaction with RunContinuationsAsynchronously to prevent deadlocks. + /// + internal static LambdaHttpTransaction Create(HttpRequestMessage request) => + new() + { + Request = request, + ResponseTcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ), + }; + + /// + /// Completes the transaction with a successful HTTP response. + /// + internal bool Respond(HttpResponseMessage response) => ResponseTcs.TrySetResult(response); + + /// + /// Completes the transaction with cancellation. + /// + internal bool Cancel() => ResponseTcs.TrySetCanceled(); +} diff --git a/src/MinimalLambda.Testing/Models/PendingInvocation.cs b/src/MinimalLambda.Testing/Models/PendingInvocation.cs new file mode 100644 index 00000000..12eb63b9 --- /dev/null +++ b/src/MinimalLambda.Testing/Models/PendingInvocation.cs @@ -0,0 +1,52 @@ +namespace MinimalLambda.Testing; + +/// +/// Represents a pending Lambda invocation waiting for Bootstrap to process and respond. +/// +internal class PendingInvocation +{ + /// + /// Timestamp when this invocation was created (for timeout tracking). + /// + internal DateTime CreatedAt { get; init; } = DateTime.UtcNow; + + /// + /// Absolute time when this invocation should be considered expired. + /// + internal required DateTimeOffset DeadlineUtc { get; init; } + + /// + /// The HTTP response containing the serialized event payload and Lambda headers + /// to send to Bootstrap when it polls for the next invocation. + /// + internal required HttpResponseMessage EventResponse { get; init; } + + /// + /// Unique request ID for this invocation (will be in Lambda-Runtime-Aws-Request-Id header). + /// + internal required string RequestId { get; init; } + + /// + /// Task completion source for the invocation result. + /// Completed when Bootstrap posts response or error with the HTTP request containing the result payload. + /// + internal required TaskCompletionSource ResponseTcs { get; init; } + + /// + /// Creates a pending invocation with proper TCS configuration. + /// + internal static PendingInvocation Create( + string requestId, + HttpResponseMessage eventResponse, + DateTimeOffset deadlineUtc + ) => + new() + { + RequestId = requestId, + EventResponse = eventResponse, + ResponseTcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ), + DeadlineUtc = deadlineUtc, + }; +} diff --git a/src/MinimalLambda.Testing/Models/RequestType.cs b/src/MinimalLambda.Testing/Models/RequestType.cs new file mode 100644 index 00000000..406ba336 --- /dev/null +++ b/src/MinimalLambda.Testing/Models/RequestType.cs @@ -0,0 +1,9 @@ +namespace MinimalLambda.Testing; + +internal enum RequestType +{ + GetNextInvocation, + PostResponse, + PostError, + PostInitError, +} diff --git a/src/MinimalLambda.Testing/Models/ServerState.cs b/src/MinimalLambda.Testing/Models/ServerState.cs new file mode 100644 index 00000000..164c3803 --- /dev/null +++ b/src/MinimalLambda.Testing/Models/ServerState.cs @@ -0,0 +1,37 @@ +namespace MinimalLambda.Testing; + +/// +/// Represents the lifecycle state of a LambdaTestServer. +/// +public enum ServerState +{ + /// + /// TestServer created but not started. + /// + Created, + + /// + /// TestServer is starting (building host). + /// + Starting, + + /// + /// TestServer is running and accepting invocations. + /// + Running, + + /// + /// TestServer is stopping. + /// + Stopping, + + /// + /// TestServer has stopped cleanly. + /// + Stopped, + + /// + /// TestServer has been disposed. + /// + Disposed, +} diff --git a/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs b/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs new file mode 100644 index 00000000..7c977de0 --- /dev/null +++ b/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs @@ -0,0 +1,28 @@ +namespace MinimalLambda.Testing; + +/// +/// Configuration options for the Lambda test client. +/// +public class LambdaServerOptions +{ + /// + /// Gets or sets additional custom headers to include in the Lambda invocation response. + /// Use this to add any additional headers beyond the standard Lambda runtime headers. + /// + public Dictionary AdditionalHeaders { get; set; } = new(); + + /// + /// Gets or sets the ARN of the Lambda function being invoked. + /// Maps to the Lambda-Runtime-Invoked-Function-Arn header. + /// + public string FunctionArn { get; set; } = + "arn:aws:lambda:us-west-2:123412341234:function:Function"; + + /// + /// Gets or sets the Lambda function timeout duration. + /// Maps to the Lambda-Runtime-Deadline-Ms header as Unix epoch milliseconds. + /// This determines when the Lambda function will timeout, calculated as the current time plus this duration. + /// Defaults to 15 minutes. + /// + public TimeSpan FunctionTimeout { get; set; } = TimeSpan.FromMinutes(15); +} diff --git a/src/MinimalLambda.Testing/Properties/AssemblyInfo.cs b/src/MinimalLambda.Testing/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..e3d42db9 --- /dev/null +++ b/src/MinimalLambda.Testing/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")] diff --git a/src/MinimalLambda.Testing/README.md b/src/MinimalLambda.Testing/README.md new file mode 100644 index 00000000..dc0a1231 --- /dev/null +++ b/src/MinimalLambda.Testing/README.md @@ -0,0 +1,118 @@ +# MinimalLambda.Testing + +In-memory Lambda runtime for end-to-end and integration testing with the MinimalLambda framework. + +> 📚 **[View Full Documentation](https://j-d-ha.github.io/minimal-lambda/)** + +## Overview + +MinimalLambda.Testing lets you run Lambda functions entirely in memory, exercising the same runtime +API that AWS provides without deploying or opening network ports. It follows the familiar ASP.NET +Core `WebApplicationFactory` pattern: reusing your real `Program` entry point via +`LambdaApplicationFactory` and driving it through a `LambdaTestServer` that mimics the +Lambda Runtime API (init, invocation, and shutdown). + +Use it to: + +- **Boot real apps**: Spin up your Lambda entry point with `LambdaApplicationFactory` just like + ASP.NET's `WebApplicationFactory` +- **Simulate Runtime API**: `LambdaTestServer` feeds events and receives responses/errors over the + Lambda Runtime HTTP contract—no mocks or stubs +- **Typed Invocations**: `InvokeAsync` sends strongly typed events and returns + typed responses, including structured error details +- **Lifecycle Coverage**: Exercise `OnInit` and `OnShutdown` hooks and verify cold-start logic +- **Host Customization**: Override configuration and services for tests with `WithHostBuilder` + +## Installation + +This package extends [MinimalLambda](../MinimalLambda/README.md); install both: + +```bash +dotnet add package MinimalLambda +dotnet add package MinimalLambda.Testing +``` + +Ensure your project uses C# 11 or later: + +```xml + + + 11 + + +``` + +## Quick Start + +Write an end-to-end test that drives your Lambda through the in-memory runtime: + +```csharp +using MinimalLambda.Testing; +using Xunit; + +public class LambdaTests +{ + [Fact] + public async Task HelloWorldHandler_ReturnsGreeting() + { + await using var factory = new LambdaApplicationFactory(); + + await factory.TestServer.StartAsync(); + + var response = await factory.TestServer.InvokeAsync("World"); + + Assert.True(response.WasSuccess); + Assert.Equal("Hello World!", response.Response); + } +} +``` + +Customize the host configuration for a specific test: + +```csharp +await using var factory = new LambdaApplicationFactory().WithHostBuilder(builder => +{ + builder.ConfigureServices((_, services) => + { + // Override registrations or configuration for this test run + }); +}); +``` + +Use `LambdaServerOptions` to tweak runtime details such as timeouts, ARN, or custom headers returned +by the simulated Runtime API. + +## Key Features + +- **Runtime-accurate simulation** – Emulates the Lambda Runtime API (init, `/invocation/next`, + response/error posts) over an in-memory message channel +- **End-to-end coverage** – Drives source-generated handlers, middleware, envelopes, DI scopes, and + lifecycle hooks exactly as they run in production +- **Typed invocation helpers** – `InvokeAsync` returns structured + `InvocationResponse` objects with success flags and error payloads +- **Host customization** – `WithHostBuilder` and `LambdaApplicationFactoryContentRootAttribute` + mirror ASP.NET testing patterns for overriding configuration and locating content roots +- **Concurrency safe** – Handles multiple pending invocations FIFO with per-request correlation + +## Other Packages + +Additional packages in the minimal-lambda framework for abstractions, observability, and event +source handling. + +| Package | NuGet | Downloads | +|---------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [**MinimalLambda**](../MinimalLambda/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.svg)](https://www.nuget.org/packages/MinimalLambda) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.svg)](https://www.nuget.org/packages/MinimalLambda/) | +| [**MinimalLambda.Abstractions**](../MinimalLambda.Abstractions/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Abstractions.svg)](https://www.nuget.org/packages/MinimalLambda.Abstractions) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Abstractions.svg)](https://www.nuget.org/packages/MinimalLambda.Abstractions/) | +| [**MinimalLambda.OpenTelemetry**](../MinimalLambda.OpenTelemetry/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.OpenTelemetry.svg)](https://www.nuget.org/packages/MinimalLambda.OpenTelemetry) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.OpenTelemetry.svg)](https://www.nuget.org/packages/MinimalLambda.OpenTelemetry/) | +| [**MinimalLambda.Envelopes.Sqs**](../Envelopes/MinimalLambda.Envelopes.Sqs/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.Sqs.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Sqs) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.Sqs.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Sqs/) | +| [**MinimalLambda.Envelopes.ApiGateway**](../Envelopes/MinimalLambda.Envelopes.ApiGateway/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.ApiGateway.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.ApiGateway) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.ApiGateway.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.ApiGateway/) | +| [**MinimalLambda.Envelopes.Sns**](../Envelopes/MinimalLambda.Envelopes.Sns/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.Sns.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Sns) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.Sns.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Sns/) | +| [**MinimalLambda.Envelopes.Kinesis**](../Envelopes/MinimalLambda.Envelopes.Kinesis/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.Kinesis.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Kinesis) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.Kinesis.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Kinesis/) | +| [**MinimalLambda.Envelopes.KinesisFirehose**](../Envelopes/MinimalLambda.Envelopes.KinesisFirehose/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.KinesisFirehose.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.KinesisFirehose) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.KinesisFirehose.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.KinesisFirehose/) | +| [**MinimalLambda.Envelopes.Kafka**](../Envelopes/MinimalLambda.Envelopes.Kafka/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.Kafka.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Kafka) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.Kafka.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Kafka/) | +| [**MinimalLambda.Envelopes.CloudWatchLogs**](../Envelopes/MinimalLambda.Envelopes.CloudWatchLogs/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.CloudWatchLogs.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.CloudWatchLogs) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.CloudWatchLogs.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.CloudWatchLogs/) | +| [**MinimalLambda.Envelopes.Alb**](../Envelopes/MinimalLambda.Envelopes.Alb/README.md) | [![NuGet](https://img.shields.io/nuget/v/MinimalLambda.Envelopes.Alb.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Alb) | [![Downloads](https://img.shields.io/nuget/dt/MinimalLambda.Envelopes.Alb.svg)](https://www.nuget.org/packages/MinimalLambda.Envelopes.Alb/) | + +## License + +This project is licensed under the MIT License. See [LICENSE](../../LICENSE) for details. diff --git a/src/MinimalLambda.Testing/TaskHelpers.cs b/src/MinimalLambda.Testing/TaskHelpers.cs new file mode 100644 index 00000000..dede2c6d --- /dev/null +++ b/src/MinimalLambda.Testing/TaskHelpers.cs @@ -0,0 +1,47 @@ +namespace MinimalLambda.Testing; + +internal static class TaskHelpers +{ + internal static async Task WhenAny(params Task[] tasks) + { + await Task.WhenAny(tasks); + return ExtractExceptions(tasks); + } + + internal static async Task WhenAll(params Task[] tasks) + { + await Task.WhenAll(tasks); + return ExtractExceptions(tasks); + } + + private static Exception[] ExtractExceptions(Task[] tasks) => + tasks + .Where(t => + t + is { Exception: not null } + or Task { Status: TaskStatus.RanToCompletion, Result: not null } + or Task { Status: TaskStatus.RanToCompletion, Result: Exception } + ) + .Select(t => + t switch + { + { Exception: not null } => t.Exception!, + Task { Result: { } ex } => ex, + Task { Result: Exception ex } => ex, + _ => null, + } + ) + .Where(static ex => ex is not null) + .Cast() + .ToArray(); + + extension(Task exceptionsTask) + { + internal async Task UnwrapAndThrow(string errorMessage) + { + var exceptions = await exceptionsTask; + if (exceptions.Length > 0) + throw new AggregateException(errorMessage, exceptions); + } + } +} diff --git a/src/MinimalLambda/Runtime/LambdaHostedService.cs b/src/MinimalLambda/Runtime/LambdaHostedService.cs index 7557a17a..c1ccd572 100644 --- a/src/MinimalLambda/Runtime/LambdaHostedService.cs +++ b/src/MinimalLambda/Runtime/LambdaHostedService.cs @@ -117,14 +117,15 @@ public async Task StopAsync(CancellationToken cancellationToken) } catch (TaskCanceledException) { - // if the task completes due to the cancellation token triggering, then we need to tell - // the user that shutdown failed - exceptions.Add( - new OperationCanceledException( - "Graceful shutdown of the Lambda function failed: the bootstrap operation did " - + "not complete within the allocated timeout period." - ) - ); + // If cancellation was requested, the task timed out during shutdown - report error. + // Otherwise, the TaskCanceledException came from LambdaBootstrap and can be ignored. + if (cancellationToken.IsCancellationRequested) + exceptions.Add( + new OperationCanceledException( + "Graceful shutdown of the Lambda function failed: the bootstrap operation did " + + "not complete within the allocated timeout period." + ) + ); } catch (Exception ex) { diff --git a/tests/MinimalLambda.Envelopes.UnitTests/MinimalLambda.Envelopes.UnitTests.csproj b/tests/MinimalLambda.Envelopes.UnitTests/MinimalLambda.Envelopes.UnitTests.csproj index e6b4c2bf..0f54f85c 100644 --- a/tests/MinimalLambda.Envelopes.UnitTests/MinimalLambda.Envelopes.UnitTests.csproj +++ b/tests/MinimalLambda.Envelopes.UnitTests/MinimalLambda.Envelopes.UnitTests.csproj @@ -17,6 +17,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/MinimalLambda.OpenTelemetry.UnitTests/MinimalLambda.OpenTelemetry.UnitTests.csproj b/tests/MinimalLambda.OpenTelemetry.UnitTests/MinimalLambda.OpenTelemetry.UnitTests.csproj index 4d2e6a12..47ac57d3 100644 --- a/tests/MinimalLambda.OpenTelemetry.UnitTests/MinimalLambda.OpenTelemetry.UnitTests.csproj +++ b/tests/MinimalLambda.OpenTelemetry.UnitTests/MinimalLambda.OpenTelemetry.UnitTests.csproj @@ -22,6 +22,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/MinimalLambda.Testing.UnitTests.DiLambda.csproj b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/MinimalLambda.Testing.UnitTests.DiLambda.csproj new file mode 100644 index 00000000..e26f02d7 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/MinimalLambda.Testing.UnitTests.DiLambda.csproj @@ -0,0 +1,28 @@ + + + Exe + net8.0;net9.0;net10.0 + preview + enable + enable + true + Lambda + true + true + $(InterceptorsNamespaces);MinimalLambda.Generated + false + + + + + + + + PreserveNewest + + + diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs new file mode 100644 index 00000000..5cae4ce2 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +await using var lambda = builder.Build(); + +lambda.OnInit( + (ILifecycleService service, ILogger logger) => + { + logger.LogInformation("Init 1"); + return service.OnStart(); + } +); + +lambda.UseMiddleware( + async (context, next) => + { + var logger = context.ServiceProvider.GetRequiredService>(); + + logger.LogInformation("Middleware 1: Before"); + await next(context); + logger.LogInformation("Middleware 1: After"); + } +); + +lambda.MapHandler( + ([Event] DiLambdaRequest diLambdaRequest, IService service, ILogger logger) => + { + logger.LogInformation("Lambda handler"); + return new DiLambdaResponse(service.GetMessage(diLambdaRequest.Name), DateTime.UtcNow); + } +); + +lambda.OnShutdown( + (ILifecycleService service, ILogger logger) => + { + logger.LogInformation("Shutdown 1"); + service.OnStop(); + } +); + +await lambda.RunAsync(); + +public class DiLambda; + +internal record DiLambdaRequest(string Name); + +internal record DiLambdaResponse(string Message, DateTime TimestampUtc); + +internal interface IService +{ + string GetMessage(string name); +} + +internal class Service : IService +{ + public string GetMessage(string name) => $"Hello {name}!"; +} + +internal interface ILifecycleService +{ + bool OnStart(); + void OnStop(); +} + +internal class LifecycleService : ILifecycleService +{ + public bool OnStart() => true; + + public void OnStop() { } +} diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Properties/AssemblyInfo.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..3af1a611 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/appsettings.json b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/appsettings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/appsettings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/MinimalLambda.Testing.UnitTests.NoEventLambda.csproj b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/MinimalLambda.Testing.UnitTests.NoEventLambda.csproj new file mode 100644 index 00000000..e26f02d7 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/MinimalLambda.Testing.UnitTests.NoEventLambda.csproj @@ -0,0 +1,28 @@ + + + Exe + net8.0;net9.0;net10.0 + preview + enable + enable + true + Lambda + true + true + $(InterceptorsNamespaces);MinimalLambda.Generated + false + + + + + + + + PreserveNewest + + + diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs new file mode 100644 index 00000000..238e3d57 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +builder.Configuration.AddInMemoryCollection( + new Dictionary { ["MESSAGE"] = "Hello World!" }! +); + +await using var lambda = builder.Build(); + +lambda.MapHandler( + (IConfiguration configuration) => + new NoEventLambdaResponse(configuration["MESSAGE"] ?? "NOT_FOUND", DateTime.UtcNow) +); + +await lambda.RunAsync(); + +public class NoEventLambda; + +internal record NoEventLambdaResponse(string Message, DateTime TimestampUtc); diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Properties/AssemblyInfo.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..e3d42db9 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")] diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/appsettings.json b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/appsettings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/appsettings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj new file mode 100644 index 00000000..e26f02d7 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj @@ -0,0 +1,28 @@ + + + Exe + net8.0;net9.0;net10.0 + preview + enable + enable + true + Lambda + true + true + $(InterceptorsNamespaces);MinimalLambda.Generated + false + + + + + + + + PreserveNewest + + + diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs new file mode 100644 index 00000000..601f87cc --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Hosting; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +await using var lambda = builder.Build(); + +lambda.MapHandler(([Event] NoResponseLambdaRequest request) => { }); + +await lambda.RunAsync(); + +public class NoResponseLambda; + +internal record NoResponseLambdaRequest(string Name); diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Properties/AssemblyInfo.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..e3d42db9 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")] diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/appsettings.json b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/appsettings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/appsettings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/MinimalLambda.Testing.UnitTests.SimpleLambda.csproj b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/MinimalLambda.Testing.UnitTests.SimpleLambda.csproj new file mode 100644 index 00000000..e26f02d7 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/MinimalLambda.Testing.UnitTests.SimpleLambda.csproj @@ -0,0 +1,28 @@ + + + Exe + net8.0;net9.0;net10.0 + preview + enable + enable + true + Lambda + true + true + $(InterceptorsNamespaces);MinimalLambda.Generated + false + + + + + + + + PreserveNewest + + + diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Program.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Program.cs new file mode 100644 index 00000000..75ae8144 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Hosting; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +await using var lambda = builder.Build(); + +lambda.MapHandler( + ([Event] string name) => + { + if (string.IsNullOrWhiteSpace(name)) + throw new Exception("Name is required"); + + return $"Hello {name}!"; + } +); + +await lambda.RunAsync(); + +public class SimpleLambda; diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Properties/AssemblyInfo.cs b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..e3d42db9 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")] diff --git a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/appsettings.json b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/appsettings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/appsettings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/AutoNSubstituteDataAttribute.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/AutoNSubstituteDataAttribute.cs new file mode 100644 index 00000000..cb3b4557 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/AutoNSubstituteDataAttribute.cs @@ -0,0 +1,15 @@ +using AutoFixture.AutoNSubstitute; +using AutoFixture.Xunit3; + +namespace MinimalLambda.UnitTests; + +public class AutoNSubstituteDataAttribute() + : AutoDataAttribute(() => + new Fixture().Customize(new AutoNSubstituteCustomization { ConfigureMembers = true }) + ); + +public class InlineAutoNSubstituteDataAttribute : InlineAutoDataAttribute +{ + public InlineAutoNSubstituteDataAttribute(params object[] args) + : base(new AutoNSubstituteDataAttribute(), args) { } +} diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs new file mode 100644 index 00000000..7d1d0599 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs @@ -0,0 +1,187 @@ +using Autofac; +using Autofac.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using MinimalLambda.UnitTests; +using NSubstitute.ExceptionExtensions; + +namespace MinimalLambda.Testing.UnitTests; + +public class DiLambdaTests +{ + [Fact] + public async Task DiLambda_ReturnsExpectedValue() + { + await using var factory = new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var response = await factory.TestServer.InvokeAsync( + new DiLambdaRequest("World"), + TestContext.Current.CancellationToken + ); + + response.Should().NotBeNull(); + response.WasSuccess.Should().BeTrue(); + response.Response.Should().NotBeNull(); + response.Response.Message.Should().Be("Hello World!"); + } + + [Fact] + internal async Task DiLambda_InitStopped() + { + var lifecycleService = Substitute.For(); + await using var factory = new LambdaApplicationFactory() + .WithCancellationToken(TestContext.Current.CancellationToken) + .WithHostBuilder(builder => + builder.ConfigureServices( + (_, services) => + { + services.RemoveAll(); + services.AddSingleton(_ => lifecycleService); + } + ) + ); + + lifecycleService.OnStart().Returns(false); + + var initResult = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + initResult.InitStatus.Should().Be(InitStatus.HostExited); + } + + [Fact] + internal async Task DiLambda_InitThrowsException() + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken + ); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + + var lifecycleService = Substitute.For(); + + await using var factory = new LambdaApplicationFactory() + .WithCancellationToken(cts.Token) + .WithHostBuilder(builder => + builder.ConfigureServices( + (_, services) => + { + services.RemoveAll(); + services.AddSingleton(_ => lifecycleService); + } + ) + ); + + lifecycleService.OnStart().Throws(new Exception("Test init error")); + + var initResult = await factory.TestServer.StartAsync(cts.Token); + initResult.InitStatus.Should().Be(InitStatus.InitError); + initResult.Error.Should().NotBeNull(); + initResult + .Error.ErrorMessage.Should() + .Be("Encountered errors while running OnInit handlers: (Test init error)"); + } + + [Theory] + [AutoNSubstituteData] + internal async Task DiLambda_ShutdownThrowsException(ILifecycleService lifecycleService) + { + await using var factory = new LambdaApplicationFactory() + .WithCancellationToken(TestContext.Current.CancellationToken) + .WithHostBuilder(builder => + builder.ConfigureServices( + (_, services) => + { + services.RemoveAll(); + services.AddSingleton(_ => lifecycleService); + } + ) + ); + + lifecycleService.OnStart().Returns(true); + lifecycleService.When(x => x.OnStop()).Do(_ => throw new Exception("Test init error")); + + var initResult = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + initResult.InitStatus.Should().Be(InitStatus.InitCompleted); + + var act = async () => + await factory.TestServer.StopAsync(TestContext.Current.CancellationToken); + + (await act.Should().ThrowAsync()) + .WithInnerException() + .WithInnerException() + .WithInnerException() + .WithMessage("Test init error"); + } + + [Theory] + [AutoNSubstituteData] + internal async Task DiLambda_DiContainerCanBeReplaced( + ILifecycleService lifecycleService, + IService service + ) + { + await using var factory = new LambdaApplicationFactory() + .WithCancellationToken(TestContext.Current.CancellationToken) + .WithHostBuilder(builder => + builder + .ConfigureContainer( + (_, containerBuilder) => + { + containerBuilder.RegisterInstance(service).As(); + containerBuilder + .RegisterInstance(lifecycleService) + .As(); + } + ) + .UseServiceProviderFactory(new AutofacServiceProviderFactory()) + ); + + service.GetMessage(Arg.Any()).Returns("Hello Bob!"); + + var response = await factory.TestServer.InvokeAsync( + new DiLambdaRequest("World"), + TestContext.Current.CancellationToken + ); + + response.Should().NotBeNull(); + response.WasSuccess.Should().BeTrue(); + response.Response.Should().NotBeNull(); + response.Response.Message.Should().Be("Hello Bob!"); + } + + [Theory] + [AutoNSubstituteData] + internal async Task DiLambda_DiContainerCanBeReplacedWithFactory( + ILifecycleService lifecycleService, + IService service + ) + { + await using var factory = new LambdaApplicationFactory() + .WithCancellationToken(TestContext.Current.CancellationToken) + .WithHostBuilder(builder => + builder + .ConfigureContainer( + (_, containerBuilder) => + { + containerBuilder.RegisterInstance(service).As(); + containerBuilder + .RegisterInstance(lifecycleService) + .As(); + } + ) + .UseServiceProviderFactory(_ => new AutofacServiceProviderFactory()) + ); + + service.GetMessage(Arg.Any()).Returns("Hello Joe!"); + + var response = await factory.TestServer.InvokeAsync( + new DiLambdaRequest("World"), + TestContext.Current.CancellationToken + ); + + response.Should().NotBeNull(); + response.WasSuccess.Should().BeTrue(); + response.Response.Should().NotBeNull(); + response.Response.Message.Should().Be("Hello Joe!"); + } +} diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs new file mode 100644 index 00000000..46206a9e --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs @@ -0,0 +1,48 @@ +using JetBrains.Annotations; + +namespace MinimalLambda.Testing.UnitTests; + +[TestSubject(typeof(DictionaryExtensions))] +public class DictionaryExtensionsTest +{ + private readonly Dictionary _dictionary = new() + { + ["key1"] = "value1", + ["key2"] = "value2", + }; + + [Fact] + public void GetRequired_WhenKeyExists_ReturnsValue() + { + _dictionary.GetRequired("key1", out var value); + + value.Should().Be("value1"); + } + + [Fact] + public void AddRequired_WhenKeyAlreadyExists_ThrowsInvalidOperationException() + { + var act = () => _dictionary.AddRequired("key1", "newValue"); + + act.Should().Throw().WithMessage("Key 'key1' already exists."); + } + + [Fact] + public void AddRequired_WhenKeyIsNew_AddsValue() + { + var act = () => _dictionary.AddRequired("key3", "value3"); + + act.Should().NotThrow(); + _dictionary["key3"].Should().Be("value3"); + } + + [Fact] + public void GetRequired_WhenKeyIsMissing_ThrowsInvalidOperationException() + { + var act = () => _dictionary.GetRequired("missing", out _); + + act.Should() + .Throw() + .WithMessage("Key 'missing' is null or does not exist."); + } +} diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs new file mode 100644 index 00000000..bd60b342 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using AutoFixture; +global using AwesomeAssertions; +global using NSubstitute; +global using Xunit; diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj new file mode 100644 index 00000000..657cdaec --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj @@ -0,0 +1,37 @@ + + + net8.0;net9.0;net10.0 + enable + enable + false + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs new file mode 100644 index 00000000..1ceef171 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Configuration; + +namespace MinimalLambda.Testing.UnitTests; + +public class NoEventLambdaTests +{ + [Fact] + public async Task NoEvent_ReturnsExpectedValue() + { + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var response = await factory.TestServer.InvokeNoEventAsync( + TestContext.Current.CancellationToken + ); + + response.Should().NotBeNull(); + response.WasSuccess.Should().BeTrue(); + response.Response.Should().NotBeNull(); + response.Response.Message.Should().Be("Hello World!"); + } + + [Fact] + public async Task NoEvent_ConfigurationCanBeOverwritten() + { + await using var factory = new LambdaApplicationFactory() + .WithCancellationToken(TestContext.Current.CancellationToken) + .WithHostBuilder(builder => + builder.ConfigureAppConfiguration( + (_, config) => + config.AddInMemoryCollection( + new Dictionary { ["MESSAGE"] = "Hello Mars!" }! + ) + ) + ); + + var response = await factory.TestServer.InvokeNoEventAsync( + TestContext.Current.CancellationToken + ); + + response.Should().NotBeNull(); + response.WasSuccess.Should().BeTrue(); + response.Response.Should().NotBeNull(); + response.Response.Message.Should().Be("Hello Mars!"); + } +} diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs new file mode 100644 index 00000000..57741c42 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MinimalLambda.Testing.UnitTests; + +public class NoResponseLambdaTests +{ + [Fact] + public async Task NoResponseLambda_ReturnsExpectedValue() + { + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var response = await factory.TestServer.InvokeNoResponseAsync( + new NoResponseLambdaRequest("World"), + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Should().NotBeNull(); + } + + [Fact] + public async Task NoResponseLambda_ServicesIsAccessible() + { + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var act = () => factory.TestServer.Services.GetRequiredService(); + + act.Should().NotThrow(); + } + + [Fact] + public async Task NoResponseLambda_DisposeCanBeCalledMultipleTimes() + { + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var act = async () => + { + await factory.TestServer.DisposeAsync(); + await factory.TestServer.DisposeAsync(); + }; + + await act.Should().NotThrowAsync(); + } +} diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs new file mode 100644 index 00000000..48b30e1f --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs @@ -0,0 +1,180 @@ +using Microsoft.Extensions.DependencyInjection; +using MinimalLambda.Options; + +namespace MinimalLambda.Testing.UnitTests; + +public class SimpleLambdaTests +{ + [Fact] + public async Task SimpleLambda_ReturnsExpectedValue() + { + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + setup.InitStatus.Should().Be(InitStatus.InitCompleted); + + var response = await factory.TestServer.InvokeAsync( + "World", + cancellationToken: TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Should().NotBeNull(); + response.Response.Should().Be("Hello World!"); + } + + [Fact] + public async Task SimpleLambda_WorksWhenStartIsNotCalled() + { + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var response = await factory.TestServer.InvokeAsync( + "World", + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Should().NotBeNull(); + response.Response.Should().Be("Hello World!"); + } + + [Fact] + public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes() + { + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + // Launch 5 concurrent invocations + var tasks = Enumerable + .Range(1, 5) + .Select(i => + factory.TestServer.InvokeAsync( + $"User{i}", + TestContext.Current.CancellationToken + ) + ) + .ToArray(); + + var responses = await Task.WhenAll(tasks); + + responses.Should().AllSatisfy(r => r.WasSuccess.Should().BeTrue()); + + responses + .Select(r => r.Response) + .Should() + .ContainInOrder( + "Hello User1!", + "Hello User2!", + "Hello User3!", + "Hello User4!", + "Hello User5!" + ); + } + + [Fact] + public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes_WithoutStart() + { + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + // Launch 5 concurrent invocations + var tasks = Enumerable + .Range(1, 5) + .Select(i => + factory.TestServer.InvokeAsync( + $"User{i}", + TestContext.Current.CancellationToken + ) + ) + .ToArray(); + + var responses = await Task.WhenAll(tasks); + + responses.Should().AllSatisfy(r => r.WasSuccess.Should().BeTrue()); + + responses + .Select(r => r.Response) + .Should() + .ContainInOrder( + "Hello User1!", + "Hello User2!", + "Hello User3!", + "Hello User4!", + "Hello User5!" + ); + } + + [Fact] + public async Task SimpleLambda_ReturnsError() + { + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var response = await factory.TestServer.InvokeAsync( + "", + TestContext.Current.CancellationToken + ); + + response.Should().NotBeNull(); + response.WasSuccess.Should().BeFalse(); + response.Error.Should().NotBeNull(); + response.Error.ErrorMessage.Should().Be("Name is required"); + } + + [Fact] + public async Task SimpleLambda_ErrorsArePropagated() + { + await using var factory = new LambdaApplicationFactory() + .WithCancellationToken(TestContext.Current.CancellationToken) + .WithHostBuilder(builder => + { + builder.ConfigureServices( + (_, services) => + { + services.Configure(options => + { + options.BootstrapOptions.RuntimeApiEndpoint = "http://localhost:3002"; + }); + } + ); + }); + + var act = async () => + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + await act.Should() + .ThrowAsync() + .WithMessage( + "Unexpected request received from the Lambda HTTP handler: GET http://http//localhost:3002/2018-06-01/runtime/invocation/next" + ); + } + + [Fact] + public async Task SimpleLambda_WithPreCanceledToken_CancelsInvocation() + { + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var act = () => factory.TestServer.InvokeAsync("Jonas", cts.Token); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj b/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj index caf6b611..bd37d950 100644 --- a/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj +++ b/tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj @@ -23,6 +23,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers; buildtransitive