From 491b32941016cdf25f821fdd413e9136dc7f5f2f Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 14:47:26 -0500 Subject: [PATCH 001/132] feat(testing): add AwsLambda.Host.Testing project - Added a new project `AwsLambda.Host.Testing` to support unit testing for AwsLambda.Host. - Configured multi-targeting for .NET 8.0, 9.0, and 10.0 with preview language features. - Included necessary metadata for NuGet packaging, including README file support. - Updated solution file to include the new project. --- MinimalLambda.sln | 15 +++++++++++++ .../AwsLambda.Host.Testing.csproj | 22 +++++++++++++++++++ src/AwsLambda.Host.Testing/README.md | 1 + 3 files changed, 38 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj create mode 100644 src/AwsLambda.Host.Testing/README.md diff --git a/MinimalLambda.sln b/MinimalLambda.sln index cd134e3f..24cec6fc 100644 --- a/MinimalLambda.sln +++ b/MinimalLambda.sln @@ -71,6 +71,8 @@ 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}") = "AwsLambda.Host.Testing", "src\AwsLambda.Host.Testing\AwsLambda.Host.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -345,6 +347,18 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -373,5 +387,6 @@ 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} EndGlobalSection EndGlobal diff --git a/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj b/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj new file mode 100644 index 00000000..f45a96d9 --- /dev/null +++ b/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj @@ -0,0 +1,22 @@ + + + net8.0;net9.0;net10.0 + preview + enable + enable + true + true + true + + AwsLambda.Host.Testing + Support for writting tests for AwsLambda.Host + README.md + + + + + + + + + diff --git a/src/AwsLambda.Host.Testing/README.md b/src/AwsLambda.Host.Testing/README.md new file mode 100644 index 00000000..30dfae36 --- /dev/null +++ b/src/AwsLambda.Host.Testing/README.md @@ -0,0 +1 @@ +# AwsLambda.Host.Testing \ No newline at end of file From b056510eca83f06c7d8b671148f3b0994ff55637 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 14:52:01 -0500 Subject: [PATCH 002/132] feat(examples): add AwsLambda.Host.Examples.Testing project - Introduced new example project to demonstrate testing with AwsLambda.Host. - Configured the project to target .NET 10.0 with appropriate properties and metadata. - Added basic Lambda handler implementation with a `name` parameter. - Included `appsettings.json` for logging configuration. - Configured `launchSettings.json` for local testing with mock runtime. - Updated solution to include the new example project. --- MinimalLambda.sln | 15 +++++++++ .../AwsLambda.Host.Examples.Testing.csproj | 31 +++++++++++++++++++ .../Program.cs | 14 +++++++++ .../Properties/launchSettings.json | 15 +++++++++ .../appsettings.json | 20 ++++++++++++ 5 files changed, 95 insertions(+) create mode 100644 examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj create mode 100644 examples/AwsLambda.Host.Examples.Testing/Program.cs create mode 100644 examples/AwsLambda.Host.Examples.Testing/Properties/launchSettings.json create mode 100644 examples/AwsLambda.Host.Examples.Testing/appsettings.json diff --git a/MinimalLambda.sln b/MinimalLambda.sln index 24cec6fc..e541ab61 100644 --- a/MinimalLambda.sln +++ b/MinimalLambda.sln @@ -73,6 +73,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Envelopes.Clo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda.Host.Testing", "src\AwsLambda.Host.Testing\AwsLambda.Host.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda.Host.Examples.Testing", "examples\AwsLambda.Host.Examples.Testing\AwsLambda.Host.Examples.Testing.csproj", "{884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -359,6 +361,18 @@ Global {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 + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x64.ActiveCfg = Debug|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x64.Build.0 = Debug|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x86.ActiveCfg = Debug|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x86.Build.0 = Debug|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|Any CPU.Build.0 = Release|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x64.ActiveCfg = Release|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x64.Build.0 = Release|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x86.ActiveCfg = Release|Any CPU + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -388,5 +402,6 @@ Global {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} + {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} EndGlobalSection EndGlobal diff --git a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj new file mode 100644 index 00000000..372f9772 --- /dev/null +++ b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj @@ -0,0 +1,31 @@ + + + Exe + net10.0 + preview + disable + enable + true + Lambda + + true + + true + $(InterceptorsNamespaces);AwsLambda.Host + Lambda.Host.Example.HelloWorld + false + + + + PreserveNewest + + + + + + + diff --git a/examples/AwsLambda.Host.Examples.Testing/Program.cs b/examples/AwsLambda.Host.Examples.Testing/Program.cs new file mode 100644 index 00000000..a773bd4c --- /dev/null +++ b/examples/AwsLambda.Host.Examples.Testing/Program.cs @@ -0,0 +1,14 @@ +using AwsLambda.Host.Builder; +using Microsoft.Extensions.Hosting; + +// Create the application builder +var builder = LambdaApplication.CreateBuilder(); + +// Build the Lambda application +var lambda = builder.Build(); + +// Map your handler - the event is automatically injected +lambda.MapHandler(([Event] string name) => $"Hello {name}!"); + +// Run the Lambda +await lambda.RunAsync(); diff --git a/examples/AwsLambda.Host.Examples.Testing/Properties/launchSettings.json b/examples/AwsLambda.Host.Examples.Testing/Properties/launchSettings.json new file mode 100644 index 00000000..9f3c9991 --- /dev/null +++ b/examples/AwsLambda.Host.Examples.Testing/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Local": { + "commandName": "Project", + "environmentVariables": { + "AWS_LAMBDA_RUNTIME_API": "localhost:5050", + "DOTNET_ENVIRONMENT": "Development", + "AWS_LAMBDA_LOG_FORMAT": "JSON", + "AWS_LAMBDA_LOG_LEVEL": "DEBUG", + "AWS_LAMBDA_FUNCTION_NAME": "Lambda-Host-Example-HelloWorld" + } + } + } +} diff --git a/examples/AwsLambda.Host.Examples.Testing/appsettings.json b/examples/AwsLambda.Host.Examples.Testing/appsettings.json new file mode 100644 index 00000000..53160eba --- /dev/null +++ b/examples/AwsLambda.Host.Examples.Testing/appsettings.json @@ -0,0 +1,20 @@ +{ + "AwsLambdaHost": { + "InvocationCancellationBuffer": "00:00:05" + }, + "Logging": { + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "IncludeScopes": false, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss ", + "UseUtcTimestamp": false + } + }, + "LogLevel": { + "Microsoft": "Warning", + "Default": "Information" + } + } +} \ No newline at end of file From 9e168639dc87f3238f8b40477fe990f20cbcb4d8 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 14:54:55 -0500 Subject: [PATCH 003/132] feat(testing): enhance AwsLambda.Host.Examples.Testing with unit testing setup - Added `LambdaHostTest` class with a sample test for validating Lambda host functionality. - Configured necessary testing dependencies like xUnit, NSubstitute, and coverlet.collector. - Included `appsettings.json` with `PreserveNewest` copy directive for output directory. --- .../AwsLambda.Host.Examples.Testing.csproj | 22 +++++++++++++++++++ .../LambdaHostTest.cs | 12 ++++++++++ .../Program.cs | 2 ++ 3 files changed, 36 insertions(+) create mode 100644 examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs diff --git a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj index 372f9772..24fb3ea6 100644 --- a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj +++ b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj @@ -28,4 +28,26 @@ OutputItemType="Analyzer" /> + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + PreserveNewest + + diff --git a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs new file mode 100644 index 00000000..8c12446a --- /dev/null +++ b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using JetBrains.Annotations; +using Xunit; + +namespace Lambda.Host.Example.HelloWorld; + +[TestSubject(typeof(Program))] +public class LambdaHostTest +{ + [Fact] + public async Task LambdaHost_CanStartWithoutError() { } +} diff --git a/examples/AwsLambda.Host.Examples.Testing/Program.cs b/examples/AwsLambda.Host.Examples.Testing/Program.cs index a773bd4c..4ff121e3 100644 --- a/examples/AwsLambda.Host.Examples.Testing/Program.cs +++ b/examples/AwsLambda.Host.Examples.Testing/Program.cs @@ -12,3 +12,5 @@ // Run the Lambda await lambda.RunAsync(); + +public partial class Program; From 2916e80f52f62acec5e433c53ed1e721d02d2f11 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 16:40:54 -0500 Subject: [PATCH 004/132] feat(testing): add DeferredHostBuilder to AwsLambda.Host.Testing - Introduced `DeferredHostBuilder` to enable deferred execution of IHostBuilder configuration. - Added support for capturing and replaying configuration calls with `ConfigureHostBuilder`. - Implemented `DeferredHost` to manage deferred host startup and ensure proper disposal. - Linked implementation with MIT-licensed parts from aspnetcore, providing proper attribution. --- .../DeferredHostBuilder.cs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/DeferredHostBuilder.cs diff --git a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs new file mode 100644 index 00000000..1253080e --- /dev/null +++ b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs @@ -0,0 +1,174 @@ +// 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 +// 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 AwsLambda.Host.Testing; + +// This host builder captures calls to the IHostBuilder then replays them in the call to +// ConfigureHostBuilder +internal sealed class DeferredHostBuilder : IHostBuilder +{ + public IDictionary Properties { get; } = new Dictionary(); + + private Action _configure; + private Func? _hostFactory; + + 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 + ); + + 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 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()); + + // We can't return the host directly since we need to defer the call to StartAsync + return new DeferredHost(host, _hostStartTcs); + } + + 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(); + } + + public void SetHostFactory(Func hostFactory) => _hostFactory = hostFactory; + + private sealed class DeferredHost : IHost, IAsyncDisposable + { + private readonly IHost _host; + private readonly TaskCompletionSource _hostStartedTcs; + + public DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) + { + _host = host; + _hostStartedTcs = hostStartedTcs; + } + + public IServiceProvider Services => _host.Services; + + public void Dispose() => _host.Dispose(); + + public async ValueTask DisposeAsync() + { + if (_host is IAsyncDisposable disposable) + { + await disposable.DisposeAsync().ConfigureAwait(false); + return; + } + + 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. + + using var reg = cancellationToken.UnsafeRegister( + _ => _hostStartedTcs.TrySetCanceled(), + null + ); + + // REVIEW: This will deadlock if the application creates the host but never calls start. + // This is mitigated by the cancellationToken + // but it's rarely a valid token for Start + using var reg2 = _host + .Services.GetRequiredService() + .ApplicationStarted.UnsafeRegister(_ => _hostStartedTcs.TrySetResult(), null); + + await _hostStartedTcs.Task.ConfigureAwait(false); + } + + public Task StopAsync(CancellationToken cancellationToken = default) => + _host.StopAsync(cancellationToken); + } +} From 726ae909ab4db69a3aa8595579514f71871bfc04 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 16:41:04 -0500 Subject: [PATCH 005/132] feat(testing): add LambdaApplicationFactory to AwsLambda.Host.Testing - Introduced `LambdaApplicationFactory` for managing and testing Lambda application lifecycle. - Implemented proper disposal handling with `Dispose` and `DisposeAsync` methods. - Ensured compatibility with `IHost` for seamless integration and teardown. --- .../LambdaApplicationFactory.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs new file mode 100644 index 00000000..968b5d84 --- /dev/null +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Hosting; + +namespace AwsLambda.Host.Testing; + +public class LambdaApplicationFactory : IDisposable, IAsyncDisposable + where TStartup : class +{ + private bool _disposed; + private bool _disposedAsync; + + private IHost? _host; + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + if (_disposedAsync) + return; + + if (_host != null) + { + await _host.StopAsync().ConfigureAwait(false); + _host?.Dispose(); + } + + _disposedAsync = true; + + Dispose(true); + + GC.SuppressFinalize(this); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing && !_disposedAsync) + DisposeAsync().AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + + _disposed = true; + } +} From af9ade7ac9bf76a4e47dd8b137dd2ac8d6583999 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 16:41:20 -0500 Subject: [PATCH 006/132] docs(testing): update README with HTTP request/response examples - Added detailed examples for AWS Lambda local invocation happy and error paths. - Included formatted HTTP request and response content for better understanding. - Documented the error handling flow, including stack traces and error serialization. --- src/AwsLambda.Host.Testing/README.md | 180 ++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/src/AwsLambda.Host.Testing/README.md b/src/AwsLambda.Host.Testing/README.md index 30dfae36..303f128f 100644 --- a/src/AwsLambda.Host.Testing/README.md +++ b/src/AwsLambda.Host.Testing/README.md @@ -1 +1,179 @@ -# AwsLambda.Host.Testing \ No newline at end of file +# AwsLambda.Host.Testing + +AWS Lambda Local Invocation - Happy Path +```text +========== HTTP REQUEST ========== +GET http://localhost:5050/2018-06-01/runtime/invocation/next +Version: 1.1 + +Headers: + Accept: application/json + +========== HTTP RESPONSE ========== +Status: 200 OK +Version: 1.1 + +Headers: + Date: Thu, 04 Dec 2025 20:40:53 GMT + Server: Kestrel + Transfer-Encoding: chunked + Lambda-Runtime-Deadline-Ms: 1764881754010 + Lambda-Runtime-Aws-Request-Id: 000000000002 + Lambda-Runtime-Trace-Id: 2a159b6d-ca3c-4991-8533-c2b2a8da0640 + Lambda-Runtime-Invoked-Function-Arn: arn:aws:lambda:us-west-2:123412341234:function:Function + +Content Headers: + Content-Type: application/json + +Body: +"James" +=================================== + +========== HTTP REQUEST ========== +POST http://localhost:5050/2018-06-01/runtime/invocation/000000000002/response +Version: 1.1 + +Headers: + Accept: application/json + +Content Headers: + Content-Type: application/json + +Body: +"Hello James!" + +========== HTTP RESPONSE ========== +Status: 202 Accepted +Version: 1.1 + +Headers: + Date: Thu, 04 Dec 2025 20:40:53 GMT + Server: Kestrel + Transfer-Encoding: chunked + +Content Headers: + Content-Type: application/json; charset=utf-8 + +Body: +{"status":"success"} +=================================== + +========== HTTP REQUEST ========== +GET http://localhost:5050/2018-06-01/runtime/invocation/next +Version: 1.1 + +Headers: + Accept: application/json + + +``` + +AWS Lambda Local Invocation - Error Path +```text +========== HTTP REQUEST ========== +GET http://localhost:5050/2018-06-01/runtime/invocation/next +Version: 1.1 + +Headers: + Accept: application/json + +========== HTTP RESPONSE ========== +Status: 200 OK +Version: 1.1 + +Headers: + Date: Thu, 04 Dec 2025 20:44:06 GMT + Server: Kestrel + Transfer-Encoding: chunked + Lambda-Runtime-Deadline-Ms: 1764881946613 + Lambda-Runtime-Aws-Request-Id: 000000000004 + Lambda-Runtime-Trace-Id: 849e7f8f-6a67-4132-b371-7740c9ad9084 + Lambda-Runtime-Invoked-Function-Arn: arn:aws:lambda:us-west-2:123412341234:function:Function + +Content Headers: + Content-Type: application/json + +Body: +2 +=================================== + +========== HTTP REQUEST ========== +POST http://localhost:5050/2018-06-01/runtime/invocation/000000000004/error +Version: 1.1 + +Headers: + Lambda-Runtime-Function-Error-Type: JsonSerializerException + Lambda-Runtime-Function-XRay-Error-Cause: { "working_directory": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Examples.Testing/bin/Debug/net10.0", "exceptions": [ { "type": "JsonSerializerException", "message": "Error converting the Lambda event JSON payload to type System.String: The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.", "stack": [ { "label": "AbstractLambdaJsonSerializer.Deserialize" }, { "path": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Core/Features/DefaultEventFeature.cs", "label": "DefaultEventFeature`1.GetEvent", "line": 28 }, { "path": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Core/Features/DefaultEventFeature.cs", "label": "DefaultEventFeature`1.AwsLambda.Host.Core.IEventFeature.GetEvent", "line": 35 }, { "path": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/Middleware/RequestEnvelopeMiddleware.cs", "label": "<b__2>d.MoveNext", "line": 45 }, { "label": "ExceptionDispatchInfo.Throw" }, { "label": "TaskAwaiter.ThrowForNonSuccess" }, { "label": "TaskAwaiter.HandleNonSuccessAndDebuggerNotification" }, { "path": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Runtime/LambdaHandlerComposer.cs", "label": "<g__CreateRequestHandler|0>d.MoveNext", "line": 78 }, { "label": "ExceptionDispatchInfo.Throw" }, { "path": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Runtime/LambdaHandlerComposer.cs", "label": "<g__CreateRequestHandler|0>d.MoveNext", "line": 84 }, { "label": "ExceptionDispatchInfo.Throw" }, { "label": "TaskAwaiter.ThrowForNonSuccess" }, { "label": "TaskAwaiter.HandleNonSuccessAndDebuggerNotification" }, { "label": "TaskAwaiter`1.GetResult" }, { "label": "<b__0>d.MoveNext" }, { "label": "ExceptionDispatchInfo.Throw" }, { "label": "TaskAwaiter.ThrowForNonSuccess" }, { "label": "TaskAwaiter.HandleNonSuccessAndDebuggerNotification" }, { "label": "TaskAwaiter`1.GetResult" }, { "label": "<b__0>d.MoveNext" } ] } ], "paths": ["/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Core/Features/DefaultEventFeature.cs","/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/Middleware/RequestEnvelopeMiddleware.cs","/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Runtime/LambdaHandlerComposer.cs" ] } + Accept: application/json + +Content Headers: + Content-Type: application/vnd.aws.lambda.error+json + +Body: +{ + "errorType": "JsonSerializerException", + "errorMessage": "Error converting the Lambda event JSON payload to type System.String: The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.", + "stackTrace": [ + "at Amazon.Lambda.Serialization.SystemTextJson.AbstractLambdaJsonSerializer.Deserialize[T](Stream requestStream)", + "at AwsLambda.Host.Core.DefaultEventFeature`1.GetEvent(ILambdaHostContext context) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Core/Features/DefaultEventFeature.cs:line 28", + "at AwsLambda.Host.Core.DefaultEventFeature`1.AwsLambda.Host.Core.IEventFeature.GetEvent(ILambdaHostContext context) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Core/Features/DefaultEventFeature.cs:line 35", + "at AwsLambda.Host.Builder.RequestEnvelopeMiddleware.<>c__DisplayClass1_1.<b__2>d.MoveNext() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/Middleware/RequestEnvelopeMiddleware.cs:line 45", + "--- End of stack trace from previous location ---", + "at AwsLambda.Host.Runtime.LambdaHandlerComposer.<>c__DisplayClass6_0.<g__CreateRequestHandler|0>d.MoveNext() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Runtime/LambdaHandlerComposer.cs:line 78", + "--- End of stack trace from previous location ---", + "at AwsLambda.Host.Runtime.LambdaHandlerComposer.<>c__DisplayClass6_0.<g__CreateRequestHandler|0>d.MoveNext() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Runtime/LambdaHandlerComposer.cs:line 84", + "--- End of stack trace from previous location ---", + "at Amazon.Lambda.RuntimeSupport.HandlerWrapper.<>c__DisplayClass19_0.<b__0>d.MoveNext()", + "--- End of stack trace from previous location ---", + "at Amazon.Lambda.RuntimeSupport.LambdaBootstrap.<>c__DisplayClass26_0.<b__0>d.MoveNext()" + ], + "cause": { + "errorType": "JsonException", + "errorMessage": "The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.", + "stackTrace": [ + "at System.Text.Json.ThrowHelper.ReThrowWithPath(ReadStack& state, Utf8JsonReader& reader, Exception ex)", + "at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, T& value, JsonSerializerOptions options, ReadStack& state)", + "at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.Deserialize(Utf8JsonReader& reader, ReadStack& state)", + "at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1 utf8Json, JsonTypeInfo`1 jsonTypeInfo, Nullable`1 actualByteCount)", + "at Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer.InternalDeserialize[T](Byte[] utf8Json)", + "at Amazon.Lambda.Serialization.SystemTextJson.AbstractLambdaJsonSerializer.Deserialize[T](Stream requestStream)" + ], + "cause": { + "errorType": "InvalidOperationException", + "errorMessage": "Cannot get the value of a token type 'Number' as a string.", + "stackTrace": [ + "at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_ExpectedString(JsonTokenType tokenType)", + "at System.Text.Json.Utf8JsonReader.GetString()", + "at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue)", + "at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, T& value, JsonSerializerOptions options, ReadStack& state)" + ] + } + } +} + + +========== HTTP RESPONSE ========== +Status: 202 Accepted +Version: 1.1 + +Headers: + Date: Thu, 04 Dec 2025 20:44:06 GMT + Server: Kestrel + Transfer-Encoding: chunked + +Content Headers: + Content-Type: application/json; charset=utf-8 + +Body: +{"status":"success"} +=================================== + +========== HTTP REQUEST ========== +GET http://localhost:5050/2018-06-01/runtime/invocation/next +Version: 1.1 + +Headers: + Accept: application/json + + +``` \ No newline at end of file From f9db8c9a498bb8657b26ebe39c475d0e1b9793bf Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 16:41:54 -0500 Subject: [PATCH 007/132] docs(testing): add licensing attribution for LambdaApplicationFactory - Added attribution to aspnetcore's `WebApplicationFactory.cs` under the MIT license. - Included reference to the source repository and license file for transparency. - Updated header comments in `LambdaApplicationFactory.cs` for proper acknowledgment. --- src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index 968b5d84..7320a90a 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -1,3 +1,11 @@ +// Portions of this file are derived from aspnetcore +// Source: +// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs +// Copyright (c) .NET Foundation +// 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.Hosting; namespace AwsLambda.Host.Testing; From 38165d13496d0712dc9b6ce66de3cbb18020c8bb Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 16:42:09 -0500 Subject: [PATCH 008/132] feat(examples): add HTTP request/response logging to testing example - Introduced `LoggingHttpHandler` for detailed HTTP request and response logging. - Updated `Program.cs` to configure `BootstrapHttpClient` using `LoggingHttpHandler`. - Enabled `ClearLambdaOutputFormatting` for better Lambda output readability. --- .../Program.cs | 97 ++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/examples/AwsLambda.Host.Examples.Testing/Program.cs b/examples/AwsLambda.Host.Examples.Testing/Program.cs index 4ff121e3..3e3c7305 100644 --- a/examples/AwsLambda.Host.Examples.Testing/Program.cs +++ b/examples/AwsLambda.Host.Examples.Testing/Program.cs @@ -1,9 +1,22 @@ -using AwsLambda.Host.Builder; +using System; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AwsLambda.Host.Builder; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; // Create the application builder var builder = LambdaApplication.CreateBuilder(); +builder.Services.ConfigureLambdaHostOptions(options => +{ + options.BootstrapHttpClient = new HttpClient(new LoggingHttpHandler()); + options.ClearLambdaOutputFormatting = true; +}); + // Build the Lambda application var lambda = builder.Build(); @@ -14,3 +27,85 @@ await lambda.RunAsync(); public partial class Program; + +public class LoggingHttpHandler : DelegatingHandler +{ + public LoggingHttpHandler(HttpMessageHandler innerHandler) => + InnerHandler = innerHandler ?? throw new ArgumentNullException(nameof(innerHandler)); + + // Convenience constructor for default handler + public LoggingHttpHandler() + : this(new HttpClientHandler()) { } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + // Log the request + Console.WriteLine("========== HTTP REQUEST =========="); + Console.WriteLine($"{request.Method} {request.RequestUri}"); + Console.WriteLine($"Version: {request.Version}"); + + Console.WriteLine("\nHeaders:"); + foreach (var header in request.Headers) + Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); + + if (request.Content != null) + { + Console.WriteLine("\nContent Headers:"); + foreach (var header in request.Content.Headers) + Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); + + // Buffer the content so we can read it without consuming it + await request.Content.LoadIntoBufferAsync(); + var requestBody = await request.Content.ReadAsStringAsync(); + + Console.WriteLine("\nBody:"); + Console.WriteLine(requestBody); + } + + // Send the request through the inner handler (via base.SendAsync) + var response = await base.SendAsync(request, cancellationToken); + + // Log the response + Console.WriteLine("\n========== HTTP RESPONSE =========="); + Console.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}"); + Console.WriteLine($"Version: {response.Version}"); + + Console.WriteLine("\nHeaders:"); + foreach (var header in response.Headers) + Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); + + if (response.Content != null) + { + Console.WriteLine("\nContent Headers:"); + foreach (var header in response.Content.Headers) + Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); + + // Read and log response body, then restore it + var responseBody = await response.Content.ReadAsStringAsync(); + Console.WriteLine("\nBody:"); + Console.WriteLine(responseBody); + + // Restore the content so it can be read again by the caller + var originalContentHeaders = response.Content.Headers.ToList(); + response.Content = new StringContent( + responseBody, + Encoding.UTF8, + response.Content.Headers.ContentType?.MediaType ?? "application/json" + ); + + // Restore all content headers + foreach (var header in originalContentHeaders) + { + response.Content.Headers.Remove(header.Key); + response.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + Console.WriteLine("===================================\n"); + + return response; + } +} From 00d69e3a9715dbdccf386afc1801c2c8aabaa78d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 18:02:16 -0500 Subject: [PATCH 009/132] fix(scripts): update version extraction logic in validate-version.sh - Changed version extraction from `` to `` in `Directory.Build.props`. - Updated error message to reflect the new version tag. --- scripts/validate-version.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 5595e2e1befa172daa559b4df6dd20292c778c0c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 20:00:53 -0500 Subject: [PATCH 010/132] feat(testing): add HostFactoryResolver to AwsLambda.Host.Testing - Added `HostFactoryResolver`, copied and adapted from .NET runtime's implementation. - Enables dynamic resolution and creation of `IHost` and `IServiceProvider` for testing scenarios. - Included proper licensing attribution in the file header. - Ensures compatibility with diagnostic event-based host building patterns. --- .../HostFactoryResolver.cs | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/HostFactoryResolver.cs diff --git a/src/AwsLambda.Host.Testing/HostFactoryResolver.cs b/src/AwsLambda.Host.Testing/HostFactoryResolver.cs new file mode 100644 index 00000000..345b46fe --- /dev/null +++ b/src/AwsLambda.Host.Testing/HostFactoryResolver.cs @@ -0,0 +1,423 @@ +// 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; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +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 uint timeoutInSeconds + ) + ) + { + return TimeSpan.FromSeconds((int)timeoutInSeconds); + } + + return TimeSpan.FromMinutes(5); + } + + public static Func? ResolveWebHostFactory(Assembly assembly) + { + return ResolveFactory(assembly, BuildWebHost); + } + + public static Func< + string[], + TWebHostBuilder + >? ResolveWebHostBuilderFactory(Assembly assembly) + { + return ResolveFactory(assembly, CreateWebHostBuilder); + } + + public static Func? ResolveHostBuilderFactory( + Assembly assembly + ) + { + return 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) + { + return 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: 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 readonly string[] _args; + private readonly MethodInfo _entryPoint; + private readonly TimeSpan _waitTimeout; + private readonly bool _stopApplication; + + private readonly TaskCompletionSource _hostTcs = new(); + private IDisposable? _disposable; + private readonly Action? _configure; + private readonly Action? _entrypointCompleted; + private static readonly AsyncLocal _currentListener = new(); + + 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 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(); + } + + 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(); + } + } + } + + // 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() + { + Type? publicHostAbortedExceptionType = Type.GetType( + "Microsoft.Extensions.Hosting.HostAbortedException, Microsoft.Extensions.Hosting.Abstractions", + throwOnError: false + ); + if (publicHostAbortedExceptionType != null) + { + throw (Exception)Activator.CreateInstance(publicHostAbortedExceptionType)!; + } + else + { + throw new HostAbortedException(); + } + } + + private sealed class HostAbortedException : Exception { } + } + } +} From 52af97c1a57f638952749db78759b3ba671ca96d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 20:01:11 -0500 Subject: [PATCH 011/132] chore(licenses): update copyright and licensing attributions - Updated copyright notices to include ".NET Foundation and Contributors" in relevant files. - Added new entry for the .NET runtime license to THIRD-PARTY-LICENSES.txt. - Ensured consistency in licensing attribution across files for proper acknowledgment. --- THIRD-PARTY-LICENSES.txt | 32 +++++++++++++++++++ .../DeferredHostBuilder.cs | 2 +- .../LambdaApplicationFactory.cs | 4 ++- 3 files changed, 36 insertions(+), 2 deletions(-) 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/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs index 1253080e..06e8f127 100644 --- a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs +++ b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs @@ -1,7 +1,7 @@ // 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 +// 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 diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index 7320a90a..baf252e6 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -1,7 +1,7 @@ // Portions of this file are derived from aspnetcore // Source: // https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs -// Copyright (c) .NET Foundation +// 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 @@ -20,6 +20,8 @@ public class LambdaApplicationFactory : IDisposable, IAsyncDisposable public async ValueTask DisposeAsync() { + // HostFactoryResolver. + if (_disposed) return; From 725f1ae317ebe42906c0b1bd1cf425db91bf8375 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 4 Dec 2025 20:01:25 -0500 Subject: [PATCH 012/132] chore(config): add .csharpierignore and update .editorconfig settings - Added `.csharpierignore` to exclude specific files from formatting checks. - Updated `.editorconfig` to disable formatting for `HostFactoryResolver.cs`. - Included `.csharpierignore` and `.editorconfig` in the solution file (`AwsLambda.Host.sln`). --- .csharpierignore | 1 + .editorconfig | 6 +++++- MinimalLambda.sln | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .csharpierignore 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/MinimalLambda.sln b/MinimalLambda.sln index e541ab61..c16a581e 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}" From 15674ca540d95a6d419f87ba4350b1dd843629cd Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 11:22:36 -0500 Subject: [PATCH 013/132] feat(testing): add LambdaApplicationFactoryClientOptions and LambdaTestingHttpHandler - Introduced `LambdaApplicationFactoryClientOptions` for customizing test client configurations. - Added `LambdaTestingHttpHandler` for handling HTTP-specific test scenarios. - Extended `LambdaApplicationFactory` to improve support for Lambda app testing workflows. --- .../LambdaApplicationFactory.cs | 642 +++++++++++++++++- .../LambdaApplicationFactoryClientOptions.cs | 3 + .../LambdaTestingHttpHandler.cs | 3 + 3 files changed, 632 insertions(+), 16 deletions(-) create mode 100644 src/AwsLambda.Host.Testing/LambdaApplicationFactoryClientOptions.cs create mode 100644 src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index baf252e6..aa8855db 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -6,55 +6,665 @@ // 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 System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace AwsLambda.Host.Testing; -public class LambdaApplicationFactory : IDisposable, IAsyncDisposable - where TStartup : class +/// +/// 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 partial class WebApplicationFactory : IDisposable, IAsyncDisposable + where TEntryPoint : class { private bool _disposed; private bool _disposedAsync; - + private TestServer? _server; private IHost? _host; + private Action _configuration; + private readonly List _clients = new(); + private readonly List> _derivedFactories = new(); + + /// + /// + /// Creates an instance of . This factory can be used to + /// create a instance using the MVC application defined by + /// and one or more instances used to send to the . + /// The will find the entry point class of + /// assembly and initialize the application by calling IWebHostBuilder CreateWebHostBuilder(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 WebApplicationFactory() => _configuration = ConfigureWebHost; + + /// + /// Finalizes an instance of the class. + /// + ~WebApplicationFactory() => Dispose(false); - public async ValueTask DisposeAsync() + /// + /// Gets the created by this . + /// + public TestServer Server { - // HostFactoryResolver. + get + { + EnsureServer(); + return _server; + } + } - if (_disposed) + /// + /// Gets the created by the server associated with this . + /// + public virtual IServiceProvider Services + { + get + { + EnsureServer(); + return _host?.Services ?? _server.Host.Services; + } + } + + /// + /// Gets the of factories created from this factory + /// by further customizing the when calling + /// . + /// + public IReadOnlyList> Factories => + _derivedFactories.AsReadOnly(); + + /// + /// Gets the used by . + /// + public LambdaApplicationFactoryClientOptions ClientOptions { get; private set; } = new(); + + /// + /// Creates a new with a + /// that is further customized by . + /// + /// + /// An to configure the . + /// + /// A new . + public WebApplicationFactory WithWebHostBuilder( + Action configuration + ) => WithWebHostBuilderCore(configuration); + + internal virtual WebApplicationFactory WithWebHostBuilderCore( + Action configuration + ) + { + var factory = new DelegatedWebApplicationFactory( + ClientOptions, + CreateServer, + CreateHost, + CreateWebHostBuilder, + CreateHostBuilder, + GetTestAssemblies, + ConfigureClient, + builder => + { + _configuration(builder); + configuration(builder); + } + ); + + _derivedFactories.Add(factory); + + return factory; + } + + [MemberNotNull(nameof(_server))] + private void EnsureServer() + { + if (_server != null) return; - if (_disposedAsync) + EnsureDepsFile(); + + var hostBuilder = CreateHostBuilder(); + if (hostBuilder is not null) + { + ConfigureHostBuilder(hostBuilder); return; + } - if (_host != null) + var builder = CreateWebHostBuilder(); + if (builder is null) { - await _host.StopAsync().ConfigureAwait(false); - _host?.Dispose(); + 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); + return; + } + + throw new InvalidOperationException( + Resources.FormatMissingBuilderMethod( + nameof(IHostBuilder), + nameof(IWebHostBuilder), + typeof(TEntryPoint).Assembly.EntryPoint!.DeclaringType!.FullName, + typeof(WebApplicationFactory).Name, + nameof(CreateHostBuilder), + nameof(CreateWebHostBuilder) + ) + ); } + else + { + SetContentRoot(builder); + _configuration(builder); + _server = CreateServer(builder); + } + } - _disposedAsync = true; + [MemberNotNull(nameof(_server))] + private void ConfigureHostBuilder(IHostBuilder hostBuilder) + { + hostBuilder.ConfigureWebHost(webHostBuilder => + { + SetContentRoot(webHostBuilder); + _configuration(webHostBuilder); + webHostBuilder.UseTestServer(); + }); + _host = CreateHost(hostBuilder); + _server = (TestServer)_host.Services.GetRequiredService(); + } - Dispose(true); + private void SetContentRoot(IWebHostBuilder builder) + { + if (SetContentRootFromSetting(builder)) + return; - GC.SuppressFinalize(this); + var fromFile = File.Exists("MvcTestingAppManifest.json"); + var contentRoot = fromFile + ? GetContentRootFromFile("MvcTestingAppManifest.json") + : GetContentRootFromAssembly(); + + if (contentRoot != null) + builder.UseContentRoot(contentRoot); + else + builder.UseSolutionRelativeContentRoot(typeof(TEntryPoint).Assembly.GetName().Name!); + } + + private static string? GetContentRootFromFile(string file) + { + var data = JsonSerializer.Deserialize( + File.ReadAllBytes(file), + CustomJsonSerializerContext.Default.IDictionaryStringString + )!; + var key = typeof(TEntryPoint).Assembly.GetName().FullName; + + // If the `ContentRoot` is not provided in the app manifest, then return null + // and fallback to setting the content root relative to the entrypoint's assembly. + if (!data.TryGetValue(key, out var contentRoot)) + return null; + + return contentRoot == "~" ? AppContext.BaseDirectory : contentRoot; + } + + [JsonSerializable(typeof(IDictionary))] + private sealed partial class CustomJsonSerializerContext : JsonSerializerContext; + + private string? GetContentRootFromAssembly() + { + var metadataAttributes = GetContentRootMetadataAttributes( + typeof(TEntryPoint).Assembly.FullName!, + typeof(TEntryPoint).Assembly.GetName().Name! + ); + + string? contentRoot = null; + for (var i = 0; i < metadataAttributes.Length; i++) + { + var contentRootAttribute = metadataAttributes[i]; + 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 static bool SetContentRootFromSetting(IWebHostBuilder builder) + { + // Attempt to look for TEST_CONTENTROOT_APPNAME in settings. This should result in looking + // for + // ASPNETCORE_TEST_CONTENTROOT_APPNAME environment variable. + var assemblyName = typeof(TEntryPoint).Assembly.GetName().Name!; + var settingSuffix = assemblyName.ToUpperInvariant().Replace(".", "_"); + var settingName = $"TEST_CONTENTROOT_{settingSuffix}"; + + var settingValue = builder.GetSetting(settingName); + if (settingValue == null) + return false; + + builder.UseContentRoot(settingValue); + return true; + } + + private WebApplicationFactoryContentRootAttribute[] 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 new[] { Assembly.Load(AppDomain.CurrentDomain.FriendlyName) }; + + var runtimeProjectLibraries = context.RuntimeLibraries.ToDictionary( + r => r.Name, + r => r, + StringComparer.Ordinal + ); + + // Find the list of projects + var 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) { } + + return Array.Empty(); } + private static void EnsureDepsFile() + { + if (typeof(TEntryPoint).Assembly.EntryPoint == null) + throw new InvalidOperationException( + Resources.FormatInvalidAssemblyEntryPoint(typeof(TEntryPoint).Name) + ); + + var depsFileName = $"{typeof(TEntryPoint).Assembly.GetName().Name}.deps.json"; + var depsFile = new FileInfo(Path.Combine(AppContext.BaseDirectory, depsFileName)); + if (!depsFile.Exists) + throw new InvalidOperationException( + Resources.FormatMissingDepsFile( + depsFile.FullName, + Path.GetFileName(depsFile.FullName) + ) + ); + } + + /// + /// Creates a used to set up . + /// + /// + /// The default implementation of this method looks for a public static IHostBuilder CreateHostBuilder(string[] args) + /// method defined on the entry point of the assembly of and invokes it passing an empty string + /// array as arguments. + /// + /// A instance. + protected virtual IHostBuilder? CreateHostBuilder() + { + var hostBuilder = HostFactoryResolver + .ResolveHostBuilderFactory(typeof(TEntryPoint).Assembly) + ?.Invoke(Array.Empty()); + + hostBuilder?.UseEnvironment(Environments.Development); + return hostBuilder; + } + + /// + /// Creates a used to set up . + /// + /// + /// The default implementation of this method looks for a public static IWebHostBuilder CreateWebHostBuilder(string[] args) + /// method defined on the entry point of the assembly of and invokes it passing an empty string + /// array as arguments. + /// + /// A instance. + protected virtual IWebHostBuilder? CreateWebHostBuilder() + { + var builder = WebHostBuilderFactory.CreateFromTypesAssemblyEntryPoint( + Array.Empty() + ); + + if (builder is not null) + return builder.UseEnvironment(Environments.Development); + + return null; + } + + /// + /// Creates the with the bootstrapped application in . + /// This is only called for applications using . Applications based on + /// will use instead. + /// + /// The used to + /// create the server. + /// The with the bootstrapped application. + protected virtual TestServer CreateServer(IWebHostBuilder builder) => new(builder); + + /// + /// Creates the with the bootstrapped application in . + /// This is only called for applications using . Applications based on + /// will use instead. + /// + /// The used to create the host. + /// The with the bootstrapped application. + protected virtual IHost CreateHost(IHostBuilder builder) + { + var host = builder.Build(); + host.Start(); + return host; + } + + /// + /// Gives a fixture an opportunity to configure the application before it gets built. + /// + /// The for the application. + protected virtual void ConfigureWebHost(IWebHostBuilder builder) { } + + /// + /// Creates an instance of that automatically follows + /// redirects and handles cookies. + /// + /// The . + public HttpClient CreateClient() => CreateClient(ClientOptions); + + /// + /// Creates an instance of that automatically follows + /// redirects and handles cookies. + /// + /// The . + public HttpClient CreateClient(WebApplicationFactoryClientOptions options) => + CreateDefaultClient(options.BaseAddress, options.CreateHandlers()); + + /// + /// Creates a new instance of an that can be used to + /// send to the server. The base address of the + /// instance will be set to http://localhost. + /// + /// A list of instances to set up on the + /// . + /// The . + public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers) + { + EnsureServer(); + + HttpClient client; + if (handlers == null || handlers.Length == 0) + { + client = _server.CreateClient(); + } + else + { + for (var i = handlers.Length - 1; i > 0; i--) + handlers[i - 1].InnerHandler = handlers[i]; + + var serverHandler = _server.CreateHandler(); + handlers[^1].InnerHandler = serverHandler; + + client = new HttpClient(handlers[0]); + } + + _clients.Add(client); + + ConfigureClient(client); + + return client; + } + + /// + /// Configures instances created by this . + /// + /// The instance getting configured. + protected virtual void ConfigureClient(HttpClient client) + { + ArgumentNullException.ThrowIfNull(client); + + client.BaseAddress = new Uri("http://localhost"); + } + + /// + /// Creates a new instance of an that can be used to + /// send to the server. + /// + /// The base address of the instance. + /// A list of instances to set up on the + /// . + /// The . + public HttpClient CreateDefaultClient(Uri baseAddress, params DelegatingHandler[] handlers) + { + var client = CreateDefaultClient(handlers); + client.BaseAddress = baseAddress; + + return client; + } + + /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } - private void Dispose(bool disposing) + /// + /// 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(); + if (disposing) + if (!_disposedAsync) + DisposeAsync().AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); _disposed = true; } + + /// + public virtual async ValueTask DisposeAsync() + { + if (_disposed) + return; + + if (_disposedAsync) + { + return; + } + + foreach (var client in _clients) + client.Dispose(); + + foreach (var factory in _derivedFactories) + await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); + + _server?.Dispose(); + + if (_host != null) + { + await _host.StopAsync().ConfigureAwait(false); + _host?.Dispose(); + } + + _disposedAsync = true; + + Dispose(true); + + GC.SuppressFinalize(this); + } + + private sealed class DelegatedWebApplicationFactory : WebApplicationFactory + { + private readonly Func _createServer; + private readonly Func _createHost; + private readonly Func _createWebHostBuilder; + private readonly Func _createHostBuilder; + private readonly Func> _getTestAssemblies; + private readonly Action _configureClient; + + public DelegatedWebApplicationFactory( + WebApplicationFactoryClientOptions options, + Func createServer, + Func createHost, + Func createWebHostBuilder, + Func createHostBuilder, + Func> getTestAssemblies, + Action configureClient, + Action configureWebHost + ) + { + ClientOptions = new WebApplicationFactoryClientOptions(options); + _createServer = createServer; + _createHost = createHost; + _createWebHostBuilder = createWebHostBuilder; + _createHostBuilder = createHostBuilder; + _getTestAssemblies = getTestAssemblies; + _configureClient = configureClient; + _configuration = configureWebHost; + } + + protected override TestServer CreateServer(IWebHostBuilder builder) => + _createServer(builder); + + protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder); + + protected override IWebHostBuilder? CreateWebHostBuilder() => _createWebHostBuilder(); + + protected override IHostBuilder? CreateHostBuilder() => _createHostBuilder(); + + protected override IEnumerable GetTestAssemblies() => _getTestAssemblies(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) => + _configuration(builder); + + protected override void ConfigureClient(HttpClient client) => _configureClient(client); + + internal override WebApplicationFactory WithWebHostBuilderCore( + Action configuration + ) => + new DelegatedWebApplicationFactory( + ClientOptions, + _createServer, + _createHost, + _createWebHostBuilder, + _createHostBuilder, + _getTestAssemblies, + _configureClient, + builder => + { + _configuration(builder); + configuration(builder); + } + ); + } } diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryClientOptions.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryClientOptions.cs new file mode 100644 index 00000000..f7b4fe69 --- /dev/null +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryClientOptions.cs @@ -0,0 +1,3 @@ +namespace AwsLambda.Host.Testing; + +public class LambdaApplicationFactoryClientOptions { } diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs new file mode 100644 index 00000000..0b93ea9e --- /dev/null +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -0,0 +1,3 @@ +namespace AwsLambda.Host.Testing; + +public class LambdaTestingHttpHandler : DelegatingHandler { } From 3fc216dc0ad38871130cd3f9539c0b87f81d3e99 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 11:27:24 -0500 Subject: [PATCH 014/132] feat(testing): migrate LambdaApplicationFactory to use IHostBuilder - Replaced IWebHostBuilder with IHostBuilder across LambdaApplicationFactory. - Updated documentation to reflect the use of IHostBuilder methods and patterns. - Simplified host configuration by deprecating IWebHostBuilder-specific logic. --- .../LambdaApplicationFactory.cs | 160 ++++++++---------- 1 file changed, 69 insertions(+), 91 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index aa8855db..4b3afd77 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -28,7 +28,7 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis private bool _disposedAsync; private TestServer? _server; private IHost? _host; - private Action _configuration; + private Action _configuration; private readonly List _clients = new(); private readonly List> _derivedFactories = new(); @@ -38,7 +38,7 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis /// create a instance using the MVC application defined by /// and one or more instances used to send to the . /// The will find the entry point class of - /// assembly and initialize the application by calling IWebHostBuilder CreateWebHostBuilder(string [] args) + /// assembly and initialize the application by calling IHostBuilder CreateWebHostBuilder(string [] args) /// on . /// /// @@ -88,8 +88,8 @@ public virtual IServiceProvider Services /// /// Gets the of factories created from this factory - /// by further customizing the when calling - /// . + /// by further customizing the when calling + /// . /// public IReadOnlyList> Factories => _derivedFactories.AsReadOnly(); @@ -100,19 +100,19 @@ public virtual IServiceProvider Services public LambdaApplicationFactoryClientOptions ClientOptions { get; private set; } = new(); /// - /// Creates a new with a + /// Creates a new with a /// that is further customized by . /// /// - /// An to configure the . + /// An to configure the . /// /// A new . public WebApplicationFactory WithWebHostBuilder( - Action configuration + Action configuration ) => WithWebHostBuilderCore(configuration); internal virtual WebApplicationFactory WithWebHostBuilderCore( - Action configuration + Action configuration ) { var factory = new DelegatedWebApplicationFactory( @@ -143,76 +143,56 @@ private void EnsureServer() EnsureDepsFile(); - var hostBuilder = CreateHostBuilder(); - if (hostBuilder is not null) + 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 => { - ConfigureHostBuilder(hostBuilder); - return; - } - - var builder = CreateWebHostBuilder(); - if (builder is null) - { - 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 + 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 + 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); - return; - } - - throw new InvalidOperationException( - Resources.FormatMissingBuilderMethod( - nameof(IHostBuilder), - nameof(IWebHostBuilder), - typeof(TEntryPoint).Assembly.EntryPoint!.DeclaringType!.FullName, - typeof(WebApplicationFactory).Name, - nameof(CreateHostBuilder), - nameof(CreateWebHostBuilder) - ) - ); - } - else + if (factory is not null) { - SetContentRoot(builder); - _configuration(builder); - _server = CreateServer(builder); + // 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); + return; } + + throw new InvalidOperationException("Unable to create IHostBuilder instance."); } [MemberNotNull(nameof(_server))] private void ConfigureHostBuilder(IHostBuilder hostBuilder) { + hostBuilder.ConfigureWebHost(webHostBuilder => + { + SetContentRoot(webHostBuilder); + _configuration(webHostBuilder); + webHostBuilder.UseTestServer(); + }); hostBuilder.ConfigureWebHost(webHostBuilder => { SetContentRoot(webHostBuilder); @@ -223,7 +203,7 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) _server = (TestServer)_host.Services.GetRequiredService(); } - private void SetContentRoot(IWebHostBuilder builder) + private void SetContentRoot(IHostBuilder builder) { if (SetContentRootFromSetting(builder)) return; @@ -289,7 +269,7 @@ private sealed partial class CustomJsonSerializerContext : JsonSerializerContext return contentRoot; } - private static bool SetContentRootFromSetting(IWebHostBuilder builder) + private static bool SetContentRootFromSetting(IHostBuilder builder) { // Attempt to look for TEST_CONTENTROOT_APPNAME in settings. This should result in looking // for @@ -416,15 +396,15 @@ private static void EnsureDepsFile() } /// - /// Creates a used to set up . + /// Creates a used to set up . /// /// - /// The default implementation of this method looks for a public static IWebHostBuilder CreateWebHostBuilder(string[] args) + /// The default implementation of this method looks for a public static IHostBuilder CreateWebHostBuilder(string[] args) /// method defined on the entry point of the assembly of and invokes it passing an empty string /// array as arguments. /// - /// A instance. - protected virtual IWebHostBuilder? CreateWebHostBuilder() + /// A instance. + protected virtual IHostBuilder? CreateWebHostBuilder() { var builder = WebHostBuilderFactory.CreateFromTypesAssemblyEntryPoint( Array.Empty() @@ -438,18 +418,18 @@ private static void EnsureDepsFile() /// /// Creates the with the bootstrapped application in . - /// This is only called for applications using . Applications based on + /// This is only called for applications using . Applications based on /// will use instead. /// - /// The used to + /// The used to /// create the server. /// The with the bootstrapped application. - protected virtual TestServer CreateServer(IWebHostBuilder builder) => new(builder); + protected virtual TestServer CreateServer(IHostBuilder builder) => new(builder); /// /// Creates the with the bootstrapped application in . /// This is only called for applications using . Applications based on - /// will use instead. + /// will use instead. /// /// The used to create the host. /// The with the bootstrapped application. @@ -463,8 +443,8 @@ protected virtual IHost CreateHost(IHostBuilder builder) /// /// Gives a fixture an opportunity to configure the application before it gets built. /// - /// The for the application. - protected virtual void ConfigureWebHost(IWebHostBuilder builder) { } + /// The for the application. + protected virtual void ConfigureWebHost(IHostBuilder builder) { } /// /// Creates an instance of that automatically follows @@ -478,7 +458,7 @@ protected virtual void ConfigureWebHost(IWebHostBuilder builder) { } /// redirects and handles cookies. /// /// The . - public HttpClient CreateClient(WebApplicationFactoryClientOptions options) => + public HttpClient CreateClient(LambdaApplicationFactoryClientOptions options) => CreateDefaultClient(options.BaseAddress, options.CreateHandlers()); /// @@ -605,22 +585,22 @@ public virtual async ValueTask DisposeAsync() private sealed class DelegatedWebApplicationFactory : WebApplicationFactory { - private readonly Func _createServer; + private readonly Func _createServer; private readonly Func _createHost; - private readonly Func _createWebHostBuilder; + private readonly Func _createWebHostBuilder; private readonly Func _createHostBuilder; private readonly Func> _getTestAssemblies; private readonly Action _configureClient; public DelegatedWebApplicationFactory( WebApplicationFactoryClientOptions options, - Func createServer, + Func createServer, Func createHost, - Func createWebHostBuilder, + Func createWebHostBuilder, Func createHostBuilder, Func> getTestAssemblies, Action configureClient, - Action configureWebHost + Action configureWebHost ) { ClientOptions = new WebApplicationFactoryClientOptions(options); @@ -633,24 +613,22 @@ Action configureWebHost _configuration = configureWebHost; } - protected override TestServer CreateServer(IWebHostBuilder builder) => - _createServer(builder); + protected override TestServer CreateServer(IHostBuilder builder) => _createServer(builder); protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder); - protected override IWebHostBuilder? CreateWebHostBuilder() => _createWebHostBuilder(); + protected override IHostBuilder? CreateWebHostBuilder() => _createWebHostBuilder(); protected override IHostBuilder? CreateHostBuilder() => _createHostBuilder(); protected override IEnumerable GetTestAssemblies() => _getTestAssemblies(); - protected override void ConfigureWebHost(IWebHostBuilder builder) => - _configuration(builder); + protected override void ConfigureWebHost(IHostBuilder builder) => _configuration(builder); protected override void ConfigureClient(HttpClient client) => _configureClient(client); internal override WebApplicationFactory WithWebHostBuilderCore( - Action configuration + Action configuration ) => new DelegatedWebApplicationFactory( ClientOptions, From 72948011dab9221a62fe87cefd567c0c4fe682e1 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:31:07 -0500 Subject: [PATCH 015/132] feat(testing): add Microsoft.AspNetCore.Routing package - Added `Microsoft.AspNetCore.Routing` as a package reference in the Testing project. - Updated `Directory.Packages.props` to include `Microsoft.AspNetCore.Routing` version 2.3.0. --- Directory.Packages.props | 1 + src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6dd2e2ac..7806a9c4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj b/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj index f45a96d9..3d7ae38c 100644 --- a/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj +++ b/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj @@ -19,4 +19,7 @@ + + + From eadbf230648cd975efacbfe608dd43e10f2d376c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:31:16 -0500 Subject: [PATCH 016/132] feat(testing): add ErrorResponse model to represent structured error data - Introduced `ErrorResponse` model for consistent error representation in testing scenarios. - Added nested `ErrorCause` class to handle detailed error information with potential nested causes. - Included JSON serialization attributes for seamless JSON handling. --- .../Models/ErrorResponse.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/Models/ErrorResponse.cs diff --git a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs new file mode 100644 index 00000000..ada60032 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; + +namespace AwsLambda.Host.Testing; + +/// +/// Represents an error response with type, message, stack trace, and optional cause. +/// +public class ErrorResponse +{ + /// + /// The type of error that occurred. + /// + [JsonPropertyName("errorType")] + public string ErrorType { get; set; } + + /// + /// The error message describing what went wrong. + /// + [JsonPropertyName("errorMessage")] + public string ErrorMessage { get; set; } + + /// + /// The stack trace showing where the error occurred. + /// + [JsonPropertyName("stackTrace")] + public List StackTrace { get; set; } + + /// + /// The underlying cause of this error, if any. + /// + [JsonPropertyName("cause")] + public ErrorCause? Cause { get; set; } + + /// + /// Represents the cause of an error, which can have its own nested cause. + /// + public class ErrorCause + { + /// + /// The type of error that occurred. + /// + [JsonPropertyName("errorType")] + public string ErrorType { get; set; } + + /// + /// The error message describing what went wrong. + /// + [JsonPropertyName("errorMessage")] + public string ErrorMessage { get; set; } + + /// + /// The stack trace showing where the error occurred. + /// + [JsonPropertyName("stackTrace")] + public List StackTrace { get; set; } + + /// + /// The underlying cause of this error, if any. + /// + [JsonPropertyName("cause")] + public ErrorCause? Cause { get; set; } + } +} From dadaf34f11a7065ed5041ce363cf47be1cd1647f Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:31:33 -0500 Subject: [PATCH 017/132] feat(testing): add LambdaRuntimeRouteManager with route matching implementation - Introduced `LambdaRuntimeRouteManager` and `ILambdaRuntimeRouteManager` for route matching logic. - Implemented support for matching HTTP requests to specific Lambda runtime routes. - Defined route templates for `/invocation/next`, `/invocation/{requestId}/response`, and `/invocation/{requestId}/error`. - Utilized `TemplateMatcher` for parsing and matching route templates. --- .../ILambdaRuntimeRouteManager.cs | 12 +++ .../LambdaRuntimeRouteManager.cs | 75 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs create mode 100644 src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs diff --git a/src/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs b/src/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs new file mode 100644 index 00000000..041586b6 --- /dev/null +++ b/src/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Routing; + +namespace AwsLambda.Host.Testing; + +internal interface ILambdaRuntimeRouteManager +{ + bool TryMatch( + HttpRequestMessage request, + out RequestType? routeType, + out RouteValueDictionary? values + ); +} diff --git a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs b/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs new file mode 100644 index 00000000..36e2657f --- /dev/null +++ b/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs @@ -0,0 +1,75 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Template; + +namespace AwsLambda.Host.Testing; + +internal class LambdaRuntimeRouteManager : ILambdaRuntimeRouteManager +{ + private static readonly RouteTemplate[] Routes = + [ + new() + { + Type = RequestType.GetNextInvocation, + Method = HttpMethod.Get.Method, + Matcher = new TemplateMatcher( + TemplateParser.Parse("2018-06-01/runtime/invocation/next"), + new RouteValueDictionary() + ), + }, + new() + { + Type = RequestType.PostResponse, + Method = HttpMethod.Post.Method, + Matcher = new TemplateMatcher( + TemplateParser.Parse("2018-06-01/runtime/invocation/{requestId}/response"), + new RouteValueDictionary() + ), + }, + new() + { + Type = RequestType.PostError, + Method = HttpMethod.Post.Method, + Matcher = new TemplateMatcher( + TemplateParser.Parse("2018-06-01/runtime/invocation/{requestId}/error"), + new RouteValueDictionary() + ), + }, + ]; + + public 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.TrimStart('/') ?? 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 class RouteTemplate + { + internal required TemplateMatcher Matcher { get; init; } + internal required string Method { get; init; } + internal required RequestType Type { get; init; } + } +} From 97269f14886268f53c318b28f001a0bed62aec76 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:31:47 -0500 Subject: [PATCH 018/132] feat(testing): add RequestType enum for Lambda runtime request classification - Introduced `RequestType` enum with values: `GetNextInvocation`, `PostResponse`, and `PostError`. - Enables clear categorization of Lambda runtime request types in testing scenarios. --- src/AwsLambda.Host.Testing/Models/RequestType.cs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/Models/RequestType.cs diff --git a/src/AwsLambda.Host.Testing/Models/RequestType.cs b/src/AwsLambda.Host.Testing/Models/RequestType.cs new file mode 100644 index 00000000..935bd790 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/RequestType.cs @@ -0,0 +1,8 @@ +namespace AwsLambda.Host.Testing; + +internal enum RequestType +{ + GetNextInvocation, + PostResponse, + PostError, +} From 2200769f8562cdf6e17585fb8b56ef0086c3da8a Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:32:07 -0500 Subject: [PATCH 019/132] feat(testing): add InvocationResponse model for Lambda testing - Introduced `InvocationResponse` for modeling Lambda invocation outcomes. - Added `WasSuccess`, `Response`, and `Error` properties for detailed testing scenarios. --- src/AwsLambda.Host.Testing/Models/InvocationResponse.cs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/Models/InvocationResponse.cs diff --git a/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs b/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs new file mode 100644 index 00000000..de8fbf79 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs @@ -0,0 +1,8 @@ +namespace AwsLambda.Host.Testing; + +public class InvocationResponse +{ + public bool WasSuccess { get; internal set; } + public TResponse? Response { get; internal set; } + public ErrorResponse? Error { get; internal set; } +} From d71423374a970d522e6ea2be2bf34da5494e606b Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:32:17 -0500 Subject: [PATCH 020/132] feat(testing): add LambdaBootstrapRequest model for request handling - Introduced `LambdaBootstrapRequest` model to encapsulate Lambda runtime request data. - Added `RequestType`, `RequestMessage`, and `RouteValue` properties as required fields. - Implemented `RequestId` property using `RouteValueDictionary` for request identification. --- .../Models/LambdaBootstrapRequest.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs diff --git a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs new file mode 100644 index 00000000..fd51a78a --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Routing; + +namespace AwsLambda.Host.Testing; + +internal class LambdaBootstrapRequest +{ + internal required RequestType RequestType { get; init; } + internal required HttpRequestMessage RequestMessage { get; init; } + internal required RouteValueDictionary RouteValue { get; init; } + + internal string RequestId + { + get + { + field ??= + RouteValue.TryGetValue("RequestId", out var requestId) + && requestId is string requestIdString + ? requestIdString + : string.Empty; + + return field; + } + } +} From d0a0a032a6cebc7e2c8993a986e49b8b5d0a59df Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:32:28 -0500 Subject: [PATCH 021/132] feat(testing): add LambdaClient for managing Lambda runtime requests - Introduced `LambdaClient` to handle Lambda runtime requests in testing scenarios. - Implemented `WaitForBootstrapAsync` to process bootstrap-specific requests. - Added `WaitForRequestAsync` with support for route matching using `ILambdaRuntimeRouteManager`. - Included `Invoke` method for serializing events and initiating invocation responses. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 70 ++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/LambdaClient.cs diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs new file mode 100644 index 00000000..d25068fd --- /dev/null +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using System.Threading.Channels; + +namespace AwsLambda.Host.Testing; + +public class LambdaClient +{ + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly Channel _requestChanel; + private readonly Channel _responseChanel; + private readonly ILambdaRuntimeRouteManager _routeManager; + private bool _isBootstrappingComplete; + + internal LambdaClient( + Channel requestChanel, + Channel responseChanel, + JsonSerializerOptions jsonSerializerOptions, + ILambdaRuntimeRouteManager routeManager + ) + { + _requestChanel = requestChanel; + _responseChanel = responseChanel; + _jsonSerializerOptions = jsonSerializerOptions; + _routeManager = routeManager; + } + + internal async Task WaitForBootstrapAsync(CancellationToken cancellationToken = default) + { + if (_isBootstrappingComplete) + return; + + var request = await WaitForRequestAsync(cancellationToken); + + if (request.RequestType != RequestType.GetNextInvocation) + throw new InvalidOperationException( + $"Unexpected request received during bootstrap: {request.RequestType.ToString()}" + ); + + _isBootstrappingComplete = true; + } + + private async Task WaitForRequestAsync( + CancellationToken cancellationToken = default + ) + { + var request = await _requestChanel.Reader.ReadAsync(cancellationToken); + + if (!_routeManager.TryMatch(request, out var routeType, out var routeValue)) + throw new InvalidOperationException( + $"Unexpected request received: {request.Method} {request.RequestUri?.PathAndQuery ?? "(no URI)"}" + ); + + return new LambdaBootstrapRequest + { + RequestType = routeType!.Value, + RequestMessage = request, + RouteValue = routeValue!, + }; + } + + public async Task> Invoke( + TEvent invokeEvent, + CancellationToken cancellationToken = default + ) + { + var eventJson = JsonSerializer.Serialize(invokeEvent, _jsonSerializerOptions); + + return default; + } +} From abdf25c891d6963316945f8fbedd6ede097c4472 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:32:38 -0500 Subject: [PATCH 022/132] feat(testing): enhance LambdaTestingHttpHandler with request/response channel support - Added constructor to `LambdaTestingHttpHandler` accepting request and response channels. - Implemented `SendAsync` to pass requests to `requestChannel` and receive responses from `responseChannel`. --- .../LambdaTestingHttpHandler.cs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index 0b93ea9e..bdd87e67 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -1,3 +1,21 @@ +using System.Threading.Channels; + namespace AwsLambda.Host.Testing; -public class LambdaTestingHttpHandler : DelegatingHandler { } +public class LambdaTestingHttpHandler( + Channel requestChanel, + Channel responseChanel +) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + // pass the request out to the client + await requestChanel.Writer.WriteAsync(request, cancellationToken); + + // block here until the client sends a response. There may not be a response. + return await responseChanel.Reader.ReadAsync(cancellationToken); + } +} From 053b086a51ee11219237088543b5d9e8e5516b34 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:34:03 -0500 Subject: [PATCH 023/132] feat(testing): update route templates to support version-based paths - Replaced hardcoded "2018-06-01" route version with a dynamic "{version}" placeholder. - Updated route templates for `GetNextInvocation`, `PostResponse`, and `PostError` requests. - Improved flexibility and compatibility for handling versioned Lambda runtime paths. --- src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs b/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs index 36e2657f..7228226e 100644 --- a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs +++ b/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs @@ -13,7 +13,7 @@ internal class LambdaRuntimeRouteManager : ILambdaRuntimeRouteManager Type = RequestType.GetNextInvocation, Method = HttpMethod.Get.Method, Matcher = new TemplateMatcher( - TemplateParser.Parse("2018-06-01/runtime/invocation/next"), + TemplateParser.Parse("{version}/runtime/invocation/next"), new RouteValueDictionary() ), }, @@ -22,7 +22,7 @@ internal class LambdaRuntimeRouteManager : ILambdaRuntimeRouteManager Type = RequestType.PostResponse, Method = HttpMethod.Post.Method, Matcher = new TemplateMatcher( - TemplateParser.Parse("2018-06-01/runtime/invocation/{requestId}/response"), + TemplateParser.Parse("{version}/runtime/invocation/{requestId}/response"), new RouteValueDictionary() ), }, @@ -31,7 +31,7 @@ internal class LambdaRuntimeRouteManager : ILambdaRuntimeRouteManager Type = RequestType.PostError, Method = HttpMethod.Post.Method, Matcher = new TemplateMatcher( - TemplateParser.Parse("2018-06-01/runtime/invocation/{requestId}/error"), + TemplateParser.Parse("{version}/runtime/invocation/{requestId}/error"), new RouteValueDictionary() ), }, From 42f14b1c9e2ad1d5713dd6533094720cf137ea23 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:48:53 -0500 Subject: [PATCH 024/132] feat(testing): add customizable Lambda invocation headers and response handling - Introduced `LambdaClientOptions` to configure response headers for Lambda invocations. - Added `LambdaInvocationHeaders` to customize attributes like date, chunked transfer, and AWS metadata. - Enhanced `Invoke` method to return a mocked `HttpResponseMessage` with serialized invocation data. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 74 +++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index d25068fd..1ec95327 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Text; using System.Text.Json; using System.Threading.Channels; @@ -63,8 +65,78 @@ public async Task> Invoke( CancellationToken cancellationToken = default ) { - var eventJson = JsonSerializer.Serialize(invokeEvent, _jsonSerializerOptions); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + JsonSerializer.Serialize(invokeEvent, _jsonSerializerOptions), + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + }; + // Add response headers + response.Headers.Date = new DateTimeOffset(2025, 12, 4, 20, 40, 53, TimeSpan.Zero); + response.Headers.TransferEncodingChunked = true; + + // Add custom Lambda headers + response.Headers.Add("ambda-Runtime-Deadline-Ms", "1764881754010"); + response.Headers.Add("Lambda-Runtime-Aws-Request-Id", "000000000002"); + response.Headers.Add("Lambda-Runtime-Trace-Id", "2a159b6d-ca3c-4991-8533-c2b2a8da0640"); + response.Headers.Add( + "Lambda-Runtime-Invoked-Function-Arn", + "arn:aws:lambda:us-west-2:123412341234:function:Function" + ); return default; } } + +/// +/// Configuration options for the Lambda test client. +/// +public class LambdaClientOptions +{ + /// + /// Gets or sets the headers to include in Lambda invocation responses. + /// + public LambdaInvocationHeaders InvocationHeaders { get; set; } = new(); +} + +/// +/// Headers returned in Lambda runtime API invocation responses. +/// +public class LambdaInvocationHeaders +{ + /// + /// Gets or sets the response date header. Defaults to current UTC time. + /// + public DateTime Date { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets whether to use chunked transfer encoding. Defaults to true. + /// + public bool TransferEncodingChunked { get; set; } = true; + + /// + /// Gets or sets the Lambda invocation deadline in milliseconds since Unix epoch. + /// This indicates when the Lambda function will timeout. + /// + public long DeadlineMs { get; set; } = + DateTimeOffset.UtcNow.AddMinutes(15).ToUnixTimeMilliseconds(); + + /// + /// Gets or sets the AWS request ID for this invocation. + /// + public string RequestId { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the AWS X-Ray trace ID for distributed tracing. + /// + public string TraceId { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the ARN of the Lambda function being invoked. + /// + public string FunctionArn { get; set; } = + "arn:aws:lambda:us-west-2:123412341234:function:Function"; +} From 3ac1fb99b89264720ab3a2bd639360376c654494 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:50:49 -0500 Subject: [PATCH 025/132] feat(testing): add configuration support to LambdaClient - Added `LambdaClientOptions` as a configurable option for `LambdaClient`. - Implemented `ConfigureOptions` method to allow runtime customization of `LambdaClientOptions`. - Ensured null-check for `configureOptions` to prevent potential null reference exceptions. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 1ec95327..2e7c0ff8 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -12,6 +12,7 @@ public class LambdaClient private readonly Channel _responseChanel; private readonly ILambdaRuntimeRouteManager _routeManager; private bool _isBootstrappingComplete; + private readonly LambdaClientOptions _lambdaClientOptions; internal LambdaClient( Channel requestChanel, @@ -24,6 +25,16 @@ ILambdaRuntimeRouteManager routeManager _responseChanel = responseChanel; _jsonSerializerOptions = jsonSerializerOptions; _routeManager = routeManager; + _lambdaClientOptions = new LambdaClientOptions(); + } + + public LambdaClient ConfigureOptions(Action configureOptions) + { + ArgumentNullException.ThrowIfNull(configureOptions); + + configureOptions(_lambdaClientOptions); + + return this; } internal async Task WaitForBootstrapAsync(CancellationToken cancellationToken = default) From 827748b70c8ffc1cb538298dade996a17e2b12c1 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 16:52:45 -0500 Subject: [PATCH 026/132] refactor(testing): extract request creation logic into `CreateRequest` method in `LambdaClient` - Moved request creation code from `Invoke` to a new private method `CreateRequest`. - Simplified `Invoke` by reusing the `CreateRequest` method. - Improved maintainability and readability by centralizing HTTP response setup logic. - Updated to use values from `LambdaClientOptions.InvocationHeaders` for response headers. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 45 +++++++++++++++------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 2e7c0ff8..9c9a704a 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -71,10 +71,7 @@ private async Task WaitForRequestAsync( }; } - public async Task> Invoke( - TEvent invokeEvent, - CancellationToken cancellationToken = default - ) + private HttpResponseMessage CreateRequest(TEvent invokeEvent) { var response = new HttpResponseMessage(HttpStatusCode.OK) { @@ -85,19 +82,41 @@ public async Task> Invoke( ), Version = Version.Parse("1.1"), }; - // Add response headers - response.Headers.Date = new DateTimeOffset(2025, 12, 4, 20, 40, 53, TimeSpan.Zero); - response.Headers.TransferEncodingChunked = true; - - // Add custom Lambda headers - response.Headers.Add("ambda-Runtime-Deadline-Ms", "1764881754010"); - response.Headers.Add("Lambda-Runtime-Aws-Request-Id", "000000000002"); - response.Headers.Add("Lambda-Runtime-Trace-Id", "2a159b6d-ca3c-4991-8533-c2b2a8da0640"); + + // Add standard HTTP headers + response.Headers.Date = new DateTimeOffset(_lambdaClientOptions.InvocationHeaders.Date); + response.Headers.TransferEncodingChunked = _lambdaClientOptions + .InvocationHeaders + .TransferEncodingChunked; + + // Add custom Lambda runtime headers + response.Headers.Add( + "Lambda-Runtime-Deadline-Ms", + _lambdaClientOptions.InvocationHeaders.DeadlineMs.ToString() + ); + response.Headers.Add( + "Lambda-Runtime-Aws-Request-Id", + _lambdaClientOptions.InvocationHeaders.RequestId + ); + response.Headers.Add( + "Lambda-Runtime-Trace-Id", + _lambdaClientOptions.InvocationHeaders.TraceId + ); response.Headers.Add( "Lambda-Runtime-Invoked-Function-Arn", - "arn:aws:lambda:us-west-2:123412341234:function:Function" + _lambdaClientOptions.InvocationHeaders.FunctionArn ); + return response; + } + + public async Task> Invoke( + TEvent invokeEvent, + CancellationToken cancellationToken = default + ) + { + var response = CreateRequest(invokeEvent); + return default; } } From 3f8da1eb3b88b57a24c49b86b406d8f48da747ce Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 17:02:23 -0500 Subject: [PATCH 027/132] feat(testing): add options for customizing Lambda invocation headers and client configuration - Introduced `LambdaInvocationHeaderOptions` to define additional headers, metadata, and timeouts. - Added `LambdaClientOptions` to encapsulate and configure invocation header options. --- .../Options/LambdaClientOptions.cs | 12 +++++ .../Options/LambdaInvocationHeaderOptions.cs | 52 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs create mode 100644 src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs diff --git a/src/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs new file mode 100644 index 00000000..025719b1 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs @@ -0,0 +1,12 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Configuration options for the Lambda test client. +/// +public class LambdaClientOptions +{ + /// + /// Gets or sets the headers to include in Lambda invocation responses. + /// + public LambdaInvocationHeaderOptions InvocationHeaderOptions { get; set; } = new(); +} diff --git a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs new file mode 100644 index 00000000..fd96d44c --- /dev/null +++ b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs @@ -0,0 +1,52 @@ +namespace AwsLambda.Host.Testing; + +/// +/// Headers returned in Lambda runtime API invocation responses. +/// +public class LambdaInvocationHeaderOptions +{ + /// + /// 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 response date header. + /// Maps to the Date HTTP header. Defaults to current UTC time. + /// + public DateTime Date { get; set; } = DateTime.UtcNow; + + /// + /// 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); + + /// + /// Gets or sets the AWS request ID for this invocation. + /// Maps to the Lambda-Runtime-Aws-Request-Id header. + /// + public string RequestId { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the AWS X-Ray trace ID for distributed tracing. + /// Maps to the Lambda-Runtime-Trace-Id header. + /// + public string TraceId { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets whether to use chunked transfer encoding. + /// Maps to the Transfer-Encoding: chunked HTTP header. Defaults to true. + /// + public bool TransferEncodingChunked { get; set; } = true; +} From c7b4754399a43a42a59d1b1fc1b616e06cfbb82e Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 17:02:32 -0500 Subject: [PATCH 028/132] refactor(testing): improve response header configuration and LambdaClient options - Reorganized `LambdaClientOptions` within `LambdaClient` for clarity and proper initialization. - Refactored response header setup to use `InvocationHeaderOptions` for better flexibility. - Added support for calculating dynamic `DeadlineMs` header based on function timeout. - Enabled adding custom additional headers via `InvocationHeaderOptions.AdditionalHeaders`. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 76 +++++----------------- 1 file changed, 16 insertions(+), 60 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 9c9a704a..886d48ba 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -8,11 +8,11 @@ namespace AwsLambda.Host.Testing; public class LambdaClient { private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly LambdaClientOptions _lambdaClientOptions; private readonly Channel _requestChanel; private readonly Channel _responseChanel; private readonly ILambdaRuntimeRouteManager _routeManager; private bool _isBootstrappingComplete; - private readonly LambdaClientOptions _lambdaClientOptions; internal LambdaClient( Channel requestChanel, @@ -84,29 +84,35 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) }; // Add standard HTTP headers - response.Headers.Date = new DateTimeOffset(_lambdaClientOptions.InvocationHeaders.Date); + response.Headers.Date = new DateTimeOffset( + _lambdaClientOptions.InvocationHeaderOptions.Date + ); response.Headers.TransferEncodingChunked = _lambdaClientOptions - .InvocationHeaders + .InvocationHeaderOptions .TransferEncodingChunked; // Add custom Lambda runtime headers - response.Headers.Add( - "Lambda-Runtime-Deadline-Ms", - _lambdaClientOptions.InvocationHeaders.DeadlineMs.ToString() - ); + var deadlineMs = DateTimeOffset + .UtcNow.Add(_lambdaClientOptions.InvocationHeaderOptions.FunctionTimeout) + .ToUnixTimeMilliseconds(); + response.Headers.Add("Lambda-Runtime-Deadline-Ms", deadlineMs.ToString()); response.Headers.Add( "Lambda-Runtime-Aws-Request-Id", - _lambdaClientOptions.InvocationHeaders.RequestId + _lambdaClientOptions.InvocationHeaderOptions.RequestId ); response.Headers.Add( "Lambda-Runtime-Trace-Id", - _lambdaClientOptions.InvocationHeaders.TraceId + _lambdaClientOptions.InvocationHeaderOptions.TraceId ); response.Headers.Add( "Lambda-Runtime-Invoked-Function-Arn", - _lambdaClientOptions.InvocationHeaders.FunctionArn + _lambdaClientOptions.InvocationHeaderOptions.FunctionArn ); + // Add any additional custom headers + foreach (var header in _lambdaClientOptions.InvocationHeaderOptions.AdditionalHeaders) + response.Headers.Add(header.Key, header.Value); + return response; } @@ -120,53 +126,3 @@ public async Task> Invoke( return default; } } - -/// -/// Configuration options for the Lambda test client. -/// -public class LambdaClientOptions -{ - /// - /// Gets or sets the headers to include in Lambda invocation responses. - /// - public LambdaInvocationHeaders InvocationHeaders { get; set; } = new(); -} - -/// -/// Headers returned in Lambda runtime API invocation responses. -/// -public class LambdaInvocationHeaders -{ - /// - /// Gets or sets the response date header. Defaults to current UTC time. - /// - public DateTime Date { get; set; } = DateTime.UtcNow; - - /// - /// Gets or sets whether to use chunked transfer encoding. Defaults to true. - /// - public bool TransferEncodingChunked { get; set; } = true; - - /// - /// Gets or sets the Lambda invocation deadline in milliseconds since Unix epoch. - /// This indicates when the Lambda function will timeout. - /// - public long DeadlineMs { get; set; } = - DateTimeOffset.UtcNow.AddMinutes(15).ToUnixTimeMilliseconds(); - - /// - /// Gets or sets the AWS request ID for this invocation. - /// - public string RequestId { get; set; } = Guid.NewGuid().ToString(); - - /// - /// Gets or sets the AWS X-Ray trace ID for distributed tracing. - /// - public string TraceId { get; set; } = Guid.NewGuid().ToString(); - - /// - /// Gets or sets the ARN of the Lambda function being invoked. - /// - public string FunctionArn { get; set; } = - "arn:aws:lambda:us-west-2:123412341234:function:Function"; -} From 6ffae76812c90657b225f1bbd24164b32ff91e3a Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 17:08:03 -0500 Subject: [PATCH 029/132] feat(testing): improve request ID generation and invocation handling in LambdaClient - Replaced `RequestId` with `InvocationCounter` to generate zero-padded 12-digit request IDs. - Incremented `InvocationCounter` after each invocation to ensure unique request IDs. - Renamed `Invoke` to `InvokeAsync` for consistency with asynchronous method naming conventions. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 16 +++++++++++----- .../Options/LambdaInvocationHeaderOptions.cs | 7 ++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 886d48ba..b7318517 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -96,10 +96,13 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) .UtcNow.Add(_lambdaClientOptions.InvocationHeaderOptions.FunctionTimeout) .ToUnixTimeMilliseconds(); response.Headers.Add("Lambda-Runtime-Deadline-Ms", deadlineMs.ToString()); - response.Headers.Add( - "Lambda-Runtime-Aws-Request-Id", - _lambdaClientOptions.InvocationHeaderOptions.RequestId - ); + + // Generate request ID with proper padding (12 digits, zero-padded) + var requestId = _lambdaClientOptions + .InvocationHeaderOptions.InvocationCounter.ToString() + .PadLeft(12, '0'); + response.Headers.Add("Lambda-Runtime-Aws-Request-Id", requestId); + response.Headers.Add( "Lambda-Runtime-Trace-Id", _lambdaClientOptions.InvocationHeaderOptions.TraceId @@ -116,13 +119,16 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) return response; } - public async Task> Invoke( + public async Task> InvokeAsync( TEvent invokeEvent, CancellationToken cancellationToken = default ) { var response = CreateRequest(invokeEvent); + // Increment the invocation counter after the request is made + _lambdaClientOptions.InvocationHeaderOptions.InvocationCounter++; + return default; } } diff --git a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs index fd96d44c..cfcc8d3e 100644 --- a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs +++ b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs @@ -33,10 +33,11 @@ public class LambdaInvocationHeaderOptions public TimeSpan FunctionTimeout { get; set; } = TimeSpan.FromMinutes(15); /// - /// Gets or sets the AWS request ID for this invocation. - /// Maps to the Lambda-Runtime-Aws-Request-Id header. + /// Gets or sets the invocation counter used to generate the request ID. + /// Maps to the Lambda-Runtime-Aws-Request-Id header as a zero-padded 12-digit number. + /// The counter is incremented after each invocation. Defaults to 1. /// - public string RequestId { get; set; } = Guid.NewGuid().ToString(); + public int InvocationCounter { get; set; } = 1; /// /// Gets or sets the AWS X-Ray trace ID for distributed tracing. From 9d7fc04b6f2bc70f0d03a5d5a889d48fce63437e Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 18:30:06 -0500 Subject: [PATCH 030/132] feat(testing): enhance Lambda request/response handling in LambdaClient - Renamed `WaitForBootstrapAsync` to `WaitForNextRequestAsync` for improved clarity. - Refactored `Invoke` response logic to handle success and error scenarios more robustly. - Added support for serializing invocation responses including success status and error details. - Simplified the invocation flow to improve readability and maintainability. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 57 +++++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index b7318517..97f1d0e1 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Threading.Channels; @@ -37,19 +38,14 @@ public LambdaClient ConfigureOptions(Action configureOption return this; } - internal async Task WaitForBootstrapAsync(CancellationToken cancellationToken = default) + internal async Task WaitForNextRequestAsync(CancellationToken cancellationToken = default) { - if (_isBootstrappingComplete) - return; - var request = await WaitForRequestAsync(cancellationToken); if (request.RequestType != RequestType.GetNextInvocation) throw new InvalidOperationException( $"Unexpected request received during bootstrap: {request.RequestType.ToString()}" ); - - _isBootstrappingComplete = true; } private async Task WaitForRequestAsync( @@ -126,9 +122,54 @@ public async Task> InvokeAsync( { var response = CreateRequest(invokeEvent); - // Increment the invocation counter after the request is made + await _responseChanel.Writer.WriteAsync(response, cancellationToken); + _lambdaClientOptions.InvocationHeaderOptions.InvocationCounter++; - return default; + var request = await WaitForRequestAsync(cancellationToken); + + if (request.RequestType == RequestType.GetNextInvocation) + throw new InvalidOperationException( + $"Expected PostResponse or PostError request, but received {request.RequestType}" + ); + + await _responseChanel.Writer.WriteAsync( + new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent( + JsonSerializer.Serialize( + new Dictionary { ["status"] = "success" }, + _jsonSerializerOptions + ), + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + }, + cancellationToken + ); + + await WaitForNextRequestAsync(cancellationToken); + + var wasSuccess = request.RequestType == RequestType.PostResponse; + + var invocationResponse = new InvocationResponse + { + WasSuccess = wasSuccess, + Response = wasSuccess + ? await request.RequestMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cancellationToken + ) + : default, + Error = !wasSuccess + ? await request.RequestMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cancellationToken + ) + : null, + }; + + return invocationResponse; } } From 6cac8a3cd4664e33907db1c02966b028cfa7a507 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 18:33:37 -0500 Subject: [PATCH 031/132] fix(testing): handle null deserialization fallback in LambdaClient response handling - Added null fallback handling for `ReadFromJsonAsync` to avoid potential null reference exceptions. - Ensured default value (`default` or `null`) is used when no content is available to deserialize. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 97f1d0e1..4cb4a09d 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -157,15 +157,19 @@ await _responseChanel.Writer.WriteAsync( { WasSuccess = wasSuccess, Response = wasSuccess - ? await request.RequestMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cancellationToken + ? await ( + request.RequestMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cancellationToken + ) ?? Task.FromResult(default) ) : default, Error = !wasSuccess - ? await request.RequestMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cancellationToken + ? await ( + request.RequestMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cancellationToken + ) ?? Task.FromResult(null) ) : null, }; From d12498bfb3ccd50031eef6a2fbedfc1ada402cb6 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 5 Dec 2025 18:34:12 -0500 Subject: [PATCH 032/132] refactor(testing): remove unnecessary WaitForNextRequestAsync call - Eliminated redundant `WaitForNextRequestAsync` invocation in `InvokeAsync`. - Simplified invocation flow for improved readability and maintainability. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 4cb4a09d..0b647bae 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -149,8 +149,6 @@ await _responseChanel.Writer.WriteAsync( cancellationToken ); - await WaitForNextRequestAsync(cancellationToken); - var wasSuccess = request.RequestType == RequestType.PostResponse; var invocationResponse = new InvocationResponse From 297edb16f4be776c7062e6973d77944a4b9e3f13 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 07:16:35 -0500 Subject: [PATCH 033/132] fix(testing): allow null RequestId and update fallback logic - Changed `RequestId` property to nullable to correctly reflect potential absence of value. - Updated fallback logic to return `null` instead of an empty string when no `RequestId` is found. --- src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs index fd51a78a..62f84525 100644 --- a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs +++ b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs @@ -8,7 +8,7 @@ internal class LambdaBootstrapRequest internal required HttpRequestMessage RequestMessage { get; init; } internal required RouteValueDictionary RouteValue { get; init; } - internal string RequestId + internal string? RequestId { get { @@ -16,7 +16,7 @@ internal string RequestId RouteValue.TryGetValue("RequestId", out var requestId) && requestId is string requestIdString ? requestIdString - : string.Empty; + : null; return field; } From d23cd771a7810aefc2277519d5126ede5b96ba67 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 07:35:33 -0500 Subject: [PATCH 034/132] refactor(testing): simplify request ID generation and remove unused invocation counter - Added `GetRequestId` method to centralize and simplify request ID generation logic. - Removed `InvocationCounter` property from `LambdaInvocationHeaderOptions` as it is no longer used. - Updated `LambdaClient` to increment and zero-pad request counter directly via `GetRequestId` method. - Improved code clarity and maintainability by eliminating redundant properties and methods. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 11 +++++------ .../Options/LambdaInvocationHeaderOptions.cs | 7 ------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 0b647bae..cbbcaae3 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -14,6 +14,7 @@ public class LambdaClient private readonly Channel _responseChanel; private readonly ILambdaRuntimeRouteManager _routeManager; private bool _isBootstrappingComplete; + private int _requestCounter; internal LambdaClient( Channel requestChanel, @@ -94,10 +95,7 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) response.Headers.Add("Lambda-Runtime-Deadline-Ms", deadlineMs.ToString()); // Generate request ID with proper padding (12 digits, zero-padded) - var requestId = _lambdaClientOptions - .InvocationHeaderOptions.InvocationCounter.ToString() - .PadLeft(12, '0'); - response.Headers.Add("Lambda-Runtime-Aws-Request-Id", requestId); + response.Headers.Add("Lambda-Runtime-Aws-Request-Id", GetRequestId()); response.Headers.Add( "Lambda-Runtime-Trace-Id", @@ -115,6 +113,9 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) return response; } + private string GetRequestId() => + Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); + public async Task> InvokeAsync( TEvent invokeEvent, CancellationToken cancellationToken = default @@ -124,8 +125,6 @@ public async Task> InvokeAsync( await _responseChanel.Writer.WriteAsync(response, cancellationToken); - _lambdaClientOptions.InvocationHeaderOptions.InvocationCounter++; - var request = await WaitForRequestAsync(cancellationToken); if (request.RequestType == RequestType.GetNextInvocation) diff --git a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs index cfcc8d3e..2f540e6c 100644 --- a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs +++ b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs @@ -32,13 +32,6 @@ public class LambdaInvocationHeaderOptions /// public TimeSpan FunctionTimeout { get; set; } = TimeSpan.FromMinutes(15); - /// - /// Gets or sets the invocation counter used to generate the request ID. - /// Maps to the Lambda-Runtime-Aws-Request-Id header as a zero-padded 12-digit number. - /// The counter is incremented after each invocation. Defaults to 1. - /// - public int InvocationCounter { get; set; } = 1; - /// /// Gets or sets the AWS X-Ray trace ID for distributed tracing. /// Maps to the Lambda-Runtime-Trace-Id header. From 5f14c2e4e4af91509b5bef6b89564a891a2ecb34 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 07:35:40 -0500 Subject: [PATCH 035/132] refactor(testing): replace DelegatingHandler with HttpMessageHandler in LambdaTestingHttpHandler - Changed base class of `LambdaTestingHttpHandler` from `DelegatingHandler` to `HttpMessageHandler`. - Simplified class implementation for improved compatibility and maintainability. --- src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index bdd87e67..371b5a35 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -5,7 +5,7 @@ namespace AwsLambda.Host.Testing; public class LambdaTestingHttpHandler( Channel requestChanel, Channel responseChanel -) : DelegatingHandler +) : HttpMessageHandler { protected override async Task SendAsync( HttpRequestMessage request, From 5fa0a860cb257283548c0f6aa9fc7f08af6e85a7 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 07:41:48 -0500 Subject: [PATCH 036/132] feat(testing): add LambdaTestServer implementation and update dependencies - Introduced `LambdaTestServer` class as an `IServer` implementation for testing purposes. - Added required `Microsoft.AspNetCore.Hosting.Abstractions` package reference to project and props files. - Updated `AwsLambda.Host.Testing.csproj` and `Directory.Packages.props` to include new dependency. --- Directory.Packages.props | 1 + .../AwsLambda.Host.Testing.csproj | 1 + .../LambdaTestServer.cs | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/LambdaTestServer.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 7806a9c4..04c45b45 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj b/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj index 3d7ae38c..8a6e771d 100644 --- a/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj +++ b/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj @@ -20,6 +20,7 @@ + diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs new file mode 100644 index 00000000..0bdf56c3 --- /dev/null +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; + +namespace AwsLambda.Host.Testing; + +public class LambdaTestServer : IServer +{ + public void Dispose() => throw new NotImplementedException(); + + public Task StartAsync( + IHttpApplication application, + CancellationToken cancellationToken + ) => throw new NotImplementedException(); + + public Task StopAsync(CancellationToken cancellationToken) => + throw new NotImplementedException(); + + public IFeatureCollection Features { get; } +} From 3a35bb8ddb3c1742aefb43a8bdf3324fd229637c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 07:41:57 -0500 Subject: [PATCH 037/132] refactor(testing): replace TestServer with LambdaTestServer in LambdaApplicationFactory - Updated `LambdaApplicationFactory` to use `LambdaTestServer` instead of `TestServer` for consistency. - Modified related methods, properties, and comments to reflect the change. - Adjusted `DelegatedWebApplicationFactory` to align with the updated server type. --- .../LambdaApplicationFactory.cs | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index 4b3afd77..a3d9dd85 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -26,17 +26,17 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis { private bool _disposed; private bool _disposedAsync; - private TestServer? _server; + private LambdaTestServer? _server; private IHost? _host; private Action _configuration; - private readonly List _clients = new(); - private readonly List> _derivedFactories = new(); + private readonly List _clients = []; + private readonly List> _derivedFactories = []; /// /// /// Creates an instance of . This factory can be used to - /// create a instance using the MVC application defined by - /// and one or more instances used to send to the . + /// create a instance using the MVC application defined by + /// and one or more instances used to send to the . /// The will find the entry point class of /// assembly and initialize the application by calling IHostBuilder CreateWebHostBuilder(string [] args) /// on . @@ -63,9 +63,9 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis ~WebApplicationFactory() => Dispose(false); /// - /// Gets the created by this . + /// Gets the created by this . /// - public TestServer Server + public LambdaTestServer Server { get { @@ -82,7 +82,8 @@ public virtual IServiceProvider Services get { EnsureServer(); - return _host?.Services ?? _server.Host.Services; + // return _host?.Services ?? _server.Host.Services; + return _host?.Services; } } @@ -200,7 +201,7 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) webHostBuilder.UseTestServer(); }); _host = CreateHost(hostBuilder); - _server = (TestServer)_host.Services.GetRequiredService(); + _server = (LambdaTestServer)_host.Services.GetRequiredService(); } private void SetContentRoot(IHostBuilder builder) @@ -377,7 +378,7 @@ private static void EnsureDepsFile() } /// - /// Creates a used to set up . + /// Creates a used to set up . /// /// /// The default implementation of this method looks for a public static IHostBuilder CreateHostBuilder(string[] args) @@ -396,7 +397,7 @@ private static void EnsureDepsFile() } /// - /// Creates a used to set up . + /// Creates a used to set up . /// /// /// The default implementation of this method looks for a public static IHostBuilder CreateWebHostBuilder(string[] args) @@ -417,14 +418,14 @@ private static void EnsureDepsFile() } /// - /// Creates the with the bootstrapped application in . + /// Creates the with the bootstrapped application in . /// This is only called for applications using . Applications based on /// will use instead. /// /// The used to /// create the server. - /// The with the bootstrapped application. - protected virtual TestServer CreateServer(IHostBuilder builder) => new(builder); + /// The with the bootstrapped application. + protected virtual LambdaTestServer CreateServer(IHostBuilder builder) => new(builder); /// /// Creates the with the bootstrapped application in . @@ -585,7 +586,7 @@ public virtual async ValueTask DisposeAsync() private sealed class DelegatedWebApplicationFactory : WebApplicationFactory { - private readonly Func _createServer; + private readonly Func _createServer; private readonly Func _createHost; private readonly Func _createWebHostBuilder; private readonly Func _createHostBuilder; @@ -594,7 +595,7 @@ private sealed class DelegatedWebApplicationFactory : WebApplicationFactory createServer, + Func createServer, Func createHost, Func createWebHostBuilder, Func createHostBuilder, @@ -613,7 +614,8 @@ Action configureWebHost _configuration = configureWebHost; } - protected override TestServer CreateServer(IHostBuilder builder) => _createServer(builder); + protected override LambdaTestServer CreateServer(IHostBuilder builder) => + _createServer(builder); protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder); From 268c8a929a8bcaa4a3b42109afe5d4b692b5695f Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 07:53:29 -0500 Subject: [PATCH 038/132] refactor(testing): remove redundant TestServer configuration in LambdaApplicationFactory - Simplified `ConfigureHostBuilder` by consolidating configuration logic. - Removed duplicate `ConfigureWebHost` calls with `TestServer` setup. - Updated comments to reflect the introduction of Lambda-specific logic. --- .../LambdaApplicationFactory.cs | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index a3d9dd85..e202da30 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -188,18 +188,16 @@ private void EnsureServer() [MemberNotNull(nameof(_server))] private void ConfigureHostBuilder(IHostBuilder hostBuilder) { - hostBuilder.ConfigureWebHost(webHostBuilder => - { - SetContentRoot(webHostBuilder); - _configuration(webHostBuilder); - webHostBuilder.UseTestServer(); - }); - hostBuilder.ConfigureWebHost(webHostBuilder => - { - SetContentRoot(webHostBuilder); - _configuration(webHostBuilder); - webHostBuilder.UseTestServer(); - }); + SetContentRoot(hostBuilder); + _configuration(hostBuilder); + + // hostBuilder.ConfigureWebHost(webHostBuilder => + // { + // webHostBuilder.UseTestServer(); + // }); + + // set Lambda Bootstrap Http Client + _host = CreateHost(hostBuilder); _server = (LambdaTestServer)_host.Services.GetRequiredService(); } From f10f5966b62b1c9810bdfcd1bab78dbe0a64c2cc Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 09:32:42 -0500 Subject: [PATCH 039/132] feat(testing): add System.Net.Http and System.Text.RegularExpressions to test projects - Updated multiple test project files to include `System.Net.Http` and `System.Text.RegularExpressions`. - Ensured consistent dependencies across unit test and example projects. --- .../AwsLambda.Host.Examples.Testing.csproj | 2 ++ .../MinimalLambda.Envelopes.UnitTests.csproj | 2 ++ .../MinimalLambda.OpenTelemetry.UnitTests.csproj | 2 ++ tests/MinimalLambda.UnitTests/MinimalLambda.UnitTests.csproj | 2 ++ 4 files changed, 8 insertions(+) diff --git a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj index 24fb3ea6..4244bd58 100644 --- a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj +++ b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj @@ -39,6 +39,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers; buildtransitive 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.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 From ae36d1ce40df9fa97854f2423152717399b4034b Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 09:40:39 -0500 Subject: [PATCH 040/132] refactor(testing): update class access modifiers and refactor LambdaTestServer - Changed `LambdaTestingHttpHandler` and `LambdaTestServer` classes from public to internal. - Removed unused `_isBootstrappingComplete` field from `LambdaClient`. - Added request and response channel initialization in `LambdaTestServer`. - Created helper methods `CreateTestingHandler` and `CreateLambdaClient` in `LambdaTestServer`. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 1 - .../LambdaTestServer.cs | 21 ++++++++++++++++++- .../LambdaTestingHttpHandler.cs | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index cbbcaae3..706f0045 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -13,7 +13,6 @@ public class LambdaClient private readonly Channel _requestChanel; private readonly Channel _responseChanel; private readonly ILambdaRuntimeRouteManager _routeManager; - private bool _isBootstrappingComplete; private int _requestCounter; internal LambdaClient( diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index 0bdf56c3..b40809d9 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -1,10 +1,29 @@ +using System.Text.Json; +using System.Threading.Channels; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; namespace AwsLambda.Host.Testing; -public class LambdaTestServer : IServer +internal class LambdaTestServer : IServer { + private readonly Channel _requestChanel; + private readonly Channel _responseChanel; + + public LambdaTestServer() + { + _requestChanel = Channel.CreateUnbounded(); + _responseChanel = Channel.CreateUnbounded(); + } + + internal HttpMessageHandler CreateTestingHandler() => + new LambdaTestingHttpHandler(_requestChanel, _responseChanel); + + internal LambdaClient CreateLambdaClient( + JsonSerializerOptions jsonSerializerOptions, + ILambdaRuntimeRouteManager routeManager + ) => new(_requestChanel, _responseChanel, jsonSerializerOptions, routeManager); + public void Dispose() => throw new NotImplementedException(); public Task StartAsync( diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index 371b5a35..e712a800 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -2,7 +2,7 @@ namespace AwsLambda.Host.Testing; -public class LambdaTestingHttpHandler( +internal class LambdaTestingHttpHandler( Channel requestChanel, Channel responseChanel ) : HttpMessageHandler From 2d386aeeb9895202e7963cef01ce03783fd3c7c8 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 09:40:52 -0500 Subject: [PATCH 041/132] feat(testing): replace TestServer with LambdaTestServer and add bootstrap HTTP client setup - Modified `LambdaApplicationFactory` to instantiate `LambdaTestServer` directly. - Configured Lambda bootstrap HTTP client with testing handler in `ConfigureHostBuilder`. - Removed unused `TestServer` logic and related comments. - Updated dependencies to include `AwsLambda.Host.Builder.Extensions`. --- .../LambdaApplicationFactory.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index e202da30..34d58ca5 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -10,8 +10,8 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using AwsLambda.Host.Builder.Extensions; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace AwsLambda.Host.Testing; @@ -191,15 +191,15 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) SetContentRoot(hostBuilder); _configuration(hostBuilder); - // hostBuilder.ConfigureWebHost(webHostBuilder => - // { - // webHostBuilder.UseTestServer(); - // }); + _server = new LambdaTestServer(); // set Lambda Bootstrap Http Client + hostBuilder.ConfigureServices(services => + { + services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateTestingHandler())); + }); _host = CreateHost(hostBuilder); - _server = (LambdaTestServer)_host.Services.GetRequiredService(); } private void SetContentRoot(IHostBuilder builder) From e4dfcecf284e8dbd3fd213231b92c6413019d0b7 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 09:45:31 -0500 Subject: [PATCH 042/132] feat(testing): add LambdaApplicationFactoryContentRootAttribute implementation - Introduced `LambdaApplicationFactoryContentRootAttribute` to configure content root for testing. - Enables content root path and marker validation in `WebApplicationFactory` instances. - Supports priority-based attribute handling for better content root resolution. --- ...aApplicationFactoryContentRootAttribute.cs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs new file mode 100644 index 00000000..6ff50425 --- /dev/null +++ b/src/AwsLambda.Host.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 AwsLambda.Host.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 key for the content root associated with this project. Typically . + /// + public string Key { get; } + + /// + /// 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 a number for determining the probing order when multiple + /// instances with the same key are present on the test . + /// + public int Priority { get; } +} From 4abe2e65c42c458ff7cd9014a680fb7e914821e5 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 10:22:59 -0500 Subject: [PATCH 043/132] feat(testing): add Microsoft.Extensions.DependencyModel package to project - Added `Microsoft.Extensions.DependencyModel` to `AwsLambda.Host.Testing.csproj` as a package reference. - Updated `Directory.Packages.props` to include version `10.0.0` of `Microsoft.Extensions.DependencyModel`. --- Directory.Packages.props | 1 + src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj | 1 + 2 files changed, 2 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index 04c45b45..54f3ce01 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,7 @@ + diff --git a/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj b/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj index 8a6e771d..4e8d6f62 100644 --- a/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj +++ b/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj @@ -22,5 +22,6 @@ + From 9e437f72aceeca7cf103ca629a2fcd3ee8cb801c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 10:23:10 -0500 Subject: [PATCH 044/132] refactor(testing): clean up and optimize LambdaApplicationFactory content root resolution - Replaced `SetContentRootFromSetting` logic with enhanced `UseSolutionRelativeContentRoot` method. - Updated to use `LambdaApplicationFactoryContentRootAttribute` instead of `WebApplicationFactoryContentRootAttribute`. - Simplified content root resolution by removing redundant logic and improving error handling. - Changed visibility of `Server` property from public to internal for consistent access control. --- .../LambdaApplicationFactory.cs | 72 +++++++++++-------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index 34d58ca5..6cd1a72d 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -12,6 +12,7 @@ using System.Text.Json.Serialization; using AwsLambda.Host.Builder.Extensions; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyModel; using Microsoft.Extensions.Hosting; namespace AwsLambda.Host.Testing; @@ -43,7 +44,7 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis /// /// /// This constructor will infer the application content root path by searching for a - /// on the assembly containing the functional tests with + /// 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 @@ -65,7 +66,7 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis /// /// Gets the created by this . /// - public LambdaTestServer Server + internal LambdaTestServer Server { get { @@ -204,9 +205,6 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) private void SetContentRoot(IHostBuilder builder) { - if (SetContentRootFromSetting(builder)) - return; - var fromFile = File.Exists("MvcTestingAppManifest.json"); var contentRoot = fromFile ? GetContentRootFromFile("MvcTestingAppManifest.json") @@ -215,7 +213,42 @@ private void SetContentRoot(IHostBuilder builder) if (contentRoot != null) builder.UseContentRoot(contentRoot); else - builder.UseSolutionRelativeContentRoot(typeof(TEntryPoint).Assembly.GetName().Name!); + 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 static string? GetContentRootFromFile(string file) @@ -268,31 +301,14 @@ private sealed partial class CustomJsonSerializerContext : JsonSerializerContext return contentRoot; } - private static bool SetContentRootFromSetting(IHostBuilder builder) - { - // Attempt to look for TEST_CONTENTROOT_APPNAME in settings. This should result in looking - // for - // ASPNETCORE_TEST_CONTENTROOT_APPNAME environment variable. - var assemblyName = typeof(TEntryPoint).Assembly.GetName().Name!; - var settingSuffix = assemblyName.ToUpperInvariant().Replace(".", "_"); - var settingName = $"TEST_CONTENTROOT_{settingSuffix}"; - - var settingValue = builder.GetSetting(settingName); - if (settingValue == null) - return false; - - builder.UseContentRoot(settingValue); - return true; - } - - private WebApplicationFactoryContentRootAttribute[] GetContentRootMetadataAttributes( + private LambdaApplicationFactoryContentRootAttribute[] GetContentRootMetadataAttributes( string tEntryPointAssemblyFullName, string tEntryPointAssemblyName ) { var testAssembly = GetTestAssemblies(); var metadataAttributes = testAssembly - .SelectMany(a => a.GetCustomAttributes()) + .SelectMany(a => a.GetCustomAttributes()) .Where(a => string.Equals( a.Key, @@ -309,7 +325,7 @@ string tEntryPointAssemblyName /// /// Gets the assemblies containing the functional tests. The - /// applied to these + /// applied to these /// assemblies defines the content root to use for the given /// . /// @@ -592,7 +608,7 @@ private sealed class DelegatedWebApplicationFactory : WebApplicationFactory _configureClient; public DelegatedWebApplicationFactory( - WebApplicationFactoryClientOptions options, + LambdaApplicationFactoryClientOptions options, Func createServer, Func createHost, Func createWebHostBuilder, @@ -602,7 +618,7 @@ public DelegatedWebApplicationFactory( Action configureWebHost ) { - ClientOptions = new WebApplicationFactoryClientOptions(options); + ClientOptions = options; _createServer = createServer; _createHost = createHost; _createWebHostBuilder = createWebHostBuilder; From 90035e8a439a771a159aedd04db35c4f45d92d14 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 10:25:15 -0500 Subject: [PATCH 045/132] refactor(testing): simplify exception handling in LambdaApplicationFactory - Replaced resource-based error messages with inline string format for clarity. - Simplified `InvalidOperationException` messages for missing entry points and dependency files. - Removed dependency on `Resources` for improved maintainability. --- src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index 6cd1a72d..49dee810 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -377,18 +377,13 @@ private static void EnsureDepsFile() { if (typeof(TEntryPoint).Assembly.EntryPoint == null) throw new InvalidOperationException( - Resources.FormatInvalidAssemblyEntryPoint(typeof(TEntryPoint).Name) + $"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( - Resources.FormatMissingDepsFile( - depsFile.FullName, - Path.GetFileName(depsFile.FullName) - ) - ); + throw new InvalidOperationException($"Missing deps file: {depsFile.FullName}"); } /// From 10f9ac9e329f6dd63cb9d04e96a03825fe81382a Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 10:34:52 -0500 Subject: [PATCH 046/132] refactor(testing): comment out unused methods and clean up HttpClient logic in LambdaApplicationFactory - Commented out unused methods, including `CreateWebHostBuilder`, `CreateClient`, and `CreateDefaultClient`. - Updated `EnsureServices` method with safer null-forgiving operator for `_host`. - Commented out `CreateServer` method and related lambda factory logic. - Cleaned up redundant `HttpClient` creation and disposal logic. - Removed references to deprecated or redundant WebHost-related logic in DelegatedWebApplicationFactory. --- .../LambdaApplicationFactory.cs | 170 +++++++----------- 1 file changed, 65 insertions(+), 105 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index 49dee810..be59affa 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -30,7 +30,8 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis private LambdaTestServer? _server; private IHost? _host; private Action _configuration; - private readonly List _clients = []; + + // private readonly List _clients = []; private readonly List> _derivedFactories = []; /// @@ -84,7 +85,7 @@ public virtual IServiceProvider Services { EnsureServer(); // return _host?.Services ?? _server.Host.Services; - return _host?.Services; + return _host!.Services; } } @@ -119,9 +120,8 @@ Action configuration { var factory = new DelegatedWebApplicationFactory( ClientOptions, - CreateServer, + // CreateServer, CreateHost, - CreateWebHostBuilder, CreateHostBuilder, GetTestAssemblies, ConfigureClient, @@ -405,27 +405,6 @@ private static void EnsureDepsFile() return hostBuilder; } - /// - /// Creates a used to set up . - /// - /// - /// The default implementation of this method looks for a public static IHostBuilder CreateWebHostBuilder(string[] args) - /// method defined on the entry point of the assembly of and invokes it passing an empty string - /// array as arguments. - /// - /// A instance. - protected virtual IHostBuilder? CreateWebHostBuilder() - { - var builder = WebHostBuilderFactory.CreateFromTypesAssemblyEntryPoint( - Array.Empty() - ); - - if (builder is not null) - return builder.UseEnvironment(Environments.Development); - - return null; - } - /// /// Creates the with the bootstrapped application in . /// This is only called for applications using . Applications based on @@ -434,7 +413,8 @@ private static void EnsureDepsFile() /// The used to /// create the server. /// The with the bootstrapped application. - protected virtual LambdaTestServer CreateServer(IHostBuilder builder) => new(builder); + // protected internal virtual LambdaTestServer CreateServer(IHostBuilder builder) => + // new(builder); /// /// Creates the with the bootstrapped application in . @@ -456,55 +436,42 @@ protected virtual IHost CreateHost(IHostBuilder builder) /// The for the application. protected virtual void ConfigureWebHost(IHostBuilder builder) { } - /// - /// Creates an instance of that automatically follows - /// redirects and handles cookies. - /// - /// The . - public HttpClient CreateClient() => CreateClient(ClientOptions); - - /// - /// Creates an instance of that automatically follows - /// redirects and handles cookies. - /// - /// The . - public HttpClient CreateClient(LambdaApplicationFactoryClientOptions options) => - CreateDefaultClient(options.BaseAddress, options.CreateHandlers()); - - /// - /// Creates a new instance of an that can be used to - /// send to the server. The base address of the - /// instance will be set to http://localhost. - /// - /// A list of instances to set up on the - /// . - /// The . - public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers) - { - EnsureServer(); - - HttpClient client; - if (handlers == null || handlers.Length == 0) - { - client = _server.CreateClient(); - } - else - { - for (var i = handlers.Length - 1; i > 0; i--) - handlers[i - 1].InnerHandler = handlers[i]; - - var serverHandler = _server.CreateHandler(); - handlers[^1].InnerHandler = serverHandler; - - client = new HttpClient(handlers[0]); - } - - _clients.Add(client); - - ConfigureClient(client); - - return client; - } + // /// + // /// Creates a new instance of an that can be used to + // /// send to the server. The base address of the + // /// instance will be set to http://localhost. + // /// + // /// A list of instances to set up on + // the + // /// . + // /// The . + // public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers) + // { + // EnsureServer(); + // + // HttpClient client; + // if (handlers == null || handlers.Length == 0) + // { + // client = _server.CreateClient(); + // } + // else + // { + // for (var i = handlers.Length - 1; i > 0; i--) + // handlers[i - 1].InnerHandler = handlers[i]; + // + // var serverHandler = _server.CreateHandler(); + // handlers[^1].InnerHandler = serverHandler; + // + // client = new HttpClient(handlers[0]); + // } + // + // _clients.Add(client); + // + // ConfigureClient(client); + // + // return client; + // } /// /// Configures instances created by this . @@ -517,21 +484,23 @@ protected virtual void ConfigureClient(HttpClient client) client.BaseAddress = new Uri("http://localhost"); } - /// - /// Creates a new instance of an that can be used to - /// send to the server. - /// - /// The base address of the instance. - /// A list of instances to set up on the - /// . - /// The . - public HttpClient CreateDefaultClient(Uri baseAddress, params DelegatingHandler[] handlers) - { - var client = CreateDefaultClient(handlers); - client.BaseAddress = baseAddress; - - return client; - } + // /// + // /// Creates a new instance of an that can be used to + // /// send to the server. + // /// + // /// The base address of the + // instance. + // /// A list of instances to set up on + // the + // /// . + // /// The . + // public HttpClient CreateDefaultClient(Uri baseAddress, params DelegatingHandler[] handlers) + // { + // var client = CreateDefaultClient(handlers); + // client.BaseAddress = baseAddress; + // + // return client; + // } /// public void Dispose() @@ -572,8 +541,8 @@ public virtual async ValueTask DisposeAsync() return; } - foreach (var client in _clients) - client.Dispose(); + // foreach (var client in _clients) + // client.Dispose(); foreach (var factory in _derivedFactories) await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); @@ -595,18 +564,16 @@ public virtual async ValueTask DisposeAsync() private sealed class DelegatedWebApplicationFactory : WebApplicationFactory { - private readonly Func _createServer; + // private readonly Func _createServer; private readonly Func _createHost; - private readonly Func _createWebHostBuilder; private readonly Func _createHostBuilder; private readonly Func> _getTestAssemblies; private readonly Action _configureClient; public DelegatedWebApplicationFactory( LambdaApplicationFactoryClientOptions options, - Func createServer, + // Func createServer, Func createHost, - Func createWebHostBuilder, Func createHostBuilder, Func> getTestAssemblies, Action configureClient, @@ -614,22 +581,16 @@ Action configureWebHost ) { ClientOptions = options; - _createServer = createServer; + // _createServer = createServer; _createHost = createHost; - _createWebHostBuilder = createWebHostBuilder; _createHostBuilder = createHostBuilder; _getTestAssemblies = getTestAssemblies; _configureClient = configureClient; _configuration = configureWebHost; } - protected override LambdaTestServer CreateServer(IHostBuilder builder) => - _createServer(builder); - protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder); - protected override IHostBuilder? CreateWebHostBuilder() => _createWebHostBuilder(); - protected override IHostBuilder? CreateHostBuilder() => _createHostBuilder(); protected override IEnumerable GetTestAssemblies() => _getTestAssemblies(); @@ -643,9 +604,8 @@ Action configuration ) => new DelegatedWebApplicationFactory( ClientOptions, - _createServer, + // _createServer, _createHost, - _createWebHostBuilder, _createHostBuilder, _getTestAssemblies, _configureClient, From 1371f0a63576e1316f2b9e840216be2046514f7c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 11:17:51 -0500 Subject: [PATCH 047/132] refactor(testing): remove LoggingHttpHandler and streamline HTTP client configuration - Commented out `LoggingHttpHandler` class and associated logic in `Program.cs`. - Added reference to `AwsLambda.Host.Testing.csproj` in the example project. - Refactored HTTP client configuration by disabling commented-out custom handlers. --- .../AwsLambda.Host.Examples.Testing.csproj | 1 + .../Program.cs | 173 +++++++++--------- 2 files changed, 85 insertions(+), 89 deletions(-) diff --git a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj index 4244bd58..89192b1d 100644 --- a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj +++ b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj @@ -21,6 +21,7 @@ + { - options.BootstrapHttpClient = new HttpClient(new LoggingHttpHandler()); + // options.BootstrapHttpClient = new HttpClient(new LoggingHttpHandler()); options.ClearLambdaOutputFormatting = true; + // options.BootstrapOptions.RuntimeApiEndpoint = "localhost:3002"; }); // Build the Lambda application @@ -28,84 +23,84 @@ public partial class Program; -public class LoggingHttpHandler : DelegatingHandler -{ - public LoggingHttpHandler(HttpMessageHandler innerHandler) => - InnerHandler = innerHandler ?? throw new ArgumentNullException(nameof(innerHandler)); - - // Convenience constructor for default handler - public LoggingHttpHandler() - : this(new HttpClientHandler()) { } - - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken - ) - { - // Log the request - Console.WriteLine("========== HTTP REQUEST =========="); - Console.WriteLine($"{request.Method} {request.RequestUri}"); - Console.WriteLine($"Version: {request.Version}"); - - Console.WriteLine("\nHeaders:"); - foreach (var header in request.Headers) - Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); - - if (request.Content != null) - { - Console.WriteLine("\nContent Headers:"); - foreach (var header in request.Content.Headers) - Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); - - // Buffer the content so we can read it without consuming it - await request.Content.LoadIntoBufferAsync(); - var requestBody = await request.Content.ReadAsStringAsync(); - - Console.WriteLine("\nBody:"); - Console.WriteLine(requestBody); - } - - // Send the request through the inner handler (via base.SendAsync) - var response = await base.SendAsync(request, cancellationToken); - - // Log the response - Console.WriteLine("\n========== HTTP RESPONSE =========="); - Console.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}"); - Console.WriteLine($"Version: {response.Version}"); - - Console.WriteLine("\nHeaders:"); - foreach (var header in response.Headers) - Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); - - if (response.Content != null) - { - Console.WriteLine("\nContent Headers:"); - foreach (var header in response.Content.Headers) - Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); - - // Read and log response body, then restore it - var responseBody = await response.Content.ReadAsStringAsync(); - Console.WriteLine("\nBody:"); - Console.WriteLine(responseBody); - - // Restore the content so it can be read again by the caller - var originalContentHeaders = response.Content.Headers.ToList(); - response.Content = new StringContent( - responseBody, - Encoding.UTF8, - response.Content.Headers.ContentType?.MediaType ?? "application/json" - ); - - // Restore all content headers - foreach (var header in originalContentHeaders) - { - response.Content.Headers.Remove(header.Key); - response.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - } - - Console.WriteLine("===================================\n"); - - return response; - } -} +// public class LoggingHttpHandler : DelegatingHandler +// { +// public LoggingHttpHandler(HttpMessageHandler innerHandler) => +// InnerHandler = innerHandler ?? throw new ArgumentNullException(nameof(innerHandler)); +// +// // Convenience constructor for default handler +// public LoggingHttpHandler() +// : this(new HttpClientHandler()) { } +// +// protected override async Task SendAsync( +// HttpRequestMessage request, +// CancellationToken cancellationToken +// ) +// { +// // Log the request +// Console.WriteLine("========== HTTP REQUEST =========="); +// Console.WriteLine($"{request.Method} {request.RequestUri}"); +// Console.WriteLine($"Version: {request.Version}"); +// +// Console.WriteLine("\nHeaders:"); +// foreach (var header in request.Headers) +// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); +// +// if (request.Content != null) +// { +// Console.WriteLine("\nContent Headers:"); +// foreach (var header in request.Content.Headers) +// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); +// +// // Buffer the content so we can read it without consuming it +// await request.Content.LoadIntoBufferAsync(); +// var requestBody = await request.Content.ReadAsStringAsync(); +// +// Console.WriteLine("\nBody:"); +// Console.WriteLine(requestBody); +// } +// +// // Send the request through the inner handler (via base.SendAsync) +// var response = await base.SendAsync(request, cancellationToken); +// +// // Log the response +// Console.WriteLine("\n========== HTTP RESPONSE =========="); +// Console.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}"); +// Console.WriteLine($"Version: {response.Version}"); +// +// Console.WriteLine("\nHeaders:"); +// foreach (var header in response.Headers) +// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); +// +// if (response.Content != null) +// { +// Console.WriteLine("\nContent Headers:"); +// foreach (var header in response.Content.Headers) +// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); +// +// // Read and log response body, then restore it +// var responseBody = await response.Content.ReadAsStringAsync(); +// Console.WriteLine("\nBody:"); +// Console.WriteLine(responseBody); +// +// // Restore the content so it can be read again by the caller +// var originalContentHeaders = response.Content.Headers.ToList(); +// response.Content = new StringContent( +// responseBody, +// Encoding.UTF8, +// response.Content.Headers.ContentType?.MediaType ?? "application/json" +// ); +// +// // Restore all content headers +// foreach (var header in originalContentHeaders) +// { +// response.Content.Headers.Remove(header.Key); +// response.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); +// } +// } +// +// Console.WriteLine("===================================\n"); +// +// return response; +// } +// } From 46cfe360d1c5083f1a4e961bb7425b23c8089e79 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 11:18:07 -0500 Subject: [PATCH 048/132] refactor(testing): update method visibility and parameter order in LambdaClient - Changed `WaitForNextRequestAsync` method visibility from `internal` to `public`. - Updated `InvokeAsync` method to switch parameter order of `TResponse` and `TEvent`. - Adjusted the placement of response channel write logic for improved readability. --- src/AwsLambda.Host.Testing/LambdaClient.cs | 36 +++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 706f0045..5ec6e3f3 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -38,7 +38,7 @@ public LambdaClient ConfigureOptions(Action configureOption return this; } - internal async Task WaitForNextRequestAsync(CancellationToken cancellationToken = default) + public async Task WaitForNextRequestAsync(CancellationToken cancellationToken = default) { var request = await WaitForRequestAsync(cancellationToken); @@ -115,7 +115,7 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) private string GetRequestId() => Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); - public async Task> InvokeAsync( + public async Task> InvokeAsync( TEvent invokeEvent, CancellationToken cancellationToken = default ) @@ -131,22 +131,6 @@ public async Task> InvokeAsync( $"Expected PostResponse or PostError request, but received {request.RequestType}" ); - await _responseChanel.Writer.WriteAsync( - new HttpResponseMessage(HttpStatusCode.Accepted) - { - Content = new StringContent( - JsonSerializer.Serialize( - new Dictionary { ["status"] = "success" }, - _jsonSerializerOptions - ), - Encoding.UTF8, - "application/json" - ), - Version = Version.Parse("1.1"), - }, - cancellationToken - ); - var wasSuccess = request.RequestType == RequestType.PostResponse; var invocationResponse = new InvocationResponse @@ -170,6 +154,22 @@ await _responseChanel.Writer.WriteAsync( : null, }; + await _responseChanel.Writer.WriteAsync( + new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent( + JsonSerializer.Serialize( + new Dictionary { ["status"] = "success" }, + _jsonSerializerOptions + ), + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + }, + cancellationToken + ); + return invocationResponse; } } From 142cc7f1f7e39af19132927b1abf83758a15ddc3 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 11:18:21 -0500 Subject: [PATCH 049/132] feat(testing): add LambdaClient creation and enhance Lambda bootstrap HTTP client setup - Added `GetClient` method in `LambdaApplicationFactory` for easier `LambdaClient` instantiation. - Enhanced Lambda bootstrap HTTP client logic with base address and improved endpoint configuration. - Introduced post-configuration for `LambdaHostOptions` to set default `RuntimeApiEndpoint`. --- .../LambdaApplicationFactory.cs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index be59affa..269b9e35 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -11,7 +11,9 @@ using System.Text.Json; using System.Text.Json.Serialization; using AwsLambda.Host.Builder.Extensions; +using AwsLambda.Host.Options; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyModel; using Microsoft.Extensions.Hosting; @@ -89,6 +91,15 @@ public virtual IServiceProvider Services } } + public LambdaClient GetClient() + { + EnsureServer(); + return _server!.CreateLambdaClient( + new JsonSerializerOptions(), + new LambdaRuntimeRouteManager() + ); + } + /// /// Gets the of factories created from this factory /// by further customizing the when calling @@ -197,7 +208,20 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) // set Lambda Bootstrap Http Client hostBuilder.ConfigureServices(services => { - services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateTestingHandler())); + services.AddLambdaBootstrapHttpClient( + (_, _) => + { + var client = new HttpClient(_server.CreateTestingHandler()); + client.BaseAddress = new Uri("localhost:8080"); + return client; + } + ); + + services.PostConfigure(options => + { + if (string.IsNullOrEmpty(options.BootstrapOptions.RuntimeApiEndpoint)) + options.BootstrapOptions.RuntimeApiEndpoint = "localhost:3002"; + }); }); _host = CreateHost(hostBuilder); From 6987af28418f439cb3906a6d7624c118116f902c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 11:18:32 -0500 Subject: [PATCH 050/132] refactor(testing): remove unimplemented Dispose method in LambdaTestServer - Replaced `NotImplementedException` in `Dispose` method with an empty implementation. --- src/AwsLambda.Host.Testing/LambdaTestServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index b40809d9..c1a3a404 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -24,7 +24,7 @@ internal LambdaClient CreateLambdaClient( ILambdaRuntimeRouteManager routeManager ) => new(_requestChanel, _responseChanel, jsonSerializerOptions, routeManager); - public void Dispose() => throw new NotImplementedException(); + public void Dispose() { } public Task StartAsync( IHttpApplication application, From 31bccdf8582f3ba7fda50a349893366e360045bb Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 11:18:40 -0500 Subject: [PATCH 051/132] feat(testing): add integration test for LambdaHost - Implemented a test case to verify `LambdaHost` startup without errors. - Utilized `WebApplicationFactory` to bootstrap the application. - Added assertions for client response and successful invocation. --- .../LambdaHostTest.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs index 8c12446a..3b791fdf 100644 --- a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using AwsLambda.Host.Testing; using JetBrains.Annotations; using Xunit; @@ -8,5 +9,15 @@ namespace Lambda.Host.Example.HelloWorld; public class LambdaHostTest { [Fact] - public async Task LambdaHost_CanStartWithoutError() { } + public async Task LambdaHost_CanStartWithoutError() + { + await using var factory = new WebApplicationFactory(); + + var client = factory.GetClient(); + await client.WaitForNextRequestAsync(); + var response = await client.InvokeAsync("Jonas"); + Assert.True(response.WasSuccess); + Assert.NotNull(response); + Assert.Equal("Hello Jonas!", response.Response); + } } From 0d3313133e4dfed9e9c9492f37024d3571e6e68c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 11:18:47 -0500 Subject: [PATCH 052/132] refactor(testing): simplify path extraction logic in LambdaRuntimeRouteManager - Removed unnecessary `TrimStart` method from `RequestUri.AbsolutePath`. - Updated path extraction to ensure the original value remains intact. --- src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs b/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs index 7228226e..e60d02c3 100644 --- a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs +++ b/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs @@ -47,7 +47,8 @@ public bool TryMatch( values = null; var method = request.Method.Method; - var path = request.RequestUri?.AbsolutePath.TrimStart('/') ?? string.Empty; + // var path = request.RequestUri?.AbsolutePath.TrimStart('/') ?? string.Empty; + var path = request.RequestUri?.AbsolutePath ?? string.Empty; foreach (var route in Routes) { From 1287b9dbd25d19500a691e2dfe231a6fa2f6abe0 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 11:19:53 -0500 Subject: [PATCH 053/132] refactor(testing): remove unimplemented server methods in LambdaTestServer - Removed `StartAsync` and `StopAsync` methods as they were not implemented. - Deleted unused `Features` property from the class. - Updated `LambdaTestServer` to implement `IDisposable` for proper resource cleanup. --- src/AwsLambda.Host.Testing/LambdaTestServer.cs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index c1a3a404..af2d534a 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -1,11 +1,9 @@ using System.Text.Json; using System.Threading.Channels; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.Http.Features; namespace AwsLambda.Host.Testing; -internal class LambdaTestServer : IServer +internal class LambdaTestServer : IDisposable { private readonly Channel _requestChanel; private readonly Channel _responseChanel; @@ -25,14 +23,4 @@ ILambdaRuntimeRouteManager routeManager ) => new(_requestChanel, _responseChanel, jsonSerializerOptions, routeManager); public void Dispose() { } - - public Task StartAsync( - IHttpApplication application, - CancellationToken cancellationToken - ) => throw new NotImplementedException(); - - public Task StopAsync(CancellationToken cancellationToken) => - throw new NotImplementedException(); - - public IFeatureCollection Features { get; } } From 966cb3410c0dbb21e573137e9fc884d8b99ac140 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 6 Dec 2025 11:22:03 -0500 Subject: [PATCH 054/132] refactor(testing): rename `GetClient` to `CreateClient` and remove unused/commented-out methods - Renamed `GetClient` to `CreateClient` for better semantics and consistency. - Removed unused and commented-out methods like `CreateServer`, `CreateDefaultClient`, and others. - Cleaned up redundant XML documentation for removed methods. --- .../LambdaHostTest.cs | 2 +- .../LambdaApplicationFactory.cs | 68 +------------------ 2 files changed, 2 insertions(+), 68 deletions(-) diff --git a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs index 3b791fdf..492a8ced 100644 --- a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs @@ -13,7 +13,7 @@ public async Task LambdaHost_CanStartWithoutError() { await using var factory = new WebApplicationFactory(); - var client = factory.GetClient(); + var client = factory.CreateClient(); await client.WaitForNextRequestAsync(); var response = await client.InvokeAsync("Jonas"); Assert.True(response.WasSuccess); diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index 269b9e35..e9c02cff 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -91,7 +91,7 @@ public virtual IServiceProvider Services } } - public LambdaClient GetClient() + public LambdaClient CreateClient() { EnsureServer(); return _server!.CreateLambdaClient( @@ -429,17 +429,6 @@ private static void EnsureDepsFile() return hostBuilder; } - /// - /// Creates the with the bootstrapped application in . - /// This is only called for applications using . Applications based on - /// will use instead. - /// - /// The used to - /// create the server. - /// The with the bootstrapped application. - // protected internal virtual LambdaTestServer CreateServer(IHostBuilder builder) => - // new(builder); - /// /// Creates the with the bootstrapped application in . /// This is only called for applications using . Applications based on @@ -460,43 +449,6 @@ protected virtual IHost CreateHost(IHostBuilder builder) /// The for the application. protected virtual void ConfigureWebHost(IHostBuilder builder) { } - // /// - // /// Creates a new instance of an that can be used to - // /// send to the server. The base address of the - // /// instance will be set to http://localhost. - // /// - // /// A list of instances to set up on - // the - // /// . - // /// The . - // public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers) - // { - // EnsureServer(); - // - // HttpClient client; - // if (handlers == null || handlers.Length == 0) - // { - // client = _server.CreateClient(); - // } - // else - // { - // for (var i = handlers.Length - 1; i > 0; i--) - // handlers[i - 1].InnerHandler = handlers[i]; - // - // var serverHandler = _server.CreateHandler(); - // handlers[^1].InnerHandler = serverHandler; - // - // client = new HttpClient(handlers[0]); - // } - // - // _clients.Add(client); - // - // ConfigureClient(client); - // - // return client; - // } - /// /// Configures instances created by this . /// @@ -508,24 +460,6 @@ protected virtual void ConfigureClient(HttpClient client) client.BaseAddress = new Uri("http://localhost"); } - // /// - // /// Creates a new instance of an that can be used to - // /// send to the server. - // /// - // /// The base address of the - // instance. - // /// A list of instances to set up on - // the - // /// . - // /// The . - // public HttpClient CreateDefaultClient(Uri baseAddress, params DelegatingHandler[] handlers) - // { - // var client = CreateDefaultClient(handlers); - // client.BaseAddress = baseAddress; - // - // return client; - // } - /// public void Dispose() { From fdaf0e1b826160939ddba45938b541d334fdcd3b Mon Sep 17 00:00:00 2001 From: Jonas Ha <61319894+j-d-ha@users.noreply.github.com> Date: Sun, 7 Dec 2025 15:16:43 -0500 Subject: [PATCH 055/132] feat(testing): enhance LambdaTestServer with transaction-based architecture (#220) * refactor(testing): implement transaction-based architecture with active server processing Transform the Lambda testing infrastructure from a passive dual-channel model to an active server-based architecture with transactional HTTP handling. This refactoring enables FIFO processing of concurrent invocations and provides automatic request-response correlation. BREAKING CHANGE: Removed `LambdaClient.WaitForNextRequestAsync()` method. The server now automatically handles the initial Bootstrap request, so users only need to call `InvokeAsync()`. ## Architecture Changes ### Before (Passive Dual-Channel Model) - Two separate channels (request + response) requiring manual correlation - LambdaClient handled all routing logic (SRP violation) - No FIFO guarantee for concurrent requests - Users required to call `WaitForNextRequestAsync()` before invoking ### After (Active Server-Based Model) - Single channel carrying self-contained transactions (automatic correlation) - Active LambdaTestServer manages routing and state in background processing loop - FIFO processing via ConcurrentQueue + ConcurrentDictionary - Simplified user API - only `InvokeAsync()` needed ## New Components - **LambdaHttpTransaction**: Bundles HTTP request with TaskCompletionSource for automatic correlation. Uses `TaskCreationOptions.RunContinuationsAsynchronously` to prevent deadlocks. - **PendingInvocation**: Tracks invocations awaiting Bootstrap processing. Stores event response, request ID, and completion TCS. ## Modified Components - **LambdaTestingHttpHandler**: Creates transactions instead of writing to separate channels. Single channel communication with proper cancellation handling. - **LambdaTestServer**: Transformed into active server with: - Background processing loop (`ProcessTransactionsAsync`) - Request routing logic (GET /next, POST /response, POST /error) - FIFO queue for concurrent invocations - Automatic queuing of `/next` requests when no work available - Server creates and manages client instance - **LambdaClient**: Simplified to delegate to server: - Removed `WaitForNextRequestAsync()` (BREAKING) - Removed `WaitForRequestAsync()` helper - Now references server instead of channels - Clean API that only exposes `InvokeAsync()` - **LambdaApplicationFactory**: Updated to start server's background processing loop after host creation. Fixed URI schemes for BaseAddress and RuntimeApiEndpoint. ## Test Changes - Removed `WaitForNextRequestAsync()` call from integration test - Added test for concurrent invocations with FIFO ordering verification - Tests now demonstrate simplified user experience ## Key Benefits 1. **Simplified User API**: Users only call `InvokeAsync()` - everything else automatic 2. **FIFO Processing**: Concurrent invocations processed in first-in-first-out order 3. **Automatic Correlation**: Transaction-based design eliminates manual matching 4. **Server in the Middle**: Clear separation of concerns (client/server/handler) 5. **Better Concurrency**: Handles multiple concurrent requests correctly with queuing * refactor(models): reorder properties and clean up XML comments in model classes - Reordered properties in `ErrorResponse`, `InvocationResponse`, and others for better readability. - Updated or removed redundant XML documentation in multiple classes. - Simplified property initializations and declarations. - Moved nested class definitions for clarity and consistency. * refactor(testing): change LambdaTestServer to use IAsyncDisposable instead of IDisposable Since the server has a background processing task that needs to be awaited during shutdown, it's more appropriate to implement IAsyncDisposable rather than IDisposable. ## Changes - **LambdaTestServer**: Changed from `IDisposable` to `IAsyncDisposable` - Replaced `Dispose()` with `DisposeAsync()` - Use `await _shutdownCts.CancelAsync()` instead of `Cancel()` - Properly await `_processingTask` instead of blocking with `Wait()` - Catch and suppress expected `OperationCanceledException` - **LambdaApplicationFactory**: Updated `DisposeAsync()` to properly dispose server - Changed from `_server?.Dispose()` to `await _server.DisposeAsync()` ## Benefits 1. **Proper async cleanup**: No more blocking `Wait()` calls during disposal 2. **Better exception handling**: Explicitly catches expected `OperationCanceledException` 3. **Follows best practices**: Async operations should use async disposal pattern 4. **No blocking**: Prevents potential thread pool starvation during shutdown * feat(testing): buffer request content for re-readability in LambdaTestingHttpHandler - Added logic to read and buffer the request content as a byte array. - Wrapped buffered content in a `ByteArrayContent` instance for downstream consumers. * refactor(testing): improve cleanup and transaction handling in LambdaTestServer - Enhanced `DisposeAsync` to properly handle shutdown with error propagation and cancellation. - Added `TryDequeuePendingInvocation` and `CancelPendingInvocation` methods for clarity and reuse. - Improved transaction handling to prevent orphaned or unprocessed requests upon shutdown. - Simplified transaction queuing and invocation dequeue logic. - Replaced repetitive error handling with consolidated helper methods for better maintainability. * feat(testing): handle shutdown gracefully when transaction queue is full - Added cancellation handling when `TryWrite` fails due to a full transaction queue. - Ensured proper cleanup by canceling `TaskCompletionSource` during shutdown scenarios. - Replaced `WriteAsync` with `TryWrite` for improved queuing performance. * feat(testing): add return values to transaction completion methods - Updated `Respond`, `Fail`, and `Cancel` methods in `LambdaHttpTransaction` to return `bool` indicating success or failure of the operation. - Enhanced `RespondToNextRequest` to handle cases where transactions fail to complete: - Re-enqueues pending invocations if transaction was canceled to prevent data loss. * feat(testing): preserve headers when buffering request content in LambdaTestingHttpHandler - Updated buffering logic to copy headers from the original content to the buffered content. - Disposed of the original content after creating the buffered `ByteArrayContent`. * refactor(testing): simplify LambdaTestServer and factory client creation - Removed dependency injection of serializer options and route manager during `CreateClient` call. - Updated `LambdaTestServer` to accept optional parameters for serializer options and route manager. - Adjusted `LambdaApplicationFactory` to initialize `LambdaTestServer` with parameters instead. - Modified example tests to include cancellation token when invoking Lambda functions. * feat(testing): enhance invocation handling with metadata and response type - Added `InvocationCompletion` model to encapsulate Lambda invocation completion metadata. - Updated `PendingInvocation.ResponseTcs` to use `InvocationCompletion` instead of `HttpRequestMessage`. - Refactored `LambdaClient` to parse response type directly from `InvocationCompletion`. - Simplified response processing by removing `DetermineSuccess` helper method. - Modified `LambdaTestServer.QueueInvocationAsync` to complete responses with `InvocationCompletion`. * feat(testing): add invocation timeout and enhance pending invocation handling - Introduced `InvocationTimeout` in `LambdaInvocationHeaderOptions` with default timeout + buffer. - Added `DeadlineUtc` to `PendingInvocation` to manage expiration of invocations. - Updated `PendingInvocation.Create` to accept and initialize `DeadlineUtc`. - Enhanced `TryDequeuePendingInvocation` to cancel expired invocations. - Refactored `LambdaTestServer` for bounded channels with capacity limits and queuing improvements. - Handled `ChannelClosedException` in `LambdaTestingHttpHandler` and `OperationCanceledException` during shutdown gracefully in `QueueInvocationAsync`. - Updated `LambdaClient` to include `DeadlineUtc` in invocations and use linked cancellation tokens. * feat(testing): add new tests for invocation error and cancellation scenarios - Added test to validate behavior when invoking with an invalid payload. - Implemented test to handle invocation cancellation with a pre-canceled token. - Added test to verify cancellation with zero timeout during invocation. * refactor(testing): centralize invocation completion creation in LambdaTestServer - Added `CreateCompletion` helper method to encapsulate invocation completion object creation. - Replaced inline object initializations with `CreateCompletion` in `PostResponse` and `PostError` handling. - Improved readability by reducing redundancy in response completion logic. * fix(testing): address disposal and exception type in LambdaTestServer and examples - Fixed disposal in `LambdaTestServer` by marking cancellation registration as `await using`. - Updated example test to assert `AggregateException` instead of `OperationCanceledException`. * refactor(examples): restructure AwsLambda.Host example and testing projects - Moved `AwsLambda.Host.Examples.Testing` to `AwsLambda.Host.Example.Testing` for a more consistent naming convention. - Split the project into separate `Lambda` and `Tests` projects for better organization. - Updated solution file to reflect the new structure and project references. - Renamed and relocated files to adhere to the new structure. * test(examples): uncomment and include `LambdaHostTest` in the project - Uncommented the `LambdaHostTest` implementation in the example testing project. - Added `Lambda` project reference to `Tests.csproj` for proper dependency resolution. * fix(testing): update exception type in LambdaHostTest assertion - Changed exception type from `TaskCanceledException` to `AggregateException` in assertion. - Ensures consistency with updated error handling in invocation scenarios. * refactor(testing): simplify async handling and invocation completion logic - Updated `HandlePostResponseAsync` and `HandlePostErrorAsync` to use `async` methods for clarity. - Refactored `CreateCompletion` to `CreateCompletionAsync` to streamline response creation. - Improved invocation handling by removing redundant `Task.CompletedTask` returns. - Fixed nullability issue in `TryDequeuePendingInvocation` by marking `pendingInvocation` as non-nullable. - Preserved `HttpRequestMessage.Options` during cloning for request consistency. * refactor(models): enforce required properties and initialize collections in ErrorResponse - Marked `ErrorMessage` and `ErrorType` properties as `required` in `ErrorResponse`. - Updated `StackTrace` collections to initialize with an empty list by default. * refactor(testing): replace `InvocationTimeout` with `ClientWaitTimeout` in Lambda header options - Renamed `InvocationTimeout` to `ClientWaitTimeout` in `LambdaInvocationHeaderOptions`. - Added `[Obsolete]` attribute to `InvocationTimeout` for backward compatibility. - Updated `LambdaClient` to use `ClientWaitTimeout` for deadlines and timeouts. * refactor(testing): simplify `InvokeAsync_WithZeroTimeout_CancelsInvocation` in LambdaHostTest - Refactored the test to use an expression-bodied method for brevity and clarity. - Streamlined aggregation exception assertion logic with `Assert.ThrowsAsync`. * refactor(testing): replace `WebApplicationFactory` with `LambdaApplicationFactory` - Updated all test cases in `LambdaHostTest` to use `LambdaApplicationFactory` for consistency. - Renamed all instances of `WebApplicationFactory` in `LambdaApplicationFactory.cs`. - Updated XML comments and references to align with the new naming convention. - Refactored derived factory to `DelegatedLambdaApplicationFactory`. - Improved clarity and adherence to the Lambda application testing framework. * fix(testing): throw exception after setting `TrySetException` in `DeferredHostBuilder` - Ensures the exception is re-thrown for proper error propagation. - Adds a missing code block to handle exception scenarios consistently. - Prevents potential silent failures when `_hostStartTcs` is set. * refactor(testing): simplify `DeferredHost` by consolidating parameters - Replaced private fields `_host` and `_hostStartedTcs` with primary constructor parameters. - Simplified disposal logic by removing redundant `_host` access. - Updated cancellation registration and task handling to use new constructor parameters. - Improved overall code readability and reduced redundancy in `DeferredHost`. * test(lambda-host): add new test for handling bad configuration - Added `LambdaHost_CrashesWithBadConfiguration_ThrowsException` test to validate configuration errors. - Used `LambdaApplicationFactory` to simulate runtime environment and verify exception handling. - Enhanced test coverage for invalid `LambdaHostOptions` scenarios. * fix(testing): handle deferred host task completion consistently - Added missing `else` block to ensure `_hostStartTcs.TrySetResult()` only runs when no exception occurs. - Prevents unexpected behavior by separating successful and exceptional task completion scenarios. * fix(testing): throw exception for invalid or unexpected transactions in `LambdaTestServer` - Replaced `transaction.Respond` with `InvalidOperationException` for unmatched requests. - Ensures clearer error messages for unexpected HTTP methods or unknown request types. - Improves debugging by including request info in exception details. --- MinimalLambda.sln | 49 +- .../Lambda/Lambda.csproj | 31 ++ .../Lambda/Program.cs | 22 + .../Lambda}/Properties/launchSettings.json | 0 .../Lambda}/appsettings.json | 0 .../Tests/LambdaHostTest.cs | 139 ++++++ .../Tests/Tests.csproj | 32 ++ .../AwsLambda.Host.Examples.Testing.csproj | 56 --- .../LambdaHostTest.cs | 23 - .../Program.cs | 106 ----- .../DeferredHostBuilder.cs | 49 +- .../LambdaApplicationFactory.cs | 179 ++++---- ...aApplicationFactoryContentRootAttribute.cs | 18 +- src/AwsLambda.Host.Testing/LambdaClient.cs | 159 +++---- .../LambdaTestServer.cs | 417 +++++++++++++++++- .../LambdaTestingHttpHandler.cs | 51 ++- .../Models/ErrorResponse.cs | 40 +- .../Models/InvocationCompletion.cs | 10 + .../Models/InvocationResponse.cs | 4 +- .../Models/LambdaBootstrapRequest.cs | 8 +- .../Models/LambdaHttpTransaction.cs | 46 ++ .../Models/PendingInvocation.cs | 52 +++ .../Options/LambdaInvocationHeaderOptions.cs | 17 + src/AwsLambda.Host.Testing/README.md | 129 +++++- 24 files changed, 1167 insertions(+), 470 deletions(-) create mode 100644 examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj create mode 100644 examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs rename examples/{AwsLambda.Host.Examples.Testing => AwsLambda.Host.Example.Testing/Lambda}/Properties/launchSettings.json (100%) rename examples/{AwsLambda.Host.Examples.Testing => AwsLambda.Host.Example.Testing/Lambda}/appsettings.json (100%) create mode 100644 examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs create mode 100644 examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj delete mode 100644 examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj delete mode 100644 examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs delete mode 100644 examples/AwsLambda.Host.Examples.Testing/Program.cs create mode 100644 src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs create mode 100644 src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs create mode 100644 src/AwsLambda.Host.Testing/Models/PendingInvocation.cs diff --git a/MinimalLambda.sln b/MinimalLambda.sln index c16a581e..15dd3aee 100644 --- a/MinimalLambda.sln +++ b/MinimalLambda.sln @@ -75,7 +75,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Envelopes.Clo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda.Host.Testing", "src\AwsLambda.Host.Testing\AwsLambda.Host.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AwsLambda.Host.Examples.Testing", "examples\AwsLambda.Host.Examples.Testing\AwsLambda.Host.Examples.Testing.csproj", "{884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AwsLambda.Host.Example.Testing", "AwsLambda.Host.Example.Testing", "{26B446C5-76B0-4190-9022-1BF514737E2E}" + ProjectSection(SolutionItems) = preProject + examples\AwsLambda.Host.Example.Testing\Properties\launchSettings.json = examples\AwsLambda.Host.Example.Testing\Properties\launchSettings.json + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "examples\AwsLambda.Host.Example.Testing\Tests\Tests.csproj", "{0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lambda", "examples\AwsLambda.Host.Example.Testing\Lambda\Lambda.csproj", "{6DB827E6-C534-4C6A-9128-05F6A05DD6C5}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -363,18 +370,30 @@ Global {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 - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x64.ActiveCfg = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x64.Build.0 = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x86.ActiveCfg = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Debug|x86.Build.0 = Debug|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|Any CPU.Build.0 = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x64.ActiveCfg = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x64.Build.0 = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x86.ActiveCfg = Release|Any CPU - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B}.Release|x86.Build.0 = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x64.ActiveCfg = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x64.Build.0 = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x86.ActiveCfg = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x86.Build.0 = Debug|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|Any CPU.Build.0 = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x64.ActiveCfg = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x64.Build.0 = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x86.ActiveCfg = Release|Any CPU + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x86.Build.0 = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x64.Build.0 = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x86.Build.0 = Debug|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|Any CPU.Build.0 = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x64.ActiveCfg = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x64.Build.0 = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x86.ActiveCfg = Release|Any CPU + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -404,6 +423,8 @@ Global {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} - {884FD7E2-72D3-422B-82B4-FF5E7F92CE6B} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {26B446C5-76B0-4190-9022-1BF514737E2E} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818} = {26B446C5-76B0-4190-9022-1BF514737E2E} + {6DB827E6-C534-4C6A-9128-05F6A05DD6C5} = {26B446C5-76B0-4190-9022-1BF514737E2E} EndGlobalSection EndGlobal diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj new file mode 100644 index 00000000..cfcc1781 --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj @@ -0,0 +1,31 @@ + + + Exe + net10.0 + preview + enable + enable + true + Lambda + + true + + true + $(InterceptorsNamespaces);AwsLambda.Host + false + + + + + + + + + PreserveNewest + + + diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs new file mode 100644 index 00000000..fc3329b0 --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs @@ -0,0 +1,22 @@ +using AwsLambda.Host.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +// Create the application builder +var builder = LambdaApplication.CreateBuilder(); + +builder.Services.ConfigureLambdaHostOptions(options => +{ + options.ClearLambdaOutputFormatting = true; +}); + +// Build the Lambda application +var lambda = builder.Build(); + +// Map your handler - the event is automatically injected +lambda.MapHandler(([Event] string name) => $"Hello {name}!"); + +// Run the Lambda +await lambda.RunAsync(); + +public partial class Program; diff --git a/examples/AwsLambda.Host.Examples.Testing/Properties/launchSettings.json b/examples/AwsLambda.Host.Example.Testing/Lambda/Properties/launchSettings.json similarity index 100% rename from examples/AwsLambda.Host.Examples.Testing/Properties/launchSettings.json rename to examples/AwsLambda.Host.Example.Testing/Lambda/Properties/launchSettings.json diff --git a/examples/AwsLambda.Host.Examples.Testing/appsettings.json b/examples/AwsLambda.Host.Example.Testing/Lambda/appsettings.json similarity index 100% rename from examples/AwsLambda.Host.Examples.Testing/appsettings.json rename to examples/AwsLambda.Host.Example.Testing/Lambda/appsettings.json diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs new file mode 100644 index 00000000..9d736874 --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -0,0 +1,139 @@ +using AwsLambda.Host.Options; +using AwsLambda.Host.Testing; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Lambda.Host.Example.HelloWorld; + +[TestSubject(typeof(Program))] +public class LambdaHostTest +{ + [Fact] + public async Task LambdaHost_CanStartWithoutError() + { + await using var factory = new LambdaApplicationFactory(); + + var client = factory.CreateClient(); + // No need to wait for next request - server handles this automatically + var response = await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + Assert.True(response.WasSuccess); + Assert.NotNull(response); + Assert.Equal("Hello Jonas!", response.Response); + } + + [Fact] + public async Task LambdaHost_CrashesWithBadConfiguration_ThrowsException() + { + await using var factory = new LambdaApplicationFactory().WithWebHostBuilder( + builder => + { + builder.ConfigureServices( + (_, services) => + { + services.Configure(options => + { + options.BootstrapOptions.RuntimeApiEndpoint = "http://localhost:3002"; + }); + } + ); + } + ); + + var client = factory.CreateClient(); + // No need to wait for next request - server handles this automatically + var response = await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + Assert.True(response.WasSuccess); + Assert.NotNull(response); + Assert.Equal("Hello Jonas!", response.Response); + } + + [Fact] + public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() + { + await using var factory = new LambdaApplicationFactory(); + var client = factory.CreateClient(); + + // Launch 5 concurrent invocations + var tasks = Enumerable + .Range(1, 5) + .Select(i => + client.InvokeAsync( + $"User{i}", + TestContext.Current.CancellationToken + ) + ) + .ToArray(); + + var responses = await Task.WhenAll(tasks); + + // All should complete successfully + Assert.All(responses, r => Assert.True(r.WasSuccess)); + Assert.Equal("Hello User1!", responses[0].Response); + Assert.Equal("Hello User2!", responses[1].Response); + Assert.Equal("Hello User3!", responses[2].Response); + Assert.Equal("Hello User4!", responses[3].Response); + Assert.Equal("Hello User5!", responses[4].Response); + } + + [Fact] + public async Task InvokeAsync_WithInvalidPayload_ReturnsError() + { + await using var factory = new LambdaApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.InvokeAsync( + 123, + TestContext.Current.CancellationToken + ); + + Assert.False(response.WasSuccess); + Assert.NotNull(response.Error); + Assert.Contains("Json", response.Error!.ErrorType, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() + { + await using var factory = new LambdaApplicationFactory(); + var client = factory.CreateClient(); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(() => + client.InvokeAsync("Jonas", cts.Token) + ); + } + + [Fact] + public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() => + await Assert.ThrowsAsync(async () => + { + try + { + await using var factory = new LambdaApplicationFactory(); + var client = factory + .CreateClient() + .ConfigureOptions(options => + options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero + ); + + await client.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + } + catch (Exception e) + { + Console.WriteLine(e.GetType().FullName); + throw; + } + }); +} diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj new file mode 100644 index 00000000..b686ce26 --- /dev/null +++ b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj @@ -0,0 +1,32 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj b/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj deleted file mode 100644 index 89192b1d..00000000 --- a/examples/AwsLambda.Host.Examples.Testing/AwsLambda.Host.Examples.Testing.csproj +++ /dev/null @@ -1,56 +0,0 @@ - - - Exe - net10.0 - preview - disable - enable - true - Lambda - - true - - true - $(InterceptorsNamespaces);AwsLambda.Host - Lambda.Host.Example.HelloWorld - false - - - - PreserveNewest - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - PreserveNewest - - - diff --git a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs b/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs deleted file mode 100644 index 492a8ced..00000000 --- a/examples/AwsLambda.Host.Examples.Testing/LambdaHostTest.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Threading.Tasks; -using AwsLambda.Host.Testing; -using JetBrains.Annotations; -using Xunit; - -namespace Lambda.Host.Example.HelloWorld; - -[TestSubject(typeof(Program))] -public class LambdaHostTest -{ - [Fact] - public async Task LambdaHost_CanStartWithoutError() - { - await using var factory = new WebApplicationFactory(); - - var client = factory.CreateClient(); - await client.WaitForNextRequestAsync(); - var response = await client.InvokeAsync("Jonas"); - Assert.True(response.WasSuccess); - Assert.NotNull(response); - Assert.Equal("Hello Jonas!", response.Response); - } -} diff --git a/examples/AwsLambda.Host.Examples.Testing/Program.cs b/examples/AwsLambda.Host.Examples.Testing/Program.cs deleted file mode 100644 index cc93ac71..00000000 --- a/examples/AwsLambda.Host.Examples.Testing/Program.cs +++ /dev/null @@ -1,106 +0,0 @@ -using AwsLambda.Host.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -// Create the application builder -var builder = LambdaApplication.CreateBuilder(); - -builder.Services.ConfigureLambdaHostOptions(options => -{ - // options.BootstrapHttpClient = new HttpClient(new LoggingHttpHandler()); - options.ClearLambdaOutputFormatting = true; - // options.BootstrapOptions.RuntimeApiEndpoint = "localhost:3002"; -}); - -// Build the Lambda application -var lambda = builder.Build(); - -// Map your handler - the event is automatically injected -lambda.MapHandler(([Event] string name) => $"Hello {name}!"); - -// Run the Lambda -await lambda.RunAsync(); - -public partial class Program; - -// public class LoggingHttpHandler : DelegatingHandler -// { -// public LoggingHttpHandler(HttpMessageHandler innerHandler) => -// InnerHandler = innerHandler ?? throw new ArgumentNullException(nameof(innerHandler)); -// -// // Convenience constructor for default handler -// public LoggingHttpHandler() -// : this(new HttpClientHandler()) { } -// -// protected override async Task SendAsync( -// HttpRequestMessage request, -// CancellationToken cancellationToken -// ) -// { -// // Log the request -// Console.WriteLine("========== HTTP REQUEST =========="); -// Console.WriteLine($"{request.Method} {request.RequestUri}"); -// Console.WriteLine($"Version: {request.Version}"); -// -// Console.WriteLine("\nHeaders:"); -// foreach (var header in request.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// if (request.Content != null) -// { -// Console.WriteLine("\nContent Headers:"); -// foreach (var header in request.Content.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// // Buffer the content so we can read it without consuming it -// await request.Content.LoadIntoBufferAsync(); -// var requestBody = await request.Content.ReadAsStringAsync(); -// -// Console.WriteLine("\nBody:"); -// Console.WriteLine(requestBody); -// } -// -// // Send the request through the inner handler (via base.SendAsync) -// var response = await base.SendAsync(request, cancellationToken); -// -// // Log the response -// Console.WriteLine("\n========== HTTP RESPONSE =========="); -// Console.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}"); -// Console.WriteLine($"Version: {response.Version}"); -// -// Console.WriteLine("\nHeaders:"); -// foreach (var header in response.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// if (response.Content != null) -// { -// Console.WriteLine("\nContent Headers:"); -// foreach (var header in response.Content.Headers) -// Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); -// -// // Read and log response body, then restore it -// var responseBody = await response.Content.ReadAsStringAsync(); -// Console.WriteLine("\nBody:"); -// Console.WriteLine(responseBody); -// -// // Restore the content so it can be read again by the caller -// var originalContentHeaders = response.Content.Headers.ToList(); -// response.Content = new StringContent( -// responseBody, -// Encoding.UTF8, -// response.Content.Headers.ContentType?.MediaType ?? "application/json" -// ); -// -// // Restore all content headers -// foreach (var header in originalContentHeaders) -// { -// response.Content.Headers.Remove(header.Key); -// response.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); -// } -// } -// -// Console.WriteLine("===================================\n"); -// -// return response; -// } -// } diff --git a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs index 06e8f127..7fe11895 100644 --- a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs +++ b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs @@ -16,11 +16,6 @@ namespace AwsLambda.Host.Testing; // ConfigureHostBuilder internal sealed class DeferredHostBuilder : IHostBuilder { - public IDictionary Properties { get; } = new Dictionary(); - - private Action _configure; - private Func? _hostFactory; - private readonly ConfigurationManager _hostConfiguration = new(); // This task represents a call to IHost.Start, we create it here preemptively in case the @@ -30,6 +25,9 @@ internal sealed class DeferredHostBuilder : IHostBuilder TaskCreationOptions.RunContinuationsAsynchronously ); + private Action _configure; + private Func? _hostFactory; + public DeferredHostBuilder() => _configure = b => { @@ -39,6 +37,8 @@ public DeferredHostBuilder() => b.Properties[pair.Key] = pair.Value; }; + public IDictionary Properties { get; } = new Dictionary(); + public IHost Build() { // Hosting configuration is being provided by args so that @@ -114,31 +114,24 @@ 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(); + } } public void SetHostFactory(Func hostFactory) => _hostFactory = hostFactory; - private sealed class DeferredHost : IHost, IAsyncDisposable + private sealed class DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) + : IHost, + IAsyncDisposable { - private readonly IHost _host; - private readonly TaskCompletionSource _hostStartedTcs; - - public DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) - { - _host = host; - _hostStartedTcs = hostStartedTcs; - } - - public IServiceProvider Services => _host.Services; - - public void Dispose() => _host.Dispose(); - public async ValueTask DisposeAsync() { - if (_host is IAsyncDisposable disposable) + if (host is IAsyncDisposable disposable) { await disposable.DisposeAsync().ConfigureAwait(false); return; @@ -147,28 +140,32 @@ public async ValueTask DisposeAsync() 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. - using var reg = cancellationToken.UnsafeRegister( - _ => _hostStartedTcs.TrySetCanceled(), + await using var reg = cancellationToken.UnsafeRegister( + _ => hostStartedTcs.TrySetCanceled(), null ); // REVIEW: This will deadlock if the application creates the host but never calls start. // This is mitigated by the cancellationToken // but it's rarely a valid token for Start - using var reg2 = _host + await using var reg2 = host .Services.GetRequiredService() - .ApplicationStarted.UnsafeRegister(_ => _hostStartedTcs.TrySetResult(), null); + .ApplicationStarted.UnsafeRegister(_ => hostStartedTcs.TrySetResult(), null); - await _hostStartedTcs.Task.ConfigureAwait(false); + await hostStartedTcs.Task.ConfigureAwait(false); } public Task StopAsync(CancellationToken cancellationToken = default) => - _host.StopAsync(cancellationToken); + host.StopAsync(cancellationToken); } } diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index e9c02cff..ae4fca38 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -1,6 +1,6 @@ // Portions of this file are derived from aspnetcore // Source: -// https://github.com/dotnet/aspnetcore/blob/v10.0.0/src/Mvc/Mvc.Testing/src/WebApplicationFactory.cs +// 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 @@ -24,24 +24,23 @@ namespace AwsLambda.Host.Testing; /// /// A type in the entry point assembly of the application. /// Typically the Startup or Program classes can be used. -public partial class WebApplicationFactory : IDisposable, IAsyncDisposable +public partial class LambdaApplicationFactory : IDisposable, IAsyncDisposable where TEntryPoint : class { + // private readonly List _clients = []; + private readonly List> _derivedFactories = []; + private Action _configuration; private bool _disposed; private bool _disposedAsync; - private LambdaTestServer? _server; private IHost? _host; - private Action _configuration; - - // private readonly List _clients = []; - private readonly List> _derivedFactories = []; + private LambdaTestServer? _server; /// /// - /// Creates an instance of . This factory can be used to + /// Creates an instance of . This factory can be used to /// create a instance using the MVC application defined by /// and one or more instances used to send to the . - /// The will find the entry point class of + /// The will find the entry point class of /// assembly and initialize the application by calling IHostBuilder CreateWebHostBuilder(string [] args) /// on . /// @@ -49,7 +48,7 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis /// 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, + /// 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. /// @@ -59,15 +58,23 @@ public partial class WebApplicationFactory : IDisposable, IAsyncDis /// will be loaded as application assemblies. /// /// - public WebApplicationFactory() => _configuration = ConfigureWebHost; + public LambdaApplicationFactory() => _configuration = ConfigureWebHost; /// - /// Finalizes an instance of the class. + /// Gets the used by . /// - ~WebApplicationFactory() => Dispose(false); + public LambdaApplicationFactoryClientOptions ClientOptions { get; private set; } = new(); /// - /// Gets the created by this . + /// Gets the of factories created from this factory + /// by further customizing the when calling + /// . + /// + public IReadOnlyList> Factories => + _derivedFactories.AsReadOnly(); + + /// + /// Gets the created by this . /// internal LambdaTestServer Server { @@ -79,7 +86,7 @@ internal LambdaTestServer Server } /// - /// Gets the created by the server associated with this . + /// Gets the created by the server associated with this . /// public virtual IServiceProvider Services { @@ -91,45 +98,72 @@ public virtual IServiceProvider Services } } - public LambdaClient CreateClient() + /// + public virtual async ValueTask DisposeAsync() { - EnsureServer(); - return _server!.CreateLambdaClient( - new JsonSerializerOptions(), - new LambdaRuntimeRouteManager() - ); + if (_disposed) + return; + + if (_disposedAsync) + return; + + // foreach (var client in _clients) + // client.Dispose(); + + foreach (var factory in _derivedFactories) + await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); + + if (_server != null) + await _server.DisposeAsync().ConfigureAwait(false); + + if (_host != null) + { + await _host.StopAsync().ConfigureAwait(false); + _host?.Dispose(); + } + + _disposedAsync = true; + + Dispose(true); + + GC.SuppressFinalize(this); } - /// - /// Gets the of factories created from this factory - /// by further customizing the when calling - /// . - /// - public IReadOnlyList> Factories => - _derivedFactories.AsReadOnly(); + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } /// - /// Gets the used by . + /// Finalizes an instance of the class. /// - public LambdaApplicationFactoryClientOptions ClientOptions { get; private set; } = new(); + ~LambdaApplicationFactory() => Dispose(false); + + public LambdaClient CreateClient() + { + EnsureServer(); + return _server!.CreateLambdaClient(); + } /// - /// Creates a new with a + /// Creates a new with a /// that is further customized by . /// /// /// An to configure the . /// - /// A new . - public WebApplicationFactory WithWebHostBuilder( + /// A new . + public LambdaApplicationFactory WithWebHostBuilder( Action configuration ) => WithWebHostBuilderCore(configuration); - internal virtual WebApplicationFactory WithWebHostBuilderCore( + internal virtual LambdaApplicationFactory WithWebHostBuilderCore( Action configuration ) { - var factory = new DelegatedWebApplicationFactory( + var factory = new DelegatedLambdaApplicationFactory( ClientOptions, // CreateServer, CreateHost, @@ -203,19 +237,15 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) SetContentRoot(hostBuilder); _configuration(hostBuilder); - _server = new LambdaTestServer(); + var serializerOptions = new JsonSerializerOptions(); + var routeManager = new LambdaRuntimeRouteManager(); + + _server = new LambdaTestServer(serializerOptions, routeManager); // set Lambda Bootstrap Http Client hostBuilder.ConfigureServices(services => { - services.AddLambdaBootstrapHttpClient( - (_, _) => - { - var client = new HttpClient(_server.CreateTestingHandler()); - client.BaseAddress = new Uri("localhost:8080"); - return client; - } - ); + services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateTestingHandler())); services.PostConfigure(options => { @@ -225,6 +255,9 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) }); _host = CreateHost(hostBuilder); + + // Start the server's background processing loop after host is ready + _server.Start(); } private void SetContentRoot(IHostBuilder builder) @@ -291,9 +324,6 @@ string solutionRelativePath return contentRoot == "~" ? AppContext.BaseDirectory : contentRoot; } - [JsonSerializable(typeof(IDictionary))] - private sealed partial class CustomJsonSerializerContext : JsonSerializerContext; - private string? GetContentRootFromAssembly() { var metadataAttributes = GetContentRootMetadataAttributes( @@ -450,7 +480,7 @@ protected virtual IHost CreateHost(IHostBuilder builder) protected virtual void ConfigureWebHost(IHostBuilder builder) { } /// - /// Configures instances created by this . + /// Configures instances created by this . /// /// The instance getting configured. protected virtual void ConfigureClient(HttpClient client) @@ -460,13 +490,6 @@ protected virtual void ConfigureClient(HttpClient client) client.BaseAddress = new Uri("http://localhost"); } - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// @@ -477,9 +500,7 @@ public void Dispose() protected virtual void Dispose(bool disposing) { if (_disposed) - { return; - } if (disposing) if (!_disposedAsync) @@ -488,47 +509,19 @@ protected virtual void Dispose(bool disposing) _disposed = true; } - /// - public virtual async ValueTask DisposeAsync() - { - if (_disposed) - return; - - if (_disposedAsync) - { - return; - } - - // foreach (var client in _clients) - // client.Dispose(); - - foreach (var factory in _derivedFactories) - await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); - - _server?.Dispose(); - - if (_host != null) - { - await _host.StopAsync().ConfigureAwait(false); - _host?.Dispose(); - } - - _disposedAsync = true; - - Dispose(true); - - GC.SuppressFinalize(this); - } + [JsonSerializable(typeof(IDictionary))] + private sealed partial class CustomJsonSerializerContext : JsonSerializerContext; - private sealed class DelegatedWebApplicationFactory : WebApplicationFactory + private sealed class DelegatedLambdaApplicationFactory : LambdaApplicationFactory { + private readonly Action _configureClient; + // private readonly Func _createServer; private readonly Func _createHost; private readonly Func _createHostBuilder; private readonly Func> _getTestAssemblies; - private readonly Action _configureClient; - public DelegatedWebApplicationFactory( + public DelegatedLambdaApplicationFactory( LambdaApplicationFactoryClientOptions options, // Func createServer, Func createHost, @@ -557,10 +550,10 @@ Action configureWebHost protected override void ConfigureClient(HttpClient client) => _configureClient(client); - internal override WebApplicationFactory WithWebHostBuilderCore( + internal override LambdaApplicationFactory WithWebHostBuilderCore( Action configuration ) => - new DelegatedWebApplicationFactory( + new DelegatedLambdaApplicationFactory( ClientOptions, // _createServer, _createHost, diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs index 6ff50425..f577c417 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs @@ -12,13 +12,13 @@ namespace AwsLambda.Host.Testing; /// -/// Metadata that uses to find out the content +/// Metadata that uses to find out the content /// root for the web application represented by TEntryPoint. -/// will iterate over all the instances of +/// 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 +/// 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 . /// @@ -30,7 +30,7 @@ public sealed class LambdaApplicationFactoryContentRootAttribute : Attribute /// /// /// The key of this . This - /// key is used by to determine what of the + /// key is used by to determine what of the /// instances on the test assembly should be used /// to match a given TEntryPoint class. /// @@ -67,11 +67,6 @@ out var parsedPriority Priority = parsedPriority; } - /// - /// Gets the key for the content root associated with this project. Typically . - /// - public string Key { get; } - /// /// 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 . @@ -83,6 +78,11 @@ out var parsedPriority /// 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 . diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaClient.cs index 5ec6e3f3..cb370a24 100644 --- a/src/AwsLambda.Host.Testing/LambdaClient.cs +++ b/src/AwsLambda.Host.Testing/LambdaClient.cs @@ -2,33 +2,30 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; -using System.Threading.Channels; namespace AwsLambda.Host.Testing; +/// +/// Client for invoking Lambda functions in tests. +/// Provides a clean API that abstracts HTTP details. +/// public class LambdaClient { private readonly JsonSerializerOptions _jsonSerializerOptions; private readonly LambdaClientOptions _lambdaClientOptions; - private readonly Channel _requestChanel; - private readonly Channel _responseChanel; - private readonly ILambdaRuntimeRouteManager _routeManager; + private readonly LambdaTestServer _server; private int _requestCounter; - internal LambdaClient( - Channel requestChanel, - Channel responseChanel, - JsonSerializerOptions jsonSerializerOptions, - ILambdaRuntimeRouteManager routeManager - ) + internal LambdaClient(LambdaTestServer server, JsonSerializerOptions jsonSerializerOptions) { - _requestChanel = requestChanel; - _responseChanel = responseChanel; + _server = server; _jsonSerializerOptions = jsonSerializerOptions; - _routeManager = routeManager; _lambdaClientOptions = new LambdaClientOptions(); } + /// + /// Configures client options for invocation headers. + /// public LambdaClient ConfigureOptions(Action configureOptions) { ArgumentNullException.ThrowIfNull(configureOptions); @@ -38,36 +35,67 @@ public LambdaClient ConfigureOptions(Action configureOption return this; } - public async Task WaitForNextRequestAsync(CancellationToken cancellationToken = default) - { - var request = await WaitForRequestAsync(cancellationToken); - - if (request.RequestType != RequestType.GetNextInvocation) - throw new InvalidOperationException( - $"Unexpected request received during bootstrap: {request.RequestType.ToString()}" - ); - } - - private async Task WaitForRequestAsync( + /// + /// Invokes the Lambda function with the given event and waits for the response. + /// + public async Task> InvokeAsync( + TEvent invokeEvent, CancellationToken cancellationToken = default ) { - var request = await _requestChanel.Reader.ReadAsync(cancellationToken); + // Generate unique request ID + var requestId = GetRequestId(); + + // Create the event response with Lambda headers + var eventResponse = CreateEventResponse(invokeEvent, requestId); + var deadlineUtc = DateTimeOffset.UtcNow.Add( + _lambdaClientOptions.InvocationHeaderOptions.ClientWaitTimeout + ); - if (!_routeManager.TryMatch(request, out var routeType, out var routeValue)) - throw new InvalidOperationException( - $"Unexpected request received: {request.Method} {request.RequestUri?.PathAndQuery ?? "(no URI)"}" - ); + // Queue invocation and wait for Bootstrap to process it + var waitTimeout = _lambdaClientOptions.InvocationHeaderOptions.ClientWaitTimeout; - return new LambdaBootstrapRequest + using var timeoutCts = new CancellationTokenSource(waitTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); + + var completion = await _server.QueueInvocationAsync( + requestId, + eventResponse, + deadlineUtc, + linkedCts.Token + ); + + var responseMessage = completion.Request; + var wasSuccess = completion.RequestType == RequestType.PostResponse; + + var invocationResponse = new InvocationResponse { - RequestType = routeType!.Value, - RequestMessage = request, - RouteValue = routeValue!, + WasSuccess = wasSuccess, + Response = wasSuccess + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cancellationToken + ) ?? Task.FromResult(default) + ) + : default, + Error = !wasSuccess + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cancellationToken + ) ?? Task.FromResult(null) + ) + : null, }; + + return invocationResponse; } - private HttpResponseMessage CreateRequest(TEvent invokeEvent) + private HttpResponseMessage CreateEventResponse(TEvent invokeEvent, string requestId) { var response = new HttpResponseMessage(HttpStatusCode.OK) { @@ -92,10 +120,7 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) .UtcNow.Add(_lambdaClientOptions.InvocationHeaderOptions.FunctionTimeout) .ToUnixTimeMilliseconds(); response.Headers.Add("Lambda-Runtime-Deadline-Ms", deadlineMs.ToString()); - - // Generate request ID with proper padding (12 digits, zero-padded) - response.Headers.Add("Lambda-Runtime-Aws-Request-Id", GetRequestId()); - + response.Headers.Add("Lambda-Runtime-Aws-Request-Id", requestId); response.Headers.Add( "Lambda-Runtime-Trace-Id", _lambdaClientOptions.InvocationHeaderOptions.TraceId @@ -114,62 +139,4 @@ private HttpResponseMessage CreateRequest(TEvent invokeEvent) private string GetRequestId() => Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); - - public async Task> InvokeAsync( - TEvent invokeEvent, - CancellationToken cancellationToken = default - ) - { - var response = CreateRequest(invokeEvent); - - await _responseChanel.Writer.WriteAsync(response, cancellationToken); - - var request = await WaitForRequestAsync(cancellationToken); - - if (request.RequestType == RequestType.GetNextInvocation) - throw new InvalidOperationException( - $"Expected PostResponse or PostError request, but received {request.RequestType}" - ); - - var wasSuccess = request.RequestType == RequestType.PostResponse; - - var invocationResponse = new InvocationResponse - { - WasSuccess = wasSuccess, - Response = wasSuccess - ? await ( - request.RequestMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cancellationToken - ) ?? Task.FromResult(default) - ) - : default, - Error = !wasSuccess - ? await ( - request.RequestMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cancellationToken - ) ?? Task.FromResult(null) - ) - : null, - }; - - await _responseChanel.Writer.WriteAsync( - new HttpResponseMessage(HttpStatusCode.Accepted) - { - Content = new StringContent( - JsonSerializer.Serialize( - new Dictionary { ["status"] = "success" }, - _jsonSerializerOptions - ), - Encoding.UTF8, - "application/json" - ), - Version = Version.Parse("1.1"), - }, - cancellationToken - ); - - return invocationResponse; - } } diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index af2d534a..773dceb3 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -1,26 +1,419 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text; using System.Text.Json; using System.Threading.Channels; +using Microsoft.AspNetCore.Routing; namespace AwsLambda.Host.Testing; -internal class LambdaTestServer : IDisposable +/// +/// Test server that processes HTTP transactions from Lambda Bootstrap. +/// Routes requests, queues invocations, and manages request-response correlation. +/// +internal class LambdaTestServer : IAsyncDisposable { - private readonly Channel _requestChanel; - private readonly Channel _responseChanel; + private const int TransactionChannelCapacity = 1024; + private const int NextRequestChannelCapacity = 1024; + private static readonly OperationCanceledException DisposedException = new( + "LambdaTestServer disposed" + ); - public LambdaTestServer() + private readonly LambdaClient _client; + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ConcurrentQueue _pendingInvocationIds; + private readonly ConcurrentDictionary _pendingInvocations; + private readonly Channel _queuedNextRequests; + private readonly ILambdaRuntimeRouteManager _routeManager; + private readonly CancellationTokenSource _shutdownCts; + private readonly Channel _transactionChannel; + private Task? _processingTask; + + internal LambdaTestServer( + JsonSerializerOptions? jsonSerializerOptions = null, + ILambdaRuntimeRouteManager? routeManager = null + ) + { + _transactionChannel = Channel.CreateBounded( + new BoundedChannelOptions(TransactionChannelCapacity) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait, + } + ); + _pendingInvocationIds = new ConcurrentQueue(); + _pendingInvocations = new ConcurrentDictionary(); + _queuedNextRequests = Channel.CreateBounded( + new BoundedChannelOptions(NextRequestChannelCapacity) + { + SingleReader = false, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait, + } + ); + _routeManager = routeManager ?? new LambdaRuntimeRouteManager(); + _jsonSerializerOptions = jsonSerializerOptions ?? new JsonSerializerOptions(); + _shutdownCts = new CancellationTokenSource(); + + // Create client that communicates with this server + _client = new LambdaClient(this, _jsonSerializerOptions); + } + + public async ValueTask DisposeAsync() { - _requestChanel = Channel.CreateUnbounded(); - _responseChanel = Channel.CreateUnbounded(); + await _shutdownCts.CancelAsync(); + + _transactionChannel.Writer.TryComplete(); + _queuedNextRequests.Writer.TryComplete(); + + // Cancel any transactions waiting for work + while (_queuedNextRequests.Reader.TryRead(out var queuedTransaction)) + queuedTransaction.Fail(DisposedException); + + // Fail any in-flight transactions that haven't been processed yet + while (_transactionChannel.Reader.TryRead(out var transaction)) + transaction.Fail(DisposedException); + + // Cancel any pending invocations waiting for bootstrap responses + foreach (var pendingInvocation in _pendingInvocations.Values) + pendingInvocation.ResponseTcs.TrySetCanceled(_shutdownCts.Token); + + if (_processingTask != null) + { + try + { + await _processingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected when task is canceled + } + } + + _shutdownCts.Dispose(); } + /// + /// Creates the HTTP handler for Lambda Bootstrap to use. + /// internal HttpMessageHandler CreateTestingHandler() => - new LambdaTestingHttpHandler(_requestChanel, _responseChanel); + new LambdaTestingHttpHandler(_transactionChannel); + + /// + /// Gets the client for test code to invoke Lambda functions. + /// + internal LambdaClient CreateLambdaClient() => _client; + + /// + /// Starts the background processing loop. + /// Called automatically by LambdaApplicationFactory after host starts. + /// + internal void Start() + { + if (_processingTask != null) + throw new InvalidOperationException("Server already started"); + + _processingTask = Task.Run(ProcessTransactionsAsync); + } + + /// + /// Queues a new invocation to be processed by Lambda Bootstrap. + /// Called by LambdaClient.InvokeAsync(). + /// + internal async Task QueueInvocationAsync( + string requestId, + HttpResponseMessage eventResponse, + DateTimeOffset deadlineUtc, + CancellationToken cancellationToken + ) + { + var pending = PendingInvocation.Create(requestId, eventResponse, deadlineUtc); + + if (!_pendingInvocations.TryAdd(requestId, pending)) + throw new InvalidOperationException($"Duplicate request ID: {requestId}"); + + _pendingInvocationIds.Enqueue(requestId); + + // If there's a queued /next request, serve it immediately + if (_queuedNextRequests.Reader.TryRead(out var nextTransaction)) + RespondToNextRequest(nextTransaction); + + using var cancellationRegistration = cancellationToken.Register(() => + CancelPendingInvocation(requestId, cancellationToken) + ); + + // Wait for Bootstrap to process and respond + return await pending.ResponseTcs.Task.WaitAsync(cancellationToken); + } + + /// + /// Background loop that processes transactions from the handler. + /// + private async Task ProcessTransactionsAsync() + { + await foreach ( + var transaction in _transactionChannel.Reader.ReadAllAsync(_shutdownCts.Token) + ) + try + { + await ProcessTransactionAsync(transaction); + } + catch (Exception ex) + { + // Fail the transaction and continue processing + transaction.Fail(ex); + } + } + + /// + /// Routes a single transaction based on request type. + /// + private async Task ProcessTransactionAsync(LambdaHttpTransaction transaction) + { + if (!_routeManager.TryMatch(transaction.Request, out var requestType, out var routeValues)) + throw new InvalidOperationException( + $"Unexpected request: {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; + + default: + throw new InvalidOperationException( + $"Unexpected request type {requestType} for {transaction.Request.RequestUri}" + ); + } + } + + /// + /// Handles GET /invocation/next - Bootstrap polling for work. + /// + private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transaction) + { + // Try to dequeue next pending invocation (FIFO) + if (!TryDequeuePendingInvocation(out var pending)) + { + // No work available - queue this /next request + try + { + await _queuedNextRequests.Writer.WriteAsync(transaction, _shutdownCts.Token); + } + catch (OperationCanceledException) + { + transaction.Fail(DisposedException); + } + + return; + } + + RespondToNextRequest(transaction, pending); + } - internal LambdaClient CreateLambdaClient( - JsonSerializerOptions jsonSerializerOptions, - ILambdaRuntimeRouteManager routeManager - ) => new(_requestChanel, _responseChanel, jsonSerializerOptions, routeManager); + /// + /// Responds to a /next request with a pending invocation. + /// + private void RespondToNextRequest(LambdaHttpTransaction transaction, PendingInvocation pending) + { + // Respond with the event payload and Lambda headers + if (transaction.Respond(pending.EventResponse)) + return; + + // Request was already canceled; re-enqueue invocation to avoid dropping it + _pendingInvocationIds.Enqueue(pending.RequestId); + } + + /// + /// Responds to a /next request with a pending invocation by looking up the next one. + /// + private void RespondToNextRequest(LambdaHttpTransaction transaction) + { + // Try to dequeue next pending invocation (FIFO) + if (!TryDequeuePendingInvocation(out var pending)) + { + // This shouldn't happen, but if it does, respond with error + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("No pending invocations available"), + } + ); + return; + } + + RespondToNextRequest(transaction, pending); + } + + /// + /// Handles POST /invocation/{requestId}/response - successful function execution. + /// + private async Task HandlePostResponseAsync( + LambdaHttpTransaction transaction, + RouteValueDictionary routeValues + ) + { + var requestId = routeValues["requestId"]?.ToString(); + + if ( + string.IsNullOrEmpty(requestId) + || !_pendingInvocations.TryRemove(requestId, out var pending) + ) + { + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + string.IsNullOrEmpty(requestId) + ? "Missing requestId" + : $"No pending invocation for request ID: {requestId}" + ), + } + ); + return; + } - public void Dispose() { } + // Complete the invocation with the response from Bootstrap + pending.ResponseTcs.SetResult( + await CreateCompletionAsync(RequestType.PostResponse, transaction.Request) + ); + + // Acknowledge to Bootstrap + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent( + JsonSerializer.Serialize( + new Dictionary { ["status"] = "success" }, + _jsonSerializerOptions + ), + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + } + ); + + return; + } + + /// + /// Handles POST /invocation/{requestId}/error - function execution failed. + /// + private async Task HandlePostErrorAsync( + LambdaHttpTransaction transaction, + RouteValueDictionary routeValues + ) + { + var requestId = routeValues["requestId"]?.ToString(); + + if ( + string.IsNullOrEmpty(requestId) + || !_pendingInvocations.TryRemove(requestId, out var pending) + ) + { + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent( + string.IsNullOrEmpty(requestId) + ? "Missing requestId" + : $"No pending invocation for request ID: {requestId}" + ), + } + ); + return; + } + + // Complete the invocation with the error response from Bootstrap + pending.ResponseTcs.SetResult( + await CreateCompletionAsync(RequestType.PostError, transaction.Request) + ); + + // Acknowledge to Bootstrap + transaction.Respond( + new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent( + "{\"status\":\"success\"}", + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + } + ); + + return; + } + + private bool TryDequeuePendingInvocation(out PendingInvocation pendingInvocation) + { + var now = DateTimeOffset.UtcNow; + + while (_pendingInvocationIds.TryDequeue(out var requestId)) + { + if (_pendingInvocations.TryGetValue(requestId, out pendingInvocation)) + { + if (pendingInvocation.DeadlineUtc <= now) + { + if (_pendingInvocations.TryRemove(requestId, out var expiredInvocation)) + expiredInvocation.ResponseTcs.TrySetCanceled(); + + continue; + } + + return true; + } + } + + pendingInvocation = null!; + return false; + } + + private void CancelPendingInvocation(string requestId, CancellationToken cancellationToken) + { + if (_pendingInvocations.TryRemove(requestId, out var pendingInvocation)) + pendingInvocation.ResponseTcs.TrySetCanceled(cancellationToken); + } + + 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 }; + } } diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs index e712a800..bf7cc1a9 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs @@ -2,20 +2,55 @@ namespace AwsLambda.Host.Testing; -internal class LambdaTestingHttpHandler( - Channel requestChanel, - Channel responseChanel -) : HttpMessageHandler +/// +/// HTTP message handler that intercepts Lambda Bootstrap HTTP calls and +/// routes them through the test server via transactions. +/// +internal class LambdaTestingHttpHandler(Channel transactionChannel) + : HttpMessageHandler { protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken ) { - // pass the request out to the client - await requestChanel.Writer.WriteAsync(request, cancellationToken); + // Buffer the content to make it re-readable for downstream consumers + if (request.Content != null) + { + var originalContent = request.Content; + var bytes = await originalContent.ReadAsByteArrayAsync(cancellationToken); + var bufferedContent = new ByteArrayContent(bytes); - // block here until the client sends a response. There may not be a response. - return await responseChanel.Reader.ReadAsync(cancellationToken); + 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 + using var registration = cancellationToken.Register(() => transaction.Cancel()); + + // Send transaction to server + try + { + await transactionChannel.Writer.WriteAsync(transaction, cancellationToken); + } + catch (ChannelClosedException) + { + // Server is shutting down; propagate cancellation to caller + var canceled = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + canceled.TrySetCanceled(); + return await canceled.Task; + } + + // Wait for server to complete the transaction + var response = await transaction.ResponseTcs.Task; + return response; } } diff --git a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs index ada60032..540fd345 100644 --- a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs +++ b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs @@ -8,28 +8,28 @@ namespace AwsLambda.Host.Testing; public class ErrorResponse { /// - /// The type of error that occurred. + /// The underlying cause of this error, if any. /// - [JsonPropertyName("errorType")] - public string ErrorType { get; set; } + [JsonPropertyName("cause")] + public ErrorCause? Cause { get; set; } /// /// The error message describing what went wrong. /// [JsonPropertyName("errorMessage")] - public string ErrorMessage { get; set; } + public required string ErrorMessage { get; set; } /// - /// The stack trace showing where the error occurred. + /// The type of error that occurred. /// - [JsonPropertyName("stackTrace")] - public List StackTrace { get; set; } + [JsonPropertyName("errorType")] + public required string ErrorType { get; set; } /// - /// The underlying cause of this error, if any. + /// The stack trace showing where the error occurred. /// - [JsonPropertyName("cause")] - public ErrorCause? Cause { get; set; } + [JsonPropertyName("stackTrace")] + public List StackTrace { get; set; } = []; /// /// Represents the cause of an error, which can have its own nested cause. @@ -37,27 +37,27 @@ public class ErrorResponse public class ErrorCause { /// - /// The type of error that occurred. + /// The underlying cause of this error, if any. /// - [JsonPropertyName("errorType")] - public string ErrorType { get; set; } + [JsonPropertyName("cause")] + public ErrorCause? Cause { get; set; } /// /// The error message describing what went wrong. /// [JsonPropertyName("errorMessage")] - public string ErrorMessage { get; set; } + public required string ErrorMessage { get; set; } /// - /// The stack trace showing where the error occurred. + /// The type of error that occurred. /// - [JsonPropertyName("stackTrace")] - public List StackTrace { get; set; } + [JsonPropertyName("errorType")] + public string ErrorType { get; set; } /// - /// The underlying cause of this error, if any. + /// The stack trace showing where the error occurred. /// - [JsonPropertyName("cause")] - public ErrorCause? Cause { get; set; } + [JsonPropertyName("stackTrace")] + public List StackTrace { get; set; } = []; } } diff --git a/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs b/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs new file mode 100644 index 00000000..9bcc72b9 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs @@ -0,0 +1,10 @@ +namespace AwsLambda.Host.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/AwsLambda.Host.Testing/Models/InvocationResponse.cs b/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs index de8fbf79..68d1ab5e 100644 --- a/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs +++ b/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs @@ -2,7 +2,7 @@ namespace AwsLambda.Host.Testing; public class InvocationResponse { - public bool WasSuccess { get; internal set; } - public TResponse? Response { get; internal set; } public ErrorResponse? Error { get; internal set; } + public TResponse? Response { get; internal set; } + public bool WasSuccess { get; internal set; } } diff --git a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs index 62f84525..b4ab21da 100644 --- a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs +++ b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs @@ -4,10 +4,6 @@ namespace AwsLambda.Host.Testing; internal class LambdaBootstrapRequest { - internal required RequestType RequestType { get; init; } - internal required HttpRequestMessage RequestMessage { get; init; } - internal required RouteValueDictionary RouteValue { get; init; } - internal string? RequestId { get @@ -21,4 +17,8 @@ internal string? RequestId return field; } } + + internal required HttpRequestMessage RequestMessage { get; init; } + internal required RequestType RequestType { get; init; } + internal required RouteValueDictionary RouteValue { get; init; } } diff --git a/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs b/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs new file mode 100644 index 00000000..0d061a16 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs @@ -0,0 +1,46 @@ +namespace AwsLambda.Host.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 an exception. + /// + internal bool Fail(Exception exception) => ResponseTcs.TrySetException(exception); + + /// + /// Completes the transaction with cancellation. + /// + internal bool Cancel() => ResponseTcs.TrySetCanceled(); +} diff --git a/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs b/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs new file mode 100644 index 00000000..f9c203c2 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs @@ -0,0 +1,52 @@ +namespace AwsLambda.Host.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; + + /// + /// 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; } + + /// + /// Absolute time when this invocation should be considered expired. + /// + internal required DateTimeOffset DeadlineUtc { 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/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs index 2f540e6c..45379cc0 100644 --- a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs +++ b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs @@ -32,6 +32,23 @@ public class LambdaInvocationHeaderOptions /// public TimeSpan FunctionTimeout { get; set; } = TimeSpan.FromMinutes(15); + /// + /// Gets or sets the maximum amount of time to wait for a response from the Lambda bootstrap. + /// Defaults to the function timeout plus a small buffer. + /// + public TimeSpan ClientWaitTimeout { get; set; } = + TimeSpan.FromMinutes(15).Add(TimeSpan.FromSeconds(5)); + + /// + /// Legacy property for client wait timeout. Prefer . + /// + [Obsolete("Use ClientWaitTimeout instead.")] + public TimeSpan InvocationTimeout + { + get => ClientWaitTimeout; + set => ClientWaitTimeout = value; + } + /// /// Gets or sets the AWS X-Ray trace ID for distributed tracing. /// Maps to the Lambda-Runtime-Trace-Id header. diff --git a/src/AwsLambda.Host.Testing/README.md b/src/AwsLambda.Host.Testing/README.md index 303f128f..afb866ba 100644 --- a/src/AwsLambda.Host.Testing/README.md +++ b/src/AwsLambda.Host.Testing/README.md @@ -176,4 +176,131 @@ Headers: Accept: application/json -``` \ No newline at end of file +``` + +# In-Memory Lambda Testing Client Implementation Summary + +## Overview + +This implementation provides an in-memory testing infrastructure for AWS Lambda functions using .NET. It intercepts HTTP requests from the Lambda Bootstrap and allows test code to simulate the Lambda Runtime API without any network calls. + +## Core Components + +### 1. LambdaHttpTransaction + +A simple class that bundles an HTTP request with its response mechanism: + +- **Request**: The `HttpRequestMessage` sent by Lambda Bootstrap +- **ResponseTcs**: A `TaskCompletionSource` that the test infrastructure completes when ready +- **Convenience methods**: `Respond()` and `Fail()` for easy response handling + +The key insight is that each transaction carries its own completion mechanism, providing automatic correlation between requests and responses without needing IDs or separate channels. + +### 2. LambdaTestingHttpHandler + +A custom `HttpMessageHandler` that intercepts all HTTP calls from Lambda Bootstrap: + +- Creates a `Channel` for outbound communication +- On `SendAsync()`: + 1. Wraps the request in a `LambdaHttpTransaction` + 2. Writes it to the channel + 3. Awaits the transaction's `TaskCompletionSource` +- Handles cancellation by registering a callback that cancels the TCS + +This replaces the original two-channel design (request channel + response channel) with a single channel carrying self-contained transactions. + +### 3. LambdaTestServer + +The intermediary that processes HTTP transactions from the handler: + +- Reads transactions from the handler's channel +- Routes requests based on the Lambda Runtime API paths +- Manages queued `/next` requests when no invocations are pending +- Matches response posts back to pending invocations by request ID + +### 4. LambdaTestClient + +The user-facing API that abstracts away all HTTP details: + +- Exposes a clean `InvokeAsync()` method +- Communicates with the server to queue invocations +- Tracks pending invocations in a `ConcurrentDictionary` keyed by request ID +- Returns typed `InvocationResponse` to callers + +## Request Flow +```mermaid +sequenceDiagram + participant User as User Code + participant Client as LambdaTestClient + participant Server as LambdaTestServer + participant Handler as LambdaTestingHttpHandler + participant Bootstrap as Lambda Bootstrap + + User->>Client: InvokeAsync(event) + Client->>Server: Queue pending invocation + + Bootstrap->>Handler: GET /invocation/next + Handler->>Server: Transaction via Channel + Server->>Server: Match with pending invocation + Server->>Handler: Respond(event payload) + Handler->>Bootstrap: HTTP 200 + event + headers + + Note over Bootstrap: Lambda function executes + + Bootstrap->>Handler: POST /invocation/{id}/response + Handler->>Server: Transaction via Channel + Server->>Server: Find pending by request ID + Server->>Handler: Respond(HTTP 202) + Handler->>Bootstrap: HTTP 202 Accepted + Server->>Client: Complete invocation TCS + Client->>User: InvocationResponse +``` + +## Concurrency Handling + +The implementation correctly handles multiple concurrent invocations: + +1. **Correlation via TCS**: Each `LambdaHttpTransaction` has its own `TaskCompletionSource`, so responses automatically route to the correct caller regardless of completion order. + +2. **Request ID tracking**: Each invocation gets a unique GUID. The server tracks pending invocations by this ID and matches Bootstrap's response posts back to the original caller. + +3. **Queued /next requests**: If Bootstrap polls for work before any invocations are pending, the request is queued and served when an invocation arrives. + +## Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Single channel with TCS | Eliminates correlation problem inherent in separate request/response channels | +| `TaskCreationOptions.RunContinuationsAsynchronously` | Prevents deadlocks and stack dives when completing the TCS | +| Background processing loop in server | Decouples user's invoke calls from Bootstrap's polling pattern | +| `ConcurrentDictionary` for pending invocations | Thread-safe tracking for concurrent invoke calls | + +## Usage Example +```csharp +// Setup +var handler = new LambdaTestingHttpHandler(); +var server = new LambdaTestServer(handler); +var client = new LambdaTestClient(server); + +server.Start(); + +// Wire handler to Lambda Bootstrap's HttpClient +// ... bootstrap configuration ... + +// Invoke - user only sees events and responses, no HTTP +var response = await client.InvokeAsync( + new MyEvent { UserId = 123 }); + +if (response.IsSuccess) +{ + Console.WriteLine(response.Response.Result); +} +``` + +## Benefits + +- **Clean API**: Users work with typed events and responses, not HTTP +- **Correct concurrency**: Multiple simultaneous invocations work correctly +- **Testable**: No network, no ports, no external dependencies +- **Faithful simulation**: Follows the actual Lambda Runtime API contract +- **Faithful simulation**: Follows the actual Lambda Runtime API contract \ No newline at end of file From 59b71d7fd56aa6a0fada127c6fb1ff1ab15e7cab Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sun, 7 Dec 2025 15:18:36 -0500 Subject: [PATCH 056/132] refactor(testing): rename testing handler and client for clarity - Renamed `LambdaTestingHttpHandler` to `LambdaHttpHandler` for consistency. - Renamed `LambdaClient` to `LambdaTestClient` to better reflect its testing purpose. --- .../{LambdaTestingHttpHandler.cs => LambdaHttpHandler.cs} | 0 .../{LambdaClient.cs => LambdaTestClient.cs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/AwsLambda.Host.Testing/{LambdaTestingHttpHandler.cs => LambdaHttpHandler.cs} (100%) rename src/AwsLambda.Host.Testing/{LambdaClient.cs => LambdaTestClient.cs} (100%) diff --git a/src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs similarity index 100% rename from src/AwsLambda.Host.Testing/LambdaTestingHttpHandler.cs rename to src/AwsLambda.Host.Testing/LambdaHttpHandler.cs diff --git a/src/AwsLambda.Host.Testing/LambdaClient.cs b/src/AwsLambda.Host.Testing/LambdaTestClient.cs similarity index 100% rename from src/AwsLambda.Host.Testing/LambdaClient.cs rename to src/AwsLambda.Host.Testing/LambdaTestClient.cs From c92d1e3f443dde766d7f55e133f496829ea50c27 Mon Sep 17 00:00:00 2001 From: Jonas Ha <61319894+j-d-ha@users.noreply.github.com> Date: Sun, 7 Dec 2025 16:10:18 -0500 Subject: [PATCH 057/132] refactor(testing): clean up and simplify LambdaApplicationFactory (#221) * refactor(testing): remove unused client configuration and streamline server creation - Deleted `ConfigureClient(HttpClient client)` method and related XML documentation. - Removed commented-out and redundant `_createServer` and `ConfigureClient` references. - Introduced `CreateServer` for internal use in `LambdaApplicationFactory`. - Updated `DelegatedLambdaApplicationFactory` to simplify server initialization logic. * refactor(testing): update default RuntimeApiEndpoint value in LambdaHostOptions - Changed default `RuntimeApiEndpoint` from `localhost:3002` to `localhost` in `LambdaApplicationFactory`. * refactor(testing): remove redundant logic and commented-out code in LambdaApplicationFactory - Deleted unused and commented-out `EnsureServer` and `Return Services` logic. - Simplified `SetContentRoot` by removing conditional file checks. - Updated `GetContentRoot` to always use `GetContentRootFromAssembly`. * refactor(testing): remove unused methods and simplify logic in LambdaApplicationFactory - Deleted `GetContentRootFromFile` and replaced all usage with `GetContentRootFromAssembly`. - Updated `GetContentRootFromAssembly` to use a `foreach` loop for clarity. - Returned `[]` instead of `Array.Empty` to simplify empty array initialization. - Rearranged fields in `DelegatedLambdaApplicationFactory` for consistent ordering. - Added comments to ignored exceptions for better readability. * refactor(testing): rename WithWebHostBuilder to WithHostBuilder in LambdaApplicationFactory - Renamed `WithWebHostBuilder` and `WithWebHostBuilderCore` to `WithHostBuilder` and `WithHostBuilderCore`. - Updated XML documentation comments to reflect the new method names. - Removed unused and commented-out server creation code in `DelegatedLambdaApplicationFactory`. - Updated example tests in `LambdaHostTest` to use the renamed `WithHostBuilder` method. * refactor(testing): simplify server initialization in LambdaApplicationFactory - Replaced inline server creation logic with a call to `CreateServer`. - Removed unused variables `serializerOptions` and `routeManager` during initialization. * refactor(testing): clean up and simplify LambdaApplicationFactory - Removed commented-out and unused code, including `_clients` disposal and `CustomJsonSerializerContext`. - Dropped `partial` keyword from `LambdaApplicationFactory` class declaration. - Replaced `?.Name ?? string.Empty` with `.Name` for assembly name retrieval. - Updated array initialization to use brackets instead of `new[]`. - Simplified disposal logic for `Dispose` and `DisposeAsync` methods. --- .../Tests/LambdaHostTest.cs | 24 ++--- .../LambdaApplicationFactory.cs | 102 +++++------------- 2 files changed, 39 insertions(+), 87 deletions(-) diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index 9d736874..bcab7c64 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -28,20 +28,18 @@ public async Task LambdaHost_CanStartWithoutError() [Fact] public async Task LambdaHost_CrashesWithBadConfiguration_ThrowsException() { - await using var factory = new LambdaApplicationFactory().WithWebHostBuilder( - builder => - { - builder.ConfigureServices( - (_, services) => + await using var factory = new LambdaApplicationFactory().WithHostBuilder(builder => + { + builder.ConfigureServices( + (_, services) => + { + services.Configure(options => { - services.Configure(options => - { - options.BootstrapOptions.RuntimeApiEndpoint = "http://localhost:3002"; - }); - } - ); - } - ); + options.BootstrapOptions.RuntimeApiEndpoint = "http://localhost:3002"; + }); + } + ); + }); var client = factory.CreateClient(); // No need to wait for next request - server handles this automatically diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index ae4fca38..440371dc 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -8,8 +8,6 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; -using System.Text.Json; -using System.Text.Json.Serialization; using AwsLambda.Host.Builder.Extensions; using AwsLambda.Host.Options; using Microsoft.Extensions.Configuration; @@ -24,10 +22,9 @@ namespace AwsLambda.Host.Testing; /// /// A type in the entry point assembly of the application. /// Typically the Startup or Program classes can be used. -public partial class LambdaApplicationFactory : IDisposable, IAsyncDisposable +public class LambdaApplicationFactory : IDisposable, IAsyncDisposable where TEntryPoint : class { - // private readonly List _clients = []; private readonly List> _derivedFactories = []; private Action _configuration; private bool _disposed; @@ -68,7 +65,7 @@ public partial class LambdaApplicationFactory : IDisposable, IAsync /// /// Gets the of factories created from this factory /// by further customizing the when calling - /// . + /// . /// public IReadOnlyList> Factories => _derivedFactories.AsReadOnly(); @@ -93,7 +90,6 @@ public virtual IServiceProvider Services get { EnsureServer(); - // return _host?.Services ?? _server.Host.Services; return _host!.Services; } } @@ -107,9 +103,6 @@ public virtual async ValueTask DisposeAsync() if (_disposedAsync) return; - // foreach (var client in _clients) - // client.Dispose(); - foreach (var factory in _derivedFactories) await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false); @@ -155,21 +148,22 @@ public LambdaClient CreateClient() /// An to configure the . /// /// A new . - public LambdaApplicationFactory WithWebHostBuilder( + public LambdaApplicationFactory WithHostBuilder( Action configuration - ) => WithWebHostBuilderCore(configuration); + ) => WithHostBuilderCore(configuration); + + internal virtual LambdaTestServer CreateServer() => new(); - internal virtual LambdaApplicationFactory WithWebHostBuilderCore( + internal virtual LambdaApplicationFactory WithHostBuilderCore( Action configuration ) { var factory = new DelegatedLambdaApplicationFactory( ClientOptions, - // CreateServer, + CreateServer, CreateHost, CreateHostBuilder, GetTestAssemblies, - ConfigureClient, builder => { _configuration(builder); @@ -202,7 +196,7 @@ private void EnsureServer() { { HostDefaults.ApplicationKey, - typeof(TEntryPoint).Assembly.GetName()?.Name ?? string.Empty + typeof(TEntryPoint).Assembly.GetName().Name ?? string.Empty }, } ); @@ -237,10 +231,7 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) SetContentRoot(hostBuilder); _configuration(hostBuilder); - var serializerOptions = new JsonSerializerOptions(); - var routeManager = new LambdaRuntimeRouteManager(); - - _server = new LambdaTestServer(serializerOptions, routeManager); + _server = CreateServer(); // set Lambda Bootstrap Http Client hostBuilder.ConfigureServices(services => @@ -250,7 +241,7 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) services.PostConfigure(options => { if (string.IsNullOrEmpty(options.BootstrapOptions.RuntimeApiEndpoint)) - options.BootstrapOptions.RuntimeApiEndpoint = "localhost:3002"; + options.BootstrapOptions.RuntimeApiEndpoint = "localhost"; }); }); @@ -262,10 +253,7 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) private void SetContentRoot(IHostBuilder builder) { - var fromFile = File.Exists("MvcTestingAppManifest.json"); - var contentRoot = fromFile - ? GetContentRootFromFile("MvcTestingAppManifest.json") - : GetContentRootFromAssembly(); + var contentRoot = GetContentRootFromAssembly(); if (contentRoot != null) builder.UseContentRoot(contentRoot); @@ -308,22 +296,6 @@ string solutionRelativePath ); } - private static string? GetContentRootFromFile(string file) - { - var data = JsonSerializer.Deserialize( - File.ReadAllBytes(file), - CustomJsonSerializerContext.Default.IDictionaryStringString - )!; - var key = typeof(TEntryPoint).Assembly.GetName().FullName; - - // If the `ContentRoot` is not provided in the app manifest, then return null - // and fallback to setting the content root relative to the entrypoint's assembly. - if (!data.TryGetValue(key, out var contentRoot)) - return null; - - return contentRoot == "~" ? AppContext.BaseDirectory : contentRoot; - } - private string? GetContentRootFromAssembly() { var metadataAttributes = GetContentRootMetadataAttributes( @@ -332,9 +304,8 @@ string solutionRelativePath ); string? contentRoot = null; - for (var i = 0; i < metadataAttributes.Length; i++) + foreach (var contentRootAttribute in metadataAttributes) { - var contentRootAttribute = metadataAttributes[i]; var contentRootCandidate = Path.Combine( AppContext.BaseDirectory, contentRootAttribute.ContentRootPath @@ -392,7 +363,7 @@ protected virtual IEnumerable GetTestAssemblies() var context = DependencyContext.Default; if (context == null || context.CompileLibraries.Count == 0) // The app domain friendly name will be populated in full framework. - return new[] { Assembly.Load(AppDomain.CurrentDomain.FriendlyName) }; + return [Assembly.Load(AppDomain.CurrentDomain.FriendlyName)]; var runtimeProjectLibraries = context.RuntimeLibraries.ToDictionary( r => r.Name, @@ -422,9 +393,12 @@ protected virtual IEnumerable GetTestAssemblies() return testAssemblies; } - catch (Exception) { } + catch (Exception) + { + // Ignore + } - return Array.Empty(); + return []; } private static void EnsureDepsFile() @@ -479,17 +453,6 @@ protected virtual IHost CreateHost(IHostBuilder builder) /// The for the application. protected virtual void ConfigureWebHost(IHostBuilder builder) { } - /// - /// Configures instances created by this . - /// - /// The instance getting configured. - protected virtual void ConfigureClient(HttpClient client) - { - ArgumentNullException.ThrowIfNull(client); - - client.BaseAddress = new Uri("http://localhost"); - } - /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// @@ -502,64 +465,55 @@ protected virtual void Dispose(bool disposing) if (_disposed) return; - if (disposing) - if (!_disposedAsync) - DisposeAsync().AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); + if (disposing && !_disposedAsync) + DisposeAsync().AsTask().ConfigureAwait(false).GetAwaiter().GetResult(); _disposed = true; } - [JsonSerializable(typeof(IDictionary))] - private sealed partial class CustomJsonSerializerContext : JsonSerializerContext; - private sealed class DelegatedLambdaApplicationFactory : LambdaApplicationFactory { - private readonly Action _configureClient; - - // private readonly Func _createServer; private readonly Func _createHost; private readonly Func _createHostBuilder; + private readonly Func _createServer; private readonly Func> _getTestAssemblies; public DelegatedLambdaApplicationFactory( LambdaApplicationFactoryClientOptions options, - // Func createServer, + Func createServer, Func createHost, Func createHostBuilder, Func> getTestAssemblies, - Action configureClient, Action configureWebHost ) { ClientOptions = options; - // _createServer = createServer; + _createServer = createServer; _createHost = createHost; _createHostBuilder = createHostBuilder; _getTestAssemblies = getTestAssemblies; - _configureClient = configureClient; _configuration = configureWebHost; } protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder); + internal override LambdaTestServer CreateServer() => _createServer(); + protected override IHostBuilder? CreateHostBuilder() => _createHostBuilder(); protected override IEnumerable GetTestAssemblies() => _getTestAssemblies(); protected override void ConfigureWebHost(IHostBuilder builder) => _configuration(builder); - protected override void ConfigureClient(HttpClient client) => _configureClient(client); - - internal override LambdaApplicationFactory WithWebHostBuilderCore( + internal override LambdaApplicationFactory WithHostBuilderCore( Action configuration ) => new DelegatedLambdaApplicationFactory( ClientOptions, - // _createServer, + _createServer, _createHost, _createHostBuilder, _getTestAssemblies, - _configureClient, builder => { _configuration(builder); From 3384b03484778f4d390404f1634035e9fbee93da Mon Sep 17 00:00:00 2001 From: Jonas Ha <61319894+j-d-ha@users.noreply.github.com> Date: Thu, 11 Dec 2025 20:30:36 -0500 Subject: [PATCH 058/132] feat(testing): update server implementation and enhance testing infrastructure (#230) * fix(ci): remove sign-commits from changelog workflow (#223) * chore(changelog): update for v1.3.0 release - Add new features, bug fixes, and refactoring notes. - Document maintenance updates and improvements. - Prepare for the next release iteration. * chore(workflows): update changelog workflow settings - Remove `sign-commits` option from changelog workflow. - Adjust minor formatting inconsistency in `id` field. * feat(context): add TenantId and TraceId properties to DefaultLambdaHostContext (#224) - Expose TenantId and TraceId from the lambda context. - Helps in identifying tenant-specific and traceable context data. * chore: update changelog for v1.3.1 (#225) Co-authored-by: j-d-ha <61319894+j-d-ha@users.noreply.github.com> * refactor(testing): replace LambdaClient with InvocationProcessor and enhance test server - Replaced `LambdaClient` with `InvocationProcessor` for improved invocation handling and clarity. - Updated `LambdaTestServer` to manage lifecycle and invocation processing via `InvocationProcessor`. - Removed `LambdaClient` creation logic from `LambdaApplicationFactory`. - Consolidated redundant methods in `LambdaTestServer` for a streamlined design. - Introduced the `ServerState` enum to represent `LambdaTestServer`'s state transitions. * fix(testing): correct exception type assertion and re-throw caught exception in disposal - Updated `LambdaHostTest` to assert `AggregateException` instead of `TaskCanceledException`. - Re-added a `throw` statement in `LambdaTestServer` to propagate caught exceptions during disposal. * feat(testing): enhance entry point completion handling and exception propagation - Added `FailPendingInvocations` in `InvocationProcessor` to handle failed invocations gracefully. - Updated `ConfigureHostBuilder` to accommodate `entryPointCompletion` for better fault handling. - Enhanced `LambdaTestServer` with entry point completion monitoring and fault propagation methods. - Introduced `_entryPointCompletionTcs` in `DeferredHostBuilder` for tracking entry point execution state. * feat(testing): enhance lifecycle handling and initialization in InvocationProcessor and LambdaTestServer - Introduced `ProcessorState` enum to track `InvocationProcessor` lifecycle states. - Enhanced `InvocationProcessor` with initialization error handling and server state transitions. - Added `HandlePostInitErrorAsync` in `InvocationProcessor` to process initialization errors. - Updated `LambdaTestServer.StartAsync` to wait for or fail on host initialization. - Created `InitResponse` model to encapsulate initialization result in testing framework. - Updated `runtime-api.yaml` with new POST `/runtime/init/error` endpoint specification. - Extended `RequestType` and `LambdaRuntimeRouteManager` to support `PostInitError` requests. * feat(testing): add LambdaServerV2 for async server lifecycle management - Implemented `LambdaServerV2` class with async start, invoke, and stop methods. - Added `DisposeAsync` method for proper resource cleanup. - Introduced `InvokeAsync` operation with generic request/response handling. * feat(abstractions): introduce DefaultLambdaJsonSerializerOptions for standardized JSON setup - Added `DefaultLambdaJsonSerializerOptions` to centralize JSON serializer configuration for AWS Lambda. - Updated `EnvelopeOptions` to use `DefaultLambdaJsonSerializerOptions.Create()` for default JSON setup. - Removed redundant inline JSON configuration in `EnvelopeOptions`. * refactor(testing): simplify InvocationProcessor and update related tests - Removed unused parameters from `InvocationProcessor` constructor. - Updated `LambdaApplicationFactory` to use `LambdaServerV2` instead of `LambdaTestServer`. - Commented out unused test methods in `LambdaHostTest`. - Modified `Program.cs` to comment out unneeded initialization logic. - Enhanced `LambdaServerV2` with improved request processing methods. - Added private methods to handle internal server logic in `LambdaServerV2`. * refactor(testing): update InvocationProcessor and enhance LambdaServerV2 initialization - Replaced `InitSuccess` with `InitStatus` in `InitResponse` for more detailed server initialization status. - Enhanced `InvocationProcessor` state transitions and updated related handling to accommodate `InitStatus`. - Refactored `LambdaServerV2` to improve lifecycle methods and handle entry point completion gracefully. - Reinstated and updated `LambdaHostTest` method to verify exception handling on bad configuration. - Added `InitStatus` enum to standardize server initialization state representation. * test(testing): reinstate and update LambdaHost tests for functionality verification - Re-enabled and updated `LambdaHost_ProcessesConcurrentInvocationsInFifoOrder` test for concurrency validation. - Reinstated logic in `LambdaHostTest` to verify response handling and assertion correctness. - Updated comments in `Program.cs` and adjusted initialization-related code. - Added XML documentation to `InitResponse` and `InitStatus` for clarity and better maintainability. - Refactored `LambdaServerV2` with internal method enhancements and exception processing optimizations. * feat(testing): enhance LambdaServerV2 request handling and header management - Introduced `_clientOptions` field to centralize Lambda client configuration. - Added `GetRequestId` method for unique request ID generation with padding. - Implemented `CreateEventResponse` to generate event responses with headers and JSON content. - Replaced `ConcurrentDictionary` with `Channel` for `PendingInvocation` management. - Enhanced `InvokeAsync` to process and return invocation responses with error handling. * feat(testing): refactor LambdaServerV2 and update LambdaHostTest assertions - Simplified `LambdaServerV2` by replacing `ConcurrentQueue` with `Channel` for invocation management. - Refactored variable initialization and lifecycle handling for clarity and reuse. - Updated `LambdaHostTest` to use Fluent Assertions for improved readability and maintainability. - Introduced a private method, `CreateCompletionAsync`, for cloning and processing HTTP requests. - Added safeguards against duplicate request IDs in `LambdaServerV2`. * refactor(testing): extract common success response creation in LambdaServerV2 - Introduced `CreateSuccessResponse` method to centralize success response generation. - Updated `HandlePostErrorAsync` and related methods to use `CreateSuccessResponse`. - Improved code reuse and reduced duplication in response creation logic. * test(lambda-host): add test case for LambdaHost error response validation - Introduced `LambdaHost_HandlerReturnsError` to verify error handling with missing name parameter. - Asserted error response properties, including error message and success flag. - Ensured proper server initialization and invocation flow. * feat(lambda-host): add validation for empty name parameter in handler - Updated handler to throw `ArgumentNullException` if the name parameter is null or whitespace. - Enhanced input validation to ensure proper error handling during Lambda execution. * feat(testing): enhance LambdaServerV2 lifecycle and application management - Added `_applicationLifetime` to manage host application lifecycle. - Updated `Services` property to use null-forgiving operator on `_host`. - Integrated `_applicationLifetime.StopApplication()` during server shutdown. - Ensured proper server stop handling with `_entryPointCompletion` await logic. * test(lambda-host): add test for verifying LambdaHost shutdown process - Introduced `LambdaHost_CanBeShutdown` to verify proper server initialization and shutdown handling. - Asserted initialization status, response properties, and server shutdown behavior. - Added `OnShutdown` callback in `Program.cs` to log shutdown event. * feat(testing): enhance LambdaServerV2 lifecycle and shutdown process - Improved member initialization in `LambdaServerV2` for clarity and consistency. - Added XML documentation to fields and methods for better code maintainability. - Updated shutdown logic to use `_shutdownCts.CancelAsync()` for proper task cancellation. - Refactored unused or duplicated logic in constructor. - Enhanced server state management during shutdown to enforce lifecycle rules. - Integrated `DefaultLambdaJsonSerializerOptions.Create()` for standardized JSON serialization. * feat(testing): add `TaskHelpers` utility for task exception handling - Introduced `WhenAny` and `WhenAll` methods to extract exceptions from completed tasks. - Added `UnwrapAndThrow` extension for awaiting tasks and throwing aggregate exceptions. - Improved task exception handling for testing scenarios in `AwsLambda.Host.Testing`. * feat(testing): add DictionaryExtensions for dictionary operations - Introduced `AddRequired` method to ensure unique key addition and throw on duplicates. - Added `GetRequired` method to retrieve values by key and enforce key existence. - Improved error handling for dictionary operations in testing scenarios. * feat(testing): replace exception-handling utilities and improve invocation handling in LambdaServerV2 - Replaced custom exception-extracting methods with `TaskHelpers` utility methods (`WhenAny`, `WhenAll`). - Simplified exception handling using `UnwrapAndThrow` for consistent error reporting. - Streamlined `InvokeAsync` response handling by splitting success and error processing. - Replaced duplicate `TryAdd` and `TryGetValue` calls with `AddRequired` and `GetRequired`. - Cleaned up unused methods and redundant logic to enhance maintainability. * feat(testing): improve error handling and lifecycle management in LambdaServerV2 - Added specific error messages for invalid state transitions during lifecycle operations. - Enhanced `DisposeAsync` to prevent redundant `StopAsync` calls when the server is not running. - Improved state validation logic with descriptive exceptions for `StartAsync`, `InvokeAsync`, and `StopAsync`. - Streamlined shutdown behavior to enforce consistent state management and avoid silent failures. * feat(testing): enhance cancellation handling and error safety in LambdaServerV2 - Added `_shutdownCts.Token` to `CancellationTokenSource.CreateLinkedTokenSource` for consistency. - Improved `ProcessTransactionsAsync` with try-catch to handle task cancellations gracefully. - Ensured proper cancellation behavior in `StopAsync` with `.WaitAsync(cancellationToken)`. * feat(testing): remove unused Lambda server testing infrastructure - Deleted `ILambdaRuntimeRouteManager`, `InvocationProcessor`, and `_ProcessorState`. - Removed `LambdaTestServer` class and its related models and methods. - Cleared redundant internal classes like `LambdaBootstrapRequest` from `AwsLambda.Host.Testing`. - Cleaned up unnecessary internal abstractions to streamline testing code and reduce maintenance burden. * refactor(testing): rename LambdaServerV2 to LambdaTestServer for clarity - Replaced all occurrences of `LambdaServerV2` with `LambdaTestServer` across testing infrastructure. - Updated references in `LambdaApplicationFactory` and related methods to use `LambdaTestServer`. - Adjusted test cases to align with the updated `LambdaTestServer` naming convention. - Updated XML documentation and comments to reflect the new naming. * feat(testing): update Lambda server options and invocation handling - Renamed `LambdaClientOptions` to `LambdaServerOptions` and refactored related usage. - Updated invocation timeout handling to use `FunctionTimeout` in `LambdaServerOptions`. - Refined `InvokeAsync` method to include `traceId` and improved error handling for pending IDs. - Enhanced response creation with additional headers and updated `traceId` logic. - Removed unused properties like `Date`, `InvocationTimeout`, and `TransferEncodingChunked`. - Improved cancellation logic with `LinkedCtsWithInvocationDeadline` and `LinkedCts` methods. - Introduced `LambdaTestServerExtensions` for simplified invocation in test cases. * feat(testing): remove LambdaInvocationOptions class - Deleted `LambdaInvocationOptions` class from `AwsLambda.Host.Testing`. - Removed related properties like `AdditionalHeaders`, `FunctionArn`, `FunctionTimeout`, and `TraceId`. - Streamlined testing code by eliminating unused infrastructure. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../Lambda/Program.cs | 25 +- .../Tests/LambdaHostTest.cs | 147 ++-- .../DeferredHostBuilder.cs | 8 + .../DictionaryExtensions.cs | 19 + .../ILambdaRuntimeRouteManager.cs | 12 - .../LambdaApplicationFactory.cs | 60 +- .../LambdaHttpHandler.cs | 2 +- .../LambdaRuntimeRouteManager.cs | 11 +- .../LambdaTestClient.cs | 142 ---- .../LambdaTestServer.cs | 630 ++++++++++-------- .../LambdaTestServerExtensions.cs | 16 + .../Models/ErrorResponse.cs | 3 + .../Models/InitResponse.cs | 17 + .../Models/InitStatus.cs | 22 + .../Models/LambdaBootstrapRequest.cs | 24 - .../Models/RequestType.cs | 1 + .../Models/ServerState.cs | 37 + .../Options/LambdaClientOptions.cs | 12 - .../Options/LambdaInvocationHeaderOptions.cs | 63 -- .../Options/LambdaServerOptions.cs | 28 + src/AwsLambda.Host.Testing/README.md | 31 + src/AwsLambda.Host.Testing/TaskHelpers.cs | 36 + .../DefaultLambdaJsonSerializerOptions.cs | 0 .../Options/EnvelopeOptions.cs | 26 +- 24 files changed, 718 insertions(+), 654 deletions(-) create mode 100644 src/AwsLambda.Host.Testing/DictionaryExtensions.cs delete mode 100644 src/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs delete mode 100644 src/AwsLambda.Host.Testing/LambdaTestClient.cs create mode 100644 src/AwsLambda.Host.Testing/LambdaTestServerExtensions.cs create mode 100644 src/AwsLambda.Host.Testing/Models/InitResponse.cs create mode 100644 src/AwsLambda.Host.Testing/Models/InitStatus.cs delete mode 100644 src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs create mode 100644 src/AwsLambda.Host.Testing/Models/ServerState.cs delete mode 100644 src/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs delete mode 100644 src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs create mode 100644 src/AwsLambda.Host.Testing/Options/LambdaServerOptions.cs create mode 100644 src/AwsLambda.Host.Testing/TaskHelpers.cs create mode 100644 src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs index fc3329b0..e641ddc1 100644 --- a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs @@ -1,4 +1,5 @@ using AwsLambda.Host.Builder; +using AwsLambda.Host.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -13,8 +14,30 @@ // Build the Lambda application var lambda = builder.Build(); +// throw new Exception("Init failed"); + +// lambda.OnInit(() => +// { +// // throw new Exception("Init failed"); +// // return false; +// }); + // Map your handler - the event is automatically injected -lambda.MapHandler(([Event] string name) => $"Hello {name}!"); +lambda.MapHandler( + async ([Event] string name, ILambdaHostContext context, CancellationToken cancellationToken) => + { + await Task.Delay(TimeSpan.FromSeconds(60), cancellationToken); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentNullException(nameof(name), "Name is required."); + + return $"Hello {name}!"; + } +); + +lambda.OnShutdown(() => +{ + Console.WriteLine("Shutdown"); +}); // Run the Lambda await lambda.RunAsync(); diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index bcab7c64..81a1e338 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -1,3 +1,4 @@ +using AwesomeAssertions; using AwsLambda.Host.Options; using AwsLambda.Host.Testing; using JetBrains.Annotations; @@ -14,19 +15,63 @@ public async Task LambdaHost_CanStartWithoutError() { await using var factory = new LambdaApplicationFactory(); - var client = factory.CreateClient(); - // No need to wait for next request - server handles this automatically - var response = await client.InvokeAsync( + var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + setup.InitStatus.Should().Be(InitStatus.InitCompleted); + + var response = await factory.TestServer.InvokeAsync( "Jonas", + "1", TestContext.Current.CancellationToken ); - Assert.True(response.WasSuccess); - Assert.NotNull(response); - Assert.Equal("Hello Jonas!", response.Response); + + response.WasSuccess.Should().BeTrue(); + response.Should().NotBeNull(); + response.Response.Should().Be("Hello Jonas!"); } [Fact] - public async Task LambdaHost_CrashesWithBadConfiguration_ThrowsException() + public async Task LambdaHost_HandlerReturnsError() + { + await using var factory = new LambdaApplicationFactory(); + + var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + setup.InitStatus.Should().Be(InitStatus.InitCompleted); + + var response = await factory.TestServer.InvokeAsync( + "", + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeFalse(); + response.Error.Should().NotBeNull(); + response.Error?.ErrorMessage.Should().Be("Name is required. (Parameter 'name')"); + } + + [Fact] + public async Task LambdaHost_CanBeShutdown() + { + await using var factory = new LambdaApplicationFactory(); + + var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + setup.InitStatus.Should().Be(InitStatus.InitCompleted); + + var response = await factory.TestServer.InvokeAsync( + "Jonas", + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Should().NotBeNull(); + response.Response.Should().Be("Hello Jonas!"); + + await factory.TestServer.StopAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task LambdaHost_ServerInternalExceptions_AreCaughtAndReturnedAsError() { await using var factory = new LambdaApplicationFactory().WithHostBuilder(builder => { @@ -41,28 +86,30 @@ public async Task LambdaHost_CrashesWithBadConfiguration_ThrowsException() ); }); - var client = factory.CreateClient(); - // No need to wait for next request - server handles this automatically - var response = await client.InvokeAsync( - "Jonas", - TestContext.Current.CancellationToken - ); - Assert.True(response.WasSuccess); - Assert.NotNull(response); - Assert.Equal("Hello Jonas!", response.Response); + var act = async () => + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + (await act.Should().ThrowAsync()) + .And.InnerExceptions.Should() + .ContainSingle(ex => + ex is InvalidOperationException + && ex.Message.Contains( + "Unexpected request received from the Lambda HTTP handler: GET http://http//localhost:3002/2018-06-01/runtime/invocation/next" + ) + ); } [Fact] public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() { await using var factory = new LambdaApplicationFactory(); - var client = factory.CreateClient(); + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); // Launch 5 concurrent invocations var tasks = Enumerable .Range(1, 5) .Select(i => - client.InvokeAsync( + factory.TestServer.InvokeAsync( $"User{i}", TestContext.Current.CancellationToken ) @@ -84,9 +131,9 @@ public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() public async Task InvokeAsync_WithInvalidPayload_ReturnsError() { await using var factory = new LambdaApplicationFactory(); - var client = factory.CreateClient(); + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - var response = await client.InvokeAsync( + var response = await factory.TestServer.InvokeAsync( 123, TestContext.Current.CancellationToken ); @@ -100,38 +147,46 @@ public async Task InvokeAsync_WithInvalidPayload_ReturnsError() public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() { await using var factory = new LambdaApplicationFactory(); - var client = factory.CreateClient(); + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Assert.ThrowsAsync(() => - client.InvokeAsync("Jonas", cts.Token) + factory.TestServer.InvokeAsync("Jonas", cts.Token) ); } - [Fact] - public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() => - await Assert.ThrowsAsync(async () => - { - try - { - await using var factory = new LambdaApplicationFactory(); - var client = factory - .CreateClient() - .ConfigureOptions(options => - options.InvocationHeaderOptions.ClientWaitTimeout = TimeSpan.Zero - ); - - await client.InvokeAsync( - "Jonas", - TestContext.Current.CancellationToken - ); - } - catch (Exception e) - { - Console.WriteLine(e.GetType().FullName); - throw; - } - }); + // [Fact] + // public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() => + // await Assert.ThrowsAsync(async () => + // { + // await using var factory = new LambdaApplicationFactory(); + // await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + // + // var options = new LambdaServerOptions(); + // options.InvocationOptions.ClientWaitTimeout = TimeSpan.Zero; + // + // await factory.TestServer.InvokeAsync( + // "Jonas", + // options, + // TestContext.Current.CancellationToken + // ); + // }); + + // [Fact] + // public async Task StartAsync_WithFailingInit_ReturnsInitError() + // { + // // This test verifies that when OnInit returns false (as configured in Program.cs), + // // the runtime posts to /runtime/init/error and StartAsync returns InitResponse with + // //error + // await using var factory = new LambdaApplicationFactory(); + // + // using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + // var initResponse = await factory.TestServer.StartAsync(cts.Token); + // + // Assert.False(initResponse.InitSuccess); + // Assert.NotNull(initResponse.Error); + // Assert.Equal(ServerState.Stopped, factory.TestServer.State); + // } } diff --git a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs index 7fe11895..6e3d1fe0 100644 --- a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs +++ b/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs @@ -25,6 +25,10 @@ internal sealed class DeferredHostBuilder : IHostBuilder TaskCreationOptions.RunContinuationsAsynchronously ); + private readonly TaskCompletionSource _entryPointCompletionTcs = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + private Action _configure; private Func? _hostFactory; @@ -39,6 +43,8 @@ public DeferredHostBuilder() => public IDictionary Properties { get; } = new Dictionary(); + public Task EntryPointCompletion => _entryPointCompletionTcs.Task; + public IHost Build() { // Hosting configuration is being provided by args so that @@ -116,10 +122,12 @@ public void EntryPointCompleted(Exception? exception) if (exception is not null) { _hostStartTcs.TrySetException(exception); + _entryPointCompletionTcs.TrySetResult(exception); } else { _hostStartTcs.TrySetResult(); + _entryPointCompletionTcs.TrySetResult(null); } } diff --git a/src/AwsLambda.Host.Testing/DictionaryExtensions.cs b/src/AwsLambda.Host.Testing/DictionaryExtensions.cs new file mode 100644 index 00000000..7a8e5afc --- /dev/null +++ b/src/AwsLambda.Host.Testing/DictionaryExtensions.cs @@ -0,0 +1,19 @@ +namespace AwsLambda.Host.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/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs b/src/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs deleted file mode 100644 index 041586b6..00000000 --- a/src/AwsLambda.Host.Testing/ILambdaRuntimeRouteManager.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.AspNetCore.Routing; - -namespace AwsLambda.Host.Testing; - -internal interface ILambdaRuntimeRouteManager -{ - bool TryMatch( - HttpRequestMessage request, - out RequestType? routeType, - out RouteValueDictionary? values - ); -} diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs index 440371dc..a04f3925 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs @@ -73,7 +73,7 @@ public class LambdaApplicationFactory : IDisposable, IAsyncDisposab /// /// Gets the created by this . /// - internal LambdaTestServer Server + public LambdaTestServer TestServer { get { @@ -85,14 +85,7 @@ internal LambdaTestServer Server /// /// Gets the created by the server associated with this . /// - public virtual IServiceProvider Services - { - get - { - EnsureServer(); - return _host!.Services; - } - } + public virtual IServiceProvider Services => TestServer.Services; /// public virtual async ValueTask DisposeAsync() @@ -106,15 +99,10 @@ public virtual async ValueTask DisposeAsync() 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); - if (_host != null) - { - await _host.StopAsync().ConfigureAwait(false); - _host?.Dispose(); - } - _disposedAsync = true; Dispose(true); @@ -134,12 +122,6 @@ public void Dispose() /// ~LambdaApplicationFactory() => Dispose(false); - public LambdaClient CreateClient() - { - EnsureServer(); - return _server!.CreateLambdaClient(); - } - /// /// Creates a new with a /// that is further customized by . @@ -152,15 +134,12 @@ public LambdaApplicationFactory WithHostBuilder( Action configuration ) => WithHostBuilderCore(configuration); - internal virtual LambdaTestServer CreateServer() => new(); - internal virtual LambdaApplicationFactory WithHostBuilderCore( Action configuration ) { var factory = new DelegatedLambdaApplicationFactory( ClientOptions, - CreateServer, CreateHost, CreateHostBuilder, GetTestAssemblies, @@ -218,7 +197,7 @@ private void EnsureServer() // to IHostBuilder.Build. deferredHostBuilder.SetHostFactory(factory); - ConfigureHostBuilder(deferredHostBuilder); + ConfigureHostBuilder(deferredHostBuilder, deferredHostBuilder.EntryPointCompletion); return; } @@ -226,17 +205,20 @@ private void EnsureServer() } [MemberNotNull(nameof(_server))] - private void ConfigureHostBuilder(IHostBuilder hostBuilder) + private void ConfigureHostBuilder( + IHostBuilder hostBuilder, + Task entryPointCompletion + ) { SetContentRoot(hostBuilder); _configuration(hostBuilder); - _server = CreateServer(); + _server = new LambdaTestServer(entryPointCompletion); // set Lambda Bootstrap Http Client hostBuilder.ConfigureServices(services => { - services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateTestingHandler())); + services.AddLambdaBootstrapHttpClient(new HttpClient(_server.CreateHandler())); services.PostConfigure(options => { @@ -245,10 +227,13 @@ private void ConfigureHostBuilder(IHostBuilder hostBuilder) }); }); + // Build the host but DON'T start it - server will start it _host = CreateHost(hostBuilder); + _server.SetHost(_host); - // Start the server's background processing loop after host is ready - _server.Start(); + // Create the public server with the built (but not started) host + // _server = new LambdaTestServer(_host, processor, entryPointCompletion: + // entryPointCompletion); } private void SetContentRoot(IHostBuilder builder) @@ -440,12 +425,9 @@ private static void EnsureDepsFile() /// /// The used to create the host. /// The with the bootstrapped application. - protected virtual IHost CreateHost(IHostBuilder builder) - { - var host = builder.Build(); - host.Start(); - return host; - } + 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. @@ -475,12 +457,10 @@ private sealed class DelegatedLambdaApplicationFactory : LambdaApplicationFactor { private readonly Func _createHost; private readonly Func _createHostBuilder; - private readonly Func _createServer; private readonly Func> _getTestAssemblies; public DelegatedLambdaApplicationFactory( LambdaApplicationFactoryClientOptions options, - Func createServer, Func createHost, Func createHostBuilder, Func> getTestAssemblies, @@ -488,7 +468,6 @@ Action configureWebHost ) { ClientOptions = options; - _createServer = createServer; _createHost = createHost; _createHostBuilder = createHostBuilder; _getTestAssemblies = getTestAssemblies; @@ -497,8 +476,6 @@ Action configureWebHost protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder); - internal override LambdaTestServer CreateServer() => _createServer(); - protected override IHostBuilder? CreateHostBuilder() => _createHostBuilder(); protected override IEnumerable GetTestAssemblies() => _getTestAssemblies(); @@ -510,7 +487,6 @@ Action configuration ) => new DelegatedLambdaApplicationFactory( ClientOptions, - _createServer, _createHost, _createHostBuilder, _getTestAssemblies, diff --git a/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs b/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs index bf7cc1a9..ffc9f4df 100644 --- a/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs +++ b/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs @@ -41,7 +41,7 @@ CancellationToken cancellationToken } catch (ChannelClosedException) { - // Server is shutting down; propagate cancellation to caller + // TestServer is shutting down; propagate cancellation to caller var canceled = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ); diff --git a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs b/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs index e60d02c3..0f2fd484 100644 --- a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs +++ b/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs @@ -4,7 +4,7 @@ namespace AwsLambda.Host.Testing; -internal class LambdaRuntimeRouteManager : ILambdaRuntimeRouteManager +internal class LambdaRuntimeRouteManager { private static readonly RouteTemplate[] Routes = [ @@ -18,6 +18,15 @@ internal class LambdaRuntimeRouteManager : ILambdaRuntimeRouteManager ), }, 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, diff --git a/src/AwsLambda.Host.Testing/LambdaTestClient.cs b/src/AwsLambda.Host.Testing/LambdaTestClient.cs deleted file mode 100644 index cb370a24..00000000 --- a/src/AwsLambda.Host.Testing/LambdaTestClient.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; - -namespace AwsLambda.Host.Testing; - -/// -/// Client for invoking Lambda functions in tests. -/// Provides a clean API that abstracts HTTP details. -/// -public class LambdaClient -{ - private readonly JsonSerializerOptions _jsonSerializerOptions; - private readonly LambdaClientOptions _lambdaClientOptions; - private readonly LambdaTestServer _server; - private int _requestCounter; - - internal LambdaClient(LambdaTestServer server, JsonSerializerOptions jsonSerializerOptions) - { - _server = server; - _jsonSerializerOptions = jsonSerializerOptions; - _lambdaClientOptions = new LambdaClientOptions(); - } - - /// - /// Configures client options for invocation headers. - /// - public LambdaClient ConfigureOptions(Action configureOptions) - { - ArgumentNullException.ThrowIfNull(configureOptions); - - configureOptions(_lambdaClientOptions); - - return this; - } - - /// - /// Invokes the Lambda function with the given event and waits for the response. - /// - public async Task> InvokeAsync( - TEvent invokeEvent, - CancellationToken cancellationToken = default - ) - { - // Generate unique request ID - var requestId = GetRequestId(); - - // Create the event response with Lambda headers - var eventResponse = CreateEventResponse(invokeEvent, requestId); - var deadlineUtc = DateTimeOffset.UtcNow.Add( - _lambdaClientOptions.InvocationHeaderOptions.ClientWaitTimeout - ); - - // Queue invocation and wait for Bootstrap to process it - var waitTimeout = _lambdaClientOptions.InvocationHeaderOptions.ClientWaitTimeout; - - using var timeoutCts = new CancellationTokenSource(waitTimeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, - timeoutCts.Token - ); - - var completion = await _server.QueueInvocationAsync( - requestId, - eventResponse, - deadlineUtc, - linkedCts.Token - ); - - var responseMessage = completion.Request; - var wasSuccess = completion.RequestType == RequestType.PostResponse; - - var invocationResponse = new InvocationResponse - { - WasSuccess = wasSuccess, - Response = wasSuccess - ? await ( - responseMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cancellationToken - ) ?? Task.FromResult(default) - ) - : default, - Error = !wasSuccess - ? await ( - responseMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cancellationToken - ) ?? Task.FromResult(null) - ) - : null, - }; - - return invocationResponse; - } - - private HttpResponseMessage CreateEventResponse(TEvent invokeEvent, string requestId) - { - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - JsonSerializer.Serialize(invokeEvent, _jsonSerializerOptions), - Encoding.UTF8, - "application/json" - ), - Version = Version.Parse("1.1"), - }; - - // Add standard HTTP headers - response.Headers.Date = new DateTimeOffset( - _lambdaClientOptions.InvocationHeaderOptions.Date - ); - response.Headers.TransferEncodingChunked = _lambdaClientOptions - .InvocationHeaderOptions - .TransferEncodingChunked; - - // Add custom Lambda runtime headers - var deadlineMs = DateTimeOffset - .UtcNow.Add(_lambdaClientOptions.InvocationHeaderOptions.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", - _lambdaClientOptions.InvocationHeaderOptions.TraceId - ); - response.Headers.Add( - "Lambda-Runtime-Invoked-Function-Arn", - _lambdaClientOptions.InvocationHeaderOptions.FunctionArn - ); - - // Add any additional custom headers - foreach (var header in _lambdaClientOptions.InvocationHeaderOptions.AdditionalHeaders) - response.Headers.Add(header.Key, header.Value); - - return response; - } - - private string GetRequestId() => - Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); -} diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/AwsLambda.Host.Testing/LambdaTestServer.cs index 773dceb3..357598e8 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/AwsLambda.Host.Testing/LambdaTestServer.cs @@ -1,391 +1,420 @@ using System.Collections.Concurrent; using System.Net; +using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Threading.Channels; +using AwsLambda.Host.Options; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace AwsLambda.Host.Testing; -/// -/// Test server that processes HTTP transactions from Lambda Bootstrap. -/// Routes requests, queues invocations, and manages request-response correlation. -/// -internal class LambdaTestServer : IAsyncDisposable +public class LambdaTestServer : IAsyncDisposable { - private const int TransactionChannelCapacity = 1024; - private const int NextRequestChannelCapacity = 1024; - private static readonly OperationCanceledException DisposedException = new( - "LambdaTestServer disposed" - ); - - private readonly LambdaClient _client; - private readonly JsonSerializerOptions _jsonSerializerOptions; - private readonly ConcurrentQueue _pendingInvocationIds; - private readonly ConcurrentDictionary _pendingInvocations; - private readonly Channel _queuedNextRequests; - private readonly ILambdaRuntimeRouteManager _routeManager; - private readonly CancellationTokenSource _shutdownCts; - private readonly Channel _transactionChannel; - private Task? _processingTask; - - internal LambdaTestServer( - JsonSerializerOptions? jsonSerializerOptions = null, - ILambdaRuntimeRouteManager? routeManager = null - ) - { - _transactionChannel = Channel.CreateBounded( - new BoundedChannelOptions(TransactionChannelCapacity) - { - SingleReader = true, - SingleWriter = false, - FullMode = BoundedChannelFullMode.Wait, - } - ); - _pendingInvocationIds = new ConcurrentQueue(); - _pendingInvocations = new ConcurrentDictionary(); - _queuedNextRequests = Channel.CreateBounded( - new BoundedChannelOptions(NextRequestChannelCapacity) - { - SingleReader = false, - SingleWriter = false, - FullMode = BoundedChannelFullMode.Wait, - } - ); - _routeManager = routeManager ?? new LambdaRuntimeRouteManager(); - _jsonSerializerOptions = jsonSerializerOptions ?? new JsonSerializerOptions(); - _shutdownCts = new CancellationTokenSource(); + /// + /// Options used to configure how the server interacts with the Lambda. + /// + private readonly LambdaServerOptions _serverOptions; - // Create client that communicates with this server - _client = new LambdaClient(this, _jsonSerializerOptions); - } + /// + /// Task that represents the running Host application that has been captured. + /// + private readonly Task _entryPointCompletion; - public async ValueTask DisposeAsync() - { - await _shutdownCts.CancelAsync(); + /// + /// TCS used to signal the startup has completed + /// + private readonly TaskCompletionSource _initCompletionTcs = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); - _transactionChannel.Writer.TryComplete(); - _queuedNextRequests.Writer.TryComplete(); + /// + /// JSON serializer options used to serialize/deserilize Lambda events and responses. + /// + private readonly JsonSerializerOptions _jsonSerializerOptions; - // Cancel any transactions waiting for work - while (_queuedNextRequests.Reader.TryRead(out var queuedTransaction)) - queuedTransaction.Fail(DisposedException); + /// + /// Channel used to queue pending invocations in a FIFO manner. + /// + private readonly Channel _pendingInvocationIds = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } + ); - // Fail any in-flight transactions that haven't been processed yet - while (_transactionChannel.Reader.TryRead(out var transaction)) - transaction.Fail(DisposedException); + /// + /// Dictionary to track all invocations that have been sent to Lambda. + /// + private readonly ConcurrentDictionary _pendingInvocations = new(); - // Cancel any pending invocations waiting for bootstrap responses - foreach (var pendingInvocation in _pendingInvocations.Values) - pendingInvocation.ResponseTcs.TrySetCanceled(_shutdownCts.Token); + /// + /// Route manager to determine the route of the incoming request from the Lambda. + /// + private readonly LambdaRuntimeRouteManager _routeManager = new(); - if (_processingTask != null) - { - try - { - await _processingTask.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected when task is canceled - } - } + /// + /// CTS used to signal shutdown of the server and cancellation of pending tasks. + /// + private readonly CancellationTokenSource _shutdownCts; - _shutdownCts.Dispose(); - } + /// + /// Channel used to by the Lambda to send events to the server. + /// + private readonly Channel _transactionChannel = + Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false } + ); /// - /// Creates the HTTP handler for Lambda Bootstrap to use. + /// Host application lifetime used to signal shutdown to the captioned Host. /// - internal HttpMessageHandler CreateTestingHandler() => - new LambdaTestingHttpHandler(_transactionChannel); + private IHostApplicationLifetime _applicationLifetime; /// - /// Gets the client for test code to invoke Lambda functions. + /// The captured Host instance. /// - internal LambdaClient CreateLambdaClient() => _client; + private IHost? _host; /// - /// Starts the background processing loop. - /// Called automatically by LambdaApplicationFactory after host starts. + /// Task that is running the background processing loop to handle incoming requests from Lambda. /// - internal void Start() - { - if (_processingTask != null) - throw new InvalidOperationException("Server already started"); + private Task? _processingTask; - _processingTask = Task.Run(ProcessTransactionsAsync); - } + /// + /// Counter used to generate unique request IDs. + /// + private int _requestCounter; /// - /// Queues a new invocation to be processed by Lambda Bootstrap. - /// Called by LambdaClient.InvokeAsync(). + /// Current state of the server used to enforce lifecycle rules. /// - internal async Task QueueInvocationAsync( - string requestId, - HttpResponseMessage eventResponse, - DateTimeOffset deadlineUtc, - CancellationToken cancellationToken + private ServerState _state; + + internal LambdaTestServer( + Task? entryPointCompletion, + CancellationToken shutdownToken = default ) { - var pending = PendingInvocation.Create(requestId, eventResponse, deadlineUtc); + ArgumentNullException.ThrowIfNull(entryPointCompletion); - if (!_pendingInvocations.TryAdd(requestId, pending)) - throw new InvalidOperationException($"Duplicate request ID: {requestId}"); + _entryPointCompletion = entryPointCompletion; + _shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(shutdownToken); + _state = ServerState.Created; - _pendingInvocationIds.Enqueue(requestId); + _jsonSerializerOptions = DefaultLambdaJsonSerializerOptions.Create(); + _serverOptions = new LambdaServerOptions(); + } - // If there's a queued /next request, serve it immediately - if (_queuedNextRequests.Reader.TryRead(out var nextTransaction)) - RespondToNextRequest(nextTransaction); + public IServiceProvider Services => _host!.Services; - using var cancellationRegistration = cancellationToken.Register(() => - CancelPendingInvocation(requestId, cancellationToken) - ); + public async ValueTask DisposeAsync() + { + if (_state == ServerState.Running) + await StopAsync(); + + _transactionChannel.Writer.TryComplete(); - // Wait for Bootstrap to process and respond - return await pending.ResponseTcs.Task.WaitAsync(cancellationToken); + await _shutdownCts.CancelAsync(); + + _state = ServerState.Disposed; } - /// - /// Background loop that processes transactions from the handler. - /// - private async Task ProcessTransactionsAsync() + internal void SetHost(IHost host) { - await foreach ( - var transaction in _transactionChannel.Reader.ReadAllAsync(_shutdownCts.Token) - ) - try - { - await ProcessTransactionAsync(transaction); - } - catch (Exception ex) - { - // Fail the transaction and continue processing - transaction.Fail(ex); - } + ArgumentNullException.ThrowIfNull(host); + _host = host; } - /// - /// Routes a single transaction based on request type. - /// - private async Task ProcessTransactionAsync(LambdaHttpTransaction transaction) + internal HttpMessageHandler CreateHandler() => + new LambdaTestingHttpHandler(_transactionChannel); + + // ┌──────────────────────────────────────────────────────────┐ + // │ Public API │ + // └──────────────────────────────────────────────────────────┘ + + public async Task StartAsync(CancellationToken cancellationToken = default) { - if (!_routeManager.TryMatch(transaction.Request, out var requestType, out var routeValues)) + if (_state != ServerState.Created) throw new InvalidOperationException( - $"Unexpected request: {transaction.Request.Method} {transaction.Request.RequestUri}" + "TestServer has already been started and cannot be restarted." ); - switch (requestType!.Value) + 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) + return new InitResponse { InitStatus = InitStatus.HostExited }; + + if (_initCompletionTcs.Task.IsCompleted) { - case RequestType.GetNextInvocation: - await HandleGetNextInvocationAsync(transaction); - break; - - case RequestType.PostResponse: - await HandlePostResponseAsync(transaction, routeValues!); - break; - - case RequestType.PostError: - await HandlePostErrorAsync(transaction, routeValues!); - break; - - default: - throw new InvalidOperationException( - $"Unexpected request type {requestType} for {transaction.Request.RequestUri}" - ); + _state = + _initCompletionTcs.Task.Result.InitStatus == InitStatus.InitCompleted + ? ServerState.Running + : ServerState.Stopped; + + return _initCompletionTcs.Task.Result; } + + throw new InvalidOperationException( + "TestServer initialization failed with neither an error nor completion." + ); } - /// - /// Handles GET /invocation/next - Bootstrap polling for work. - /// - private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transaction) + public async Task> InvokeAsync( + TEvent invokeEvent, + string? traceId = null, + CancellationToken cancellationToken = default + ) { - // Try to dequeue next pending invocation (FIFO) - if (!TryDequeuePendingInvocation(out var pending)) - { - // No work available - queue this /next request - try - { - await _queuedNextRequests.Writer.WriteAsync(transaction, _shutdownCts.Token); - } - catch (OperationCanceledException) - { - transaction.Fail(DisposedException); - } + if (_state != ServerState.Running) + throw new InvalidOperationException( + "TestServer is not Running and as such an event cannot be invoked." + ); - return; - } + 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 + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cts.Token + ) ?? Task.FromResult(default) + ) + : default; - RespondToNextRequest(transaction, pending); + var error = !wasSuccess + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cts.Token + ) ?? Task.FromResult(null) + ) + : null; + + return new InvocationResponse + { + WasSuccess = wasSuccess, + Response = response, + Error = error, + }; } - /// - /// Responds to a /next request with a pending invocation. - /// - private void RespondToNextRequest(LambdaHttpTransaction transaction, PendingInvocation pending) + public async Task StopAsync(CancellationToken cancellationToken = default) { - // Respond with the event payload and Lambda headers - if (transaction.Respond(pending.EventResponse)) - return; + if (_state != ServerState.Running) + throw new InvalidOperationException( + "TestServer is not running and as such cannot be stopped." + ); + + _state = ServerState.Stopping; + + await _shutdownCts.CancelAsync(); + + _applicationLifetime.StopApplication(); + + await TaskHelpers + .WhenAll(_entryPointCompletion, _processingTask!) + .UnwrapAndThrow("Exception(s) encountered while running StopAsync") + .WaitAsync(cancellationToken); - // Request was already canceled; re-enqueue invocation to avoid dropping it - _pendingInvocationIds.Enqueue(pending.RequestId); + _state = ServerState.Stopped; } - /// - /// Responds to a /next request with a pending invocation by looking up the next one. - /// - private void RespondToNextRequest(LambdaHttpTransaction transaction) + // ┌──────────────────────────────────────────────────────────┐ + // │ Internal TestServer Logic │ + // └──────────────────────────────────────────────────────────┘ + + private async Task ProcessTransactionsAsync() { - // Try to dequeue next pending invocation (FIFO) - if (!TryDequeuePendingInvocation(out var pending)) + try { - // This shouldn't happen, but if it does, respond with error - transaction.Respond( - new HttpResponseMessage(HttpStatusCode.InternalServerError) + await foreach ( + var transaction in _transactionChannel.Reader.ReadAllAsync(_shutdownCts.Token) + ) + { + if ( + !_routeManager.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) { - Content = new StringContent("No pending invocations available"), + 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}" + ); } - ); - return; + } } + catch (OperationCanceledException) when (_shutdownCts.IsCancellationRequested) + { + // Expected when task is canceled + } + } - RespondToNextRequest(transaction, pending); + 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); + } } - /// - /// Handles POST /invocation/{requestId}/response - successful function execution. - /// private async Task HandlePostResponseAsync( LambdaHttpTransaction transaction, RouteValueDictionary routeValues ) { - var requestId = routeValues["requestId"]?.ToString(); + _pendingInvocations.GetRequired(routeValues["requestId"]?.ToString(), out var pending); - if ( - string.IsNullOrEmpty(requestId) - || !_pendingInvocations.TryRemove(requestId, out var pending) - ) - { - transaction.Respond( - new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent( - string.IsNullOrEmpty(requestId) - ? "Missing requestId" - : $"No pending invocation for request ID: {requestId}" - ), - } - ); - return; - } + // Acknowledge to Bootstrap + transaction.Respond(CreateSuccessResponse()); - // Complete the invocation with the response from Bootstrap pending.ResponseTcs.SetResult( await CreateCompletionAsync(RequestType.PostResponse, transaction.Request) ); - - // Acknowledge to Bootstrap - transaction.Respond( - new HttpResponseMessage(HttpStatusCode.Accepted) - { - Content = new StringContent( - JsonSerializer.Serialize( - new Dictionary { ["status"] = "success" }, - _jsonSerializerOptions - ), - Encoding.UTF8, - "application/json" - ), - Version = Version.Parse("1.1"), - } - ); - - return; } - /// - /// Handles POST /invocation/{requestId}/error - function execution failed. - /// private async Task HandlePostErrorAsync( LambdaHttpTransaction transaction, RouteValueDictionary routeValues ) { - var requestId = routeValues["requestId"]?.ToString(); + _pendingInvocations.GetRequired(routeValues["requestId"]?.ToString(), out var pending); - if ( - string.IsNullOrEmpty(requestId) - || !_pendingInvocations.TryRemove(requestId, out var pending) - ) - { - transaction.Respond( - new HttpResponseMessage(HttpStatusCode.NotFound) - { - Content = new StringContent( - string.IsNullOrEmpty(requestId) - ? "Missing requestId" - : $"No pending invocation for request ID: {requestId}" - ), - } - ); - return; - } + // Acknowledge to Bootstrap + transaction.Respond(CreateSuccessResponse()); - // Complete the invocation with the error response from Bootstrap pending.ResponseTcs.SetResult( await CreateCompletionAsync(RequestType.PostError, transaction.Request) ); + } - // Acknowledge to Bootstrap - transaction.Respond( - new HttpResponseMessage(HttpStatusCode.Accepted) - { - Content = new StringContent( - "{\"status\":\"success\"}", - Encoding.UTF8, - "application/json" - ), - Version = Version.Parse("1.1"), - } - ); + 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( + "TestServer is already started and as such an initialization error cannot be reported." + ); } - private bool TryDequeuePendingInvocation(out PendingInvocation pendingInvocation) + private HttpResponseMessage CreateEventResponse( + TEvent invokeEvent, + string requestId, + string traceId + ) { - var now = DateTimeOffset.UtcNow; - - while (_pendingInvocationIds.TryDequeue(out var requestId)) + var response = new HttpResponseMessage(HttpStatusCode.OK) { - if (_pendingInvocations.TryGetValue(requestId, out pendingInvocation)) - { - if (pendingInvocation.DeadlineUtc <= now) - { - if (_pendingInvocations.TryRemove(requestId, out var expiredInvocation)) - expiredInvocation.ResponseTcs.TrySetCanceled(); + Content = new StringContent( + JsonSerializer.Serialize(invokeEvent, _jsonSerializerOptions), + Encoding.UTF8, + "application/json" + ), + Version = Version.Parse("1.1"), + }; - continue; - } + // Add standard HTTP headers + response.Headers.Date = new DateTimeOffset(DateTime.UtcNow, TimeSpan.Zero); + response.Headers.TransferEncodingChunked = true; - return 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); - pendingInvocation = null!; - return false; - } + // Add any additional custom headers + foreach (var header in _serverOptions.AdditionalHeaders) + response.Headers.Add(header.Key, header.Value); - private void CancelPendingInvocation(string requestId, CancellationToken cancellationToken) - { - if (_pendingInvocations.TryRemove(requestId, out var pendingInvocation)) - pendingInvocation.ResponseTcs.TrySetCanceled(cancellationToken); + return response; } + private string GetRequestId() => + Interlocked.Increment(ref _requestCounter).ToString().PadLeft(12, '0'); + private static async Task CreateCompletionAsync( RequestType requestType, HttpRequestMessage sourceRequest @@ -416,4 +445,33 @@ HttpRequestMessage sourceRequest 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/AwsLambda.Host.Testing/LambdaTestServerExtensions.cs b/src/AwsLambda.Host.Testing/LambdaTestServerExtensions.cs new file mode 100644 index 00000000..43220ecc --- /dev/null +++ b/src/AwsLambda.Host.Testing/LambdaTestServerExtensions.cs @@ -0,0 +1,16 @@ +namespace AwsLambda.Host.Testing; + +public static class LambdaTestServerExtensions +{ + extension(LambdaTestServer server) + { + public Task> InvokeAsync( + TEvent invokeEvent, + CancellationToken cancellationToken = default + ) => + server.InvokeAsync( + invokeEvent, + cancellationToken: cancellationToken + ); + } +} diff --git a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs index 540fd345..02fb088c 100644 --- a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs +++ b/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs @@ -13,6 +13,9 @@ public class ErrorResponse [JsonPropertyName("cause")] public ErrorCause? Cause { get; set; } + [JsonPropertyName("causes")] + public List? Causes { get; set; } + /// /// The error message describing what went wrong. /// diff --git a/src/AwsLambda.Host.Testing/Models/InitResponse.cs b/src/AwsLambda.Host.Testing/Models/InitResponse.cs new file mode 100644 index 00000000..833a22b2 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/InitResponse.cs @@ -0,0 +1,17 @@ +namespace AwsLambda.Host.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/AwsLambda.Host.Testing/Models/InitStatus.cs b/src/AwsLambda.Host.Testing/Models/InitStatus.cs new file mode 100644 index 00000000..eee1c0ff --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/InitStatus.cs @@ -0,0 +1,22 @@ +namespace AwsLambda.Host.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, +} diff --git a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs b/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs deleted file mode 100644 index b4ab21da..00000000 --- a/src/AwsLambda.Host.Testing/Models/LambdaBootstrapRequest.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Routing; - -namespace AwsLambda.Host.Testing; - -internal class LambdaBootstrapRequest -{ - internal string? RequestId - { - get - { - field ??= - RouteValue.TryGetValue("RequestId", out var requestId) - && requestId is string requestIdString - ? requestIdString - : null; - - return field; - } - } - - internal required HttpRequestMessage RequestMessage { get; init; } - internal required RequestType RequestType { get; init; } - internal required RouteValueDictionary RouteValue { get; init; } -} diff --git a/src/AwsLambda.Host.Testing/Models/RequestType.cs b/src/AwsLambda.Host.Testing/Models/RequestType.cs index 935bd790..49108da9 100644 --- a/src/AwsLambda.Host.Testing/Models/RequestType.cs +++ b/src/AwsLambda.Host.Testing/Models/RequestType.cs @@ -5,4 +5,5 @@ internal enum RequestType GetNextInvocation, PostResponse, PostError, + PostInitError, } diff --git a/src/AwsLambda.Host.Testing/Models/ServerState.cs b/src/AwsLambda.Host.Testing/Models/ServerState.cs new file mode 100644 index 00000000..892b610d --- /dev/null +++ b/src/AwsLambda.Host.Testing/Models/ServerState.cs @@ -0,0 +1,37 @@ +namespace AwsLambda.Host.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/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs deleted file mode 100644 index 025719b1..00000000 --- a/src/AwsLambda.Host.Testing/Options/LambdaClientOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace AwsLambda.Host.Testing; - -/// -/// Configuration options for the Lambda test client. -/// -public class LambdaClientOptions -{ - /// - /// Gets or sets the headers to include in Lambda invocation responses. - /// - public LambdaInvocationHeaderOptions InvocationHeaderOptions { get; set; } = new(); -} diff --git a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs deleted file mode 100644 index 45379cc0..00000000 --- a/src/AwsLambda.Host.Testing/Options/LambdaInvocationHeaderOptions.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace AwsLambda.Host.Testing; - -/// -/// Headers returned in Lambda runtime API invocation responses. -/// -public class LambdaInvocationHeaderOptions -{ - /// - /// 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 response date header. - /// Maps to the Date HTTP header. Defaults to current UTC time. - /// - public DateTime Date { get; set; } = DateTime.UtcNow; - - /// - /// 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); - - /// - /// Gets or sets the maximum amount of time to wait for a response from the Lambda bootstrap. - /// Defaults to the function timeout plus a small buffer. - /// - public TimeSpan ClientWaitTimeout { get; set; } = - TimeSpan.FromMinutes(15).Add(TimeSpan.FromSeconds(5)); - - /// - /// Legacy property for client wait timeout. Prefer . - /// - [Obsolete("Use ClientWaitTimeout instead.")] - public TimeSpan InvocationTimeout - { - get => ClientWaitTimeout; - set => ClientWaitTimeout = value; - } - - /// - /// Gets or sets the AWS X-Ray trace ID for distributed tracing. - /// Maps to the Lambda-Runtime-Trace-Id header. - /// - public string TraceId { get; set; } = Guid.NewGuid().ToString(); - - /// - /// Gets or sets whether to use chunked transfer encoding. - /// Maps to the Transfer-Encoding: chunked HTTP header. Defaults to true. - /// - public bool TransferEncodingChunked { get; set; } = true; -} diff --git a/src/AwsLambda.Host.Testing/Options/LambdaServerOptions.cs b/src/AwsLambda.Host.Testing/Options/LambdaServerOptions.cs new file mode 100644 index 00000000..2b92f9a2 --- /dev/null +++ b/src/AwsLambda.Host.Testing/Options/LambdaServerOptions.cs @@ -0,0 +1,28 @@ +namespace AwsLambda.Host.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/AwsLambda.Host.Testing/README.md b/src/AwsLambda.Host.Testing/README.md index afb866ba..547ccc1c 100644 --- a/src/AwsLambda.Host.Testing/README.md +++ b/src/AwsLambda.Host.Testing/README.md @@ -178,6 +178,37 @@ Headers: ``` +```json +{ + "errorType": "AggregateException", + "errorMessage": "Encountered errors while running OnInit handlers: (Init failed)", + "stackTrace": [ + "at AwsLambda.Host.Builder.LambdaOnInitBuilder.b__10_1(CancellationToken stoppingToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/LambdaOnInitBuilder.cs:line 65", + "at Amazon.Lambda.RuntimeSupport.LambdaBootstrap.InitializeAsync()" + ], + "cause": { + "errorType": "Exception", + "errorMessage": "Init failed", + "stackTrace": [ + "at Program.<>c.<
$>b__0_1() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs:line 18", + "at AwsLambda.Host.Core.Generated.F0316F908774B11B609C941996D283BB313AD08F671E39F0BE1901FEE2BBA6D4C__GeneratedLambdaOnInitBuilderExtensions.<>c__DisplayClass0_0.g__OnInit|0(IServiceProvider serviceProvider, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/obj/Debug/net10.0/AwsLambda.Host.SourceGenerators/AwsLambda.Host.SourceGenerators.MapHandlerIncrementalGenerator/LambdaHandler.g.cs:line 109", + "at AwsLambda.Host.Builder.LambdaOnInitBuilder.RunInitHandler(LambdaInitDelegate handler, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/LambdaOnInitBuilder.cs:line 81" + ] + }, + "causes": [ + { + "errorType": "Exception", + "errorMessage": "Init failed", + "stackTrace": [ + "at Program.<>c.<
$>b__0_1() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs:line 18", + "at AwsLambda.Host.Core.Generated.F0316F908774B11B609C941996D283BB313AD08F671E39F0BE1901FEE2BBA6D4C__GeneratedLambdaOnInitBuilderExtensions.<>c__DisplayClass0_0.g__OnInit|0(IServiceProvider serviceProvider, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/obj/Debug/net10.0/AwsLambda.Host.SourceGenerators/AwsLambda.Host.SourceGenerators.MapHandlerIncrementalGenerator/LambdaHandler.g.cs:line 109", + "at AwsLambda.Host.Builder.LambdaOnInitBuilder.RunInitHandler(LambdaInitDelegate handler, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/LambdaOnInitBuilder.cs:line 81" + ] + } + ] +} +``` + # In-Memory Lambda Testing Client Implementation Summary ## Overview diff --git a/src/AwsLambda.Host.Testing/TaskHelpers.cs b/src/AwsLambda.Host.Testing/TaskHelpers.cs new file mode 100644 index 00000000..8d23fd5c --- /dev/null +++ b/src/AwsLambda.Host.Testing/TaskHelpers.cs @@ -0,0 +1,36 @@ +namespace AwsLambda.Host.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 { IsFaulted: true, Exception: not null }) + .Select(e => + e.Exception!.InnerExceptions.Count > 1 + ? e.Exception + : e.Exception.InnerExceptions[0] + ) + .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.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs b/src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs new file mode 100644 index 00000000..e69de29b 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. /// From 638e4b40c19d7867196936032b32453d0b05f4c1 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 11 Dec 2025 20:39:16 -0500 Subject: [PATCH 059/132] refactor(testing): rename namespaces and project for consistency - Renamed `AwsLambda.Host.Testing` namespace to `MinimalLambda.Testing` across all files. - Updated project file to reflect the new project name: `MinimalLambda.Testing`. - Adjusted solution references and project paths to align with the renamed project and namespace. - Added `DefaultLambdaJsonSerializerOptions` in `MinimalLambda.Abstractions` and updated related dependencies. - Replaced all usages of `AwsLambda.Host.Testing` namespace in related files and tests. - Updated `README.md` to reflect the new project name and namespace. --- MinimalLambda.sln | 2 +- .../Tests/LambdaHostTest.cs | 3 +- .../Tests/Tests.csproj | 2 +- .../DefaultLambdaJsonSerializerOptions.cs | 35 +++++++++++++++++++ .../DeferredHostBuilder.cs | 2 +- .../DictionaryExtensions.cs | 2 +- .../HostFactoryResolver.cs | 0 .../LambdaApplicationFactory.cs | 6 +--- .../LambdaApplicationFactoryClientOptions.cs | 2 +- ...aApplicationFactoryContentRootAttribute.cs | 2 +- .../LambdaHttpHandler.cs | 2 +- .../LambdaRuntimeRouteManager.cs | 2 +- .../LambdaTestServer.cs | 3 +- .../LambdaTestServerExtensions.cs | 2 +- .../MinimalLambda.Testing.csproj} | 2 +- .../Models/ErrorResponse.cs | 2 +- .../Models/InitResponse.cs | 2 +- .../Models/InitStatus.cs | 2 +- .../Models/InvocationCompletion.cs | 2 +- .../Models/InvocationResponse.cs | 2 +- .../Models/LambdaHttpTransaction.cs | 2 +- .../Models/PendingInvocation.cs | 2 +- .../Models/RequestType.cs | 2 +- .../Models/ServerState.cs | 2 +- .../Options/LambdaServerOptions.cs | 2 +- .../README.md | 0 .../TaskHelpers.cs | 2 +- 27 files changed, 59 insertions(+), 30 deletions(-) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/DeferredHostBuilder.cs (99%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/DictionaryExtensions.cs (94%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/HostFactoryResolver.cs (100%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/LambdaApplicationFactory.cs (98%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/LambdaApplicationFactoryClientOptions.cs (62%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/LambdaApplicationFactoryContentRootAttribute.cs (99%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/LambdaHttpHandler.cs (98%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/LambdaRuntimeRouteManager.cs (98%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/LambdaTestServer.cs (99%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/LambdaTestServerExtensions.cs (92%) rename src/{AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj => MinimalLambda.Testing/MinimalLambda.Testing.csproj} (95%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/Models/ErrorResponse.cs (98%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/Models/InitResponse.cs (93%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/Models/InitStatus.cs (93%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/Models/InvocationCompletion.cs (89%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/Models/InvocationResponse.cs (85%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/Models/LambdaHttpTransaction.cs (97%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/Models/PendingInvocation.cs (98%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/Models/RequestType.cs (75%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/Models/ServerState.cs (95%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/Options/LambdaServerOptions.cs (97%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/README.md (100%) rename src/{AwsLambda.Host.Testing => MinimalLambda.Testing}/TaskHelpers.cs (96%) diff --git a/MinimalLambda.sln b/MinimalLambda.sln index 15dd3aee..818ec3cd 100644 --- a/MinimalLambda.sln +++ b/MinimalLambda.sln @@ -73,7 +73,7 @@ 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}") = "AwsLambda.Host.Testing", "src\AwsLambda.Host.Testing\AwsLambda.Host.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing", "src\MinimalLambda.Testing\MinimalLambda.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AwsLambda.Host.Example.Testing", "AwsLambda.Host.Example.Testing", "{26B446C5-76B0-4190-9022-1BF514737E2E}" ProjectSection(SolutionItems) = preProject diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index 81a1e338..80b2bda0 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -1,8 +1,7 @@ using AwesomeAssertions; -using AwsLambda.Host.Options; -using AwsLambda.Host.Testing; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; +using MinimalLambda.Testing; using Xunit; namespace Lambda.Host.Example.HelloWorld; diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj index b686ce26..48c7a84b 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj +++ b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj @@ -26,7 +26,7 @@ - + diff --git a/src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs b/src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs index e69de29b..b30c5b8f 100644 --- a/src/MinimalLambda.Abstractions/Options/DefaultLambdaJsonSerializerOptions.cs +++ 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/AwsLambda.Host.Testing/DeferredHostBuilder.cs b/src/MinimalLambda.Testing/DeferredHostBuilder.cs similarity index 99% rename from src/AwsLambda.Host.Testing/DeferredHostBuilder.cs rename to src/MinimalLambda.Testing/DeferredHostBuilder.cs index 6e3d1fe0..ead9f95a 100644 --- a/src/AwsLambda.Host.Testing/DeferredHostBuilder.cs +++ b/src/MinimalLambda.Testing/DeferredHostBuilder.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; // This host builder captures calls to the IHostBuilder then replays them in the call to // ConfigureHostBuilder diff --git a/src/AwsLambda.Host.Testing/DictionaryExtensions.cs b/src/MinimalLambda.Testing/DictionaryExtensions.cs similarity index 94% rename from src/AwsLambda.Host.Testing/DictionaryExtensions.cs rename to src/MinimalLambda.Testing/DictionaryExtensions.cs index 7a8e5afc..ea887fc8 100644 --- a/src/AwsLambda.Host.Testing/DictionaryExtensions.cs +++ b/src/MinimalLambda.Testing/DictionaryExtensions.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; internal static class DictionaryExtensions { diff --git a/src/AwsLambda.Host.Testing/HostFactoryResolver.cs b/src/MinimalLambda.Testing/HostFactoryResolver.cs similarity index 100% rename from src/AwsLambda.Host.Testing/HostFactoryResolver.cs rename to src/MinimalLambda.Testing/HostFactoryResolver.cs diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs similarity index 98% rename from src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs rename to src/MinimalLambda.Testing/LambdaApplicationFactory.cs index a04f3925..1e7dac0f 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -8,14 +8,10 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; -using AwsLambda.Host.Builder.Extensions; -using AwsLambda.Host.Options; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyModel; using Microsoft.Extensions.Hosting; -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// Factory for bootstrapping an application in memory for functional end to end tests. diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryClientOptions.cs b/src/MinimalLambda.Testing/LambdaApplicationFactoryClientOptions.cs similarity index 62% rename from src/AwsLambda.Host.Testing/LambdaApplicationFactoryClientOptions.cs rename to src/MinimalLambda.Testing/LambdaApplicationFactoryClientOptions.cs index f7b4fe69..26975b07 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryClientOptions.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactoryClientOptions.cs @@ -1,3 +1,3 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; public class LambdaApplicationFactoryClientOptions { } diff --git a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs b/src/MinimalLambda.Testing/LambdaApplicationFactoryContentRootAttribute.cs similarity index 99% rename from src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs rename to src/MinimalLambda.Testing/LambdaApplicationFactoryContentRootAttribute.cs index f577c417..b79a883f 100644 --- a/src/AwsLambda.Host.Testing/LambdaApplicationFactoryContentRootAttribute.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactoryContentRootAttribute.cs @@ -9,7 +9,7 @@ using System.Globalization; using System.Reflection; -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// Metadata that uses to find out the content diff --git a/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs b/src/MinimalLambda.Testing/LambdaHttpHandler.cs similarity index 98% rename from src/AwsLambda.Host.Testing/LambdaHttpHandler.cs rename to src/MinimalLambda.Testing/LambdaHttpHandler.cs index ffc9f4df..1b831c20 100644 --- a/src/AwsLambda.Host.Testing/LambdaHttpHandler.cs +++ b/src/MinimalLambda.Testing/LambdaHttpHandler.cs @@ -1,6 +1,6 @@ using System.Threading.Channels; -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// HTTP message handler that intercepts Lambda Bootstrap HTTP calls and diff --git a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs b/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs similarity index 98% rename from src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs rename to src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs index 0f2fd484..b2b6a165 100644 --- a/src/AwsLambda.Host.Testing/LambdaRuntimeRouteManager.cs +++ b/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Template; -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; internal class LambdaRuntimeRouteManager { diff --git a/src/AwsLambda.Host.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs similarity index 99% rename from src/AwsLambda.Host.Testing/LambdaTestServer.cs rename to src/MinimalLambda.Testing/LambdaTestServer.cs index 357598e8..99c4e9dc 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -4,12 +4,11 @@ using System.Text; using System.Text.Json; using System.Threading.Channels; -using AwsLambda.Host.Options; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; public class LambdaTestServer : IAsyncDisposable { diff --git a/src/AwsLambda.Host.Testing/LambdaTestServerExtensions.cs b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs similarity index 92% rename from src/AwsLambda.Host.Testing/LambdaTestServerExtensions.cs rename to src/MinimalLambda.Testing/LambdaTestServerExtensions.cs index 43220ecc..3055a3b0 100644 --- a/src/AwsLambda.Host.Testing/LambdaTestServerExtensions.cs +++ b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; public static class LambdaTestServerExtensions { diff --git a/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj b/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj similarity index 95% rename from src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj rename to src/MinimalLambda.Testing/MinimalLambda.Testing.csproj index 4e8d6f62..efe6e0d6 100644 --- a/src/AwsLambda.Host.Testing/AwsLambda.Host.Testing.csproj +++ b/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj @@ -8,7 +8,7 @@ true true - AwsLambda.Host.Testing + MinimalLambda.Testing Support for writting tests for AwsLambda.Host README.md diff --git a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs b/src/MinimalLambda.Testing/Models/ErrorResponse.cs similarity index 98% rename from src/AwsLambda.Host.Testing/Models/ErrorResponse.cs rename to src/MinimalLambda.Testing/Models/ErrorResponse.cs index 02fb088c..0acae6ce 100644 --- a/src/AwsLambda.Host.Testing/Models/ErrorResponse.cs +++ b/src/MinimalLambda.Testing/Models/ErrorResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// Represents an error response with type, message, stack trace, and optional cause. diff --git a/src/AwsLambda.Host.Testing/Models/InitResponse.cs b/src/MinimalLambda.Testing/Models/InitResponse.cs similarity index 93% rename from src/AwsLambda.Host.Testing/Models/InitResponse.cs rename to src/MinimalLambda.Testing/Models/InitResponse.cs index 833a22b2..823f9947 100644 --- a/src/AwsLambda.Host.Testing/Models/InitResponse.cs +++ b/src/MinimalLambda.Testing/Models/InitResponse.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// Represents the result of a Lambda function initialization attempt. diff --git a/src/AwsLambda.Host.Testing/Models/InitStatus.cs b/src/MinimalLambda.Testing/Models/InitStatus.cs similarity index 93% rename from src/AwsLambda.Host.Testing/Models/InitStatus.cs rename to src/MinimalLambda.Testing/Models/InitStatus.cs index eee1c0ff..4e869c38 100644 --- a/src/AwsLambda.Host.Testing/Models/InitStatus.cs +++ b/src/MinimalLambda.Testing/Models/InitStatus.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// An enumeration of possible statuses for Lambda initialization. diff --git a/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs b/src/MinimalLambda.Testing/Models/InvocationCompletion.cs similarity index 89% rename from src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs rename to src/MinimalLambda.Testing/Models/InvocationCompletion.cs index 9bcc72b9..0737b98a 100644 --- a/src/AwsLambda.Host.Testing/Models/InvocationCompletion.cs +++ b/src/MinimalLambda.Testing/Models/InvocationCompletion.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// Represents the completion of a Lambda invocation with metadata about the outcome. diff --git a/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs b/src/MinimalLambda.Testing/Models/InvocationResponse.cs similarity index 85% rename from src/AwsLambda.Host.Testing/Models/InvocationResponse.cs rename to src/MinimalLambda.Testing/Models/InvocationResponse.cs index 68d1ab5e..608eae72 100644 --- a/src/AwsLambda.Host.Testing/Models/InvocationResponse.cs +++ b/src/MinimalLambda.Testing/Models/InvocationResponse.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; public class InvocationResponse { diff --git a/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs b/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs similarity index 97% rename from src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs rename to src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs index 0d061a16..1e64f69c 100644 --- a/src/AwsLambda.Host.Testing/Models/LambdaHttpTransaction.cs +++ b/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// Represents a single HTTP transaction from the Lambda Bootstrap. diff --git a/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs b/src/MinimalLambda.Testing/Models/PendingInvocation.cs similarity index 98% rename from src/AwsLambda.Host.Testing/Models/PendingInvocation.cs rename to src/MinimalLambda.Testing/Models/PendingInvocation.cs index f9c203c2..9409f6f6 100644 --- a/src/AwsLambda.Host.Testing/Models/PendingInvocation.cs +++ b/src/MinimalLambda.Testing/Models/PendingInvocation.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// Represents a pending Lambda invocation waiting for Bootstrap to process and respond. diff --git a/src/AwsLambda.Host.Testing/Models/RequestType.cs b/src/MinimalLambda.Testing/Models/RequestType.cs similarity index 75% rename from src/AwsLambda.Host.Testing/Models/RequestType.cs rename to src/MinimalLambda.Testing/Models/RequestType.cs index 49108da9..406ba336 100644 --- a/src/AwsLambda.Host.Testing/Models/RequestType.cs +++ b/src/MinimalLambda.Testing/Models/RequestType.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; internal enum RequestType { diff --git a/src/AwsLambda.Host.Testing/Models/ServerState.cs b/src/MinimalLambda.Testing/Models/ServerState.cs similarity index 95% rename from src/AwsLambda.Host.Testing/Models/ServerState.cs rename to src/MinimalLambda.Testing/Models/ServerState.cs index 892b610d..164c3803 100644 --- a/src/AwsLambda.Host.Testing/Models/ServerState.cs +++ b/src/MinimalLambda.Testing/Models/ServerState.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// Represents the lifecycle state of a LambdaTestServer. diff --git a/src/AwsLambda.Host.Testing/Options/LambdaServerOptions.cs b/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs similarity index 97% rename from src/AwsLambda.Host.Testing/Options/LambdaServerOptions.cs rename to src/MinimalLambda.Testing/Options/LambdaServerOptions.cs index 2b92f9a2..7c977de0 100644 --- a/src/AwsLambda.Host.Testing/Options/LambdaServerOptions.cs +++ b/src/MinimalLambda.Testing/Options/LambdaServerOptions.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; /// /// Configuration options for the Lambda test client. diff --git a/src/AwsLambda.Host.Testing/README.md b/src/MinimalLambda.Testing/README.md similarity index 100% rename from src/AwsLambda.Host.Testing/README.md rename to src/MinimalLambda.Testing/README.md diff --git a/src/AwsLambda.Host.Testing/TaskHelpers.cs b/src/MinimalLambda.Testing/TaskHelpers.cs similarity index 96% rename from src/AwsLambda.Host.Testing/TaskHelpers.cs rename to src/MinimalLambda.Testing/TaskHelpers.cs index 8d23fd5c..2224c67c 100644 --- a/src/AwsLambda.Host.Testing/TaskHelpers.cs +++ b/src/MinimalLambda.Testing/TaskHelpers.cs @@ -1,4 +1,4 @@ -namespace AwsLambda.Host.Testing; +namespace MinimalLambda.Testing; internal static class TaskHelpers { From 876ded4a04b7b52efd3870121bb2ace2fc55161a Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 11 Dec 2025 20:40:55 -0500 Subject: [PATCH 060/132] feat(testing): update dependencies and project references for MinimalLambda - Added references to `MinimalLambda.Options` and `MinimalLambda.Builder.Extensions`. - Updated project description in `.csproj` to align with `MinimalLambda`. - Replaced `AwsLambda.Host` references with `MinimalLambda` in project and solution files. --- src/MinimalLambda.Testing/LambdaApplicationFactory.cs | 4 ++++ src/MinimalLambda.Testing/LambdaTestServer.cs | 1 + src/MinimalLambda.Testing/MinimalLambda.Testing.csproj | 6 +++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index 1e7dac0f..5c83a067 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -8,8 +8,12 @@ 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; diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 99c4e9dc..082a0a51 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using MinimalLambda.Options; namespace MinimalLambda.Testing; diff --git a/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj b/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj index efe6e0d6..0e6c2457 100644 --- a/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj +++ b/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj @@ -9,15 +9,15 @@ true MinimalLambda.Testing - Support for writting tests for AwsLambda.Host + Support for writting tests for MinimalLambda README.md - - + + From 94bd399aa604d856c7357387ff0de0559da179ab Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 11 Dec 2025 20:41:42 -0500 Subject: [PATCH 061/132] feat(testing): add MinimalLambda project references and update usages - Added project references to `MinimalLambda.Abstractions` and `MinimalLambda` in the `.csproj`. - Replaced `AwsLambda.Host` with `MinimalLambda` namespaces in `Program.cs`. - Added `MinimalLambda.Options` to dependencies in `LambdaHostTest.cs` for new functionality. --- .../AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj | 2 ++ examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs | 6 +++--- .../AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj index cfcc1781..91be958c 100644 --- a/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj @@ -22,6 +22,8 @@ ReferenceOutputAssembly="false" OutputItemType="Analyzer" /> + + diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs index e641ddc1..239421f1 100644 --- a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs @@ -1,7 +1,7 @@ -using AwsLambda.Host.Builder; -using AwsLambda.Host.Core; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using MinimalLambda; +using MinimalLambda.Builder; // Create the application builder var builder = LambdaApplication.CreateBuilder(); diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index 80b2bda0..b4857210 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -1,6 +1,7 @@ using AwesomeAssertions; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; +using MinimalLambda.Options; using MinimalLambda.Testing; using Xunit; From d1986f8602a85854450277f24ffebc1f74bace5b Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 11 Dec 2025 20:42:45 -0500 Subject: [PATCH 062/132] chore(deps): update Microsoft.Extensions.Hosting package versions - Bumped `Microsoft.Extensions.Hosting` to `10.0.1`. - Bumped `Microsoft.Extensions.Hosting.Abstractions` to `10.0.1`. - Simplified `Directory.Packages.props` by consolidating package version entries. --- Directory.Packages.props | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 54f3ce01..e524a73f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -71,11 +71,8 @@ - - + + From 568a4741f7a55ebd80a003aab20792eb7ea39c12 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 11 Dec 2025 20:45:53 -0500 Subject: [PATCH 063/132] refactor(testing): update project references and namespaces for MinimalLambda - Replaced `AwsLambda.Host` project references with `MinimalLambda` in `.csproj` files. - Updated namespaces from `Lambda.Host.Example.HelloWorld` to `MinimalLambda.Example.HelloWorld`. - Removed unused dependencies `System.Net.Http` and `System.Text.RegularExpressions` from `Tests.csproj`. --- .../AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj | 7 +++---- .../AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs | 2 +- examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj | 2 -- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj index 91be958c..b62b04b1 100644 --- a/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj @@ -11,19 +11,18 @@ true true - $(InterceptorsNamespaces);AwsLambda.Host + $(InterceptorsNamespaces);MinimalLambda false - + - diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index b4857210..41b27c3b 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -5,7 +5,7 @@ using MinimalLambda.Testing; using Xunit; -namespace Lambda.Host.Example.HelloWorld; +namespace MinimalLambda.Example.HelloWorld; [TestSubject(typeof(Program))] public class LambdaHostTest diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj index 48c7a84b..aed7dadc 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj +++ b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj @@ -17,8 +17,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - all runtime; build; native; contentfiles; analyzers; buildtransitive From 8e04f74698ed79b77f149cdeb2edf270426cb84c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 11 Dec 2025 20:55:29 -0500 Subject: [PATCH 064/132] refactor(testing): replace ClientOptions with ServerOptions across factory and server - Replaced `LambdaApplicationFactoryClientOptions` with `LambdaServerOptions` for consistency. - Updated `LambdaTestServer` constructor to accept `LambdaServerOptions`. - Propagated `ServerOptions` throughout related classes and methods. - Removed commented-out and unused code for better clarity and maintenance. --- .../LambdaApplicationFactory.cs | 18 +++++++----------- src/MinimalLambda.Testing/LambdaTestServer.cs | 3 ++- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index 5c83a067..5a5f97ee 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -58,9 +58,9 @@ public class LambdaApplicationFactory : IDisposable, IAsyncDisposab public LambdaApplicationFactory() => _configuration = ConfigureWebHost; /// - /// Gets the used by . + /// Gets the used by . /// - public LambdaApplicationFactoryClientOptions ClientOptions { get; private set; } = new(); + public LambdaServerOptions ServerOptions { get; private set; } = new(); /// /// Gets the of factories created from this factory @@ -139,7 +139,7 @@ Action configuration ) { var factory = new DelegatedLambdaApplicationFactory( - ClientOptions, + ServerOptions, CreateHost, CreateHostBuilder, GetTestAssemblies, @@ -213,7 +213,7 @@ private void ConfigureHostBuilder( SetContentRoot(hostBuilder); _configuration(hostBuilder); - _server = new LambdaTestServer(entryPointCompletion); + _server = new LambdaTestServer(entryPointCompletion, ServerOptions); // set Lambda Bootstrap Http Client hostBuilder.ConfigureServices(services => @@ -230,10 +230,6 @@ private void ConfigureHostBuilder( // Build the host but DON'T start it - server will start it _host = CreateHost(hostBuilder); _server.SetHost(_host); - - // Create the public server with the built (but not started) host - // _server = new LambdaTestServer(_host, processor, entryPointCompletion: - // entryPointCompletion); } private void SetContentRoot(IHostBuilder builder) @@ -460,14 +456,14 @@ private sealed class DelegatedLambdaApplicationFactory : LambdaApplicationFactor private readonly Func> _getTestAssemblies; public DelegatedLambdaApplicationFactory( - LambdaApplicationFactoryClientOptions options, + LambdaServerOptions options, Func createHost, Func createHostBuilder, Func> getTestAssemblies, Action configureWebHost ) { - ClientOptions = options; + ServerOptions = options; _createHost = createHost; _createHostBuilder = createHostBuilder; _getTestAssemblies = getTestAssemblies; @@ -486,7 +482,7 @@ internal override LambdaApplicationFactory WithHostBuilderCore( Action configuration ) => new DelegatedLambdaApplicationFactory( - ClientOptions, + ServerOptions, _createHost, _createHostBuilder, _getTestAssemblies, diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 082a0a51..103880e0 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -92,6 +92,7 @@ public class LambdaTestServer : IAsyncDisposable internal LambdaTestServer( Task? entryPointCompletion, + LambdaServerOptions serverOptions, CancellationToken shutdownToken = default ) { @@ -102,7 +103,7 @@ internal LambdaTestServer( _state = ServerState.Created; _jsonSerializerOptions = DefaultLambdaJsonSerializerOptions.Create(); - _serverOptions = new LambdaServerOptions(); + _serverOptions = serverOptions; } public IServiceProvider Services => _host!.Services; From caadd883abf341977ef071a05d853f9cb916601f Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Thu, 11 Dec 2025 21:10:10 -0500 Subject: [PATCH 065/132] refactor(testing): enhance resource cleanup and documentation in factory and server - Added support for async and sync disposal of `_host` in `LambdaTestServer`. - Improved inline XML documentation across `LambdaApplicationFactory` and related classes. - Removed unused and redundant `ServerOptions` getter in `LambdaApplicationFactory`. - Refactored `WithHostBuilderCore` to enhance flexibility and clarity in factory chaining. - Updated comments to better describe factory behaviors and Lambda testing configurations. --- .../LambdaApplicationFactory.cs | 64 ++++++++++++++----- src/MinimalLambda.Testing/LambdaTestServer.cs | 6 ++ 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index 5a5f97ee..5091b92b 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -35,10 +35,10 @@ public class LambdaApplicationFactory : IDisposable, IAsyncDisposab /// /// /// Creates an instance of . This factory can be used to - /// create a instance using the MVC application defined by - /// and one or more instances used to send to the . + /// 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 CreateWebHostBuilder(string [] args) + /// assembly and initialize the application by calling IHostBuilder CreateHostBuilder(string[] args) /// on . /// /// @@ -57,11 +57,6 @@ public class LambdaApplicationFactory : IDisposable, IAsyncDisposab /// public LambdaApplicationFactory() => _configuration = ConfigureWebHost; - /// - /// Gets the used by . - /// - public LambdaServerOptions ServerOptions { get; private set; } = new(); - /// /// Gets the of factories created from this factory /// by further customizing the when calling @@ -70,6 +65,18 @@ public class LambdaApplicationFactory : IDisposable, IAsyncDisposab 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 . /// @@ -82,11 +89,6 @@ public LambdaTestServer TestServer } } - /// - /// Gets the created by the server associated with this . - /// - public virtual IServiceProvider Services => TestServer.Services; - /// public virtual async ValueTask DisposeAsync() { @@ -134,7 +136,24 @@ public LambdaApplicationFactory WithHostBuilder( Action configuration ) => WithHostBuilderCore(configuration); - internal virtual LambdaApplicationFactory WithHostBuilderCore( + /// + /// 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 ) { @@ -416,8 +435,8 @@ private static void EnsureDepsFile() /// /// Creates the with the bootstrapped application in . - /// This is only called for applications using . Applications based on - /// will use instead. + /// 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. @@ -449,6 +468,17 @@ protected virtual void Dispose(bool disposing) _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; @@ -478,7 +508,7 @@ Action configureWebHost protected override void ConfigureWebHost(IHostBuilder builder) => _configuration(builder); - internal override LambdaApplicationFactory WithHostBuilderCore( + protected override LambdaApplicationFactory WithHostBuilderCore( Action configuration ) => new DelegatedLambdaApplicationFactory( diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 103880e0..2520c3c3 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -117,6 +117,12 @@ public async ValueTask DisposeAsync() await _shutdownCts.CancelAsync(); + if (_host is IAsyncDisposable asyncDisposableHost) + await asyncDisposableHost.DisposeAsync(); + + if (_host is IDisposable disposableHost) + disposableHost.Dispose(); + _state = ServerState.Disposed; } From 2d781fdc0b1faff76b41bfcd7b1051bde21a59e6 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 07:26:01 -0500 Subject: [PATCH 066/132] feat(testing): enhance XML documentation for Lambda test server and invocation response - Added comprehensive XML summaries, remarks, and examples to `LambdaTestServer` methods. - Documented the lifecycle, invocation handling, and state transitions in `LambdaTestServer`. - Improved `InvocationResponse` class documentation with clear descriptions and usage guidelines. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 139 ++++++++++++++++++ .../Models/InvocationResponse.cs | 47 ++++++ 2 files changed, 186 insertions(+) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 2520c3c3..f9b4d32a 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -11,6 +11,22 @@ 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 { /// @@ -106,8 +122,38 @@ internal LambdaTestServer( _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; + /// + /// 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 + /// + /// public async ValueTask DisposeAsync() { if (_state == ServerState.Running) @@ -139,6 +185,32 @@ internal HttpMessageHandler CreateHandler() => // │ 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) { if (_state != ServerState.Created) @@ -183,6 +255,46 @@ await TaskHelpers ); } + /// + /// 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. + /// + /// The AWS X-Ray trace ID to use for this invocation. If , a new GUID will be generated. + /// + /// + /// 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, string? traceId = null, @@ -243,6 +355,33 @@ public async Task> InvokeAsync( }; } + /// + /// 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 != ServerState.Running) diff --git a/src/MinimalLambda.Testing/Models/InvocationResponse.cs b/src/MinimalLambda.Testing/Models/InvocationResponse.cs index 608eae72..51c673d1 100644 --- a/src/MinimalLambda.Testing/Models/InvocationResponse.cs +++ b/src/MinimalLambda.Testing/Models/InvocationResponse.cs @@ -1,8 +1,55 @@ namespace MinimalLambda.Testing; +/// +/// Represents the result of a Lambda function invocation, containing either a successful response +/// or error information. +/// +/// The expected type of the successful Lambda response. +/// +/// +/// 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 { + /// + /// 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 set; } + + /// + /// 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 set; } + + /// + /// Gets a value indicating whether the Lambda function invocation completed successfully. + /// + /// + /// if the invocation succeeded and contains valid data; + /// if the invocation failed and contains error information. + /// + /// + /// Use this property to determine which of or contains + /// meaningful data for the invocation result. + /// public bool WasSuccess { get; internal set; } } From 80f1ea38b430ba34bd7d4a9094f10895fcffbcf7 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 07:31:51 -0500 Subject: [PATCH 067/132] refactor(testing): reorder fields and add null-forgiveness in LambdaTestServer - Reordered `_serverOptions` field initialization for logical grouping of fields. - Added null-forgiveness operator (`!`) for `StopApplication()` to ensure runtime safety. - Removed unnecessary null-forgiveness usages with non-null values in `switch` statements. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index f9b4d32a..bf68a5d9 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -29,11 +29,6 @@ namespace MinimalLambda.Testing; /// public class LambdaTestServer : IAsyncDisposable { - /// - /// Options used to configure how the server interacts with the Lambda. - /// - private readonly LambdaServerOptions _serverOptions; - /// /// Task that represents the running Host application that has been captured. /// @@ -68,6 +63,11 @@ public class LambdaTestServer : IAsyncDisposable /// private readonly LambdaRuntimeRouteManager _routeManager = 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. /// @@ -84,7 +84,7 @@ public class LambdaTestServer : IAsyncDisposable /// /// Host application lifetime used to signal shutdown to the captioned Host. /// - private IHostApplicationLifetime _applicationLifetime; + private IHostApplicationLifetime? _applicationLifetime; /// /// The captured Host instance. @@ -393,7 +393,7 @@ public async Task StopAsync(CancellationToken cancellationToken = default) await _shutdownCts.CancelAsync(); - _applicationLifetime.StopApplication(); + _applicationLifetime!.StopApplication(); await TaskHelpers .WhenAll(_entryPointCompletion, _processingTask!) @@ -426,18 +426,18 @@ out var routeValues $"Unexpected request received from the Lambda HTTP handler: {transaction.Request.Method} {transaction.Request.RequestUri}" ); - switch (requestType!.Value) + switch (requestType.Value) { case RequestType.GetNextInvocation: await HandleGetNextInvocationAsync(transaction); break; case RequestType.PostResponse: - await HandlePostResponseAsync(transaction, routeValues!); + await HandlePostResponseAsync(transaction, routeValues); break; case RequestType.PostError: - await HandlePostErrorAsync(transaction, routeValues!); + await HandlePostErrorAsync(transaction, routeValues); break; case RequestType.PostInitError: From 9fdaab246377d2f32856d0e04dba306be28ec232 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 07:37:08 -0500 Subject: [PATCH 068/132] refactor(testing): improve shutdown and resource disposal in LambdaTestServer - Added completion of `_pendingInvocationIds` channel during shutdown for consistency. - Canceled and disposed `_shutdownCts` to enhance resource cleanup. - Updated host disposal logic to prefer async disposal and fallback to sync. - Removed unused `LambdaApplicationFactoryClientOptions` class. --- .../LambdaApplicationFactoryClientOptions.cs | 3 --- src/MinimalLambda.Testing/LambdaTestServer.cs | 17 +++++++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) delete mode 100644 src/MinimalLambda.Testing/LambdaApplicationFactoryClientOptions.cs diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactoryClientOptions.cs b/src/MinimalLambda.Testing/LambdaApplicationFactoryClientOptions.cs deleted file mode 100644 index 26975b07..00000000 --- a/src/MinimalLambda.Testing/LambdaApplicationFactoryClientOptions.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace MinimalLambda.Testing; - -public class LambdaApplicationFactoryClientOptions { } diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index bf68a5d9..302d2310 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -159,15 +159,24 @@ public async ValueTask DisposeAsync() if (_state == ServerState.Running) await StopAsync(); + // Complete both channels to prevent new items _transactionChannel.Writer.TryComplete(); + _pendingInvocationIds.Writer.TryComplete(); + // Cancel the shutdown token await _shutdownCts.CancelAsync(); - if (_host is IAsyncDisposable asyncDisposableHost) - await asyncDisposableHost.DisposeAsync(); + // Dispose the CancellationTokenSource + _shutdownCts.Dispose(); - if (_host is IDisposable disposableHost) - disposableHost.Dispose(); + // Dispose the host (prefer async, fallback to sync) + if (_host is not null) + { + if (_host is IAsyncDisposable asyncDisposableHost) + await asyncDisposableHost.DisposeAsync(); + else if (_host is IDisposable disposableHost) + disposableHost.Dispose(); + } _state = ServerState.Disposed; } From 2a90667439a5056e41b4aa22969b65bfba39d789 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 07:37:14 -0500 Subject: [PATCH 069/132] refactor(testing): simplify README for MinimalLambda.Testing - Replaced verbose documentation with a concise project overview. - Updated project name and namespace to reflect `MinimalLambda.Testing`. - Removed outdated examples and implementation details. --- src/MinimalLambda.Testing/README.md | 338 +--------------------------- 1 file changed, 1 insertion(+), 337 deletions(-) diff --git a/src/MinimalLambda.Testing/README.md b/src/MinimalLambda.Testing/README.md index 547ccc1c..06b55137 100644 --- a/src/MinimalLambda.Testing/README.md +++ b/src/MinimalLambda.Testing/README.md @@ -1,337 +1 @@ -# AwsLambda.Host.Testing - -AWS Lambda Local Invocation - Happy Path -```text -========== HTTP REQUEST ========== -GET http://localhost:5050/2018-06-01/runtime/invocation/next -Version: 1.1 - -Headers: - Accept: application/json - -========== HTTP RESPONSE ========== -Status: 200 OK -Version: 1.1 - -Headers: - Date: Thu, 04 Dec 2025 20:40:53 GMT - Server: Kestrel - Transfer-Encoding: chunked - Lambda-Runtime-Deadline-Ms: 1764881754010 - Lambda-Runtime-Aws-Request-Id: 000000000002 - Lambda-Runtime-Trace-Id: 2a159b6d-ca3c-4991-8533-c2b2a8da0640 - Lambda-Runtime-Invoked-Function-Arn: arn:aws:lambda:us-west-2:123412341234:function:Function - -Content Headers: - Content-Type: application/json - -Body: -"James" -=================================== - -========== HTTP REQUEST ========== -POST http://localhost:5050/2018-06-01/runtime/invocation/000000000002/response -Version: 1.1 - -Headers: - Accept: application/json - -Content Headers: - Content-Type: application/json - -Body: -"Hello James!" - -========== HTTP RESPONSE ========== -Status: 202 Accepted -Version: 1.1 - -Headers: - Date: Thu, 04 Dec 2025 20:40:53 GMT - Server: Kestrel - Transfer-Encoding: chunked - -Content Headers: - Content-Type: application/json; charset=utf-8 - -Body: -{"status":"success"} -=================================== - -========== HTTP REQUEST ========== -GET http://localhost:5050/2018-06-01/runtime/invocation/next -Version: 1.1 - -Headers: - Accept: application/json - - -``` - -AWS Lambda Local Invocation - Error Path -```text -========== HTTP REQUEST ========== -GET http://localhost:5050/2018-06-01/runtime/invocation/next -Version: 1.1 - -Headers: - Accept: application/json - -========== HTTP RESPONSE ========== -Status: 200 OK -Version: 1.1 - -Headers: - Date: Thu, 04 Dec 2025 20:44:06 GMT - Server: Kestrel - Transfer-Encoding: chunked - Lambda-Runtime-Deadline-Ms: 1764881946613 - Lambda-Runtime-Aws-Request-Id: 000000000004 - Lambda-Runtime-Trace-Id: 849e7f8f-6a67-4132-b371-7740c9ad9084 - Lambda-Runtime-Invoked-Function-Arn: arn:aws:lambda:us-west-2:123412341234:function:Function - -Content Headers: - Content-Type: application/json - -Body: -2 -=================================== - -========== HTTP REQUEST ========== -POST http://localhost:5050/2018-06-01/runtime/invocation/000000000004/error -Version: 1.1 - -Headers: - Lambda-Runtime-Function-Error-Type: JsonSerializerException - Lambda-Runtime-Function-XRay-Error-Cause: { "working_directory": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Examples.Testing/bin/Debug/net10.0", "exceptions": [ { "type": "JsonSerializerException", "message": "Error converting the Lambda event JSON payload to type System.String: The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.", "stack": [ { "label": "AbstractLambdaJsonSerializer.Deserialize" }, { "path": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Core/Features/DefaultEventFeature.cs", "label": "DefaultEventFeature`1.GetEvent", "line": 28 }, { "path": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Core/Features/DefaultEventFeature.cs", "label": "DefaultEventFeature`1.AwsLambda.Host.Core.IEventFeature.GetEvent", "line": 35 }, { "path": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/Middleware/RequestEnvelopeMiddleware.cs", "label": "<b__2>d.MoveNext", "line": 45 }, { "label": "ExceptionDispatchInfo.Throw" }, { "label": "TaskAwaiter.ThrowForNonSuccess" }, { "label": "TaskAwaiter.HandleNonSuccessAndDebuggerNotification" }, { "path": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Runtime/LambdaHandlerComposer.cs", "label": "<g__CreateRequestHandler|0>d.MoveNext", "line": 78 }, { "label": "ExceptionDispatchInfo.Throw" }, { "path": "/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Runtime/LambdaHandlerComposer.cs", "label": "<g__CreateRequestHandler|0>d.MoveNext", "line": 84 }, { "label": "ExceptionDispatchInfo.Throw" }, { "label": "TaskAwaiter.ThrowForNonSuccess" }, { "label": "TaskAwaiter.HandleNonSuccessAndDebuggerNotification" }, { "label": "TaskAwaiter`1.GetResult" }, { "label": "<b__0>d.MoveNext" }, { "label": "ExceptionDispatchInfo.Throw" }, { "label": "TaskAwaiter.ThrowForNonSuccess" }, { "label": "TaskAwaiter.HandleNonSuccessAndDebuggerNotification" }, { "label": "TaskAwaiter`1.GetResult" }, { "label": "<b__0>d.MoveNext" } ] } ], "paths": ["/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Core/Features/DefaultEventFeature.cs","/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/Middleware/RequestEnvelopeMiddleware.cs","/Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Runtime/LambdaHandlerComposer.cs" ] } - Accept: application/json - -Content Headers: - Content-Type: application/vnd.aws.lambda.error+json - -Body: -{ - "errorType": "JsonSerializerException", - "errorMessage": "Error converting the Lambda event JSON payload to type System.String: The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.", - "stackTrace": [ - "at Amazon.Lambda.Serialization.SystemTextJson.AbstractLambdaJsonSerializer.Deserialize[T](Stream requestStream)", - "at AwsLambda.Host.Core.DefaultEventFeature`1.GetEvent(ILambdaHostContext context) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Core/Features/DefaultEventFeature.cs:line 28", - "at AwsLambda.Host.Core.DefaultEventFeature`1.AwsLambda.Host.Core.IEventFeature.GetEvent(ILambdaHostContext context) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Core/Features/DefaultEventFeature.cs:line 35", - "at AwsLambda.Host.Builder.RequestEnvelopeMiddleware.<>c__DisplayClass1_1.<b__2>d.MoveNext() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/Middleware/RequestEnvelopeMiddleware.cs:line 45", - "--- End of stack trace from previous location ---", - "at AwsLambda.Host.Runtime.LambdaHandlerComposer.<>c__DisplayClass6_0.<g__CreateRequestHandler|0>d.MoveNext() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Runtime/LambdaHandlerComposer.cs:line 78", - "--- End of stack trace from previous location ---", - "at AwsLambda.Host.Runtime.LambdaHandlerComposer.<>c__DisplayClass6_0.<g__CreateRequestHandler|0>d.MoveNext() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Runtime/LambdaHandlerComposer.cs:line 84", - "--- End of stack trace from previous location ---", - "at Amazon.Lambda.RuntimeSupport.HandlerWrapper.<>c__DisplayClass19_0.<b__0>d.MoveNext()", - "--- End of stack trace from previous location ---", - "at Amazon.Lambda.RuntimeSupport.LambdaBootstrap.<>c__DisplayClass26_0.<b__0>d.MoveNext()" - ], - "cause": { - "errorType": "JsonException", - "errorMessage": "The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.", - "stackTrace": [ - "at System.Text.Json.ThrowHelper.ReThrowWithPath(ReadStack& state, Utf8JsonReader& reader, Exception ex)", - "at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, T& value, JsonSerializerOptions options, ReadStack& state)", - "at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.Deserialize(Utf8JsonReader& reader, ReadStack& state)", - "at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1 utf8Json, JsonTypeInfo`1 jsonTypeInfo, Nullable`1 actualByteCount)", - "at Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer.InternalDeserialize[T](Byte[] utf8Json)", - "at Amazon.Lambda.Serialization.SystemTextJson.AbstractLambdaJsonSerializer.Deserialize[T](Stream requestStream)" - ], - "cause": { - "errorType": "InvalidOperationException", - "errorMessage": "Cannot get the value of a token type 'Number' as a string.", - "stackTrace": [ - "at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_ExpectedString(JsonTokenType tokenType)", - "at System.Text.Json.Utf8JsonReader.GetString()", - "at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options, ReadStack& state, T& value, Boolean& isPopulatedValue)", - "at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader& reader, T& value, JsonSerializerOptions options, ReadStack& state)" - ] - } - } -} - - -========== HTTP RESPONSE ========== -Status: 202 Accepted -Version: 1.1 - -Headers: - Date: Thu, 04 Dec 2025 20:44:06 GMT - Server: Kestrel - Transfer-Encoding: chunked - -Content Headers: - Content-Type: application/json; charset=utf-8 - -Body: -{"status":"success"} -=================================== - -========== HTTP REQUEST ========== -GET http://localhost:5050/2018-06-01/runtime/invocation/next -Version: 1.1 - -Headers: - Accept: application/json - - -``` - -```json -{ - "errorType": "AggregateException", - "errorMessage": "Encountered errors while running OnInit handlers: (Init failed)", - "stackTrace": [ - "at AwsLambda.Host.Builder.LambdaOnInitBuilder.b__10_1(CancellationToken stoppingToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/LambdaOnInitBuilder.cs:line 65", - "at Amazon.Lambda.RuntimeSupport.LambdaBootstrap.InitializeAsync()" - ], - "cause": { - "errorType": "Exception", - "errorMessage": "Init failed", - "stackTrace": [ - "at Program.<>c.<
$>b__0_1() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs:line 18", - "at AwsLambda.Host.Core.Generated.F0316F908774B11B609C941996D283BB313AD08F671E39F0BE1901FEE2BBA6D4C__GeneratedLambdaOnInitBuilderExtensions.<>c__DisplayClass0_0.g__OnInit|0(IServiceProvider serviceProvider, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/obj/Debug/net10.0/AwsLambda.Host.SourceGenerators/AwsLambda.Host.SourceGenerators.MapHandlerIncrementalGenerator/LambdaHandler.g.cs:line 109", - "at AwsLambda.Host.Builder.LambdaOnInitBuilder.RunInitHandler(LambdaInitDelegate handler, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/LambdaOnInitBuilder.cs:line 81" - ] - }, - "causes": [ - { - "errorType": "Exception", - "errorMessage": "Init failed", - "stackTrace": [ - "at Program.<>c.<
$>b__0_1() in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs:line 18", - "at AwsLambda.Host.Core.Generated.F0316F908774B11B609C941996D283BB313AD08F671E39F0BE1901FEE2BBA6D4C__GeneratedLambdaOnInitBuilderExtensions.<>c__DisplayClass0_0.g__OnInit|0(IServiceProvider serviceProvider, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/examples/AwsLambda.Host.Example.Testing/Lambda/obj/Debug/net10.0/AwsLambda.Host.SourceGenerators/AwsLambda.Host.SourceGenerators.MapHandlerIncrementalGenerator/LambdaHandler.g.cs:line 109", - "at AwsLambda.Host.Builder.LambdaOnInitBuilder.RunInitHandler(LambdaInitDelegate handler, CancellationToken cancellationToken) in /Users/jonasha/Repos/CSharp/dotnet-lambda-host/src/AwsLambda.Host/Builder/LambdaOnInitBuilder.cs:line 81" - ] - } - ] -} -``` - -# In-Memory Lambda Testing Client Implementation Summary - -## Overview - -This implementation provides an in-memory testing infrastructure for AWS Lambda functions using .NET. It intercepts HTTP requests from the Lambda Bootstrap and allows test code to simulate the Lambda Runtime API without any network calls. - -## Core Components - -### 1. LambdaHttpTransaction - -A simple class that bundles an HTTP request with its response mechanism: - -- **Request**: The `HttpRequestMessage` sent by Lambda Bootstrap -- **ResponseTcs**: A `TaskCompletionSource` that the test infrastructure completes when ready -- **Convenience methods**: `Respond()` and `Fail()` for easy response handling - -The key insight is that each transaction carries its own completion mechanism, providing automatic correlation between requests and responses without needing IDs or separate channels. - -### 2. LambdaTestingHttpHandler - -A custom `HttpMessageHandler` that intercepts all HTTP calls from Lambda Bootstrap: - -- Creates a `Channel` for outbound communication -- On `SendAsync()`: - 1. Wraps the request in a `LambdaHttpTransaction` - 2. Writes it to the channel - 3. Awaits the transaction's `TaskCompletionSource` -- Handles cancellation by registering a callback that cancels the TCS - -This replaces the original two-channel design (request channel + response channel) with a single channel carrying self-contained transactions. - -### 3. LambdaTestServer - -The intermediary that processes HTTP transactions from the handler: - -- Reads transactions from the handler's channel -- Routes requests based on the Lambda Runtime API paths -- Manages queued `/next` requests when no invocations are pending -- Matches response posts back to pending invocations by request ID - -### 4. LambdaTestClient - -The user-facing API that abstracts away all HTTP details: - -- Exposes a clean `InvokeAsync()` method -- Communicates with the server to queue invocations -- Tracks pending invocations in a `ConcurrentDictionary` keyed by request ID -- Returns typed `InvocationResponse` to callers - -## Request Flow -```mermaid -sequenceDiagram - participant User as User Code - participant Client as LambdaTestClient - participant Server as LambdaTestServer - participant Handler as LambdaTestingHttpHandler - participant Bootstrap as Lambda Bootstrap - - User->>Client: InvokeAsync(event) - Client->>Server: Queue pending invocation - - Bootstrap->>Handler: GET /invocation/next - Handler->>Server: Transaction via Channel - Server->>Server: Match with pending invocation - Server->>Handler: Respond(event payload) - Handler->>Bootstrap: HTTP 200 + event + headers - - Note over Bootstrap: Lambda function executes - - Bootstrap->>Handler: POST /invocation/{id}/response - Handler->>Server: Transaction via Channel - Server->>Server: Find pending by request ID - Server->>Handler: Respond(HTTP 202) - Handler->>Bootstrap: HTTP 202 Accepted - Server->>Client: Complete invocation TCS - Client->>User: InvocationResponse -``` - -## Concurrency Handling - -The implementation correctly handles multiple concurrent invocations: - -1. **Correlation via TCS**: Each `LambdaHttpTransaction` has its own `TaskCompletionSource`, so responses automatically route to the correct caller regardless of completion order. - -2. **Request ID tracking**: Each invocation gets a unique GUID. The server tracks pending invocations by this ID and matches Bootstrap's response posts back to the original caller. - -3. **Queued /next requests**: If Bootstrap polls for work before any invocations are pending, the request is queued and served when an invocation arrives. - -## Key Design Decisions - -| Decision | Rationale | -|----------|-----------| -| Single channel with TCS | Eliminates correlation problem inherent in separate request/response channels | -| `TaskCreationOptions.RunContinuationsAsynchronously` | Prevents deadlocks and stack dives when completing the TCS | -| Background processing loop in server | Decouples user's invoke calls from Bootstrap's polling pattern | -| `ConcurrentDictionary` for pending invocations | Thread-safe tracking for concurrent invoke calls | - -## Usage Example -```csharp -// Setup -var handler = new LambdaTestingHttpHandler(); -var server = new LambdaTestServer(handler); -var client = new LambdaTestClient(server); - -server.Start(); - -// Wire handler to Lambda Bootstrap's HttpClient -// ... bootstrap configuration ... - -// Invoke - user only sees events and responses, no HTTP -var response = await client.InvokeAsync( - new MyEvent { UserId = 123 }); - -if (response.IsSuccess) -{ - Console.WriteLine(response.Response.Result); -} -``` - -## Benefits - -- **Clean API**: Users work with typed events and responses, not HTTP -- **Correct concurrency**: Multiple simultaneous invocations work correctly -- **Testable**: No network, no ports, no external dependencies -- **Faithful simulation**: Follows the actual Lambda Runtime API contract -- **Faithful simulation**: Follows the actual Lambda Runtime API contract \ No newline at end of file +# MinimalLambda.Testing From d4e801c3f3e0b4b2a1418d679799fb9a25f7b0c7 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 07:43:46 -0500 Subject: [PATCH 070/132] docs(testing): expand README with usage details and examples - Added detailed documentation for MinimalLambda.Testing features and setup. - Included quick start guide with code examples for end-to-end testing. - Highlighted key features such as host customization and runtime simulation. - Provided installation instructions and links to related packages. - Improved clarity by organizing examples and feature explanations. --- src/MinimalLambda.Testing/README.md | 121 ++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/src/MinimalLambda.Testing/README.md b/src/MinimalLambda.Testing/README.md index 06b55137..8c09486b 100644 --- a/src/MinimalLambda.Testing/README.md +++ b/src/MinimalLambda.Testing/README.md @@ -1 +1,122 @@ # 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 reuses your real `Program` +entry point via `LambdaApplicationFactory` and drives 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("Jonas"); + + Assert.True(response.WasSuccess); + Assert.Equal("Hello Jonas!", 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 + +## Examples + +- [examples/AwsLambda.Host.Example.Testing](../../examples/AwsLambda.Host.Example.Testing/) – Full + end-to-end tests using `LambdaApplicationFactory` and `LambdaTestServer` + +## 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. From d08f053e2abc23d2775164cda560edf032205063 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 07:45:45 -0500 Subject: [PATCH 071/132] docs(testing): clarify README description for LambdaApplicationFactory and LambdaTestServer - Expanded explanation of `LambdaApplicationFactory` and `LambdaTestServer` alignment with ASP.NET Core `WebApplicationFactory`. - Improved documentation clarity with updated descriptions of runtime simulation and invocation flow. --- src/MinimalLambda.Testing/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/MinimalLambda.Testing/README.md b/src/MinimalLambda.Testing/README.md index 8c09486b..d6067c93 100644 --- a/src/MinimalLambda.Testing/README.md +++ b/src/MinimalLambda.Testing/README.md @@ -7,9 +7,10 @@ In-memory Lambda runtime for end-to-end and integration testing with the Minimal ## 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 reuses your real `Program` -entry point via `LambdaApplicationFactory` and drives it through a `LambdaTestServer` -that mimics the Lambda Runtime API (init, invocation, and shutdown). +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: From 83bf8b4ce8db0658a907d0ae1b07d86a2a0fc451 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 07:46:33 -0500 Subject: [PATCH 072/132] docs(testing): remove outdated examples section from README - Removed the `Examples` section from `README.md` referring to `AwsLambda.Host.Example.Testing`. - Simplified documentation to focus on high-level features and related packages. --- src/MinimalLambda.Testing/README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/MinimalLambda.Testing/README.md b/src/MinimalLambda.Testing/README.md index d6067c93..9cba84a5 100644 --- a/src/MinimalLambda.Testing/README.md +++ b/src/MinimalLambda.Testing/README.md @@ -94,11 +94,6 @@ by the simulated Runtime API. mirror ASP.NET testing patterns for overriding configuration and locating content roots - **Concurrency safe** – Handles multiple pending invocations FIFO with per-request correlation -## Examples - -- [examples/AwsLambda.Host.Example.Testing](../../examples/AwsLambda.Host.Example.Testing/) – Full - end-to-end tests using `LambdaApplicationFactory` and `LambdaTestServer` - ## Other Packages Additional packages in the minimal-lambda framework for abstractions, observability, and event From 3473a7524d8b411e4a3bef8175d3204c688da61b Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 07:46:58 -0500 Subject: [PATCH 073/132] docs(testing): update package description in project file - Revised `` in `MinimalLambda.Testing.csproj` to better reflect its purpose. - Changed description to "In-memory testing harness for MinimalLambda." --- src/MinimalLambda.Testing/MinimalLambda.Testing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj b/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj index 0e6c2457..90a3e785 100644 --- a/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj +++ b/src/MinimalLambda.Testing/MinimalLambda.Testing.csproj @@ -9,7 +9,7 @@ true MinimalLambda.Testing - Support for writting tests for MinimalLambda + In-memory testing harness for MinimalLambda README.md From 44a00c79b4dfa153f14dff1e33e1c66b67c6494d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 07:47:35 -0500 Subject: [PATCH 074/132] docs(testing): update example in README to use "Hello World" - Modified the invocation example in the `README.md` for consistency with typical greetings. - Replaced "Jonas" with "World" in test case to align with standard conventions for examples. --- src/MinimalLambda.Testing/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MinimalLambda.Testing/README.md b/src/MinimalLambda.Testing/README.md index 9cba84a5..dc0a1231 100644 --- a/src/MinimalLambda.Testing/README.md +++ b/src/MinimalLambda.Testing/README.md @@ -59,10 +59,10 @@ public class LambdaTests await factory.TestServer.StartAsync(); - var response = await factory.TestServer.InvokeAsync("Jonas"); + var response = await factory.TestServer.InvokeAsync("World"); Assert.True(response.WasSuccess); - Assert.Equal("Hello Jonas!", response.Response); + Assert.Equal("Hello World!", response.Response); } } ``` From ad50cc042d4c556b7c51d70161231ecbfccc0b84 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 07:53:22 -0500 Subject: [PATCH 075/132] refactor(testing): improve accessibility, disposal, and route handling - Changed `TryMatch` method to `internal` for improved encapsulation. - Adjusted `RouteTemplate` class to `sealed` for better performance and clarity. - Updated cancellation handling in `LambdaHttpHandler` to use `await using` for proper disposal. - Added support for cancellation tokens in `TaskCompletionSource.TrySetCanceled`. --- src/MinimalLambda.Testing/LambdaHttpHandler.cs | 4 ++-- src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaHttpHandler.cs b/src/MinimalLambda.Testing/LambdaHttpHandler.cs index 1b831c20..300979b2 100644 --- a/src/MinimalLambda.Testing/LambdaHttpHandler.cs +++ b/src/MinimalLambda.Testing/LambdaHttpHandler.cs @@ -32,7 +32,7 @@ CancellationToken cancellationToken var transaction = LambdaHttpTransaction.Create(request); // Register cancellation to cancel the transaction TCS - using var registration = cancellationToken.Register(() => transaction.Cancel()); + await using var registration = cancellationToken.Register(() => transaction.Cancel()); // Send transaction to server try @@ -45,7 +45,7 @@ CancellationToken cancellationToken var canceled = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ); - canceled.TrySetCanceled(); + canceled.TrySetCanceled(cancellationToken); return await canceled.Task; } diff --git a/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs b/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs index b2b6a165..b8008d43 100644 --- a/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs +++ b/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs @@ -46,7 +46,7 @@ internal class LambdaRuntimeRouteManager }, ]; - public bool TryMatch( + internal bool TryMatch( HttpRequestMessage request, [NotNullWhen(true)] out RequestType? routeType, [NotNullWhen(true)] out RouteValueDictionary? values @@ -56,7 +56,6 @@ public bool TryMatch( values = null; var method = request.Method.Method; - // var path = request.RequestUri?.AbsolutePath.TrimStart('/') ?? string.Empty; var path = request.RequestUri?.AbsolutePath ?? string.Empty; foreach (var route in Routes) @@ -76,7 +75,7 @@ public bool TryMatch( return false; } - private class RouteTemplate + private sealed class RouteTemplate { internal required TemplateMatcher Matcher { get; init; } internal required string Method { get; init; } From 7a2edc52a93d75f90f14de50e786dd89e9b0f459 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 10:48:02 -0500 Subject: [PATCH 076/132] feat(testing): add unit tests and new support features for MinimalLambda - Introduced `MinimalLambda.Testing.UnitTests` project with `SimpleLambdaTests`. - Added tests for `SimpleLambda`, covering input validation and response handling. - Refactored `InvocationResponse` class to support inheritance and centralized error handling. - Updated `LambdaTestServer` with options for no-response and no-event invocation scenarios. - Enhanced `LambdaApplicationFactory` to support cancellation tokens. - Included new XML documentation for additional methods and response scenarios. - Updated solution and project files to include the new unit tests and dependencies. --- MinimalLambda.sln | 36 +++++++++++++++-- .../Lambda/Program.cs | 24 +++++++---- .../Tests/LambdaHostTest.cs | 40 ++++++++++++++++++- .../Tests/Tests.csproj | 1 + .../LambdaApplicationFactory.cs | 4 ++ src/MinimalLambda.Testing/LambdaTestServer.cs | 39 +++++++++++------- .../LambdaTestServerExtensions.cs | 14 +++++++ .../Models/InvocationResponse.cs | 29 ++++++++------ ...mbda.Testing.UnitTests.SimpleLambda.csproj | 28 +++++++++++++ .../Program.cs | 12 ++++++ .../appsettings.json | 1 + .../GlobalUsings.cs | 5 +++ .../MinimalLambda.Testing.UnitTests.csproj | 31 ++++++++++++++ .../SimpleLambdaTests.cs | 21 ++++++++++ 14 files changed, 245 insertions(+), 40 deletions(-) create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/MinimalLambda.Testing.UnitTests.SimpleLambda.csproj create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Program.cs create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/appsettings.json create mode 100644 tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs create mode 100644 tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj create mode 100644 tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs diff --git a/MinimalLambda.sln b/MinimalLambda.sln index 818ec3cd..73742020 100644 --- a/MinimalLambda.sln +++ b/MinimalLambda.sln @@ -76,14 +76,17 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing", "src\MinimalLambda.Testing\MinimalLambda.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AwsLambda.Host.Example.Testing", "AwsLambda.Host.Example.Testing", "{26B446C5-76B0-4190-9022-1BF514737E2E}" - ProjectSection(SolutionItems) = preProject - examples\AwsLambda.Host.Example.Testing\Properties\launchSettings.json = examples\AwsLambda.Host.Example.Testing\Properties\launchSettings.json - EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "examples\AwsLambda.Host.Example.Testing\Tests\Tests.csproj", "{0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lambda", "examples\AwsLambda.Host.Example.Testing\Lambda\Lambda.csproj", "{6DB827E6-C534-4C6A-9128-05F6A05DD6C5}" 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -394,6 +397,30 @@ Global {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x64.Build.0 = Release|Any CPU {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x86.ActiveCfg = Release|Any CPU {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -426,5 +453,8 @@ Global {26B446C5-76B0-4190-9022-1BF514737E2E} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818} = {26B446C5-76B0-4190-9022-1BF514737E2E} {6DB827E6-C534-4C6A-9128-05F6A05DD6C5} = {26B446C5-76B0-4190-9022-1BF514737E2E} + {47DD369E-341A-4E8F-A227-B1B1E04CB9D6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF} = {47DD369E-341A-4E8F-A227-B1B1E04CB9D6} + {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A} = {47DD369E-341A-4E8F-A227-B1B1E04CB9D6} EndGlobalSection EndGlobal diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs index 239421f1..b0a4926c 100644 --- a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs +++ b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using MinimalLambda; using MinimalLambda.Builder; // Create the application builder @@ -23,14 +22,25 @@ // }); // Map your handler - the event is automatically injected +// lambda.MapHandler( +// async ([Event] string name, ILambdaHostContext context, CancellationToken cancellationToken) +// => +// { +// await Task.Delay(TimeSpan.FromSeconds(60), cancellationToken); +// if (string.IsNullOrWhiteSpace(name)) +// throw new ArgumentNullException(nameof(name), "Name is required."); +// +// return $"Hello {name}!"; +// } +// ); + +// lambda.MapHandler(() => "Hello World!"); + lambda.MapHandler( - async ([Event] string name, ILambdaHostContext context, CancellationToken cancellationToken) => + ([Event] string name) => { - await Task.Delay(TimeSpan.FromSeconds(60), cancellationToken); - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentNullException(nameof(name), "Name is required."); - - return $"Hello {name}!"; + if (name != "world") + throw new Exception("bad"); } ); diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs index 41b27c3b..c4481b07 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs @@ -21,8 +21,8 @@ public async Task LambdaHost_CanStartWithoutError() var response = await factory.TestServer.InvokeAsync( "Jonas", - "1", - TestContext.Current.CancellationToken + traceId: "1", + cancellationToken: TestContext.Current.CancellationToken ); response.WasSuccess.Should().BeTrue(); @@ -30,6 +30,42 @@ public async Task LambdaHost_CanStartWithoutError() response.Response.Should().Be("Hello Jonas!"); } + [Fact] + public async Task LambdaHost_NoInput_CanStartWithoutError() + { + await using var factory = new LambdaApplicationFactory(); + + var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + setup.InitStatus.Should().Be(InitStatus.InitCompleted); + + var response = await factory.TestServer.InvokeNoEventAsync( + TestContext.Current.CancellationToken + ); + + response.Should().NotBeNull(); + response.WasSuccess.Should().BeTrue(); + response.Response.Should().Be("Hello World!"); + } + + [Fact] + public async Task LambdaHost_NoOutput_CanStartWithoutError() + { + await using var factory = new LambdaApplicationFactory(); + + var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + setup.InitStatus.Should().Be(InitStatus.InitCompleted); + + var response = await factory.TestServer.InvokeNoResponseAsync( + "world", + TestContext.Current.CancellationToken + ); + + response.Should().NotBeNull(); + response.WasSuccess.Should().BeTrue(); + } + [Fact] public async Task LambdaHost_HandlerReturnsError() { diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj index aed7dadc..4e57604b 100644 --- a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj +++ b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj @@ -13,6 +13,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index 5091b92b..dfdfd164 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -57,6 +57,10 @@ public class LambdaApplicationFactory : IDisposable, IAsyncDisposab ///
public LambdaApplicationFactory() => _configuration = ConfigureWebHost; + public LambdaApplicationFactory WithCancelationToken( + CancellationToken cancellationToken + ) => this; + /// /// Gets the of factories created from this factory /// by further customizing the when calling diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 302d2310..870e80cb 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -270,8 +270,13 @@ await TaskHelpers /// 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. + /// 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. @@ -305,7 +310,8 @@ await TaskHelpers /// /// public async Task> InvokeAsync( - TEvent invokeEvent, + TEvent? invokeEvent, + bool noResponse = false, string? traceId = null, CancellationToken cancellationToken = default ) @@ -338,14 +344,15 @@ public async Task> InvokeAsync( var responseMessage = completion.Request; var wasSuccess = completion.RequestType == RequestType.PostResponse; - var response = wasSuccess - ? await ( - responseMessage.Content?.ReadFromJsonAsync( - _jsonSerializerOptions, - cts.Token - ) ?? Task.FromResult(default) - ) - : default; + var response = + wasSuccess && !noResponse + ? await ( + responseMessage.Content?.ReadFromJsonAsync( + _jsonSerializerOptions, + cts.Token + ) ?? Task.FromResult(default) + ) + : default; var error = !wasSuccess ? await ( @@ -532,20 +539,22 @@ private async Task HandlePostInitErrorAsync(LambdaHttpTransaction transaction) } private HttpResponseMessage CreateEventResponse( - TEvent invokeEvent, + TEvent? invokeEvent, string requestId, string traceId ) { var response = new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent( + Version = Version.Parse("1.1"), + }; + + if (invokeEvent is not null) + response.Content = new StringContent( JsonSerializer.Serialize(invokeEvent, _jsonSerializerOptions), Encoding.UTF8, "application/json" - ), - Version = Version.Parse("1.1"), - }; + ); // Add standard HTTP headers response.Headers.Date = new DateTimeOffset(DateTime.UtcNow, TimeSpan.Zero); diff --git a/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs index 3055a3b0..2c7322f3 100644 --- a/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs +++ b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs @@ -12,5 +12,19 @@ public Task> InvokeAsync( invokeEvent, cancellationToken: cancellationToken ); + + public Task> InvokeNoEventAsync( + CancellationToken cancellationToken = default + ) => server.InvokeAsync(null, cancellationToken: cancellationToken); + + public async Task InvokeNoResponseAsync( + TEvent invokeEvent, + CancellationToken cancellationToken = default + ) => + await server.InvokeAsync( + invokeEvent, + true, + cancellationToken: cancellationToken + ); } } diff --git a/src/MinimalLambda.Testing/Models/InvocationResponse.cs b/src/MinimalLambda.Testing/Models/InvocationResponse.cs index 51c673d1..46d6c69f 100644 --- a/src/MinimalLambda.Testing/Models/InvocationResponse.cs +++ b/src/MinimalLambda.Testing/Models/InvocationResponse.cs @@ -12,33 +12,36 @@ namespace MinimalLambda.Testing; /// If failed, will contain details about the error. /// /// -public class InvocationResponse +public class InvocationResponse : InvocationResponse { /// - /// Gets the error information if the Lambda function invocation failed, or - /// if the invocation succeeded. + /// Gets the Lambda function's response data if the invocation succeeded, or the default value + /// of if the invocation failed. /// /// - /// An containing error details, or for successful invocations. + /// The deserialized response from the Lambda function, or for failed 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. + /// This property is populated when the Lambda function successfully completes and returns a response + /// via the runtime API's response endpoint. /// - public ErrorResponse? Error { get; internal set; } + public TResponse? Response { get; internal set; } +} +public class InvocationResponse +{ /// - /// Gets the Lambda function's response data if the invocation succeeded, or the default value - /// of if the invocation failed. + /// Gets the error information if the Lambda function invocation failed, or + /// if the invocation succeeded. /// /// - /// The deserialized response from the Lambda function, or for failed invocations. + /// An containing error details, or for successful invocations. /// /// - /// This property is populated when the Lambda function successfully completes and returns a response - /// via the runtime API's response endpoint. + /// 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 TResponse? Response { get; internal set; } + public ErrorResponse? Error { get; internal set; } /// /// Gets a value indicating whether the Lambda function invocation completed successfully. 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..1f09fb4f --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/MinimalLambda.Testing.UnitTests.SimpleLambda.csproj @@ -0,0 +1,28 @@ + + + Exe + 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..2cf8dfa4 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Program.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Hosting; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +await using var lambda = builder.Build(); + +lambda.MapHandler(([Event] string name) => $"Hello {name}!"); + +await lambda.RunAsync(); + +public class SimpleLambda; 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/GlobalUsings.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs new file mode 100644 index 00000000..9c6a94a2 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using AutoFixture; +global using AwesomeAssertions; +global using JetBrains.Annotations; +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..82f482d7 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj @@ -0,0 +1,31 @@ + + + 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/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs new file mode 100644 index 00000000..ef2ea61a --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs @@ -0,0 +1,21 @@ +namespace MinimalLambda.Testing.UnitTests; + +public class SimpleLambdaTests(LambdaApplicationFactory factory) + : IClassFixture> +{ + [Fact] + public async Task SimpleLambda_ReturnsExpectedValue() + { + 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!"); + } +} From 467bfddcad674ec1e4f6520937551ade3a7c6ca2 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 11:30:03 -0500 Subject: [PATCH 077/132] feat(testing): add unit tests for new lambda scenarios - Added projects for `NoEventLambda`, `DiLambda`, and `NoResponseLambda`. - Introduced unit test setup with corresponding `Program.cs` and `appsettings.json` files. - Updated `LambdaTestServer` to use public `State` property instead of private `_state` field. - Enhanced solution file to include new lambda project references and build configurations. --- MinimalLambda.sln | 50 ++++++++++++++++++- src/MinimalLambda.Testing/LambdaTestServer.cs | 26 +++++----- ...alLambda.Testing.UnitTests.DiLambda.csproj | 28 +++++++++++ .../Program.cs | 12 +++++ .../appsettings.json | 1 + ...bda.Testing.UnitTests.NoEventLambda.csproj | 28 +++++++++++ .../Program.cs | 12 +++++ .../appsettings.json | 1 + ....Testing.UnitTests.NoResponseLambda.csproj | 28 +++++++++++ .../Program.cs | 12 +++++ .../appsettings.json | 1 + 11 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/MinimalLambda.Testing.UnitTests.DiLambda.csproj create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/appsettings.json create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/MinimalLambda.Testing.UnitTests.NoEventLambda.csproj create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/appsettings.json create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/appsettings.json diff --git a/MinimalLambda.sln b/MinimalLambda.sln index 73742020..a9b0545c 100644 --- a/MinimalLambda.sln +++ b/MinimalLambda.sln @@ -87,6 +87,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MinimalLambda.Testing.UnitT 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 @@ -421,6 +429,42 @@ Global {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 @@ -455,6 +499,10 @@ Global {6DB827E6-C534-4C6A-9128-05F6A05DD6C5} = {26B446C5-76B0-4190-9022-1BF514737E2E} {47DD369E-341A-4E8F-A227-B1B1E04CB9D6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {A8B1FC36-7518-4D9B-9CA1-BF7634229AFF} = {47DD369E-341A-4E8F-A227-B1B1E04CB9D6} - {7E7B3004-C6C4-4A8C-8610-2A1CB61F834A} = {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/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 870e80cb..89ba45c5 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -104,7 +104,7 @@ public class LambdaTestServer : IAsyncDisposable /// /// Current state of the server used to enforce lifecycle rules. /// - private ServerState _state; + public ServerState State { get; private set; } internal LambdaTestServer( Task? entryPointCompletion, @@ -116,7 +116,7 @@ internal LambdaTestServer( _entryPointCompletion = entryPointCompletion; _shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(shutdownToken); - _state = ServerState.Created; + State = ServerState.Created; _jsonSerializerOptions = DefaultLambdaJsonSerializerOptions.Create(); _serverOptions = serverOptions; @@ -156,7 +156,7 @@ internal LambdaTestServer( /// public async ValueTask DisposeAsync() { - if (_state == ServerState.Running) + if (State == ServerState.Running) await StopAsync(); // Complete both channels to prevent new items @@ -178,7 +178,7 @@ public async ValueTask DisposeAsync() disposableHost.Dispose(); } - _state = ServerState.Disposed; + State = ServerState.Disposed; } internal void SetHost(IHost host) @@ -222,7 +222,7 @@ internal HttpMessageHandler CreateHandler() => /// public async Task StartAsync(CancellationToken cancellationToken = default) { - if (_state != ServerState.Created) + if (State != ServerState.Created) throw new InvalidOperationException( "TestServer has already been started and cannot be restarted." ); @@ -232,7 +232,7 @@ public async Task StartAsync(CancellationToken cancellationToken = using var cts = LinkedCts(cancellationToken); - _state = ServerState.Starting; + State = ServerState.Starting; _applicationLifetime = _host.Services.GetRequiredService(); @@ -251,7 +251,7 @@ await TaskHelpers if (_initCompletionTcs.Task.IsCompleted) { - _state = + State = _initCompletionTcs.Task.Result.InitStatus == InitStatus.InitCompleted ? ServerState.Running : ServerState.Stopped; @@ -316,7 +316,7 @@ public async Task> InvokeAsync( CancellationToken cancellationToken = default ) { - if (_state != ServerState.Running) + if (State != ServerState.Running) throw new InvalidOperationException( "TestServer is not Running and as such an event cannot be invoked." ); @@ -400,12 +400,12 @@ public async Task> InvokeAsync( /// public async Task StopAsync(CancellationToken cancellationToken = default) { - if (_state != ServerState.Running) + if (State != ServerState.Running) throw new InvalidOperationException( "TestServer is not running and as such cannot be stopped." ); - _state = ServerState.Stopping; + State = ServerState.Stopping; await _shutdownCts.CancelAsync(); @@ -416,7 +416,7 @@ await TaskHelpers .UnwrapAndThrow("Exception(s) encountered while running StopAsync") .WaitAsync(cancellationToken); - _state = ServerState.Stopped; + State = ServerState.Stopped; } // ┌──────────────────────────────────────────────────────────┐ @@ -475,7 +475,7 @@ out var routeValues private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transaction) { - if (_state == ServerState.Starting) + if (State == ServerState.Starting) _initCompletionTcs.SetResult( new InitResponse { InitStatus = InitStatus.InitCompleted } ); @@ -520,7 +520,7 @@ await CreateCompletionAsync(RequestType.PostError, transaction.Request) private async Task HandlePostInitErrorAsync(LambdaHttpTransaction transaction) { - if (_state == ServerState.Starting) + if (State == ServerState.Starting) _initCompletionTcs.SetResult( new InitResponse { 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..1f09fb4f --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/MinimalLambda.Testing.UnitTests.DiLambda.csproj @@ -0,0 +1,28 @@ + + + Exe + 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..82ae5f6c --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Hosting; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +await using var lambda = builder.Build(); + +lambda.MapHandler(([Event] string name) => $"Hello {name}!"); + +await lambda.RunAsync(); + +public class DiLambda; 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..1f09fb4f --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/MinimalLambda.Testing.UnitTests.NoEventLambda.csproj @@ -0,0 +1,28 @@ + + + Exe + 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..99a5f6a5 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Hosting; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +await using var lambda = builder.Build(); + +lambda.MapHandler(([Event] string name) => $"Hello {name}!"); + +await lambda.RunAsync(); + +public class NoEventLambda; 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..1f09fb4f --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/MinimalLambda.Testing.UnitTests.NoResponseLambda.csproj @@ -0,0 +1,28 @@ + + + Exe + 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..ca9a78f3 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Hosting; +using MinimalLambda.Builder; + +var builder = LambdaApplication.CreateBuilder(); + +await using var lambda = builder.Build(); + +lambda.MapHandler(([Event] string name) => $"Hello {name}!"); + +await lambda.RunAsync(); + +public class NoResponseLambda; 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 From e056990badf4935a2a31935c6df73755cd2d5328 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 11:32:36 -0500 Subject: [PATCH 078/132] feat(testing): add internal visibility for unit testing in lambda projects - Introduced `AssemblyInfo.cs` files to enable `InternalsVisibleTo` for unit testing. - Applied changes to `NoResponseLambda`, `NoEventLambda`, `SimpleLambda`, and `DiLambda` projects. --- .../Properties/AssemblyInfo.cs | 3 +++ .../Properties/AssemblyInfo.cs | 3 +++ .../Properties/AssemblyInfo.cs | 3 +++ .../Properties/AssemblyInfo.cs | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Properties/AssemblyInfo.cs create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Properties/AssemblyInfo.cs create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Properties/AssemblyInfo.cs create mode 100644 tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Properties/AssemblyInfo.cs 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..e3d42db9 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/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/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.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.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")] From 2ddb9f40bb25c1858d3e22aa2819a352a8eed159 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 11:38:09 -0500 Subject: [PATCH 079/132] feat(DiLambda): implement dependency injection and lifecycle management - Added `ILifecycleService` and `IService` interfaces with corresponding implementations. - Integrated `Startup` and `Shutdown` hooks using `OnInit` and `OnShutdown` methods. - Enhanced handler to process `Request` and generate `Response` with timestamp. - Updated `Program.cs` to register services and manage lifecycle events. --- .../Program.cs | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) 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 index 82ae5f6c..95d1fc56 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs @@ -1,12 +1,50 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using MinimalLambda.Builder; var builder = LambdaApplication.CreateBuilder(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + await using var lambda = builder.Build(); -lambda.MapHandler(([Event] string name) => $"Hello {name}!"); +lambda.OnInit((ILifecycleService service) => service.OnStart()); + +lambda.MapHandler( + ([Event] Request request, IService service) => + new Response(service.GetMessage(request.Name), DateTime.UtcNow) +); + +lambda.OnShutdown((ILifecycleService service) => service.OnStop()); await lambda.RunAsync(); public class DiLambda; + +internal record Request(string Name); + +internal record Response(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() { } +} From 87965a0d268c6cd8f07738ce88dff72fe08c2ce6 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 11:39:52 -0500 Subject: [PATCH 080/132] feat(tests): update NoEventLambda handler to return detailed response - Refactored handler to produce a `Response` object with message and UTC timestamp. - Added `Response` record to encapsulate response message and timestamp fields. --- .../MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 99a5f6a5..049ba944 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs @@ -5,8 +5,10 @@ await using var lambda = builder.Build(); -lambda.MapHandler(([Event] string name) => $"Hello {name}!"); +lambda.MapHandler(() => new Response("Hello World!", DateTime.UtcNow)); await lambda.RunAsync(); public class NoEventLambda; + +internal record Response(string Message, DateTime TimestampUtc); From 60ea39d7f017188d9d8153757c543a0f62b7cfe1 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 11:41:16 -0500 Subject: [PATCH 081/132] feat(tests): update NoResponseLambda to process Request type - Modified `MapHandler` to accept a `Request` record instead of a string. - Added internal `Request` record definition with a `Name` property. --- .../Program.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index ca9a78f3..c5a97dae 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs @@ -5,8 +5,10 @@ await using var lambda = builder.Build(); -lambda.MapHandler(([Event] string name) => $"Hello {name}!"); +lambda.MapHandler(([Event] Request request) => { }); await lambda.RunAsync(); public class NoResponseLambda; + +internal record Request(string Name); From 72f3a79ce31cc4d9647c872e5870265f58a97b97 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 11:44:14 -0500 Subject: [PATCH 082/132] feat(DiLambda): add logging and middleware support - Injected `ILogger` into lifecycle events, middleware, and lambda handler. - Added logging to `OnInit`, `OnShutdown`, middleware, and handler for better traceability. - Introduced middleware to log execution timing before and after handler invocation. --- .../Program.cs | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) 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 index 95d1fc56..ed58a89a 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using MinimalLambda.Builder; var builder = LambdaApplication.CreateBuilder(); @@ -9,14 +10,40 @@ await using var lambda = builder.Build(); -lambda.OnInit((ILifecycleService service) => service.OnStart()); +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] Request request, IService service) => - new Response(service.GetMessage(request.Name), DateTime.UtcNow) + ([Event] Request request, IService service, ILogger logger) => + { + logger.LogInformation("Lambda handler"); + return new Response(service.GetMessage(request.Name), DateTime.UtcNow); + } ); -lambda.OnShutdown((ILifecycleService service) => service.OnStop()); +lambda.OnShutdown( + (ILifecycleService service, ILogger logger) => + { + logger.LogInformation("Shutdown 1"); + service.OnStop(); + } +); await lambda.RunAsync(); From c07b1b44ba0df532611cb76a941b0f3fd274d37a Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 12:02:29 -0500 Subject: [PATCH 083/132] feat(testing): enhance disposal and cancellation handling in test infrastructure - Added `_disposed` flag to `LambdaTestServer` to prevent repeated disposal operations. - Updated `DisposeAsync` in `LambdaTestServer` to suppress finalization and ensure safe disposal. - Introduced `_stoppingToken` property in `LambdaApplicationFactory` for improved cancellation handling. - Added `WithCancelationToken` method to propagate cancellation signals to `LambdaTestServer` components. - Enhanced `DisposeAsync` in `LambdaApplicationFactory` to stop and dispose of the host instance gracefully. - Updated `LambdaTestServer` instantiation to accept the stopping token for efficient shutdown. --- .../LambdaApplicationFactory.cs | 41 ++++++++++++++++--- src/MinimalLambda.Testing/LambdaTestServer.cs | 15 +++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index dfdfd164..f0d9b0a6 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -31,6 +31,7 @@ public class LambdaApplicationFactory : IDisposable, IAsyncDisposab private bool _disposedAsync; private IHost? _host; private LambdaTestServer? _server; + private CancellationToken? _stoppingToken; /// /// @@ -57,10 +58,6 @@ public class LambdaApplicationFactory : IDisposable, IAsyncDisposab /// public LambdaApplicationFactory() => _configuration = ConfigureWebHost; - public LambdaApplicationFactory WithCancelationToken( - CancellationToken cancellationToken - ) => this; - /// /// Gets the of factories created from this factory /// by further customizing the when calling @@ -109,6 +106,12 @@ public virtual async ValueTask DisposeAsync() if (_server != null) await _server.DisposeAsync().ConfigureAwait(false); + if (_host != null) + { + await _host.StopAsync().ConfigureAwait(false); + _host?.Dispose(); + } + _disposedAsync = true; Dispose(true); @@ -123,6 +126,30 @@ public void Dispose() 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 WithCancelationToken( + CancellationToken cancellationToken + ) + { + _stoppingToken = cancellationToken; + return this; + } + /// /// Finalizes an instance of the class. /// @@ -236,7 +263,11 @@ private void ConfigureHostBuilder( SetContentRoot(hostBuilder); _configuration(hostBuilder); - _server = new LambdaTestServer(entryPointCompletion, ServerOptions); + _server = new LambdaTestServer( + entryPointCompletion, + ServerOptions, + _stoppingToken ?? CancellationToken.None + ); // set Lambda Bootstrap Http Client hostBuilder.ConfigureServices(services => diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 89ba45c5..81026437 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -68,6 +68,11 @@ public class LambdaTestServer : IAsyncDisposable /// private readonly LambdaServerOptions _serverOptions; + /// + /// Indicates whether the server has been disposed. + /// + private bool _disposed; + /// /// CTS used to signal shutdown of the server and cancellation of pending tasks. /// @@ -153,9 +158,16 @@ internal LambdaTestServer( /// 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; + if (State == ServerState.Running) await StopAsync(); @@ -179,6 +191,9 @@ public async ValueTask DisposeAsync() } State = ServerState.Disposed; + _disposed = true; + + GC.SuppressFinalize(this); } internal void SetHost(IHost host) From 9be7a1ee8a42811d58d1eb998b8cb1789bfb79c1 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 12:15:34 -0500 Subject: [PATCH 084/132] feat(tests): refactor `SimpleLambdaTests` for improved cancellation handling - Updated `SimpleLambdaTests` to use `LambdaApplicationFactory` with cancellation tokens. - Replaced constructor injection of `LambdaApplicationFactory` with `await using` pattern. - Simplified test setup by initializing factory and test server inline. --- .../MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs index ef2ea61a..7fa2c287 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs @@ -1,11 +1,13 @@ namespace MinimalLambda.Testing.UnitTests; -public class SimpleLambdaTests(LambdaApplicationFactory factory) - : IClassFixture> +public class SimpleLambdaTests { [Fact] public async Task SimpleLambda_ReturnsExpectedValue() { + await using var factory = new LambdaApplicationFactory().WithCancelationToken( + TestContext.Current.CancellationToken + ); var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); setup.InitStatus.Should().Be(InitStatus.InitCompleted); From 9580e7e7a9413b544b57a768263b4594287e33ab Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 14:34:47 -0500 Subject: [PATCH 085/132] feat(tests): validate input for `SimpleLambda` handler - Updated `SimpleLambda` handler to throw an exception if the input name is null or whitespace. - Added input validation for better error handling and robustness. --- .../Program.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 index 2cf8dfa4..75ae8144 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Program.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.SimpleLambda/Program.cs @@ -5,7 +5,15 @@ await using var lambda = builder.Build(); -lambda.MapHandler(([Event] string name) => $"Hello {name}!"); +lambda.MapHandler( + ([Event] string name) => + { + if (string.IsNullOrWhiteSpace(name)) + throw new Exception("Name is required"); + + return $"Hello {name}!"; + } +); await lambda.RunAsync(); From b245a0d7c9b2f8d81b8309f517ed99c7b649d700 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 14:43:38 -0500 Subject: [PATCH 086/132] feat(testing): extend `LambdaTestServer` methods with event response and default flags - Updated `InvokeAsync` and `InvokeNoEventAsync` to include a default flag parameter. - Enhanced method signatures to provide consistent behavior with explicit flag control. --- src/MinimalLambda.Testing/LambdaTestServerExtensions.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs index 2c7322f3..3c38874f 100644 --- a/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs +++ b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs @@ -10,12 +10,18 @@ public Task> InvokeAsync( ) => server.InvokeAsync( invokeEvent, + false, cancellationToken: cancellationToken ); public Task> InvokeNoEventAsync( CancellationToken cancellationToken = default - ) => server.InvokeAsync(null, cancellationToken: cancellationToken); + ) => + server.InvokeAsync( + null, + false, + cancellationToken: cancellationToken + ); public async Task InvokeNoResponseAsync( TEvent invokeEvent, From 7efa06f9fd2cbfb6d9139c2e0028c70f23668441 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 14:43:58 -0500 Subject: [PATCH 087/132] feat(tests): add concurrency and error propagation tests for `SimpleLambda` - Introduced new tests to verify `SimpleLambda` behavior under concurrent invocations. - Added tests to ensure proper error propagation and handling in `SimpleLambda`. - Updated `SimpleLambdaTests` to utilize `LambdaApplicationFactory` for consistent setup and teardown. --- .../SimpleLambdaTests.cs | 102 +++++++++++++++++- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs index 7fa2c287..5ee87443 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs @@ -1,7 +1,18 @@ -namespace MinimalLambda.Testing.UnitTests; +using Microsoft.Extensions.DependencyInjection; +using MinimalLambda.Options; -public class SimpleLambdaTests +namespace MinimalLambda.Testing.UnitTests; + +public class SimpleLambdaTests : IClassFixture> { + private readonly LambdaTestServer _server; + + public SimpleLambdaTests(LambdaApplicationFactory factory) + { + factory.WithCancelationToken(TestContext.Current.CancellationToken); + _server = factory.TestServer; + } + [Fact] public async Task SimpleLambda_ReturnsExpectedValue() { @@ -20,4 +31,91 @@ public async Task SimpleLambda_ReturnsExpectedValue() response.Should().NotBeNull(); response.Response.Should().Be("Hello World!"); } + + [Fact] + public async Task SimpleLambda_WorksWhenStartIsNotCalled() + { + var response = await _server.InvokeAsync( + "World", + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Should().NotBeNull(); + response.Response.Should().Be("Hello World!"); + } + + [Fact] + public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes() + { + // Launch 5 concurrent invocations + var tasks = Enumerable + .Range(1, 5) + .Select(i => + _server.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_ReturnsErrorPropperly() + { + var response = await _server.InvokeAsync( + "World", + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Should().NotBeNull(); + response.Response.Should().Be("Hello World!"); + } + + [Fact] + public async Task SimpleLambda_ErrorsArePropagated() + { + await using var factory = new LambdaApplicationFactory() + .WithCancelationToken(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()) + .And.InnerExceptions.Should() + .ContainSingle(ex => + ex is InvalidOperationException + && ex.Message.Contains( + "Unexpected request received from the Lambda HTTP handler: GET http://http//localhost:3002/2018-06-01/runtime/invocation/next" + ) + ); + } } From a9bb7bfabb3ecabf66c7fda72a49540cbfd806c4 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 14:46:03 -0500 Subject: [PATCH 088/132] feat(testing): improve `LambdaTestServer` lifecycle handling and initialization concurrency - Added `_startSemaphore` to enforce concurrency during server initialization. - Moved `_disposed` flag and `State` property declarations for improved organization. - Updated `DisposeAsync` to use a switch-case for better null checks and disposal handling. - Adjusted `StartAsync` to ensure proper lifecycle state validation with semaphore protection. - Modified `InvokeAsync` to handle server initialization if invoked in the `Created` state. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 110 +++++++++++------- 1 file changed, 67 insertions(+), 43 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 81026437..d9401f01 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -68,16 +68,13 @@ public class LambdaTestServer : IAsyncDisposable /// private readonly LambdaServerOptions _serverOptions; - /// - /// Indicates whether the server has been disposed. - /// - private bool _disposed; - /// /// 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. /// @@ -91,6 +88,11 @@ public class LambdaTestServer : IAsyncDisposable /// private IHostApplicationLifetime? _applicationLifetime; + /// + /// Indicates whether the server has been disposed. + /// + private bool _disposed; + /// /// The captured Host instance. /// @@ -106,11 +108,6 @@ public class LambdaTestServer : IAsyncDisposable ///
private int _requestCounter; - /// - /// Current state of the server used to enforce lifecycle rules. - /// - public ServerState State { get; private set; } - internal LambdaTestServer( Task? entryPointCompletion, LambdaServerOptions serverOptions, @@ -143,6 +140,11 @@ internal LambdaTestServer( /// 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 . /// @@ -182,12 +184,16 @@ public async ValueTask DisposeAsync() _shutdownCts.Dispose(); // Dispose the host (prefer async, fallback to sync) - if (_host is not null) + switch (_host) { - if (_host is IAsyncDisposable asyncDisposableHost) + case null: + break; + case IAsyncDisposable asyncDisposableHost: await asyncDisposableHost.DisposeAsync(); - else if (_host is IDisposable disposableHost) + break; + case IDisposable disposableHost: disposableHost.Dispose(); + break; } State = ServerState.Disposed; @@ -237,46 +243,54 @@ internal HttpMessageHandler CreateHandler() => /// public async Task StartAsync(CancellationToken cancellationToken = default) { - if (State != ServerState.Created) - throw new InvalidOperationException( - "TestServer has already been started and cannot be restarted." - ); + await _startSemaphore.WaitAsync(cancellationToken); + try + { + if (State != ServerState.Created) + throw new InvalidOperationException( + "TestServer has already been started and cannot be restarted." + ); - if (_host is null) - throw new InvalidOperationException("Host is not set."); + if (_host is null) + throw new InvalidOperationException("Host is not set."); - using var cts = LinkedCts(cancellationToken); + using var cts = LinkedCts(cancellationToken); - State = ServerState.Starting; + State = ServerState.Starting; - _applicationLifetime = _host.Services.GetRequiredService(); + _applicationLifetime = _host.Services.GetRequiredService(); - // Start the host - await _host.StartAsync(cts.Token); + // Start the host + await _host.StartAsync(cts.Token); - // Start background processing - _processingTask = Task.Run(ProcessTransactionsAsync, 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"); + await TaskHelpers + .WhenAny(_processingTask, _entryPointCompletion, _initCompletionTcs.Task) + .UnwrapAndThrow("Exception(s) encountered while running StartAsync"); - if (_entryPointCompletion.IsCompleted) - return new InitResponse { InitStatus = InitStatus.HostExited }; + if (_entryPointCompletion.IsCompleted) + return new InitResponse { InitStatus = InitStatus.HostExited }; - if (_initCompletionTcs.Task.IsCompleted) - { - State = - _initCompletionTcs.Task.Result.InitStatus == InitStatus.InitCompleted - ? ServerState.Running - : ServerState.Stopped; + if (_initCompletionTcs.Task.IsCompleted) + { + State = + _initCompletionTcs.Task.Result.InitStatus == InitStatus.InitCompleted + ? ServerState.Running + : ServerState.Stopped; - return _initCompletionTcs.Task.Result; - } + return _initCompletionTcs.Task.Result; + } - throw new InvalidOperationException( - "TestServer initialization failed with neither an error nor completion." - ); + throw new InvalidOperationException( + "TestServer initialization failed with neither an error nor completion." + ); + } + finally + { + _startSemaphore.Release(); + } } /// @@ -326,11 +340,21 @@ await TaskHelpers /// public async Task> InvokeAsync( TEvent? invokeEvent, - bool noResponse = false, + bool noResponse, string? traceId = null, CancellationToken cancellationToken = default ) { + // inorder to allow + if (State == ServerState.Created) + { + var initResponse = await StartAsync(cancellationToken); + if (initResponse.InitStatus != InitStatus.InitCompleted) + throw new InvalidOperationException( + $"{nameof(LambdaTestServer)} failed to initialize and returned a status of {initResponse.InitStatus.ToString()}." + ); + } + if (State != ServerState.Running) throw new InvalidOperationException( "TestServer is not Running and as such an event cannot be invoked." From 7b479e4a008c6927639e2e02e83631f6ae4dcccd Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 14:49:05 -0500 Subject: [PATCH 089/132] feat(tests): add cancellation test for `SimpleLambda` with pre-canceled token - Introduced `SimpleLambda_WithPreCanceledToken_CancelsInvocation` test. - Verified invocation is canceled when provided with a pre-canceled `CancellationToken`. - Used `LambdaApplicationFactory` to simplify setup and ensure consistent test behavior. --- .../SimpleLambdaTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs index 5ee87443..823a18e3 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs @@ -118,4 +118,20 @@ ex is InvalidOperationException ) ); } + + [Fact] + public async Task SimpleLambda_WithPreCanceledToken_CancelsInvocation() + { + await using var factory = new LambdaApplicationFactory().WithCancelationToken( + TestContext.Current.CancellationToken + ); + await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(() => + factory.TestServer.InvokeAsync("Jonas", cts.Token) + ); + } } From e1a1ee010ae92ef01f734122df28db296a540b59 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 14:50:08 -0500 Subject: [PATCH 090/132] refactor(examples): remove `AwsLambda.Host.Example.Testing` examples and related tests - Deleted `Lambda` and `Tests` directories under the `examples` folder. - Removed Lambda example dependencies from the solution file. - Updated `MinimalLambda.sln` to exclude the `AwsLambda.Host.Example.Testing` projects. - Cleaned up unused resources, configurations, and project references. --- MinimalLambda.sln | 33 --- .../Lambda/Lambda.csproj | 32 --- .../Lambda/Program.cs | 55 ----- .../Lambda/Properties/launchSettings.json | 15 -- .../Lambda/appsettings.json | 20 -- .../Tests/LambdaHostTest.cs | 228 ------------------ .../Tests/Tests.csproj | 31 --- 7 files changed, 414 deletions(-) delete mode 100644 examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj delete mode 100644 examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs delete mode 100644 examples/AwsLambda.Host.Example.Testing/Lambda/Properties/launchSettings.json delete mode 100644 examples/AwsLambda.Host.Example.Testing/Lambda/appsettings.json delete mode 100644 examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs delete mode 100644 examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj diff --git a/MinimalLambda.sln b/MinimalLambda.sln index a9b0545c..8413ae0f 100644 --- a/MinimalLambda.sln +++ b/MinimalLambda.sln @@ -75,12 +75,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Envelopes.Clo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalLambda.Testing", "src\MinimalLambda.Testing\MinimalLambda.Testing.csproj", "{9FA188D7-CF5F-4F87-B292-2AF69994FF12}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AwsLambda.Host.Example.Testing", "AwsLambda.Host.Example.Testing", "{26B446C5-76B0-4190-9022-1BF514737E2E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "examples\AwsLambda.Host.Example.Testing\Tests\Tests.csproj", "{0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lambda", "examples\AwsLambda.Host.Example.Testing\Lambda\Lambda.csproj", "{6DB827E6-C534-4C6A-9128-05F6A05DD6C5}" -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}" @@ -381,30 +375,6 @@ Global {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 - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x64.ActiveCfg = Debug|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x64.Build.0 = Debug|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x86.ActiveCfg = Debug|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Debug|x86.Build.0 = Debug|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|Any CPU.Build.0 = Release|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x64.ActiveCfg = Release|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x64.Build.0 = Release|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x86.ActiveCfg = Release|Any CPU - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818}.Release|x86.Build.0 = Release|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x64.ActiveCfg = Debug|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x64.Build.0 = Debug|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x86.ActiveCfg = Debug|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Debug|x86.Build.0 = Debug|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|Any CPU.Build.0 = Release|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x64.ActiveCfg = Release|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x64.Build.0 = Release|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.Release|x86.ActiveCfg = Release|Any CPU - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5}.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 @@ -494,9 +464,6 @@ Global {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} - {26B446C5-76B0-4190-9022-1BF514737E2E} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} - {0EDDAE60-A252-4CB0-85E2-FEA0CA0C5818} = {26B446C5-76B0-4190-9022-1BF514737E2E} - {6DB827E6-C534-4C6A-9128-05F6A05DD6C5} = {26B446C5-76B0-4190-9022-1BF514737E2E} {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} diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj b/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj deleted file mode 100644 index b62b04b1..00000000 --- a/examples/AwsLambda.Host.Example.Testing/Lambda/Lambda.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - Exe - net10.0 - preview - enable - enable - true - Lambda - - true - - true - $(InterceptorsNamespaces);MinimalLambda - false - - - - - - - - - - PreserveNewest - - - diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs b/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs deleted file mode 100644 index b0a4926c..00000000 --- a/examples/AwsLambda.Host.Example.Testing/Lambda/Program.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using MinimalLambda.Builder; - -// Create the application builder -var builder = LambdaApplication.CreateBuilder(); - -builder.Services.ConfigureLambdaHostOptions(options => -{ - options.ClearLambdaOutputFormatting = true; -}); - -// Build the Lambda application -var lambda = builder.Build(); - -// throw new Exception("Init failed"); - -// lambda.OnInit(() => -// { -// // throw new Exception("Init failed"); -// // return false; -// }); - -// Map your handler - the event is automatically injected -// lambda.MapHandler( -// async ([Event] string name, ILambdaHostContext context, CancellationToken cancellationToken) -// => -// { -// await Task.Delay(TimeSpan.FromSeconds(60), cancellationToken); -// if (string.IsNullOrWhiteSpace(name)) -// throw new ArgumentNullException(nameof(name), "Name is required."); -// -// return $"Hello {name}!"; -// } -// ); - -// lambda.MapHandler(() => "Hello World!"); - -lambda.MapHandler( - ([Event] string name) => - { - if (name != "world") - throw new Exception("bad"); - } -); - -lambda.OnShutdown(() => -{ - Console.WriteLine("Shutdown"); -}); - -// Run the Lambda -await lambda.RunAsync(); - -public partial class Program; diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/Properties/launchSettings.json b/examples/AwsLambda.Host.Example.Testing/Lambda/Properties/launchSettings.json deleted file mode 100644 index 9f3c9991..00000000 --- a/examples/AwsLambda.Host.Example.Testing/Lambda/Properties/launchSettings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "Local": { - "commandName": "Project", - "environmentVariables": { - "AWS_LAMBDA_RUNTIME_API": "localhost:5050", - "DOTNET_ENVIRONMENT": "Development", - "AWS_LAMBDA_LOG_FORMAT": "JSON", - "AWS_LAMBDA_LOG_LEVEL": "DEBUG", - "AWS_LAMBDA_FUNCTION_NAME": "Lambda-Host-Example-HelloWorld" - } - } - } -} diff --git a/examples/AwsLambda.Host.Example.Testing/Lambda/appsettings.json b/examples/AwsLambda.Host.Example.Testing/Lambda/appsettings.json deleted file mode 100644 index 53160eba..00000000 --- a/examples/AwsLambda.Host.Example.Testing/Lambda/appsettings.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "AwsLambdaHost": { - "InvocationCancellationBuffer": "00:00:05" - }, - "Logging": { - "Console": { - "FormatterName": "simple", - "FormatterOptions": { - "SingleLine": true, - "IncludeScopes": false, - "TimestampFormat": "yyyy-MM-dd HH:mm:ss ", - "UseUtcTimestamp": false - } - }, - "LogLevel": { - "Microsoft": "Warning", - "Default": "Information" - } - } -} \ No newline at end of file diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs b/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs deleted file mode 100644 index c4481b07..00000000 --- a/examples/AwsLambda.Host.Example.Testing/Tests/LambdaHostTest.cs +++ /dev/null @@ -1,228 +0,0 @@ -using AwesomeAssertions; -using JetBrains.Annotations; -using Microsoft.Extensions.DependencyInjection; -using MinimalLambda.Options; -using MinimalLambda.Testing; -using Xunit; - -namespace MinimalLambda.Example.HelloWorld; - -[TestSubject(typeof(Program))] -public class LambdaHostTest -{ - [Fact] - public async Task LambdaHost_CanStartWithoutError() - { - await using var factory = new LambdaApplicationFactory(); - - var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - - setup.InitStatus.Should().Be(InitStatus.InitCompleted); - - var response = await factory.TestServer.InvokeAsync( - "Jonas", - traceId: "1", - cancellationToken: TestContext.Current.CancellationToken - ); - - response.WasSuccess.Should().BeTrue(); - response.Should().NotBeNull(); - response.Response.Should().Be("Hello Jonas!"); - } - - [Fact] - public async Task LambdaHost_NoInput_CanStartWithoutError() - { - await using var factory = new LambdaApplicationFactory(); - - var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - - setup.InitStatus.Should().Be(InitStatus.InitCompleted); - - var response = await factory.TestServer.InvokeNoEventAsync( - TestContext.Current.CancellationToken - ); - - response.Should().NotBeNull(); - response.WasSuccess.Should().BeTrue(); - response.Response.Should().Be("Hello World!"); - } - - [Fact] - public async Task LambdaHost_NoOutput_CanStartWithoutError() - { - await using var factory = new LambdaApplicationFactory(); - - var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - - setup.InitStatus.Should().Be(InitStatus.InitCompleted); - - var response = await factory.TestServer.InvokeNoResponseAsync( - "world", - TestContext.Current.CancellationToken - ); - - response.Should().NotBeNull(); - response.WasSuccess.Should().BeTrue(); - } - - [Fact] - public async Task LambdaHost_HandlerReturnsError() - { - await using var factory = new LambdaApplicationFactory(); - - var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - - setup.InitStatus.Should().Be(InitStatus.InitCompleted); - - var response = await factory.TestServer.InvokeAsync( - "", - TestContext.Current.CancellationToken - ); - - response.WasSuccess.Should().BeFalse(); - response.Error.Should().NotBeNull(); - response.Error?.ErrorMessage.Should().Be("Name is required. (Parameter 'name')"); - } - - [Fact] - public async Task LambdaHost_CanBeShutdown() - { - await using var factory = new LambdaApplicationFactory(); - - var setup = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - - setup.InitStatus.Should().Be(InitStatus.InitCompleted); - - var response = await factory.TestServer.InvokeAsync( - "Jonas", - TestContext.Current.CancellationToken - ); - - response.WasSuccess.Should().BeTrue(); - response.Should().NotBeNull(); - response.Response.Should().Be("Hello Jonas!"); - - await factory.TestServer.StopAsync(TestContext.Current.CancellationToken); - } - - [Fact] - public async Task LambdaHost_ServerInternalExceptions_AreCaughtAndReturnedAsError() - { - await using var factory = new LambdaApplicationFactory().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()) - .And.InnerExceptions.Should() - .ContainSingle(ex => - ex is InvalidOperationException - && ex.Message.Contains( - "Unexpected request received from the Lambda HTTP handler: GET http://http//localhost:3002/2018-06-01/runtime/invocation/next" - ) - ); - } - - [Fact] - public async Task LambdaHost_ProcessesConcurrentInvocationsInFifoOrder() - { - await using var factory = new LambdaApplicationFactory(); - 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); - - // All should complete successfully - Assert.All(responses, r => Assert.True(r.WasSuccess)); - Assert.Equal("Hello User1!", responses[0].Response); - Assert.Equal("Hello User2!", responses[1].Response); - Assert.Equal("Hello User3!", responses[2].Response); - Assert.Equal("Hello User4!", responses[3].Response); - Assert.Equal("Hello User5!", responses[4].Response); - } - - [Fact] - public async Task InvokeAsync_WithInvalidPayload_ReturnsError() - { - await using var factory = new LambdaApplicationFactory(); - await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - - var response = await factory.TestServer.InvokeAsync( - 123, - TestContext.Current.CancellationToken - ); - - Assert.False(response.WasSuccess); - Assert.NotNull(response.Error); - Assert.Contains("Json", response.Error!.ErrorType, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task InvokeAsync_WithPreCanceledToken_CancelsInvocation() - { - await using var factory = new LambdaApplicationFactory(); - await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - - using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); - - await Assert.ThrowsAsync(() => - factory.TestServer.InvokeAsync("Jonas", cts.Token) - ); - } - - // [Fact] - // public async Task InvokeAsync_WithZeroTimeout_CancelsInvocation() => - // await Assert.ThrowsAsync(async () => - // { - // await using var factory = new LambdaApplicationFactory(); - // await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - // - // var options = new LambdaServerOptions(); - // options.InvocationOptions.ClientWaitTimeout = TimeSpan.Zero; - // - // await factory.TestServer.InvokeAsync( - // "Jonas", - // options, - // TestContext.Current.CancellationToken - // ); - // }); - - // [Fact] - // public async Task StartAsync_WithFailingInit_ReturnsInitError() - // { - // // This test verifies that when OnInit returns false (as configured in Program.cs), - // // the runtime posts to /runtime/init/error and StartAsync returns InitResponse with - // //error - // await using var factory = new LambdaApplicationFactory(); - // - // using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - // var initResponse = await factory.TestServer.StartAsync(cts.Token); - // - // Assert.False(initResponse.InitSuccess); - // Assert.NotNull(initResponse.Error); - // Assert.Equal(ServerState.Stopped, factory.TestServer.State); - // } -} diff --git a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj b/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj deleted file mode 100644 index 4e57604b..00000000 --- a/examples/AwsLambda.Host.Example.Testing/Tests/Tests.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - net10.0 - enable - enable - false - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - From 67b03ce0b7e49adc988a28420fb738e0974b4724 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 14:51:00 -0500 Subject: [PATCH 091/132] refactor(testing): reorganize properties and add missing members to `DeferredHostBuilder` and `PendingInvocation` - Reordered private fields in `DeferredHostBuilder` for consistency and clarity. - Added `EntryPointCompletion` to `DeferredHostBuilder` for external access to completion status. - Adjusted property order in `PendingInvocation` to align with coding standards. - Added `DeadlineUtc` initialization in `PendingInvocation` with a concise summary. - Improved whitespace and code formatting in `HostFactoryResolver` for better readability. - Optimized lambda expressions and argument parsing logic in `HostFactoryResolver`. --- .../DeferredHostBuilder.cs | 12 +- .../HostFactoryResolver.cs | 634 ++++++++---------- .../Models/PendingInvocation.cs | 10 +- 3 files changed, 299 insertions(+), 357 deletions(-) diff --git a/src/MinimalLambda.Testing/DeferredHostBuilder.cs b/src/MinimalLambda.Testing/DeferredHostBuilder.cs index ead9f95a..2645da0c 100644 --- a/src/MinimalLambda.Testing/DeferredHostBuilder.cs +++ b/src/MinimalLambda.Testing/DeferredHostBuilder.cs @@ -16,6 +16,10 @@ namespace MinimalLambda.Testing; // 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 @@ -25,10 +29,6 @@ internal sealed class DeferredHostBuilder : IHostBuilder TaskCreationOptions.RunContinuationsAsynchronously ); - private readonly TaskCompletionSource _entryPointCompletionTcs = new( - TaskCreationOptions.RunContinuationsAsynchronously - ); - private Action _configure; private Func? _hostFactory; @@ -41,10 +41,10 @@ public DeferredHostBuilder() => b.Properties[pair.Key] = pair.Value; }; - public IDictionary Properties { get; } = new Dictionary(); - public Task EntryPointCompletion => _entryPointCompletionTcs.Task; + public IDictionary Properties { get; } = new Dictionary(); + public IHost Build() { // Hosting configuration is being provided by args so that diff --git a/src/MinimalLambda.Testing/HostFactoryResolver.cs b/src/MinimalLambda.Testing/HostFactoryResolver.cs index 345b46fe..ff6a46ed 100644 --- a/src/MinimalLambda.Testing/HostFactoryResolver.cs +++ b/src/MinimalLambda.Testing/HostFactoryResolver.cs @@ -9,415 +9,357 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -namespace Microsoft.Extensions.Hosting +namespace Microsoft.Extensions.Hosting; + +internal sealed class HostFactoryResolver { - 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() { - 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 (Debugger.IsAttached) + return Timeout.InfiniteTimeSpan; - if ( - uint.TryParse( - Environment.GetEnvironmentVariable(TimeoutEnvironmentKey), - out uint timeoutInSeconds - ) + if ( + uint.TryParse( + Environment.GetEnvironmentVariable(TimeoutEnvironmentKey), + out var timeoutInSeconds ) - { - return TimeSpan.FromSeconds((int)timeoutInSeconds); - } + ) + return TimeSpan.FromSeconds((int)timeoutInSeconds); - return TimeSpan.FromMinutes(5); - } + return TimeSpan.FromMinutes(5); + } - public static Func? ResolveWebHostFactory(Assembly assembly) - { - return ResolveFactory(assembly, BuildWebHost); - } + 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; - public static Func< - string[], - TWebHostBuilder - >? ResolveWebHostBuilderFactory(Assembly assembly) + try { - return ResolveFactory(assembly, CreateWebHostBuilder); - } + // 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; - public static Func? ResolveHostBuilderFactory( - Assembly assembly - ) + // 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 { - return ResolveFactory(assembly, CreateHostBuilder); + // There was an error loading the extensions assembly, return null. + return null; } - // 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; - } + return args => + new HostingListener( + args, + assembly.EntryPoint, + waitTimeout ?? s_defaultWaitTimeout, + stopApplication, + configureHostBuilder, + entrypointCompleted + ).CreateHost(); + } - 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; - } + private static Func? ResolveFactory(Assembly assembly, string name) + { + var programType = assembly?.EntryPoint?.DeclaringType; + if (programType == null) + 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 + 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 => { - // There was an error loading the extensions assembly, return null. - return null; - } + var webHost = webHostFactory(args); + return GetServiceProvider(webHost); + }; + var webHostBuilderFactory = ResolveWebHostBuilderFactory(assembly); + if (webHostBuilderFactory != null) return args => - new HostingListener( - args, - assembly.EntryPoint, - waitTimeout ?? s_defaultWaitTimeout, - stopApplication, - configureHostBuilder, - entrypointCompleted - ).CreateHost(); - } + { + var webHostBuilder = webHostBuilderFactory(args); + var webHost = Build(webHostBuilder); + return GetServiceProvider(webHost); + }; - private static Func? ResolveFactory(Assembly assembly, string name) - { - var programType = assembly?.EntryPoint?.DeclaringType; - if (programType == null) + var hostBuilderFactory = ResolveHostBuilderFactory(assembly); + if (hostBuilderFactory != null) + return args => { - return null; - } + var hostBuilder = hostBuilderFactory(args); + var host = Build(hostBuilder); + return GetServiceProvider(host); + }; - var factory = programType.GetMethod(name, DeclaredOnlyLookup); - if (!IsFactory(factory)) + var hostFactory = ResolveHostFactory(assembly, waitTimeout); + if (hostFactory != null) + return args => { - return null; - } + static bool IsApplicationNameArg(string arg) => + arg.Equals("--applicationName", StringComparison.OrdinalIgnoreCase) + || arg.Equals("/applicationName", StringComparison.OrdinalIgnoreCase); - return args => (T)factory!.Invoke(null, new object[] { args })!; - } + if ( + !args.Any(arg => IsApplicationNameArg(arg)) + && assembly.GetName().Name is string assemblyName + ) + args = args.Concat(new[] { "--applicationName", assemblyName }).ToArray(); - // TReturn Factory(string[] args); - private static bool IsFactory(MethodInfo? factory) - { - return factory != null - && typeof(TReturn).IsAssignableFrom(factory.ReturnType) - && factory.GetParameters().Length == 1 - && typeof(string[]).Equals(factory.GetParameters()[0].ParameterType); - } + var host = hostFactory(args); + return GetServiceProvider(host); + }; + + return null; + } - // Used by EF tooling without any Hosting references. Looses some return type safety checks. - public static Func? ResolveServiceProviderFactory( - Assembly assembly, - TimeSpan? waitTimeout = 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 ) { - // 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); - }; - } + _args = args; + _entryPoint = entryPoint; + _waitTimeout = waitTimeout; + _stopApplication = stopApplication; + _configure = configure; + _entrypointCompleted = entrypointCompleted; + } - var webHostBuilderFactory = ResolveWebHostBuilderFactory(assembly); - if (webHostBuilderFactory != null) - { - return args => - { - var webHostBuilder = webHostBuilderFactory(args); - var webHost = Build(webHostBuilder); - return GetServiceProvider(webHost); - }; - } + public void OnCompleted() => _disposable?.Dispose(); - var hostBuilderFactory = ResolveHostBuilderFactory(assembly); - if (hostBuilderFactory != null) - { - return args => - { - var hostBuilder = hostBuilderFactory(args); - var host = Build(hostBuilder); - return GetServiceProvider(host); - }; - } + public void OnError(Exception error) { } - var hostFactory = ResolveHostFactory(assembly, waitTimeout: 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); - }; - } + public void OnNext(DiagnosticListener value) + { + if (_currentListener.Value != this) + // Ignore events that aren't for this listener + return; - return null; + if (value.Name == "Microsoft.Extensions.Hosting") + _disposable = value.Subscribe(this); } - private static object? Build(object builder) + public void OnNext(KeyValuePair value) { - var buildMethod = builder.GetType().GetMethod("Build"); - return buildMethod?.Invoke(builder, Array.Empty()); - } + if (_currentListener.Value != this) + // Ignore events that aren't for this listener + return; - private static IServiceProvider? GetServiceProvider(object? host) - { - if (host == null) + if (value.Key == "HostBuilding") + _configure?.Invoke(value.Value!); + + if (value.Key == "HostBuilt") { - return null; + _hostTcs.TrySetResult(value.Value!); + + if (_stopApplication) + // Stop the host from running further + ThrowHostAborted(); } - var hostType = host.GetType(); - var servicesProperty = hostType.GetProperty("Services", DeclaredOnlyLookup); - return (IServiceProvider?)servicesProperty?.GetValue(host); } - private sealed class HostingListener - : IObserver, - IObserver> + public object CreateHost() { - private readonly string[] _args; - private readonly MethodInfo _entryPoint; - private readonly TimeSpan _waitTimeout; - private readonly bool _stopApplication; - - private readonly TaskCompletionSource _hostTcs = new(); - private IDisposable? _disposable; - private readonly Action? _configure; - private readonly Action? _entrypointCompleted; - private static readonly AsyncLocal _currentListener = new(); - - 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; - } + using var subscription = DiagnosticListener.AllListeners.Subscribe(this); - public object CreateHost() + // 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(() => { - 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(); + Exception? exception = null; 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." - ); - } + // 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 (AggregateException) when (_hostTcs.Task.IsCompleted) + catch (TargetInvocationException tie) + when (tie.InnerException?.GetType().Name == "HostAbortedException") { - // Lets this propagate out of the call to GetAwaiter().GetResult() + // The host was stopped by our own logic } - - Debug.Assert(_hostTcs.Task.IsCompleted); - - return _hostTcs.Task.GetAwaiter().GetResult(); - } - - public void OnCompleted() - { - _disposable?.Dispose(); - } - - public void OnError(Exception error) { } - - public void OnNext(DiagnosticListener value) - { - if (_currentListener.Value != this) + catch (TargetInvocationException tie) { - // Ignore events that aren't for this listener - return; - } + exception = tie.InnerException ?? tie; - if (value.Name == "Microsoft.Extensions.Hosting") - { - _disposable = value.Subscribe(this); + // Another exception happened, propagate that to the caller + _hostTcs.TrySetException(exception); } - } - - public void OnNext(KeyValuePair value) - { - if (_currentListener.Value != this) + catch (Exception ex) { - // Ignore events that aren't for this listener - return; - } + exception = ex; - if (value.Key == "HostBuilding") + // Another exception happened, propagate that to the caller + _hostTcs.TrySetException(ex); + } + finally { - _configure?.Invoke(value.Value!); + // Signal that the entry point is completed + _entrypointCompleted?.Invoke(exception); } + }) + { + // Make sure this doesn't hang the process + IsBackground = true, + }; - if (value.Key == "HostBuilt") - { - _hostTcs.TrySetResult(value.Value!); + // Start the thread + thread.Start(); - if (_stopApplication) - { - // Stop the host from running further - ThrowHostAborted(); - } - } + 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." + ); } - - // 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() + catch (AggregateException) when (_hostTcs.Task.IsCompleted) { - Type? publicHostAbortedExceptionType = Type.GetType( - "Microsoft.Extensions.Hosting.HostAbortedException, Microsoft.Extensions.Hosting.Abstractions", - throwOnError: false - ); - if (publicHostAbortedExceptionType != null) - { - throw (Exception)Activator.CreateInstance(publicHostAbortedExceptionType)!; - } - else - { - throw new HostAbortedException(); - } + // Lets this propagate out of the call to GetAwaiter().GetResult() } - private sealed class HostAbortedException : Exception { } + 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/Models/PendingInvocation.cs b/src/MinimalLambda.Testing/Models/PendingInvocation.cs index 9409f6f6..12eb63b9 100644 --- a/src/MinimalLambda.Testing/Models/PendingInvocation.cs +++ b/src/MinimalLambda.Testing/Models/PendingInvocation.cs @@ -10,6 +10,11 @@ internal class PendingInvocation /// 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. @@ -27,11 +32,6 @@ internal class PendingInvocation /// internal required TaskCompletionSource ResponseTcs { get; init; } - /// - /// Absolute time when this invocation should be considered expired. - /// - internal required DateTimeOffset DeadlineUtc { get; init; } - /// /// Creates a pending invocation with proper TCS configuration. /// From 0b791473b9dda8688fd6fd7397b7c1976774b2f6 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 15:08:42 -0500 Subject: [PATCH 092/132] feat(tests): add support for multiple frameworks and enhance testing for lambdas - Updated projects to target `net8.0`, `net9.0`, and `net10.0` for broader framework compatibility. - Added `NoResponseLambdaTests` to verify `NoResponseLambda` behavior and response handling. - Refactored `SimpleLambdaTests` to improve error validation and ensure proper exception handling. - Fixed incorrect lambda mapping in `NoResponseLambda` for request handling. - Enhanced project references to include `NoResponseLambda` for accurate test coverage. --- .../LambdaApplicationFactory.cs | 2 +- ....Testing.UnitTests.NoResponseLambda.csproj | 2 +- .../Program.cs | 4 ++-- ...mbda.Testing.UnitTests.SimpleLambda.csproj | 2 +- .../MinimalLambda.Testing.UnitTests.csproj | 3 ++- .../NoResponseLambdaTests.cs | 24 +++++++++++++++++++ .../SimpleLambdaTests.cs | 11 +++++---- 7 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index f0d9b0a6..5a0f639e 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -462,7 +462,7 @@ private static void EnsureDepsFile() { var hostBuilder = HostFactoryResolver .ResolveHostBuilderFactory(typeof(TEntryPoint).Assembly) - ?.Invoke(Array.Empty()); + ?.Invoke([]); hostBuilder?.UseEnvironment(Environments.Development); return hostBuilder; 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 index 1f09fb4f..e26f02d7 100644 --- 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 @@ -1,7 +1,7 @@  Exe - net10.0 + net8.0;net9.0;net10.0 preview enable enable 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 index c5a97dae..601f87cc 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoResponseLambda/Program.cs @@ -5,10 +5,10 @@ await using var lambda = builder.Build(); -lambda.MapHandler(([Event] Request request) => { }); +lambda.MapHandler(([Event] NoResponseLambdaRequest request) => { }); await lambda.RunAsync(); public class NoResponseLambda; -internal record Request(string Name); +internal record NoResponseLambdaRequest(string Name); 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 index 1f09fb4f..e26f02d7 100644 --- 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 @@ -1,7 +1,7 @@  Exe - net10.0 + net8.0;net9.0;net10.0 preview enable enable 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 index 82f482d7..c41e17d3 100644 --- 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 @@ -1,6 +1,6 @@  - net10.0 + net8.0;net9.0;net10.0 enable enable false @@ -26,6 +26,7 @@ + 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..99549056 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs @@ -0,0 +1,24 @@ +namespace MinimalLambda.Testing.UnitTests; + +public class NoResponseLambdaTests : IClassFixture> +{ + private readonly LambdaTestServer _server; + + public NoResponseLambdaTests(LambdaApplicationFactory factory) + { + factory.WithCancelationToken(TestContext.Current.CancellationToken); + _server = factory.TestServer; + } + + [Fact] + public async Task NoResponseLambda_ReturnsExpectedValue() + { + var response = await _server.InvokeNoResponseAsync( + new NoResponseLambdaRequest("World"), + TestContext.Current.CancellationToken + ); + + response.WasSuccess.Should().BeTrue(); + response.Should().NotBeNull(); + } +} diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs index 823a18e3..ae9a2ec3 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs @@ -76,16 +76,17 @@ public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes() } [Fact] - public async Task SimpleLambda_ReturnsErrorPropperly() + public async Task SimpleLambda_ReturnsError() { var response = await _server.InvokeAsync( - "World", + "", TestContext.Current.CancellationToken ); - response.WasSuccess.Should().BeTrue(); response.Should().NotBeNull(); - response.Response.Should().Be("Hello World!"); + response.WasSuccess.Should().BeFalse(); + response.Error.Should().NotBeNull(); + response.Error.ErrorMessage.Should().Be("Name is required"); } [Fact] @@ -122,7 +123,7 @@ ex is InvalidOperationException [Fact] public async Task SimpleLambda_WithPreCanceledToken_CancelsInvocation() { - await using var factory = new LambdaApplicationFactory().WithCancelationToken( + await using var factory = new LambdaApplicationFactory().WithCancelationToken( TestContext.Current.CancellationToken ); await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); From 200c5a3a7dd796853cb1d838776867f1317f6b15 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 15:12:21 -0500 Subject: [PATCH 093/132] feat(tests): add `NoEventLambdaTests` and expand framework compatibility - Added `NoEventLambdaTests` to validate `NoEventLambda` responses and behavior. - Updated `MinimalLambda.Testing.UnitTests.NoEventLambda` to target `net8.0`, `net9.0`, and `net10.0`. - Modified project references to include `NoEventLambda` for improved test coverage. - Replaced `Response` model with `NoEventLambdaResponse` in `NoEventLambda` handler mapping. --- ...bda.Testing.UnitTests.NoEventLambda.csproj | 2 +- .../Program.cs | 4 +-- .../MinimalLambda.Testing.UnitTests.csproj | 1 + .../NoEventLambdaTests.cs | 25 +++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs 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 index 1f09fb4f..e26f02d7 100644 --- 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 @@ -1,7 +1,7 @@  Exe - net10.0 + net8.0;net9.0;net10.0 preview enable enable 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 index 049ba944..cd7fd34e 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs @@ -5,10 +5,10 @@ await using var lambda = builder.Build(); -lambda.MapHandler(() => new Response("Hello World!", DateTime.UtcNow)); +lambda.MapHandler(() => new NoEventLambdaResponse("Hello World!", DateTime.UtcNow)); await lambda.RunAsync(); public class NoEventLambda; -internal record Response(string Message, DateTime TimestampUtc); +internal record NoEventLambdaResponse(string Message, DateTime TimestampUtc); 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 index c41e17d3..05dbdbd3 100644 --- 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 @@ -26,6 +26,7 @@ + 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..b0c0674b --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs @@ -0,0 +1,25 @@ +namespace MinimalLambda.Testing.UnitTests; + +public class NoEventLambdaTests : IClassFixture> +{ + private readonly LambdaTestServer _server; + + public NoEventLambdaTests(LambdaApplicationFactory factory) + { + factory.WithCancelationToken(TestContext.Current.CancellationToken); + _server = factory.TestServer; + } + + [Fact] + public async Task NoEvent_ReturnsExpectedValue() + { + var response = await _server.InvokeNoEventAsync( + TestContext.Current.CancellationToken + ); + + response.Should().NotBeNull(); + response.WasSuccess.Should().BeTrue(); + response.Response.Should().NotBeNull(); + response.Response.Message.Should().Be("Hello World!"); + } +} From 2a40e27ca66a0d876a10f475c9826bea1fee8200 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 15:32:35 -0500 Subject: [PATCH 094/132] feat(tests): add `DiLambdaTests` and update framework compatibility - Introduced `DiLambdaTests` to validate `DiLambda` behavior and response mapping. - Updated `MinimalLambda.Testing.UnitTests.DiLambda` to target `net8.0`, `net9.0`, and `net10.0`. - Modified project references to include `DiLambda` for extended test coverage. - Refactored lambda mapping in `DiLambda` to use `DiLambdaRequest` and `DiLambdaResponse`. - Improved `LambdaTestServer` disposal logic with better error handling during shutdown. - Fixed parameter order in `InvokeAsync` and related extension methods for consistent usage. --- .../LambdaApplicationFactory.cs | 11 ++++++-- src/MinimalLambda.Testing/LambdaTestServer.cs | 24 +++++++---------- .../LambdaTestServerExtensions.cs | 8 +++--- ...alLambda.Testing.UnitTests.DiLambda.csproj | 2 +- .../Program.cs | 8 +++--- .../DiLambdaTests.cs | 26 +++++++++++++++++++ .../MinimalLambda.Testing.UnitTests.csproj | 1 + 7 files changed, 54 insertions(+), 26 deletions(-) create mode 100644 tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index 5a0f639e..50de4a93 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -108,8 +108,15 @@ public virtual async ValueTask DisposeAsync() if (_host != null) { - await _host.StopAsync().ConfigureAwait(false); - _host?.Dispose(); + switch (_host) + { + case IAsyncDisposable asyncDisposableHost: + await asyncDisposableHost.DisposeAsync(); + break; + case IDisposable disposableHost: + disposableHost.Dispose(); + break; + } } _disposedAsync = true; diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index d9401f01..db32a258 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -171,7 +171,14 @@ public async ValueTask DisposeAsync() return; if (State == ServerState.Running) - await StopAsync(); + try + { + await StopAsync(); + } + catch + { + // Best effort to stop the server, but don't fail the Dispose operation + } // Complete both channels to prevent new items _transactionChannel.Writer.TryComplete(); @@ -183,19 +190,6 @@ public async ValueTask DisposeAsync() // Dispose the CancellationTokenSource _shutdownCts.Dispose(); - // Dispose the host (prefer async, fallback to sync) - switch (_host) - { - case null: - break; - case IAsyncDisposable asyncDisposableHost: - await asyncDisposableHost.DisposeAsync(); - break; - case IDisposable disposableHost: - disposableHost.Dispose(); - break; - } - State = ServerState.Disposed; _disposed = true; @@ -338,7 +332,7 @@ await TaskHelpers /// which defaults to AWS Lambda's standard timeout behavior. /// /// - public async Task> InvokeAsync( + public async Task> InvokeAsync( TEvent? invokeEvent, bool noResponse, string? traceId = null, diff --git a/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs index 3c38874f..9aa2cca8 100644 --- a/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs +++ b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs @@ -4,11 +4,11 @@ public static class LambdaTestServerExtensions { extension(LambdaTestServer server) { - public Task> InvokeAsync( + public Task> InvokeAsync( TEvent invokeEvent, CancellationToken cancellationToken = default ) => - server.InvokeAsync( + server.InvokeAsync( invokeEvent, false, cancellationToken: cancellationToken @@ -17,7 +17,7 @@ public Task> InvokeAsync( public Task> InvokeNoEventAsync( CancellationToken cancellationToken = default ) => - server.InvokeAsync( + server.InvokeAsync( null, false, cancellationToken: cancellationToken @@ -27,7 +27,7 @@ public async Task InvokeNoResponseAsync( TEvent invokeEvent, CancellationToken cancellationToken = default ) => - await server.InvokeAsync( + await server.InvokeAsync( invokeEvent, true, cancellationToken: cancellationToken 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 index 1f09fb4f..e26f02d7 100644 --- 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 @@ -1,7 +1,7 @@  Exe - net10.0 + net8.0;net9.0;net10.0 preview enable enable 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 index ed58a89a..5cae4ce2 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.DiLambda/Program.cs @@ -30,10 +30,10 @@ ); lambda.MapHandler( - ([Event] Request request, IService service, ILogger logger) => + ([Event] DiLambdaRequest diLambdaRequest, IService service, ILogger logger) => { logger.LogInformation("Lambda handler"); - return new Response(service.GetMessage(request.Name), DateTime.UtcNow); + return new DiLambdaResponse(service.GetMessage(diLambdaRequest.Name), DateTime.UtcNow); } ); @@ -49,9 +49,9 @@ public class DiLambda; -internal record Request(string Name); +internal record DiLambdaRequest(string Name); -internal record Response(string Message, DateTime TimestampUtc); +internal record DiLambdaResponse(string Message, DateTime TimestampUtc); internal interface IService { 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..b46b5f4c --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs @@ -0,0 +1,26 @@ +namespace MinimalLambda.Testing.UnitTests; + +public class DiLambdaTests : IClassFixture> +{ + private readonly LambdaTestServer _server; + + public DiLambdaTests(LambdaApplicationFactory factory) + { + factory.WithCancelationToken(TestContext.Current.CancellationToken); + _server = factory.TestServer; + } + + [Fact] + public async Task NoEvent_ReturnsExpectedValue() + { + var response = await _server.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!"); + } +} 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 index 05dbdbd3..aadc3e10 100644 --- 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 @@ -26,6 +26,7 @@ + From 7aca35db8ecaee21fef91ab3dc615072b2e4e881 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 19:34:50 -0500 Subject: [PATCH 095/132] feat(tests): add advanced lifecycle tests for `DiLambda` - Introduced tests for initialization states, error handling, and shutdown behavior in `DiLambda`. - Added `DiLambda_InitStopped`, `DiLambda_InitThrowsException`, and `DiLambda_ShutdownThrowsException`. - Created `AutoNSubstituteDataAttribute` for simplified test data generation with AutoFixture and NSubstitute. - Updated `LambdaTestServer` to handle post-init errors and improved shutdown concurrency checks. - Modified project references to include `AutoFixture.Xunit3` and `AutoFixture.AutoNSubstitute`. - Enhanced `AssemblyInfo` for proxy generation support with `DynamicProxyGenAssembly2`. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 21 +++-- .../Properties/AssemblyInfo.cs | 1 + .../AutoNSubstituteDataAttribute.cs | 15 ++++ .../DiLambdaTests.cs | 86 ++++++++++++++++++- .../MinimalLambda.Testing.UnitTests.csproj | 2 + 5 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/AutoNSubstituteDataAttribute.cs diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index db32a258..190b3170 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -269,10 +269,10 @@ await TaskHelpers if (_initCompletionTcs.Task.IsCompleted) { - State = - _initCompletionTcs.Task.Result.InitStatus == InitStatus.InitCompleted - ? ServerState.Running - : ServerState.Stopped; + if (_initCompletionTcs.Task.Result.InitStatus == InitStatus.InitCompleted) + State = ServerState.Running; + else + await StopAsync(CancellationToken.None); return _initCompletionTcs.Task.Result; } @@ -433,19 +433,19 @@ public async Task> InvokeAsync( /// public async Task StopAsync(CancellationToken cancellationToken = default) { - if (State != ServerState.Running) - throw new InvalidOperationException( - "TestServer is not running and as such cannot be stopped." - ); + if (State is <= ServerState.Created or >= ServerState.Stopping) + throw new InvalidOperationException($"TestServer cannot be stopped in state {State}."); State = ServerState.Stopping; await _shutdownCts.CancelAsync(); + var shutdownTask = _host?.WaitForShutdownAsync(cancellationToken) ?? Task.CompletedTask; + _applicationLifetime!.StopApplication(); await TaskHelpers - .WhenAll(_entryPointCompletion, _processingTask!) + .WhenAll(_entryPointCompletion, _processingTask!, shutdownTask) .UnwrapAndThrow("Exception(s) encountered while running StopAsync") .WaitAsync(cancellationToken); @@ -554,6 +554,7 @@ await CreateCompletionAsync(RequestType.PostError, transaction.Request) private async Task HandlePostInitErrorAsync(LambdaHttpTransaction transaction) { if (State == ServerState.Starting) + { _initCompletionTcs.SetResult( new InitResponse { @@ -565,6 +566,8 @@ private async Task HandlePostInitErrorAsync(LambdaHttpTransaction transaction) InitStatus = InitStatus.InitError, } ); + return; + } throw new InvalidOperationException( "TestServer is already started and as such an initialization error cannot be reported." 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 index e3d42db9..3af1a611 100644 --- 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 @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("MinimalLambda.Testing.UnitTests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] 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 index b46b5f4c..cd92d4b9 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs @@ -1,3 +1,8 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using MinimalLambda.UnitTests; +using NSubstitute.ExceptionExtensions; + namespace MinimalLambda.Testing.UnitTests; public class DiLambdaTests : IClassFixture> @@ -11,7 +16,7 @@ public DiLambdaTests(LambdaApplicationFactory factory) } [Fact] - public async Task NoEvent_ReturnsExpectedValue() + public async Task DiLambda_ReturnsExpectedValue() { var response = await _server.InvokeAsync( new DiLambdaRequest("World"), @@ -23,4 +28,83 @@ public async Task NoEvent_ReturnsExpectedValue() response.Response.Should().NotBeNull(); response.Response.Message.Should().Be("Hello World!"); } + + [Theory] + [AutoNSubstituteData] + internal async Task DiLambda_InitStopped(ILifecycleService lifecycleService) + { + await using var factory = new LambdaApplicationFactory() + .WithCancelationToken(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); + } + + [Theory] + [AutoNSubstituteData] + internal async Task DiLambda_InitThrowsException(ILifecycleService lifecycleService) + { + await using var factory = new LambdaApplicationFactory() + .WithCancelationToken(TestContext.Current.CancellationToken) + .WithHostBuilder(builder => + builder.ConfigureServices( + (_, services) => + { + services.RemoveAll(); + services.AddSingleton(_ => lifecycleService); + } + ) + ); + + lifecycleService.OnStart().Throws(new Exception("Test init error")); + + var initResult = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + 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() + .WithCancelationToken(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() + .WithMessage("Test init error"); + } } 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 index aadc3e10..9b87cc00 100644 --- 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 @@ -7,12 +7,14 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive From efe37b39d4ddcfef155d60cf539f2deefee67c04 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 20:08:22 -0500 Subject: [PATCH 096/132] fix(runtime): handle `TaskCanceledException` more accurately during Lambda shutdown - Added conditional check to distinguish cancellation due to timeout from LambdaBootstrap-related exceptions. - Improved error reporting for graceful shutdown failures caused by cancellation. --- .../Runtime/LambdaHostedService.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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) { From 47211d12764b6aa7f482478d909e9ef206dc6e5c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 20:08:51 -0500 Subject: [PATCH 097/132] feat(testing): add cancellation token support to `LambdaTestingHttpHandler` - Added `CancellationToken` to `LambdaTestingHttpHandler` constructor for better shutdown handling. - Updated `SendAsync` to use a combined cancellation token for proper transaction management. - Modified `LambdaTestServer` to pass `_shutdownCts.Token` to `LambdaTestingHttpHandler`. --- .../LambdaHttpHandler.cs | 19 +++++++++++++------ src/MinimalLambda.Testing/LambdaTestServer.cs | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaHttpHandler.cs b/src/MinimalLambda.Testing/LambdaHttpHandler.cs index 300979b2..b01b73c4 100644 --- a/src/MinimalLambda.Testing/LambdaHttpHandler.cs +++ b/src/MinimalLambda.Testing/LambdaHttpHandler.cs @@ -6,19 +6,26 @@ 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) - : HttpMessageHandler +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(cancellationToken); + var bytes = await originalContent.ReadAsByteArrayAsync(cts.Token); var bufferedContent = new ByteArrayContent(bytes); foreach (var header in originalContent.Headers) @@ -32,12 +39,12 @@ CancellationToken cancellationToken var transaction = LambdaHttpTransaction.Create(request); // Register cancellation to cancel the transaction TCS - await using var registration = cancellationToken.Register(() => transaction.Cancel()); + await using var registration = cts.Token.Register(() => transaction.Cancel()); // Send transaction to server try { - await transactionChannel.Writer.WriteAsync(transaction, cancellationToken); + await transactionChannel.Writer.WriteAsync(transaction, cts.Token); } catch (ChannelClosedException) { @@ -45,7 +52,7 @@ CancellationToken cancellationToken var canceled = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ); - canceled.TrySetCanceled(cancellationToken); + canceled.TrySetCanceled(cts.Token); return await canceled.Task; } diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 190b3170..67da2f77 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -203,7 +203,7 @@ internal void SetHost(IHost host) } internal HttpMessageHandler CreateHandler() => - new LambdaTestingHttpHandler(_transactionChannel); + new LambdaTestingHttpHandler(_transactionChannel, _shutdownCts.Token); // ┌──────────────────────────────────────────────────────────┐ // │ Public API │ From 25b3be2950cd4f006da9b2ade353700e0aca237d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 20:49:03 -0500 Subject: [PATCH 098/132] fix(testing): improve exception handling and shutdown reliability - Updated `DiLambdaTests` to include better validation with nested `AggregateException`. - Refactored `TaskHelpers` to handle additional exception scenarios and improve fault handling. - Simplified shutdown logic in `LambdaTestServer` by removing unused `shutdownTask`. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 4 +--- src/MinimalLambda.Testing/TaskHelpers.cs | 21 ++++++++++++++----- .../DiLambdaTests.cs | 1 + 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 67da2f77..a57f6612 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -440,12 +440,10 @@ public async Task StopAsync(CancellationToken cancellationToken = default) await _shutdownCts.CancelAsync(); - var shutdownTask = _host?.WaitForShutdownAsync(cancellationToken) ?? Task.CompletedTask; - _applicationLifetime!.StopApplication(); await TaskHelpers - .WhenAll(_entryPointCompletion, _processingTask!, shutdownTask) + .WhenAll(_entryPointCompletion, _processingTask!) .UnwrapAndThrow("Exception(s) encountered while running StopAsync") .WaitAsync(cancellationToken); diff --git a/src/MinimalLambda.Testing/TaskHelpers.cs b/src/MinimalLambda.Testing/TaskHelpers.cs index 2224c67c..dede2c6d 100644 --- a/src/MinimalLambda.Testing/TaskHelpers.cs +++ b/src/MinimalLambda.Testing/TaskHelpers.cs @@ -16,12 +16,23 @@ internal static async Task WhenAll(params Task[] tasks) private static Exception[] ExtractExceptions(Task[] tasks) => tasks - .Where(t => t is { IsFaulted: true, Exception: not null }) - .Select(e => - e.Exception!.InnerExceptions.Count > 1 - ? e.Exception - : e.Exception.InnerExceptions[0] + .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) diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs index cd92d4b9..97ca79c3 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs @@ -103,6 +103,7 @@ internal async Task DiLambda_ShutdownThrowsException(ILifecycleService lifecycle await factory.TestServer.StopAsync(TestContext.Current.CancellationToken); (await act.Should().ThrowAsync()) + .WithInnerException() .WithInnerException() .WithInnerException() .WithMessage("Test init error"); From 2c9386da092b20fb76869e3a970611b9c6f91162 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 20:55:19 -0500 Subject: [PATCH 099/132] fix(tests): simplify exception assertion in `SimpleLambdaTests` - Updated exception validation to directly assert `InvalidOperationException` instead of `AggregateException`. - Used `WithMessage` for improved readability and precision in error message checks. --- .../SimpleLambdaTests.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs index ae9a2ec3..f5b10583 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs @@ -110,13 +110,10 @@ public async Task SimpleLambda_ErrorsArePropagated() var act = async () => await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); - (await act.Should().ThrowAsync()) - .And.InnerExceptions.Should() - .ContainSingle(ex => - ex is InvalidOperationException - && ex.Message.Contains( - "Unexpected request received from the Lambda HTTP handler: GET http://http//localhost:3002/2018-06-01/runtime/invocation/next" - ) + await act.Should() + .ThrowAsync() + .WithMessage( + "Unexpected request received from the Lambda HTTP handler: GET http://http//localhost:3002/2018-06-01/runtime/invocation/next" ); } From 948800cd17d60eb19d7982700039b4488894034c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 21:09:20 -0500 Subject: [PATCH 100/132] fix(tests): refactor `DiLambdaTests` and improve test configuration - Replaced `[Theory]` with `[Fact]` for `DiLambda_InitStopped` and `DiLambda_InitThrowsException`. - Introduced `Substitute.For` for proper mocking in both tests. - Adjusted lifecycle service initialization to enhance test independence. - Fixed test assembly settings to disable parallelization in `AssemblyInfo.cs`. - Adjusted method call order in `LambdaTestServer` for improved lifecycle state handling. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 4 ++-- .../DiLambdaTests.cs | 13 +++++++------ .../Properties/AssemblyInfo.cs | 1 + 3 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/Properties/AssemblyInfo.cs diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index a57f6612..d91ddfc8 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -252,11 +252,11 @@ public async Task StartAsync(CancellationToken cancellationToken = State = ServerState.Starting; - _applicationLifetime = _host.Services.GetRequiredService(); - // Start the host await _host.StartAsync(cts.Token); + _applicationLifetime = _host.Services.GetRequiredService(); + // Start background processing _processingTask = Task.Run(ProcessTransactionsAsync, cts.Token); diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs index 97ca79c3..b705a405 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs @@ -29,10 +29,10 @@ public async Task DiLambda_ReturnsExpectedValue() response.Response.Message.Should().Be("Hello World!"); } - [Theory] - [AutoNSubstituteData] - internal async Task DiLambda_InitStopped(ILifecycleService lifecycleService) + [Fact] + internal async Task DiLambda_InitStopped() { + var lifecycleService = Substitute.For(); await using var factory = new LambdaApplicationFactory() .WithCancelationToken(TestContext.Current.CancellationToken) .WithHostBuilder(builder => @@ -51,10 +51,11 @@ internal async Task DiLambda_InitStopped(ILifecycleService lifecycleService) initResult.InitStatus.Should().Be(InitStatus.HostExited); } - [Theory] - [AutoNSubstituteData] - internal async Task DiLambda_InitThrowsException(ILifecycleService lifecycleService) + [Fact] + internal async Task DiLambda_InitThrowsException() { + var lifecycleService = Substitute.For(); + await using var factory = new LambdaApplicationFactory() .WithCancelationToken(TestContext.Current.CancellationToken) .WithHostBuilder(builder => diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/Properties/AssemblyInfo.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..e5cc5d40 --- /dev/null +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/Properties/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: CollectionBehavior(DisableTestParallelization = true)] From 1442e9eac4eb4b85401ed41e5ab95040912ac65b Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Fri, 12 Dec 2025 21:27:38 -0500 Subject: [PATCH 101/132] feat(tests): enhance `NoEventLambda` configuration and add new test case - Updated `NoEventLambda` to utilize `IConfiguration` for dynamic message handling in the handler. - Added a new test, `NoEvent_ConfigurationCanBeOverwritten`, to validate custom configuration support. - Introduced in-memory configuration dictionary for improved test flexibility and coverage. --- .../Program.cs | 12 +++++++-- .../NoEventLambdaTests.cs | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) 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 index cd7fd34e..238e3d57 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Lambdas/MinimalLambda.Testing.UnitTests.NoEventLambda/Program.cs @@ -1,11 +1,19 @@ -using Microsoft.Extensions.Hosting; +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(() => new NoEventLambdaResponse("Hello World!", DateTime.UtcNow)); +lambda.MapHandler( + (IConfiguration configuration) => + new NoEventLambdaResponse(configuration["MESSAGE"] ?? "NOT_FOUND", DateTime.UtcNow) +); await lambda.RunAsync(); diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs index b0c0674b..2cbfe12e 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Configuration; + namespace MinimalLambda.Testing.UnitTests; public class NoEventLambdaTests : IClassFixture> @@ -22,4 +24,28 @@ public async Task NoEvent_ReturnsExpectedValue() response.Response.Should().NotBeNull(); response.Response.Message.Should().Be("Hello World!"); } + + [Fact] + public async Task NoEvent_ConfigurationCanBeOverwritten() + { + var factory = new LambdaApplicationFactory() + .WithCancelationToken(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!"); + } } From 7c70f3703fd46ee9b23e670664b5799deabfac39 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 08:06:53 -0500 Subject: [PATCH 102/132] feat(tests): extend `DiLambdaTests` with Autofac container support - Added tests to verify that the DI container in `DiLambda` can be replaced with Autofac. - Introduced `DiLambda_DiContainerCanBeReplaced` and `DiLambda_DiContainerCanBeReplacedWithFactory`. - Updated project files to reference `Autofac.Extensions.DependencyInjection` package (version 10.0.0). --- Directory.Packages.props | 1 + .../DiLambdaTests.cs | 74 +++++++++++++++++++ .../MinimalLambda.Testing.UnitTests.csproj | 1 + 3 files changed, 76 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index e524a73f..67464db4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs index b705a405..b9f50ce3 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs @@ -1,3 +1,5 @@ +using Autofac; +using Autofac.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using MinimalLambda.UnitTests; @@ -109,4 +111,76 @@ internal async Task DiLambda_ShutdownThrowsException(ILifecycleService lifecycle .WithInnerException() .WithMessage("Test init error"); } + + [Theory] + [AutoNSubstituteData] + internal async Task DiLambda_DiContainerCanBeReplaced( + ILifecycleService lifecycleService, + IService service + ) + { + await using var factory = new LambdaApplicationFactory() + .WithCancelationToken(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() + .WithCancelationToken(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/MinimalLambda.Testing.UnitTests.csproj b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/MinimalLambda.Testing.UnitTests.csproj index 9b87cc00..657cdaec 100644 --- 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 @@ -7,6 +7,7 @@ + From 5b58c01d70462a38fb5e24224b22fc0e7c4c7850 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 08:19:37 -0500 Subject: [PATCH 103/132] feat(tests): improve `NoResponseLambda` test coverage and enhance shutdown reliability - Added `NoResponseLambda_ServicesIsAccessible` to ensure DI services can be resolved. - Introduced `NoResponseLambda_DisposeCanBeCalledMultipleTimes` to validate safe multi-disposal. - Updated `LambdaTestServer` shutdown to suppress exceptions for better reliability. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 10 ++----- .../NoResponseLambdaTests.cs | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index d91ddfc8..00297228 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -171,14 +171,8 @@ public async ValueTask DisposeAsync() return; if (State == ServerState.Running) - try - { - await StopAsync(); - } - catch - { - // Best effort to stop the server, but don't fail the Dispose operation - } + // Best effort to stop the server, but don't fail the Dispose operation + await StopAsync().ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); // Complete both channels to prevent new items _transactionChannel.Writer.TryComplete(); diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs index 99549056..44002b81 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs @@ -1,3 +1,6 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + namespace MinimalLambda.Testing.UnitTests; public class NoResponseLambdaTests : IClassFixture> @@ -21,4 +24,29 @@ public async Task NoResponseLambda_ReturnsExpectedValue() response.WasSuccess.Should().BeTrue(); response.Should().NotBeNull(); } + + [Fact] + public void NoResponseLambda_ServicesIsAccessible() + { + var act = () => _server.Services.GetRequiredService(); + + act.Should().NotThrow(); + } + + [Fact] + public async Task NoResponseLambda_DisposeCanBeCalledMultipleTimes() + { + await using var factory = + new LambdaApplicationFactory().WithCancelationToken( + TestContext.Current.CancellationToken + ); + + var act = async () => + { + await factory.TestServer.DisposeAsync(); + await factory.TestServer.DisposeAsync(); + }; + + await act.Should().NotThrowAsync(); + } } From 2b371b0a9bcf7ef7a1de98187e790e2bb1ecef4a Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 08:48:13 -0500 Subject: [PATCH 104/132] refactor(testing): remove unused `Fail` method from `LambdaHttpTransaction` - Deleted the `Fail` method, which is no longer used in the codebase. - Simplified the `LambdaHttpTransaction` class by reducing unnecessary methods. --- src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs b/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs index 1e64f69c..cb3a6015 100644 --- a/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs +++ b/src/MinimalLambda.Testing/Models/LambdaHttpTransaction.cs @@ -34,11 +34,6 @@ internal static LambdaHttpTransaction Create(HttpRequestMessage request) => /// internal bool Respond(HttpResponseMessage response) => ResponseTcs.TrySetResult(response); - /// - /// Completes the transaction with an exception. - /// - internal bool Fail(Exception exception) => ResponseTcs.TrySetException(exception); - /// /// Completes the transaction with cancellation. /// From fe14caaa3c86efa1007618afd4c8d6b7976c22df Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 08:57:13 -0500 Subject: [PATCH 105/132] refactor(testing): remove unused `CreateHostBuilder` method and simplify `LambdaApplicationFactory` - Removed the `CreateHostBuilder` method, which was no longer being used in the codebase. - Eliminated references to `CreateHostBuilder` in the `DelegatedLambdaApplicationFactory` class. - Updated `GlobalUsings.cs` by removing unused `JetBrains.Annotations`. --- .../LambdaApplicationFactory.cs | 26 ------------------- .../GlobalUsings.cs | 1 - 2 files changed, 27 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index 50de4a93..685e7b73 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -198,7 +198,6 @@ Action configuration var factory = new DelegatedLambdaApplicationFactory( ServerOptions, CreateHost, - CreateHostBuilder, GetTestAssemblies, builder => { @@ -456,25 +455,6 @@ private static void EnsureDepsFile() throw new InvalidOperationException($"Missing deps file: {depsFile.FullName}"); } - /// - /// Creates a used to set up . - /// - /// - /// The default implementation of this method looks for a public static IHostBuilder CreateHostBuilder(string[] args) - /// method defined on the entry point of the assembly of and invokes it passing an empty string - /// array as arguments. - /// - /// A instance. - protected virtual IHostBuilder? CreateHostBuilder() - { - var hostBuilder = HostFactoryResolver - .ResolveHostBuilderFactory(typeof(TEntryPoint).Assembly) - ?.Invoke([]); - - hostBuilder?.UseEnvironment(Environments.Development); - return hostBuilder; - } - /// /// Creates the with the bootstrapped application in . /// The host is built but not started. The will start the host @@ -524,28 +504,23 @@ protected virtual void Dispose(bool disposing) private sealed class DelegatedLambdaApplicationFactory : LambdaApplicationFactory { private readonly Func _createHost; - private readonly Func _createHostBuilder; private readonly Func> _getTestAssemblies; public DelegatedLambdaApplicationFactory( LambdaServerOptions options, Func createHost, - Func createHostBuilder, Func> getTestAssemblies, Action configureWebHost ) { ServerOptions = options; _createHost = createHost; - _createHostBuilder = createHostBuilder; _getTestAssemblies = getTestAssemblies; _configuration = configureWebHost; } protected override IHost CreateHost(IHostBuilder builder) => _createHost(builder); - protected override IHostBuilder? CreateHostBuilder() => _createHostBuilder(); - protected override IEnumerable GetTestAssemblies() => _getTestAssemblies(); protected override void ConfigureWebHost(IHostBuilder builder) => _configuration(builder); @@ -556,7 +531,6 @@ Action configuration new DelegatedLambdaApplicationFactory( ServerOptions, _createHost, - _createHostBuilder, _getTestAssemblies, builder => { diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs index 9c6a94a2..bd60b342 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/GlobalUsings.cs @@ -1,5 +1,4 @@ global using AutoFixture; global using AwesomeAssertions; -global using JetBrains.Annotations; global using NSubstitute; global using Xunit; From 75f22b3ad605cf7e15c7d8211243623facaff15e Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 08:58:48 -0500 Subject: [PATCH 106/132] fix(testing): adjust `LambdaTestServer` initialization for improved reliability - Moved `_applicationLifetime` initialization before starting the host to avoid potential null issues. - Ensured proper lifecycle state management by reordering method calls. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 00297228..4205c650 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -246,11 +246,11 @@ public async Task StartAsync(CancellationToken cancellationToken = State = ServerState.Starting; + _applicationLifetime = _host.Services.GetRequiredService(); + // Start the host await _host.StartAsync(cts.Token); - _applicationLifetime = _host.Services.GetRequiredService(); - // Start background processing _processingTask = Task.Run(ProcessTransactionsAsync, cts.Token); From 5d963eaf21d7b069c67ca222b1e2b1f64c73d212 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 09:36:47 -0500 Subject: [PATCH 107/132] feat(tests): add unit tests for `DictionaryExtensions` - Introduced `DictionaryExtensionsTest` to validate `GetRequired` and `AddRequired` methods. - Added tests to ensure proper exception handling and value retrieval for missing or duplicate keys. - Verified that new keys can be added successfully without throwing exceptions. --- .../DictionaryExtensionsTest.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs 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..e0ed1540 --- /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 IDictionary _dictionary = new Dictionary + { + ["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."); + } +} From d3db8bf0320e33f7201d77a6bd02d1dc9e256a3e Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 09:37:02 -0500 Subject: [PATCH 108/132] fix(testing): improve exception handling for disposed services in host lifecycle - Added `ObjectDisposedException` handling when accessing `IHostApplicationLifetime`. - Adjusted `StartAsync` and `LambdaTestServer` methods to ensure best effort during service retrieval. - Updated `_applicationLifetime` usage to prevent potential null reference issues. --- .../DeferredHostBuilder.cs | 22 ++++++++++--------- src/MinimalLambda.Testing/LambdaTestServer.cs | 12 ++++++++-- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/MinimalLambda.Testing/DeferredHostBuilder.cs b/src/MinimalLambda.Testing/DeferredHostBuilder.cs index 2645da0c..4286e85c 100644 --- a/src/MinimalLambda.Testing/DeferredHostBuilder.cs +++ b/src/MinimalLambda.Testing/DeferredHostBuilder.cs @@ -154,22 +154,24 @@ public async ValueTask DisposeAsync() public async Task StartAsync(CancellationToken cancellationToken = default) { + IHostApplicationLifetime? lifetime = null; + try + { + lifetime = host.Services.GetService(); + } + catch (ObjectDisposedException) + { + // Best effort + } + // 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(), + await using var reg2 = lifetime?.ApplicationStarted.UnsafeRegister( + _ => hostStartedTcs.TrySetResult(), null ); - // REVIEW: This will deadlock if the application creates the host but never calls start. - // This is mitigated by the cancellationToken - // but it's rarely a valid token for Start - await using var reg2 = host - .Services.GetRequiredService() - .ApplicationStarted.UnsafeRegister(_ => hostStartedTcs.TrySetResult(), null); - await hostStartedTcs.Task.ConfigureAwait(false); } diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 4205c650..7cbfb43b 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -246,7 +246,15 @@ public async Task StartAsync(CancellationToken cancellationToken = State = ServerState.Starting; - _applicationLifetime = _host.Services.GetRequiredService(); + try + { + _applicationLifetime = + _host.Services.GetRequiredService(); + } + catch (ObjectDisposedException) + { + // Best effort + } // Start the host await _host.StartAsync(cts.Token); @@ -434,7 +442,7 @@ public async Task StopAsync(CancellationToken cancellationToken = default) await _shutdownCts.CancelAsync(); - _applicationLifetime!.StopApplication(); + _applicationLifetime?.StopApplication(); await TaskHelpers .WhenAll(_entryPointCompletion, _processingTask!) From 5a680b09162c300c32183d3e967c409645e0aa31 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 09:37:15 -0500 Subject: [PATCH 109/132] feat(tests): add internal visibility for `MinimalLambda.Testing.UnitTests` - Added `InternalsVisibleTo` attribute in `AssemblyInfo.cs` to expose internals to the test project. --- src/MinimalLambda.Testing/Properties/AssemblyInfo.cs | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/MinimalLambda.Testing/Properties/AssemblyInfo.cs 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")] From 4dde9ead41bc47108bea42fd295173bcc927748a Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 09:49:16 -0500 Subject: [PATCH 110/132] fix(ci): update SonarQube exclusions for specific file - Added `src/MinimalLambda.Testing/HostFactoryResolver.cs` to `sonar.exclusions` in CI workflows. - Updated `pr-build.yaml` and `main-build.yaml` to include the new exclusion. - Updated `codecov.yml` to exclude the same file from coverage tracking. --- .github/workflows/main-build.yaml | 2 +- .github/workflows/pr-build.yaml | 2 +- codecov.yml | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) 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/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" From 405bd657c3b1cba3e50c64c26c44e30617ab22bd Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 09:50:37 -0500 Subject: [PATCH 111/132] fix(ci): add `testing` as an allowed commit scope in PR title validation - Updated `.github/workflows/validate-pr-title.yaml` to include `testing` in the list of allowed scopes. --- .github/workflows/validate-pr-title.yaml | 1 + 1 file changed, 1 insertion(+) 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 From 216b5701b75da282af521b238b369ca1044f67e0 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 10:49:52 -0500 Subject: [PATCH 112/132] feat(tests): add concurrency test for `SimpleLambda` without `Start` - Introduced `SimpleLambda_WorksWhenInvokeCalledMultipleTimes_WithoutStart` unit test. - Validated concurrent invocations with expected responses in order. - Ensured successful execution without requiring initial `Start`. --- .../SimpleLambdaTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs index f5b10583..291949cf 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs @@ -75,6 +75,38 @@ public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes() ); } + [Fact] + public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes_WithoutStart() + { + await _server.StartAsync(TestContext.Current.CancellationToken); + + // Launch 5 concurrent invocations + var tasks = Enumerable + .Range(1, 5) + .Select(i => + _server.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() { From 421f0c2e7550fc2b5ee6afba1530ba731096a253 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 10:50:26 -0500 Subject: [PATCH 113/132] fix(testing): improve exception messages in `LambdaTestServer` - Replaced hardcoded exception messages with interpolated strings using `nameof(LambdaTestServer)`. - Enhanced clarity by including current and expected server states in specific exceptions. - Ensured consistency and maintainability of error messages throughout the code. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 7cbfb43b..7204b538 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -236,7 +236,7 @@ public async Task StartAsync(CancellationToken cancellationToken = { if (State != ServerState.Created) throw new InvalidOperationException( - "TestServer has already been started and cannot be restarted." + $"{nameof(LambdaTestServer)} has already been started and cannot be restarted." ); if (_host is null) @@ -280,7 +280,7 @@ await TaskHelpers } throw new InvalidOperationException( - "TestServer initialization failed with neither an error nor completion." + $"{nameof(LambdaTestServer)} initialization failed with neither an error nor completion." ); } finally @@ -353,7 +353,7 @@ public async Task> InvokeAsync( if (State != ServerState.Running) throw new InvalidOperationException( - "TestServer is not Running and as such an event cannot be invoked." + $"{nameof(LambdaTestServer)} is in state {State} and cannot invoke an event. Expected state: Running." ); using var cts = LinkedCtsWithInvocationDeadline(cancellationToken); @@ -436,7 +436,9 @@ public async Task> InvokeAsync( public async Task StopAsync(CancellationToken cancellationToken = default) { if (State is <= ServerState.Created or >= ServerState.Stopping) - throw new InvalidOperationException($"TestServer cannot be stopped in state {State}."); + throw new InvalidOperationException( + $"{nameof(LambdaTestServer)} cannot be stopped in state {State}." + ); State = ServerState.Stopping; @@ -570,7 +572,7 @@ private async Task HandlePostInitErrorAsync(LambdaHttpTransaction transaction) } throw new InvalidOperationException( - "TestServer is already started and as such an initialization error cannot be reported." + $"{nameof(LambdaTestServer)} is already started and as such an initialization error cannot be reported." ); } From 0e52cd433835d9057707dd4f5f6a9230a135e63a Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 11:01:24 -0500 Subject: [PATCH 114/132] feat(testing): add `InitAlreadyCompleted` status to `LambdaTestServer` - Introduced `InitAlreadyCompleted` to `InitStatus` for improved state representation. - Updated `StartAsync` to return `InitAlreadyCompleted` when already initialized. - Modified state checks to handle `InitAlreadyCompleted` in initialization flow. - Adjusted error handling logic for enhanced clarity and accurate status reporting. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 14 +++++++++++--- src/MinimalLambda.Testing/Models/InitStatus.cs | 5 +++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 7204b538..7795b989 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -234,7 +234,9 @@ public async Task StartAsync(CancellationToken cancellationToken = await _startSemaphore.WaitAsync(cancellationToken); try { - if (State != ServerState.Created) + 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." ); @@ -267,7 +269,10 @@ await TaskHelpers .UnwrapAndThrow("Exception(s) encountered while running StartAsync"); if (_entryPointCompletion.IsCompleted) + { + State = ServerState.Stopped; return new InitResponse { InitStatus = InitStatus.HostExited }; + } if (_initCompletionTcs.Task.IsCompleted) { @@ -342,10 +347,13 @@ public async Task> InvokeAsync( ) { // inorder to allow - if (State == ServerState.Created) + if (State != ServerState.Running) { var initResponse = await StartAsync(cancellationToken); - if (initResponse.InitStatus != InitStatus.InitCompleted) + 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()}." ); diff --git a/src/MinimalLambda.Testing/Models/InitStatus.cs b/src/MinimalLambda.Testing/Models/InitStatus.cs index 4e869c38..ae8f0f92 100644 --- a/src/MinimalLambda.Testing/Models/InitStatus.cs +++ b/src/MinimalLambda.Testing/Models/InitStatus.cs @@ -19,4 +19,9 @@ public enum InitStatus /// Initialization of the Lambda failed, and the Host process exited. /// HostExited, + + /// + /// Initialization has already been completed and cannot be repeated. + /// + InitAlreadyCompleted, } From 6ef435577de08e219293bd96869bf61513c9b4c6 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 11:38:34 -0500 Subject: [PATCH 115/132] feat(testing): add convenience methods to `LambdaTestServerExtensions` - Introduced new extension methods for `LambdaTestServer` to simplify invocation patterns. - `InvokeAsync`: Simplifies invoking Lambda with an event and expecting a response. - `InvokeNoEventAsync`: Allows invocation without providing an event. - `InvokeNoResponseAsync`: Enables invocation with event but no response expectation. - Added comprehensive XML documentation to assist with usage and clarify behavior. - Improved usability by wrapping `LambdaTestServer.InvokeAsync` for common scenarios. --- .../LambdaTestServerExtensions.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs index 9aa2cca8..37e31c26 100644 --- a/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs +++ b/src/MinimalLambda.Testing/LambdaTestServerExtensions.cs @@ -1,9 +1,42 @@ 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 @@ -14,6 +47,33 @@ public Task> InvokeAsync( 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 ) => @@ -23,6 +83,34 @@ public Task> InvokeNoEventAsync( 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 From c78076b82a46cdde44f330f3f2eff221abadb14d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 11:44:28 -0500 Subject: [PATCH 116/132] feat(testing): add generic `InvocationResponse` model - Extracted `InvocationResponse` from `InvocationResponse` to enable typed responses. - Updated `InvocationResponse` to serve as the non-generic base class. - Added XML documentation for improved clarity of usage and behavior. - Marked all mutable properties on `InvocationResponse` classes with `init` for immutability. --- .../Models/InvocationResponse.cs | 44 +++++++------------ .../Models/InvocationResponseT.cs | 31 +++++++++++++ 2 files changed, 48 insertions(+), 27 deletions(-) create mode 100644 src/MinimalLambda.Testing/Models/InvocationResponseT.cs diff --git a/src/MinimalLambda.Testing/Models/InvocationResponse.cs b/src/MinimalLambda.Testing/Models/InvocationResponse.cs index 46d6c69f..354e2802 100644 --- a/src/MinimalLambda.Testing/Models/InvocationResponse.cs +++ b/src/MinimalLambda.Testing/Models/InvocationResponse.cs @@ -1,33 +1,21 @@ namespace MinimalLambda.Testing; /// -/// Represents the result of a Lambda function invocation, containing either a successful response -/// or error information. +/// Represents the base result of a Lambda function invocation, containing success status and +/// error information. /// -/// The expected type of the successful Lambda response. /// /// -/// 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. +/// 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 : 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 set; } -} - public class InvocationResponse { /// @@ -41,18 +29,20 @@ public class InvocationResponse /// 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 set; } + public ErrorResponse? Error { get; internal init; } /// /// Gets a value indicating whether the Lambda function invocation completed successfully. /// /// - /// if the invocation succeeded and contains valid data; + /// 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 which of or contains - /// meaningful data for the invocation result. + /// 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 set; } + 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; } +} From c2907659a4e626d1bcc637da9f22c3da20b082f5 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 11:53:13 -0500 Subject: [PATCH 117/132] fix(testing): simplify host disposal logic in `LambdaApplicationFactory` - Replaced switch-case disposal logic with direct calls to `StopAsync` and `Dispose`. - Improved code clarity by replacing null check with `is not null`. - Ensured consistent async disposal flow for `_host` and `_server`. --- .../LambdaApplicationFactory.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index 685e7b73..499969e8 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -106,17 +106,10 @@ public virtual async ValueTask DisposeAsync() if (_server != null) await _server.DisposeAsync().ConfigureAwait(false); - if (_host != null) + if (_host is not null) { - switch (_host) - { - case IAsyncDisposable asyncDisposableHost: - await asyncDisposableHost.DisposeAsync(); - break; - case IDisposable disposableHost: - disposableHost.Dispose(); - break; - } + await _host.StopAsync().ConfigureAwait(false); + _host?.Dispose(); } _disposedAsync = true; From cdbd46e9940e01755a70e7db0e6af07961e0bff4 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 11:53:43 -0500 Subject: [PATCH 118/132] refactor(testing): replace unused variable with discard in `LambdaApplicationFactory` - Updated LINQ query to use discard (`_`) for unused `projects` variable. - Improved code readability and reduced unnecessary variable declaration. --- src/MinimalLambda.Testing/LambdaApplicationFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index 499969e8..c9793b20 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -406,7 +406,7 @@ protected virtual IEnumerable GetTestAssemblies() ); // Find the list of projects - var projects = context.CompileLibraries.Where(l => l.Type == "project"); + _ = context.CompileLibraries.Where(l => l.Type == "project"); var entryPointAssemblyName = typeof(TEntryPoint).Assembly.GetName().Name; From 9e864d05cc0a6145eaa80a83f737f2515cb95ed0 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 11:54:39 -0500 Subject: [PATCH 119/132] fix(testing): standardize method name `WithCancellationToken` across unit tests - Replaced all instances of `WithCancelationToken` with `WithCancellationToken`. - Ensured consistency in method naming throughout unit test implementations. - Updated XML documentation in `LambdaApplicationFactory` to reflect the corrected method name. --- .../LambdaApplicationFactory.cs | 2 +- .../DiLambdaTests.cs | 12 ++++++------ .../NoEventLambdaTests.cs | 4 ++-- .../NoResponseLambdaTests.cs | 4 ++-- .../SimpleLambdaTests.cs | 18 ++++++++++-------- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index c9793b20..fbf5c0c8 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -142,7 +142,7 @@ public void Dispose() /// 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 WithCancelationToken( + public LambdaApplicationFactory WithCancellationToken( CancellationToken cancellationToken ) { diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs index b9f50ce3..0a419774 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs @@ -13,7 +13,7 @@ public class DiLambdaTests : IClassFixture> public DiLambdaTests(LambdaApplicationFactory factory) { - factory.WithCancelationToken(TestContext.Current.CancellationToken); + factory.WithCancellationToken(TestContext.Current.CancellationToken); _server = factory.TestServer; } @@ -36,7 +36,7 @@ internal async Task DiLambda_InitStopped() { var lifecycleService = Substitute.For(); await using var factory = new LambdaApplicationFactory() - .WithCancelationToken(TestContext.Current.CancellationToken) + .WithCancellationToken(TestContext.Current.CancellationToken) .WithHostBuilder(builder => builder.ConfigureServices( (_, services) => @@ -59,7 +59,7 @@ internal async Task DiLambda_InitThrowsException() var lifecycleService = Substitute.For(); await using var factory = new LambdaApplicationFactory() - .WithCancelationToken(TestContext.Current.CancellationToken) + .WithCancellationToken(TestContext.Current.CancellationToken) .WithHostBuilder(builder => builder.ConfigureServices( (_, services) => @@ -85,7 +85,7 @@ internal async Task DiLambda_InitThrowsException() internal async Task DiLambda_ShutdownThrowsException(ILifecycleService lifecycleService) { await using var factory = new LambdaApplicationFactory() - .WithCancelationToken(TestContext.Current.CancellationToken) + .WithCancellationToken(TestContext.Current.CancellationToken) .WithHostBuilder(builder => builder.ConfigureServices( (_, services) => @@ -120,7 +120,7 @@ IService service ) { await using var factory = new LambdaApplicationFactory() - .WithCancelationToken(TestContext.Current.CancellationToken) + .WithCancellationToken(TestContext.Current.CancellationToken) .WithHostBuilder(builder => builder .ConfigureContainer( @@ -156,7 +156,7 @@ IService service ) { await using var factory = new LambdaApplicationFactory() - .WithCancelationToken(TestContext.Current.CancellationToken) + .WithCancellationToken(TestContext.Current.CancellationToken) .WithHostBuilder(builder => builder .ConfigureContainer( diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs index 2cbfe12e..4a3acc52 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs @@ -8,7 +8,7 @@ public class NoEventLambdaTests : IClassFixture factory) { - factory.WithCancelationToken(TestContext.Current.CancellationToken); + factory.WithCancellationToken(TestContext.Current.CancellationToken); _server = factory.TestServer; } @@ -29,7 +29,7 @@ public async Task NoEvent_ReturnsExpectedValue() public async Task NoEvent_ConfigurationCanBeOverwritten() { var factory = new LambdaApplicationFactory() - .WithCancelationToken(TestContext.Current.CancellationToken) + .WithCancellationToken(TestContext.Current.CancellationToken) .WithHostBuilder(builder => builder.ConfigureAppConfiguration( (_, config) => diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs index 44002b81..456b8810 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs @@ -9,7 +9,7 @@ public class NoResponseLambdaTests : IClassFixture factory) { - factory.WithCancelationToken(TestContext.Current.CancellationToken); + factory.WithCancellationToken(TestContext.Current.CancellationToken); _server = factory.TestServer; } @@ -37,7 +37,7 @@ public void NoResponseLambda_ServicesIsAccessible() public async Task NoResponseLambda_DisposeCanBeCalledMultipleTimes() { await using var factory = - new LambdaApplicationFactory().WithCancelationToken( + new LambdaApplicationFactory().WithCancellationToken( TestContext.Current.CancellationToken ); diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs index 291949cf..35488446 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs @@ -9,16 +9,17 @@ public class SimpleLambdaTests : IClassFixture factory) { - factory.WithCancelationToken(TestContext.Current.CancellationToken); + factory.WithCancellationToken(TestContext.Current.CancellationToken); _server = factory.TestServer; } [Fact] public async Task SimpleLambda_ReturnsExpectedValue() { - await using var factory = new LambdaApplicationFactory().WithCancelationToken( - TestContext.Current.CancellationToken - ); + 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); @@ -125,7 +126,7 @@ public async Task SimpleLambda_ReturnsError() public async Task SimpleLambda_ErrorsArePropagated() { await using var factory = new LambdaApplicationFactory() - .WithCancelationToken(TestContext.Current.CancellationToken) + .WithCancellationToken(TestContext.Current.CancellationToken) .WithHostBuilder(builder => { builder.ConfigureServices( @@ -152,9 +153,10 @@ await act.Should() [Fact] public async Task SimpleLambda_WithPreCanceledToken_CancelsInvocation() { - await using var factory = new LambdaApplicationFactory().WithCancelationToken( - TestContext.Current.CancellationToken - ); + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); using var cts = new CancellationTokenSource(); From 7577d58cfaaee77a15adb0a8b7cdd454019d7f08 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 11:56:05 -0500 Subject: [PATCH 120/132] fix(testing): correct XML documentation for `LambdaApplicationFactory.WithHostBuilder` - Updated XML documentation to reference `LambdaApplicationFactory{TEntryPoint}.WithHostBuilder`. - Removed redundant `` reference for `CreateHostBuilder` in the remarks section. - Enhanced documentation clarity by aligning with standardized conventions. --- src/MinimalLambda.Testing/LambdaApplicationFactory.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index fbf5c0c8..0bcd4f67 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -485,12 +485,12 @@ protected virtual void Dispose(bool disposing) /// /// Internal implementation of that delegates virtual method calls - /// to callbacks provided at construction. This class is used by to create derived + /// 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 (, , , + /// Each virtual method (, , /// and ) delegates to a callback function provided in the constructor, /// enabling configuration chaining while reusing the base factory infrastructure. /// From 74b98dfb1e606204954a37d0c597572eeec812bd Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 11:57:37 -0500 Subject: [PATCH 121/132] refactor(testing): make `LambdaRuntimeRouteManager` static and remove unused instance reference - Updated `LambdaRuntimeRouteManager` class to be static. - Removed unused `_routeManager` instance from `LambdaTestServer`. - Refactored code to use `LambdaRuntimeRouteManager.TryMatch` as a static method. --- src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs | 4 ++-- src/MinimalLambda.Testing/LambdaTestServer.cs | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs b/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs index b8008d43..8d474122 100644 --- a/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs +++ b/src/MinimalLambda.Testing/LambdaRuntimeRouteManager.cs @@ -4,7 +4,7 @@ namespace MinimalLambda.Testing; -internal class LambdaRuntimeRouteManager +internal static class LambdaRuntimeRouteManager { private static readonly RouteTemplate[] Routes = [ @@ -46,7 +46,7 @@ internal class LambdaRuntimeRouteManager }, ]; - internal bool TryMatch( + internal static bool TryMatch( HttpRequestMessage request, [NotNullWhen(true)] out RequestType? routeType, [NotNullWhen(true)] out RouteValueDictionary? values diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 7795b989..697c8616 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -58,11 +58,6 @@ public class LambdaTestServer : IAsyncDisposable /// private readonly ConcurrentDictionary _pendingInvocations = new(); - /// - /// Route manager to determine the route of the incoming request from the Lambda. - /// - private readonly LambdaRuntimeRouteManager _routeManager = new(); - /// /// Options used to configure how the server interacts with the Lambda. /// @@ -475,7 +470,7 @@ var transaction in _transactionChannel.Reader.ReadAllAsync(_shutdownCts.Token) ) { if ( - !_routeManager.TryMatch( + !LambdaRuntimeRouteManager.TryMatch( transaction.Request, out var requestType, out var routeValues From 05f583a4e3548bacc813e14d98fdd65df32d88a2 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 12:02:45 -0500 Subject: [PATCH 122/132] feat(testing): add `Causes` property and make `ErrorType` required in `ErrorResponse` - Added `Causes` property to represent underlying causes in errors. - Updated `ErrorType` property with `required` modifier for stricter validation. - Included XML documentation for the new `Causes` property to enhance clarity. --- src/MinimalLambda.Testing/Models/ErrorResponse.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/MinimalLambda.Testing/Models/ErrorResponse.cs b/src/MinimalLambda.Testing/Models/ErrorResponse.cs index 0acae6ce..dd0c433d 100644 --- a/src/MinimalLambda.Testing/Models/ErrorResponse.cs +++ b/src/MinimalLambda.Testing/Models/ErrorResponse.cs @@ -13,6 +13,9 @@ public class ErrorResponse [JsonPropertyName("cause")] public ErrorCause? Cause { get; set; } + /// + /// The underlying causes of this error, if any. + /// [JsonPropertyName("causes")] public List? Causes { get; set; } @@ -55,7 +58,7 @@ public class ErrorCause /// The type of error that occurred. /// [JsonPropertyName("errorType")] - public string ErrorType { get; set; } + public required string ErrorType { get; set; } /// /// The stack trace showing where the error occurred. From a51fe3c0b0b97832fc405a3566a88dc0be88ca99 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 12:04:05 -0500 Subject: [PATCH 123/132] refactor(testing): simplify dictionary initialization in `DictionaryExtensionsTest` - Replaced explicit type declaration with target-typed `new()` for `_dictionary`. - Improved code readability and adhered to modern C# conventions. --- .../MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs index e0ed1540..46206a9e 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DictionaryExtensionsTest.cs @@ -5,7 +5,7 @@ namespace MinimalLambda.Testing.UnitTests; [TestSubject(typeof(DictionaryExtensions))] public class DictionaryExtensionsTest { - private readonly IDictionary _dictionary = new Dictionary + private readonly Dictionary _dictionary = new() { ["key1"] = "value1", ["key2"] = "value2", From f1604024d8a37e85b789d195cde6bee62a5059c8 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 12:07:49 -0500 Subject: [PATCH 124/132] fix(testing): remove pending invocation after handling request - Added `_pendingInvocations.TryRemove` call to ensure request ID is removed after processing. - Prevents potential memory leaks and ensures proper cleanup of pending invocations. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 697c8616..9d339e39 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -401,6 +401,8 @@ is not (InitStatus.InitCompleted or InitStatus.InitAlreadyCompleted) ) : null; + _pendingInvocations.TryRemove(requestId, out _); + return new InvocationResponse { WasSuccess = wasSuccess, From 8750ca3ba8dc19e00f820d211ac1aa2f74c2b786 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 12:10:45 -0500 Subject: [PATCH 125/132] fix(testing): handle semaphore release on conditional acquisition in `StartAsync` - Added a check to ensure `_startSemaphore.Release` is only called if the semaphore was acquired. - Prevents potential release errors when the semaphore was not successfully acquired. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 9d339e39..e53bc34a 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -226,9 +226,12 @@ internal HttpMessageHandler CreateHandler() => /// public async Task StartAsync(CancellationToken cancellationToken = default) { - await _startSemaphore.WaitAsync(cancellationToken); + 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) @@ -285,7 +288,8 @@ await TaskHelpers } finally { - _startSemaphore.Release(); + if (semaphoreAcquired) + _startSemaphore.Release(); } } From c870d66ffc990d40e9796254eaad687dbfdf141c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 15:02:03 -0500 Subject: [PATCH 126/132] fix(testing): improve server disposal and simplify host start logic - Moved `StopAsync` call in `LambdaTestServer.DisposeAsync` to align with proper server state handling. - Replaced `StartAsync` with synchronous `_host.Start` to prevent unnecessary token usage. - Updated `WhenAll` call in `StopAsync` to handle null `_processingTask` safely. - Simplified host disposal in `LambdaApplicationFactory` by removing redundant `StopAsync`. --- src/MinimalLambda.Testing/LambdaApplicationFactory.cs | 6 +----- src/MinimalLambda.Testing/LambdaTestServer.cs | 11 +++++------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs index 0bcd4f67..79d7f337 100644 --- a/src/MinimalLambda.Testing/LambdaApplicationFactory.cs +++ b/src/MinimalLambda.Testing/LambdaApplicationFactory.cs @@ -106,11 +106,7 @@ public virtual async ValueTask DisposeAsync() if (_server != null) await _server.DisposeAsync().ConfigureAwait(false); - if (_host is not null) - { - await _host.StopAsync().ConfigureAwait(false); - _host?.Dispose(); - } + _host?.Dispose(); _disposedAsync = true; diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index e53bc34a..c8371da0 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -165,10 +165,6 @@ public async ValueTask DisposeAsync() if (_disposed) return; - if (State == ServerState.Running) - // Best effort to stop the server, but don't fail the Dispose operation - await StopAsync().ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - // Complete both channels to prevent new items _transactionChannel.Writer.TryComplete(); _pendingInvocationIds.Writer.TryComplete(); @@ -176,6 +172,9 @@ public async ValueTask DisposeAsync() // Cancel the shutdown token await _shutdownCts.CancelAsync(); + if (State == ServerState.Running) + await StopAsync().ConfigureAwait(false); + // Dispose the CancellationTokenSource _shutdownCts.Dispose(); @@ -257,7 +256,7 @@ public async Task StartAsync(CancellationToken cancellationToken = } // Start the host - await _host.StartAsync(cts.Token); + _host.Start(); // Start background processing _processingTask = Task.Run(ProcessTransactionsAsync, cts.Token); @@ -456,7 +455,7 @@ public async Task StopAsync(CancellationToken cancellationToken = default) _applicationLifetime?.StopApplication(); await TaskHelpers - .WhenAll(_entryPointCompletion, _processingTask!) + .WhenAll(_entryPointCompletion, _processingTask ?? Task.CompletedTask) .UnwrapAndThrow("Exception(s) encountered while running StopAsync") .WaitAsync(cancellationToken); From 83e21a6452e9135d5a0781ac98389cd836728f9d Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 15:05:33 -0500 Subject: [PATCH 127/132] refactor(testing): update test implementation to use factory instance inline - Removed `_server` instance and replaced with inline factory instance in `SimpleLambdaTests`. - Improved resource management by using `await using` for factory disposal. - Enhanced code maintainability by avoiding explicit constructor setup for factory. --- .../SimpleLambdaTests.cs | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs index 35488446..48b30e1f 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/SimpleLambdaTests.cs @@ -3,16 +3,8 @@ namespace MinimalLambda.Testing.UnitTests; -public class SimpleLambdaTests : IClassFixture> +public class SimpleLambdaTests { - private readonly LambdaTestServer _server; - - public SimpleLambdaTests(LambdaApplicationFactory factory) - { - factory.WithCancellationToken(TestContext.Current.CancellationToken); - _server = factory.TestServer; - } - [Fact] public async Task SimpleLambda_ReturnsExpectedValue() { @@ -36,7 +28,12 @@ public async Task SimpleLambda_ReturnsExpectedValue() [Fact] public async Task SimpleLambda_WorksWhenStartIsNotCalled() { - var response = await _server.InvokeAsync( + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var response = await factory.TestServer.InvokeAsync( "World", TestContext.Current.CancellationToken ); @@ -49,11 +46,16 @@ public async Task SimpleLambda_WorksWhenStartIsNotCalled() [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 => - _server.InvokeAsync( + factory.TestServer.InvokeAsync( $"User{i}", TestContext.Current.CancellationToken ) @@ -79,13 +81,17 @@ public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes() [Fact] public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes_WithoutStart() { - await _server.StartAsync(TestContext.Current.CancellationToken); + 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 => - _server.InvokeAsync( + factory.TestServer.InvokeAsync( $"User{i}", TestContext.Current.CancellationToken ) @@ -111,7 +117,12 @@ public async Task SimpleLambda_WorksWhenInvokeCalledMultipleTimes_WithoutStart() [Fact] public async Task SimpleLambda_ReturnsError() { - var response = await _server.InvokeAsync( + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var response = await factory.TestServer.InvokeAsync( "", TestContext.Current.CancellationToken ); @@ -162,8 +173,8 @@ public async Task SimpleLambda_WithPreCanceledToken_CancelsInvocation() using var cts = new CancellationTokenSource(); await cts.CancelAsync(); - await Assert.ThrowsAsync(() => - factory.TestServer.InvokeAsync("Jonas", cts.Token) - ); + var act = () => factory.TestServer.InvokeAsync("Jonas", cts.Token); + + await act.Should().ThrowAsync(); } } From 73a806d7b62e059ceabb1ca44c8150dc650bc4e3 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 15:08:03 -0500 Subject: [PATCH 128/132] refactor(testing): use inline factory instances in `NoResponseLambdaTests` - Replaced `_server` field with inline `LambdaApplicationFactory` instances. - Improved resource management using `await using` for factory disposal. - Simplified test setup by avoiding a constructor-based factory initialization. --- .../NoResponseLambdaTests.cs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs index 456b8810..57741c42 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoResponseLambdaTests.cs @@ -3,20 +3,17 @@ namespace MinimalLambda.Testing.UnitTests; -public class NoResponseLambdaTests : IClassFixture> +public class NoResponseLambdaTests { - private readonly LambdaTestServer _server; - - public NoResponseLambdaTests(LambdaApplicationFactory factory) - { - factory.WithCancellationToken(TestContext.Current.CancellationToken); - _server = factory.TestServer; - } - [Fact] public async Task NoResponseLambda_ReturnsExpectedValue() { - var response = await _server.InvokeNoResponseAsync( + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var response = await factory.TestServer.InvokeNoResponseAsync( new NoResponseLambdaRequest("World"), TestContext.Current.CancellationToken ); @@ -26,9 +23,14 @@ public async Task NoResponseLambda_ReturnsExpectedValue() } [Fact] - public void NoResponseLambda_ServicesIsAccessible() + public async Task NoResponseLambda_ServicesIsAccessible() { - var act = () => _server.Services.GetRequiredService(); + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var act = () => factory.TestServer.Services.GetRequiredService(); act.Should().NotThrow(); } From f6c693f5a0e36b1cd3e09abde1f0b28436b31d6c Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 15:10:26 -0500 Subject: [PATCH 129/132] refactor(testing): use inline factory instances in `DiLambdaTests` and `NoEventLambdaTests` - Replaced `_server` field with inline `LambdaApplicationFactory` instances. - Enhanced resource management by utilizing `await using` for factory disposal. - Simplified test setup by removing constructor-based factory initialization. --- .../DiLambdaTests.cs | 16 ++++++---------- .../NoEventLambdaTests.cs | 19 ++++++++----------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs index 0a419774..a12d2dcf 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs @@ -7,20 +7,16 @@ namespace MinimalLambda.Testing.UnitTests; -public class DiLambdaTests : IClassFixture> +public class DiLambdaTests { - private readonly LambdaTestServer _server; - - public DiLambdaTests(LambdaApplicationFactory factory) - { - factory.WithCancellationToken(TestContext.Current.CancellationToken); - _server = factory.TestServer; - } - [Fact] public async Task DiLambda_ReturnsExpectedValue() { - var response = await _server.InvokeAsync( + await using var factory = new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var response = await factory.TestServer.InvokeAsync( new DiLambdaRequest("World"), TestContext.Current.CancellationToken ); diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs index 4a3acc52..1ceef171 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/NoEventLambdaTests.cs @@ -2,20 +2,17 @@ namespace MinimalLambda.Testing.UnitTests; -public class NoEventLambdaTests : IClassFixture> +public class NoEventLambdaTests { - private readonly LambdaTestServer _server; - - public NoEventLambdaTests(LambdaApplicationFactory factory) - { - factory.WithCancellationToken(TestContext.Current.CancellationToken); - _server = factory.TestServer; - } - [Fact] public async Task NoEvent_ReturnsExpectedValue() { - var response = await _server.InvokeNoEventAsync( + await using var factory = + new LambdaApplicationFactory().WithCancellationToken( + TestContext.Current.CancellationToken + ); + + var response = await factory.TestServer.InvokeNoEventAsync( TestContext.Current.CancellationToken ); @@ -28,7 +25,7 @@ public async Task NoEvent_ReturnsExpectedValue() [Fact] public async Task NoEvent_ConfigurationCanBeOverwritten() { - var factory = new LambdaApplicationFactory() + await using var factory = new LambdaApplicationFactory() .WithCancellationToken(TestContext.Current.CancellationToken) .WithHostBuilder(builder => builder.ConfigureAppConfiguration( From b931baf9461a2e3046c1ab72bfdd64c3ec6423de Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 15:30:23 -0500 Subject: [PATCH 130/132] fix(testing): make `LambdaTestServer` host start asynchronous - Replaced `_host.Start()` with `await _host.StartAsync(cts.Token)` for asynchronous operation. - Ensures proper task handling and respects cancellation tokens during host startup. --- src/MinimalLambda.Testing/LambdaTestServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index c8371da0..2178655b 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -256,7 +256,7 @@ public async Task StartAsync(CancellationToken cancellationToken = } // Start the host - _host.Start(); + await _host.StartAsync(cts.Token); // Start background processing _processingTask = Task.Run(ProcessTransactionsAsync, cts.Token); From 17308db0bc74ded8f4ee8b55eb13e47bc0a50ef4 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 15:35:30 -0500 Subject: [PATCH 131/132] fix(testing): replace `GetService` with `GetRequiredService` for `IHostApplicationLifetime` - Ensures `IHostApplicationLifetime` is resolved reliably during host startup. - Removes `AssemblyInfo.cs`, disabling test parallelization. --- src/MinimalLambda.Testing/DeferredHostBuilder.cs | 2 +- .../MinimalLambda.Testing.UnitTests/Properties/AssemblyInfo.cs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/Properties/AssemblyInfo.cs diff --git a/src/MinimalLambda.Testing/DeferredHostBuilder.cs b/src/MinimalLambda.Testing/DeferredHostBuilder.cs index 4286e85c..3e8353f0 100644 --- a/src/MinimalLambda.Testing/DeferredHostBuilder.cs +++ b/src/MinimalLambda.Testing/DeferredHostBuilder.cs @@ -157,7 +157,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) IHostApplicationLifetime? lifetime = null; try { - lifetime = host.Services.GetService(); + lifetime = host.Services.GetRequiredService(); } catch (ObjectDisposedException) { diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/Properties/AssemblyInfo.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/Properties/AssemblyInfo.cs deleted file mode 100644 index e5cc5d40..00000000 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1 +0,0 @@ -[assembly: CollectionBehavior(DisableTestParallelization = true)] From 3ed067259662f5f26474e0aa0e393037af9f5643 Mon Sep 17 00:00:00 2001 From: Jonas Ha Date: Sat, 13 Dec 2025 16:58:33 -0500 Subject: [PATCH 132/132] fix(testing): improve cancellation handling and resolve dependencies explicitly - Added linked cancellation token source with timeout for `DiLambdaTests` to enhance reliability. - Updated `LambdaApplicationFactory` and `LambdaTestServer` to use `GetRequiredService` for resolving `IHostApplicationLifetime`. - Refactored `DeferredHost` to explicitly pass `IHostApplicationLifetime` and avoid redundant retrievals. - Simplified task completion logic in `DeferredHost` to ensure proper exception handling and cleanup. --- .../DeferredHostBuilder.cs | 35 ++++++++++--------- src/MinimalLambda.Testing/LambdaTestServer.cs | 10 +----- .../DiLambdaTests.cs | 9 +++-- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/MinimalLambda.Testing/DeferredHostBuilder.cs b/src/MinimalLambda.Testing/DeferredHostBuilder.cs index 3e8353f0..4b26d5aa 100644 --- a/src/MinimalLambda.Testing/DeferredHostBuilder.cs +++ b/src/MinimalLambda.Testing/DeferredHostBuilder.cs @@ -58,8 +58,10 @@ public IHost Build() // 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); + return new DeferredHost(host, _hostStartTcs, applicationLifetime); } public IHostBuilder ConfigureAppConfiguration( @@ -122,20 +124,22 @@ public void EntryPointCompleted(Exception? exception) if (exception is not null) { _hostStartTcs.TrySetException(exception); - _entryPointCompletionTcs.TrySetResult(exception); } else { _hostStartTcs.TrySetResult(); - _entryPointCompletionTcs.TrySetResult(null); } + + _entryPointCompletionTcs.TrySetResult(exception); } public void SetHostFactory(Func hostFactory) => _hostFactory = hostFactory; - private sealed class DeferredHost(IHost host, TaskCompletionSource hostStartedTcs) - : IHost, - IAsyncDisposable + private sealed class DeferredHost( + IHost host, + TaskCompletionSource hostStartedTcs, + IHostApplicationLifetime applicationLifetime + ) : IHost, IAsyncDisposable { public async ValueTask DisposeAsync() { @@ -154,20 +158,19 @@ public async ValueTask DisposeAsync() public async Task StartAsync(CancellationToken cancellationToken = default) { - IHostApplicationLifetime? lifetime = null; - try - { - lifetime = host.Services.GetRequiredService(); - } - catch (ObjectDisposedException) - { - // Best effort - } + // 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 = lifetime?.ApplicationStarted.UnsafeRegister( + await using var reg2 = applicationLifetime.ApplicationStarted.UnsafeRegister( _ => hostStartedTcs.TrySetResult(), null ); diff --git a/src/MinimalLambda.Testing/LambdaTestServer.cs b/src/MinimalLambda.Testing/LambdaTestServer.cs index 2178655b..a6dec8fd 100644 --- a/src/MinimalLambda.Testing/LambdaTestServer.cs +++ b/src/MinimalLambda.Testing/LambdaTestServer.cs @@ -245,15 +245,7 @@ public async Task StartAsync(CancellationToken cancellationToken = State = ServerState.Starting; - try - { - _applicationLifetime = - _host.Services.GetRequiredService(); - } - catch (ObjectDisposedException) - { - // Best effort - } + _applicationLifetime = _host.Services.GetRequiredService(); // Start the host await _host.StartAsync(cts.Token); diff --git a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs index a12d2dcf..7d1d0599 100644 --- a/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs +++ b/tests/MinimalLambda.Testing.UnitTests/Tests/MinimalLambda.Testing.UnitTests/DiLambdaTests.cs @@ -52,10 +52,15 @@ internal async Task DiLambda_InitStopped() [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(TestContext.Current.CancellationToken) + .WithCancellationToken(cts.Token) .WithHostBuilder(builder => builder.ConfigureServices( (_, services) => @@ -68,7 +73,7 @@ internal async Task DiLambda_InitThrowsException() lifecycleService.OnStart().Throws(new Exception("Test init error")); - var initResult = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken); + var initResult = await factory.TestServer.StartAsync(cts.Token); initResult.InitStatus.Should().Be(InitStatus.InitError); initResult.Error.Should().NotBeNull(); initResult