Skip to content
Merged
1 change: 1 addition & 0 deletions .github/workflows/pr-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jobs:
dotnet test MinimalLambda.sln --configuration Release --no-build
--results-directory ./coverage --coverage
--coverage-output-format cobertura --no-progress --no-ansi
--max-parallel-test-modules 1 -p:TestTfmsInParallel=false

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v6
Expand Down
26 changes: 15 additions & 11 deletions src/MinimalLambda.Testing/LambdaApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,20 +105,24 @@ public virtual async ValueTask DisposeAsync()
if (_disposedAsync)
return;

foreach (var factory in _derivedFactories)
await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false);

// TestServer handles disposing both processor and host
if (_server != null)
await _server.DisposeAsync().ConfigureAwait(false);

_host?.Dispose();
try
{
foreach (var factory in _derivedFactories)
await ((IAsyncDisposable)factory).DisposeAsync().ConfigureAwait(false);

_disposedAsync = true;
// TestServer handles disposing its processor.
if (_server != null)
await _server.DisposeAsync().ConfigureAwait(false);
}
finally
{
_host?.Dispose();
_disposedAsync = true;

Dispose(true);
Dispose(true);

GC.SuppressFinalize(this);
GC.SuppressFinalize(this);
}
}

/// <inheritdoc />
Expand Down
18 changes: 10 additions & 8 deletions src/MinimalLambda.Testing/LambdaTestServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,16 @@ public async ValueTask DisposeAsync()
if (_disposed)
return;

if (State == ServerState.Running)
await StopAsync().ConfigureAwait(false);

// Complete both channels to prevent new items
_transactionChannel.Writer.TryComplete();
_pendingInvocationIds.Writer.TryComplete();

// Cancel the shutdown token
await _shutdownCts.CancelAsync();

if (State == ServerState.Running)
await StopAsync().ConfigureAwait(false);

// Dispose the CancellationTokenSource
_shutdownCts.Dispose();

Expand Down Expand Up @@ -236,7 +236,10 @@ await TaskHelpers
if (_initCompletionTcs.Task.Result.InitStatus == InitStatus.InitCompleted)
State = ServerState.Running;
else
{
await _shutdownCts.CancelAsync();
await StopAsync(CancellationToken.None);
}

