Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(dotnet test:*)",
"Bash(dotnet build:*)"
]
}
}
196 changes: 196 additions & 0 deletions test/MinimalWorker.Test/BuilderPatternTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using MinimalWorker.Test.Helpers;

namespace MinimalWorker.Test;

/// <summary>
/// Tests for the IWorkerBuilder fluent API (WithName, WithErrorHandler).
/// </summary>
public class BuilderPatternTests
{
[Fact]
public async Task WithName_And_WithErrorHandler_Chaining_Works()
{
// Arrange
BackgroundWorkerExtensions.ClearRegistrations();
var errorHandlerCalled = false;
var activitiesCollected = new List<Activity>();
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

using var activityListener = new ActivityListener
{
ShouldListenTo = source => source.Name == "MinimalWorker",
Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllData,
ActivityStarted = activity =>
{
lock (activitiesCollected)
{
activitiesCollected.Add(activity);
}
signal.TrySetResult(true);
}
};
ActivitySource.AddActivityListener(activityListener);

using var host = Host.CreateDefaultBuilder().Build();

host.RunBackgroundWorker(async (CancellationToken token) =>
{
throw new InvalidOperationException("Test error");
})
.WithName("chained-worker")
.WithErrorHandler(ex => errorHandlerCalled = true);

// Act
await host.StartAsync();
await signal.Task.WaitAsync(TestConstants.SignalTimeout);
await Task.Delay(50); // Allow error handler to be called
await host.StopAsync();

// Assert
Assert.True(errorHandlerCalled, "Error handler should be called");
Assert.Contains(activitiesCollected, a =>
a.GetTagItem("worker.name")?.ToString() == "chained-worker");
}

[Fact]
public async Task WithName_Called_Multiple_Times_Uses_Last_Value()
{
// Arrange
BackgroundWorkerExtensions.ClearRegistrations();
var activitiesCollected = new List<Activity>();
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

using var activityListener = new ActivityListener
{
ShouldListenTo = source => source.Name == "MinimalWorker",
Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllData,
ActivityStarted = activity =>
{
lock (activitiesCollected)
{
activitiesCollected.Add(activity);
}
signal.TrySetResult(true);
}
};
ActivitySource.AddActivityListener(activityListener);

using var host = Host.CreateDefaultBuilder().Build();

host.RunBackgroundWorker(async (CancellationToken token) =>
{
await Task.Delay(5, token);
})
.WithName("first-name")
.WithName("second-name")
.WithName("final-name");

// Act
await host.StartAsync();
await signal.Task.WaitAsync(TestConstants.SignalTimeout);
await host.StopAsync();

// Assert - Last name should win
Assert.Contains(activitiesCollected, a =>
a.GetTagItem("worker.name")?.ToString() == "final-name");
}

[Fact]
public async Task WithName_With_Empty_String_Still_Executes()
{
// Arrange
BackgroundWorkerExtensions.ClearRegistrations();
var executed = false;

using var host = Host.CreateDefaultBuilder().Build();

host.RunBackgroundWorker(async (CancellationToken token) =>
{
executed = true;
await Task.Delay(10, token);
}).WithName("");

// Act
await host.StartAsync();
await Task.Delay(TestConstants.StandardTestWindowMs);
await host.StopAsync();

// Assert
Assert.True(executed, "Worker with empty name should still execute");
}

[Fact]
public async Task WithErrorHandler_Called_Multiple_Times_Uses_Last_Handler()
{
// Arrange
BackgroundWorkerExtensions.ClearRegistrations();
var firstHandlerCalled = false;
var secondHandlerCalled = false;
var thirdHandlerCalled = false;

using var host = Host.CreateDefaultBuilder().Build();

host.RunBackgroundWorker(async (CancellationToken token) =>
{
throw new InvalidOperationException("Test error");
})
.WithErrorHandler(ex => firstHandlerCalled = true)
.WithErrorHandler(ex => secondHandlerCalled = true)
.WithErrorHandler(ex => thirdHandlerCalled = true);

// Act
await host.StartAsync();
await Task.Delay(TestConstants.StandardTestWindowMs);
await host.StopAsync();

// Assert - Only the last handler should be called
Assert.False(firstHandlerCalled, "First handler should not be called");
Assert.False(secondHandlerCalled, "Second handler should not be called");
Assert.True(thirdHandlerCalled, "Third (last) handler should be called");
}

[Fact]
public async Task Builder_Without_Name_Uses_Generated_Name()
{
// Arrange
BackgroundWorkerExtensions.ClearRegistrations();
var activitiesCollected = new List<Activity>();
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

using var activityListener = new ActivityListener
{
ShouldListenTo = source => source.Name == "MinimalWorker",
Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllData,
ActivityStarted = activity =>
{
lock (activitiesCollected)
{
activitiesCollected.Add(activity);
}
signal.TrySetResult(true);
}
};
ActivitySource.AddActivityListener(activityListener);

using var host = Host.CreateDefaultBuilder().Build();

// Register without calling WithName
host.RunBackgroundWorker(async (CancellationToken token) =>
{
await Task.Delay(5, token);
});

// Act
await host.StartAsync();
await signal.Task.WaitAsync(TestConstants.SignalTimeout);
await host.StopAsync();

// Assert - Should have a generated name like "worker-1"
Assert.NotEmpty(activitiesCollected);
var workerName = activitiesCollected.First().GetTagItem("worker.name")?.ToString();
Assert.NotNull(workerName);
Assert.StartsWith("worker-", workerName);
}
}
50 changes: 39 additions & 11 deletions test/MinimalWorker.Test/ContinuousWorkerTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MinimalWorker.Test.Fakes;
using MinimalWorker.Test.Helpers;
using NSubstitute;