return _initCompletionTcs.Task.Result;
}
Expand Down Expand Up @@ -377,9 +380,8 @@ public async Task<InvocationResponse<TResponse>> InvokeAsync<TEvent, TResponse>(
/// <remarks>
/// <para>This method performs the following shutdown sequence:</para>
/// <list type="number">
/// <item><description>Transitions the server state to <see cref="ServerState.Stopping" /></description></item>
/// <item><description>Cancels the internal shutdown token to signal background tasks</description></item>
/// <item><description>Stops the application host via <see cref="IHostApplicationLifetime" /></description></item>
/// <item><description>Cancels the internal shutdown token to stop transaction processing</description></item>
/// <item><description>Waits for the entry point and processing tasks to complete</description></item>
/// <item><description>Transitions the server state to <see cref="ServerState.Stopped" /></description></item>
/// </list>
Expand All @@ -396,10 +398,10 @@ public async Task StopAsync(CancellationToken cancellationToken = default)

State = ServerState.Stopping;

await _shutdownCts.CancelAsync();

_applicationLifetime?.StopApplication();

await _shutdownCts.CancelAsync();

await TaskHelpers
.WhenAll(_entryPointCompletion, _processingTask ?? Task.CompletedTask)
.UnwrapAndThrow("Exception(s) encountered while running StopAsync")
Expand Down Expand Up @@ -466,7 +468,7 @@ private async Task HandleGetNextInvocationAsync(LambdaHttpTransaction transactio
{
var requestId = await _pendingInvocationIds.Reader.ReadAsync(_shutdownCts.Token);
_pendingInvocations.GetRequired(requestId, out var pendingInvocation);
transaction.ResponseTcs.SetResult(pendingInvocation.EventResponse);
transaction.Respond(pendingInvocation.EventResponse);
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/MinimalLambda/Runtime/LambdaHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ internal sealed class LambdaHostedService : IHostedService, IDisposable
private readonly LambdaHostedServiceOptions _options;
private bool _disposed;

private CancellationTokenRegistration _applicationStoppingRegistration;
private Task? _executeTask;
private Func<CancellationToken, Task>? _shutdownHandler;
private CancellationTokenSource? _stoppingCts;
Expand Down Expand Up @@ -48,6 +49,7 @@ public LambdaHostedService(
/// <inheritdoc />
public void Dispose()
{
_applicationStoppingRegistration.Dispose();
_stoppingCts?.Cancel();

if (_disposed)
Expand All @@ -69,6 +71,9 @@ public Task StartAsync(CancellationToken cancellationToken)
// Create a linked token to allow cancelling the executing task from the provided token
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

_applicationStoppingRegistration =
_lifetime.ApplicationStopping.Register(_stoppingCts.Cancel);

// Create a fully composed handler with middleware and request processing.
var requestHandler = _handlerFactory.CreateHandler(_stoppingCts.Token);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Autofac.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using MinimalLambda.UnitTests;
using NSubstitute.ExceptionExtensions;

Expand All @@ -13,8 +14,9 @@ public class DiLambdaTests
public async Task DiLambda_ReturnsExpectedValue()
{
await using var factory =
new LambdaApplicationFactory<DiLambda>().WithCancellationToken(
TestContext.Current.CancellationToken);
LambdaTestFactory
.Create<DiLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken);

var response = await factory.TestServer.InvokeAsync<DiLambdaRequest, DiLambdaResponse>(
new DiLambdaRequest("World"),
Expand All @@ -26,11 +28,26 @@ public async Task DiLambda_ReturnsExpectedValue()
response.Response.Message.Should().Be("Hello World!");
}

[Fact]
public async Task DiLambda_HostLoggingIsDisabled()
{
await using var factory =
LambdaTestFactory
.Create<DiLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken);

var logger = factory.Services.GetRequiredService<ILogger<DiLambda>>();

foreach (var level in Enum.GetValues<LogLevel>())
logger.IsEnabled(level).Should().BeFalse();
}

[Fact]
internal async Task DiLambda_InitStopped()
{
var lifecycleService = Substitute.For<ILifecycleService>();
await using var factory = new LambdaApplicationFactory<DiLambda>()
await using var factory = LambdaTestFactory
.Create<DiLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken)
.WithHostBuilder(builder => builder.ConfigureServices((_, services) =>
{
Expand Down Expand Up @@ -59,7 +76,8 @@ internal async Task DiLambda_InitThrowsException()
{
var lifecycleService = Substitute.For<ILifecycleService>();

await using var factory = new LambdaApplicationFactory<DiLambda>()
await using var factory = LambdaTestFactory
.Create<DiLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken)
.WithHostBuilder(builder => builder.ConfigureServices((_, services) =>
{
Expand Down Expand Up @@ -90,7 +108,8 @@ internal async Task DiLambda_InitThrowsException()
[AutoNSubstituteData]
internal async Task DiLambda_ShutdownThrowsException(ILifecycleService lifecycleService)
{
await using var factory = new LambdaApplicationFactory<DiLambda>()
await using var factory = LambdaTestFactory
.Create<DiLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken)
.WithHostBuilder(builder => builder.ConfigureServices((_, services) =>
{
Expand All @@ -104,15 +123,16 @@ internal async Task DiLambda_ShutdownThrowsException(ILifecycleService lifecycle
var initResult = await factory.TestServer.StartAsync(TestContext.Current.CancellationToken);
initResult.InitStatus.Should().Be(InitStatus.InitCompleted);

var act = async () =>
// ReSharper disable once AccessToDisposedClosure
await factory.TestServer.StopAsync(TestContext.Current.CancellationToken);
var act = async () => await factory.DisposeAsync();

(await act.Should().ThrowAsync<AggregateException>())
.WithInnerException<AggregateException>()
.WithInnerException<AggregateException>()
.WithInnerException<Exception>()
.WithMessage("Test init error");

var getService = () => factory.Services.GetRequiredService<ILifecycleService>();
getService.Should().Throw<ObjectDisposedException>();
}

[Theory]
Expand All @@ -121,7 +141,8 @@ internal async Task DiLambda_DiContainerCanBeReplaced(
ILifecycleService lifecycleService,
IService service)
{
await using var factory = new LambdaApplicationFactory<DiLambda>()
await using var factory = LambdaTestFactory
.Create<DiLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken)
.WithHostBuilder(builder => builder
.ConfigureContainer<ContainerBuilder>((_, containerBuilder) =>
Expand Down Expand Up @@ -149,7 +170,8 @@ internal async Task DiLambda_DiContainerCanBeReplacedWithFactory(
ILifecycleService lifecycleService,
IService service)
{
await using var factory = new LambdaApplicationFactory<DiLambda>()
await using var factory = LambdaTestFactory
.Create<DiLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken)
.WithHostBuilder(builder => builder
.ConfigureContainer<ContainerBuilder>((_, containerBuilder) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ public async Task DurableLambda_Success_RoundTripsGeneratedAdapterPipeline()
{
// Arrange
await using var factory =
new LambdaApplicationFactory<DurableLambda>().WithCancellationToken(
TestContext.Current.CancellationToken);
LambdaTestFactory
.Create<DurableLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken);
var input = CreateInvocationInput(shouldFail: false);

// Act
Expand Down Expand Up @@ -45,8 +46,9 @@ public async Task DurableLambda_Failure_ReturnsFailedOuterEnvelope()
{
// Arrange
await using var factory =
new LambdaApplicationFactory<DurableLambda>().WithCancellationToken(
TestContext.Current.CancellationToken);
LambdaTestFactory
.Create<DurableLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken);
var input = CreateInvocationInput(shouldFail: true);

// Act
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;

namespace MinimalLambda.Testing.UnitTests;

internal static class LambdaTestFactory
{
private const string LambdaLogLevelEnvironmentVariable = "AWS_LAMBDA_LOG_LEVEL";

private static readonly KeyValuePair<string, string?>[] HostConfiguration =
[
new("Logging:LogLevel:Default", "None"),
];

static LambdaTestFactory()
{
if (string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(LambdaLogLevelEnvironmentVariable)))
Environment.SetEnvironmentVariable(LambdaLogLevelEnvironmentVariable, "Critical");
}

public static LambdaApplicationFactory<TEntryPoint> Create<TEntryPoint>()
where TEntryPoint : class =>
new LambdaApplicationFactory<TEntryPoint>().WithHostBuilder(builder =>
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(HostConfiguration)));
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ public class NoEventLambdaTests
public async Task NoEvent_ReturnsExpectedValue()
{
await using var factory =
new LambdaApplicationFactory<NoEventLambda>().WithCancellationToken(
TestContext.Current.CancellationToken);
LambdaTestFactory
.Create<NoEventLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken);

var response =
await factory.TestServer.InvokeNoEventAsync<NoEventLambdaResponse>(
Expand All @@ -24,11 +25,13 @@ await factory.TestServer.InvokeNoEventAsync<NoEventLambdaResponse>(
[Fact]
public async Task NoEvent_ConfigurationCanBeOverwritten()
{
await using var factory = new LambdaApplicationFactory<NoEventLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken)
.WithHostBuilder(builder => builder.ConfigureAppConfiguration((_, config) =>
config.AddInMemoryCollection(
new Dictionary<string, string> { ["MESSAGE"] = "Hello Mars!" }!)));
await using var factory =
LambdaTestFactory
.Create<NoEventLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken)
.WithHostBuilder(builder => builder.ConfigureAppConfiguration((_, config) =>
config.AddInMemoryCollection(
new Dictionary<string, string> { ["MESSAGE"] = "Hello Mars!" }!)));

var response =
await factory.TestServer.InvokeNoEventAsync<NoEventLambdaResponse>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ public class NoResponseLambdaTests
public async Task NoResponseLambda_ReturnsExpectedValue()
{
await using var factory =
new LambdaApplicationFactory<NoResponseLambda>().WithCancellationToken(
TestContext.Current.CancellationToken);
LambdaTestFactory
.Create<NoResponseLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken);

var response = await factory.TestServer.InvokeNoResponseAsync(
new NoResponseLambdaRequest("World"),
Expand All @@ -26,8 +27,9 @@ public async Task NoResponseLambda_ReturnsExpectedValue()
public async Task NoResponseLambda_ServicesIsAccessible()
{
await using var factory =
new LambdaApplicationFactory<NoResponseLambda>().WithCancellationToken(
TestContext.Current.CancellationToken);
LambdaTestFactory
.Create<NoResponseLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken);

var act = () => factory.TestServer.Services.GetRequiredService<IHostApplicationLifetime>();

Expand All @@ -38,8 +40,9 @@ public async Task NoResponseLambda_ServicesIsAccessible()
public async Task NoResponseLambda_DisposeCanBeCalledMultipleTimes()
{
await using var factory =
new LambdaApplicationFactory<NoResponseLambda>().WithCancellationToken(
TestContext.Current.CancellationToken);
LambdaTestFactory
.Create<NoResponseLambda>()
.WithCancellationToken(TestContext.Current.CancellationToken);

var act = async () =>
{
Expand Down
Loading
Loading