namespace MinimalWorker.Test;
Expand Down Expand Up @@ -66,9 +68,9 @@ public async Task BackgroundWorker_Should_Resolve_Dependencies_And_Invoke_Action
await Task.Delay(100); // Give worker time to execute multiple times
await host.StopAsync();

// Assert - Use "at least" to avoid flakiness on different machines
// Assert - Use bounded range to avoid flakiness and detect runaway workers
var callCount = service.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Increment");
Assert.True(callCount >= 4, $"Expected at least 4 Increment calls, got {callCount}");
Assert.InRange(callCount, TestConstants.MinContinuousExecutions, TestConstants.MaxContinuousExecutions);
}

[Fact]
Expand Down Expand Up @@ -102,11 +104,11 @@ public async Task BackgroundWorker_Should_Be_Able_To_Handle_Multiple_Tasks_With_
await Task.Delay(100); // Give worker time to execute multiple times
await host.StopAsync();

// Assert - Use "at least" to avoid flakiness on different machines
// Assert - Use bounded range to avoid flakiness and detect runaway workers
var incrementCalls = service.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Increment");
var decrementCalls = service.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Decrement");
Assert.True(incrementCalls >= 3, $"Expected at least 3 Increment calls, got {incrementCalls}");
Assert.True(decrementCalls >= 2, $"Expected at least 2 Decrement calls, got {decrementCalls}");
Assert.InRange(incrementCalls, TestConstants.MinContinuousExecutions, TestConstants.MaxContinuousExecutions);
Assert.InRange(decrementCalls, 2, TestConstants.MaxContinuousExecutions);
}

[Fact]
Expand Down Expand Up @@ -145,8 +147,8 @@ public async Task BackgroundWorker_Should_Be_Able_To_Handle_Nested_Dependencies(

// Assert - Verify that the delegation chain works through real classes
// TestDependency (mock) -> ADependency (real) -> BDependency (real)
Assert.True(bDependency.IncrementCount >= 4, $"Expected at least 4 increment calls, but got {bDependency.IncrementCount}");
Assert.True(bDependency.DecrementCount >= 4, $"Expected at least 4 decrement calls, but got {bDependency.DecrementCount}");
Assert.InRange(bDependency.IncrementCount, TestConstants.MinContinuousExecutions, TestConstants.MaxContinuousExecutions);
Assert.InRange(bDependency.DecrementCount, TestConstants.MinContinuousExecutions, TestConstants.MaxContinuousExecutions);
}

[Fact]
Expand Down Expand Up @@ -193,9 +195,9 @@ public async Task BackgroundWorker_Should_Support_Multiple_Workers_With_Differen
await host.StopAsync();

// Assert - Verify that all three workers executed independently
Assert.True(serviceA.ExecuteCount >= 4, $"Worker A should execute at least 4 times, but got {serviceA.ExecuteCount}");
Assert.True(serviceB.ExecuteCount >= 4, $"Worker B should execute at least 4 times, but got {serviceB.ExecuteCount}");
Assert.True(serviceC.ExecuteCount >= 4, $"Worker C should execute at least 4 times, but got {serviceC.ExecuteCount}");
Assert.InRange(serviceA.ExecuteCount, TestConstants.MinContinuousExecutions, TestConstants.MaxContinuousExecutions);
Assert.InRange(serviceB.ExecuteCount, TestConstants.MinContinuousExecutions, TestConstants.MaxContinuousExecutions);
Assert.InRange(serviceC.ExecuteCount, TestConstants.MinContinuousExecutions, TestConstants.MaxContinuousExecutions);
}

[Fact]
Expand All @@ -219,7 +221,7 @@ public async Task BackgroundWorker_Should_Execute_Without_Any_Dependencies()
await host.StopAsync();

// Assert
Assert.True(counter >= 3, $"Expected at least 3 executions, got {counter}");
Assert.InRange(counter, TestConstants.MinContinuousExecutions, TestConstants.MaxContinuousExecutions);
}

[Fact]
Expand Down Expand Up @@ -252,4 +254,30 @@ public async Task BackgroundWorker_Should_Handle_OperationCanceledException_Grac
// Assert
Assert.False(errorHandlerCalled, "OnError should not be called for OperationCanceledException");
}

[Fact]
public async Task BackgroundWorker_Should_Support_Synchronous_Work_In_Async_Context()
{
// Arrange
BackgroundWorkerExtensions.ClearRegistrations();
var counter = 0;

using var host = Host.CreateDefaultBuilder().Build();

// Synchronous work wrapped in Task (commonly used pattern)
host.RunBackgroundWorker((CancellationToken token) =>
{
Interlocked.Increment(ref counter);
Thread.Sleep(TestConstants.StandardWorkerDelayMs); // Synchronous delay
return Task.CompletedTask;
});

// Act
await host.StartAsync();
await Task.Delay(TestConstants.StandardTestWindowMs);
await host.StopAsync();

// Assert
Assert.InRange(counter, TestConstants.MinContinuousExecutions, TestConstants.MaxContinuousExecutions);
}
}
Loading