diff --git a/docs/guide/durability/efcore/multi-tenancy.md b/docs/guide/durability/efcore/multi-tenancy.md index 16331d848..1b57e1216 100644 --- a/docs/guide/durability/efcore/multi-tenancy.md +++ b/docs/guide/durability/efcore/multi-tenancy.md @@ -111,7 +111,7 @@ opts.Services.AddMarten(m => }); }).IntegrateWithWolverine(x => { - x.MasterDatabaseConnectionString = Servers.PostgresConnectionString; + x.MainDatabaseConnectionString = Servers.PostgresConnectionString; }); opts.Services.AddDbContextWithWolverineManagedMultiTenancyByDbDataSource((builder, dataSource, _) => diff --git a/docs/guide/durability/marten/multi-tenancy.md b/docs/guide/durability/marten/multi-tenancy.md index 7d90f12b8..20ab80e8c 100644 --- a/docs/guide/durability/marten/multi-tenancy.md +++ b/docs/guide/durability/marten/multi-tenancy.md @@ -45,7 +45,7 @@ builder.Services.AddMarten(m => m.DatabaseSchemaName = "mttodo"; }) - .IntegrateWithWolverine(x => x.MasterDatabaseConnectionString = connectionString); + .IntegrateWithWolverine(x => x.MainDatabaseConnectionString = connectionString); ``` snippet source | anchor diff --git a/docs/guide/http/multi-tenancy.md b/docs/guide/http/multi-tenancy.md index 3fdc45282..f058331e1 100644 --- a/docs/guide/http/multi-tenancy.md +++ b/docs/guide/http/multi-tenancy.md @@ -118,7 +118,7 @@ builder.Services.AddMarten(m => m.DatabaseSchemaName = "mttodo"; }) - .IntegrateWithWolverine(x => x.MasterDatabaseConnectionString = connectionString); + .IntegrateWithWolverine(x => x.MainDatabaseConnectionString = connectionString); ``` snippet source | anchor diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 1fefce749..874061d50 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -814,3 +814,229 @@ Host = await AlbaHost.For(x => ``` snippet source | anchor + +## Stubbing Message Handlers + +To extend the test automation support even further, Wolverine now has a capability to "stub" +out message handlers in testing scenarios with pre-canned behavior for more reliable testing +in some situations. This feature was mostly conceived of for stubbing out calls to external +systems through `IMessageBus.InvokeAsync()` where the request would normally be sent to +an external system through a subscriber. + +Jumping into an example, let's say that your system interacts with another service that estimates +delivery costs for ordering items. At some point in the system you might reach out through +a request/reply call in Wolverine to estimate an item delivery before making a purchase +like this code: + + + +```cs +// This query message is normally sent to an external system through Wolverine +// messaging +public record EstimateDelivery(int ItemId, DateOnly Date, string PostalCode); + +// This message type is a response from an external system +public record DeliveryInformation(TimeOnly DeliveryTime, decimal Cost); + +public record MaybePurchaseItem(int ItemId, Guid LocationId, DateOnly Date, string PostalCode, decimal BudgetedCost); +public record MakePurchase(int ItemId, Guid LocationId, DateOnly Date); +public record PurchaseRejected(int ItemId, Guid LocationId, DateOnly Date); + +public static class MaybePurchaseHandler +{ + public static Task LoadAsync( + MaybePurchaseItem command, + IMessageBus bus, + CancellationToken cancellation) + { + var (itemId, _, date, postalCode, budget) = command; + var estimateDelivery = new EstimateDelivery(itemId, date, postalCode); + + // Let's say this is doing a remote request and reply to another system + // through Wolverine messaging + return bus.InvokeAsync(estimateDelivery, cancellation); + } + + public static object Handle( + MaybePurchaseItem command, + DeliveryInformation estimate) + { + + if (estimate.Cost <= command.BudgetedCost) + { + return new MakePurchase(command.ItemId, command.LocationId, command.Date); + } + + return new PurchaseRejected(command.ItemId, command.LocationId, command.Date); + } +} +``` +snippet source | anchor + + +And for a little more context, the `EstimateDelivery` message will always be sent to +an external system in this configuration: + + + +```cs +var builder = Host.CreateApplicationBuilder(); +builder.UseWolverine(opts => +{ + opts + .UseRabbitMq(builder.Configuration.GetConnectionString("rabbit")) + .AutoProvision(); + + // Just showing that EstimateDelivery is handled by + // whatever system is on the other end of the "estimates" queue + opts.PublishMessage() + .ToRabbitQueue("estimates"); +}); +``` +snippet source | anchor + + +Using our + +In testing scenarios, maybe the external system isn't available at all, or it's just much more +challenging to run tests that also include the external system, or maybe you'd just like to +write more isolated tests against your service's behavior before even trying to integrate +with the other system (my personal preference anyway). To that end we can now stub +the remote handling like this: + + + +```cs +public static async Task try_application(IHost host) +{ + host.StubWolverineMessageHandling( + query => new DeliveryInformation(new TimeOnly(17, 0), 1000)); + + var locationId = Guid.NewGuid(); + var itemId = 111; + var expectedDate = new DateOnly(2025, 12, 1); + var postalCode = "78750"; + + var maybePurchaseItem = new MaybePurchaseItem(itemId, locationId, expectedDate, postalCode, + 500); + + var tracked = + await host.InvokeMessageAndWaitAsync(maybePurchaseItem); + + // The estimated cost from the stub was more than we budgeted + // so this message should have been published + + // This line is an assertion too that there was a single message + // of this type published as part of the message handling above + var rejected = tracked.Sent.SingleMessage(); + rejected.ItemId.ShouldBe(itemId); + rejected.LocationId.ShouldBe(locationId); +} +``` +snippet source | anchor + + +After calling making this call: + +```csharp + host.StubWolverineMessageHandling( + query => new DeliveryInformation(new TimeOnly(17, 0), 1000)); + +``` + +Calling this from our Wolverine application: + +```csharp + // Let's say this is doing a remote request and reply to another system + // through Wolverine messaging + return bus.InvokeAsync(estimateDelivery, cancellation); +``` + +Will use the stubbed logic we registered. This is enabling you to use fake behavior for +difficult to use external services. + +For the next test, we can completely remove the stub behavior and revert back to the +original configuration like this: + + + +```cs +public static void revert_stub(IHost host) +{ + // Selectively clear out the stub behavior for only one message + // type + host.WolverineStubs(stubs => + { + stubs.Clear(); + }); + + // Or just clear out all active Wolverine message handler + // stubs + host.ClearAllWolverineStubs(); +} +``` +snippet source | anchor + + +Or instead, we can just completely replace the previously registered stub behavior +with completely new logic that will override our previous stub: + + + +```cs +public static void override_stub(IHost host) +{ + host.StubWolverineMessageHandling( + query => new DeliveryInformation(new TimeOnly(17, 0), 250)); + +} +``` +snippet source | anchor + + +So far, we've only looked at simple request/reply behavior, but what if a remote system +receiving our message potentially makes multiple calls back to our system? Or really just +any kind of interaction more complicated than a single response for a request message? + +We're still in business, we just have to use a little uglier signature for our stub: + + + +```cs +public static void more_complex_stub(IHost host) +{ + host.WolverineStubs(stubs => + { + stubs.Stub(async ( + EstimateDelivery message, + IMessageContext context, + IServiceProvider services, + CancellationToken cancellation) => + { + // do whatever you want, including publishing any number of messages + // back through IMessageContext + + // And grab any other services you might need from the application + // through the IServiceProvider -- but note that you will have + // to deal with scopes yourself here + + // This is an equivalent to get the response back to the + // original caller + await context.PublishAsync(new DeliveryInformation(new TimeOnly(17, 0), 250)); + }); + }); +} +``` +snippet source | anchor + + +A few notes about this capability: + +* You can use any number of stubs for different message types at the same time +* Most of the testing samples use extension methods on `IHost`, but we know there are some users who bootstrap only an IoC container for integration tests, so all of the extension methods shown in this section are also available off of `IServiceProvider` +* The "stub" functions are effectively singletons. There's nothing fancier about argument matching or anything you might expect from a full fledged mock library like NSubstitute or FakeItEasy +* You can actually fake out the routing to message types that are normally handled by handlers within the application +* We don't believe this feature will be helpful for "sticky" message handlers where you may have multiple handlers for the same message type interally + + + diff --git a/src/Samples/DocumentationSamples/StubbingHandlers.cs b/src/Samples/DocumentationSamples/StubbingHandlers.cs new file mode 100644 index 000000000..d7d3df780 --- /dev/null +++ b/src/Samples/DocumentationSamples/StubbingHandlers.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.RabbitMQ; +using Wolverine.Tracking; + +namespace DocumentationSamples; + +public class StubbingHandlers +{ + public static async Task configure() + { + #region sample_configuring_estimate_delivery + + var builder = Host.CreateApplicationBuilder(); + builder.UseWolverine(opts => + { + opts + .UseRabbitMq(builder.Configuration.GetConnectionString("rabbit")) + .AutoProvision(); + + // Just showing that EstimateDelivery is handled by + // whatever system is on the other end of the "estimates" queue + opts.PublishMessage() + .ToRabbitQueue("estimates"); + }); + + #endregion + } + + #region sample_using_stub_handler_in_testing_code + + public static async Task try_application(IHost host) + { + host.StubWolverineMessageHandling( + query => new DeliveryInformation(new TimeOnly(17, 0), 1000)); + + var locationId = Guid.NewGuid(); + var itemId = 111; + var expectedDate = new DateOnly(2025, 12, 1); + var postalCode = "78750"; + + + var maybePurchaseItem = new MaybePurchaseItem(itemId, locationId, expectedDate, postalCode, + 500); + + var tracked = + await host.InvokeMessageAndWaitAsync(maybePurchaseItem); + + // The estimated cost from the stub was more than we budgeted + // so this message should have been published + + // This line is an assertion too that there was a single message + // of this type published as part of the message handling above + var rejected = tracked.Sent.SingleMessage(); + rejected.ItemId.ShouldBe(itemId); + rejected.LocationId.ShouldBe(locationId); + } + + #endregion + + #region sample_clearing_out_stub_behavior + + public static void revert_stub(IHost host) + { + // Selectively clear out the stub behavior for only one message + // type + host.WolverineStubs(stubs => + { + stubs.Clear(); + }); + + // Or just clear out all active Wolverine message handler + // stubs + host.ClearAllWolverineStubs(); + } + + #endregion + + #region sample_override_previous_stub_behavior + + public static void override_stub(IHost host) + { + host.StubWolverineMessageHandling( + query => new DeliveryInformation(new TimeOnly(17, 0), 250)); + + } + + #endregion + + #region sample_using_more_complex_stubs + + public static void more_complex_stub(IHost host) + { + host.WolverineStubs(stubs => + { + stubs.Stub(async ( + EstimateDelivery message, + IMessageContext context, + IServiceProvider services, + CancellationToken cancellation) => + { + // do whatever you want, including publishing any number of messages + // back through IMessageContext + + // And grab any other services you might need from the application + // through the IServiceProvider -- but note that you will have + // to deal with scopes yourself here + + // This is an equivalent to get the response back to the + // original caller + await context.PublishAsync(new DeliveryInformation(new TimeOnly(17, 0), 250)); + }); + }); + } + + #endregion +} + +#region sample_code_showing_remote_request_reply + +// This query message is normally sent to an external system through Wolverine +// messaging +public record EstimateDelivery(int ItemId, DateOnly Date, string PostalCode); + +// This message type is a response from an external system +public record DeliveryInformation(TimeOnly DeliveryTime, decimal Cost); + +public record MaybePurchaseItem(int ItemId, Guid LocationId, DateOnly Date, string PostalCode, decimal BudgetedCost); +public record MakePurchase(int ItemId, Guid LocationId, DateOnly Date); +public record PurchaseRejected(int ItemId, Guid LocationId, DateOnly Date); + +public static class MaybePurchaseHandler +{ + public static Task LoadAsync( + MaybePurchaseItem command, + IMessageBus bus, + CancellationToken cancellation) + { + var (itemId, _, date, postalCode, budget) = command; + var estimateDelivery = new EstimateDelivery(itemId, date, postalCode); + + // Let's say this is doing a remote request and reply to another system + // through Wolverine messaging + return bus.InvokeAsync(estimateDelivery, cancellation); + } + + public static object Handle( + MaybePurchaseItem command, + DeliveryInformation estimate) + { + + if (estimate.Cost <= command.BudgetedCost) + { + return new MakePurchase(command.ItemId, command.LocationId, command.Date); + } + + return new PurchaseRejected(command.ItemId, command.LocationId, command.Date); + } +} + +#endregion \ No newline at end of file diff --git a/src/Testing/CoreTests/Configuration/configuring_endpoints.cs b/src/Testing/CoreTests/Configuration/configuring_endpoints.cs index ae77a1326..3bc4fc069 100644 --- a/src/Testing/CoreTests/Configuration/configuring_endpoints.cs +++ b/src/Testing/CoreTests/Configuration/configuring_endpoints.cs @@ -51,12 +51,12 @@ public configuring_endpoints() } } - private StubTransport theStubTransport + private TcpTransport theTcpTransport { get { - var transport = theOptions.Transports.GetOrCreate(); - foreach (var endpoint in transport.Endpoints) endpoint.Compile(theRuntime); + var transport = theOptions.Transports.GetOrCreate(); + foreach (var endpoint in transport.Endpoints()) endpoint.Compile(theRuntime); return transport; } @@ -249,29 +249,30 @@ public void sets_is_listener() [Fact] public void select_reply_endpoint_with_one_listener() { - theOptions.ListenForMessagesFrom("stub://2222"); - theOptions.PublishAllMessages().To("stub://3333"); + theOptions.ListenAtPort(2222); + + theOptions.PublishAllMessages().ToPort(3333); - theStubTransport.ReplyEndpoint() - .Uri.ShouldBe("stub://2222".ToUri()); + theTcpTransport.ReplyEndpoint() + .Uri.ShouldBe("tcp://localhost:2222".ToUri()); } [Fact] public void select_reply_endpoint_with_multiple_listeners_and_one_designated_reply_endpoint() { - theOptions.ListenForMessagesFrom("stub://2222"); - theOptions.ListenForMessagesFrom("stub://4444").UseForReplies(); - theOptions.ListenForMessagesFrom("stub://5555"); - theOptions.PublishAllMessages().To("stub://3333"); + theOptions.ListenAtPort(2222); + theOptions.ListenAtPort(4444).UseForReplies(); + theOptions.ListenAtPort(5555); + theOptions.PublishAllMessages().ToPort(3333); - theStubTransport.ReplyEndpoint() - .Uri.ShouldBe("stub://4444".ToUri()); + theTcpTransport.ReplyEndpoint() + .Uri.ShouldBe("tcp://localhost:4444".ToUri()); } [Fact] public void select_reply_endpoint_with_no_listeners() { - theOptions.PublishAllMessages().To("stub://3333"); - theStubTransport.ReplyEndpoint().ShouldBeNull(); + theOptions.PublishAllMessages().To("tcp://localhost:3333"); + theTcpTransport.ReplyEndpoint().ShouldBeNull(); } } \ No newline at end of file diff --git a/src/Testing/CoreTests/Runtime/MockWolverineRuntime.cs b/src/Testing/CoreTests/Runtime/MockWolverineRuntime.cs index 536d8ca28..71a9f28a1 100644 --- a/src/Testing/CoreTests/Runtime/MockWolverineRuntime.cs +++ b/src/Testing/CoreTests/Runtime/MockWolverineRuntime.cs @@ -15,6 +15,7 @@ using Wolverine.Runtime.Metrics; using Wolverine.Runtime.RemoteInvocation; using Wolverine.Runtime.Routing; +using Wolverine.Runtime.Stubs; using Wolverine.Transports; using Wolverine.Transports.Sending; using Wolverine.Util; @@ -57,6 +58,8 @@ public MockWolverineRuntime() public MetricsAccumulator MetricsAccumulator { get; } + public IStubHandlers Stubs { get; } = Substitute.For(); + public IMessageTracker MessageTracking { get; } = Substitute.For(); void IObserver.OnCompleted() diff --git a/src/Testing/CoreTests/Runtime/Stubs/using_stubs_end_to_end.cs b/src/Testing/CoreTests/Runtime/Stubs/using_stubs_end_to_end.cs new file mode 100644 index 000000000..2225003c4 --- /dev/null +++ b/src/Testing/CoreTests/Runtime/Stubs/using_stubs_end_to_end.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Hosting; +using Wolverine.Tracking; +using Wolverine.Transports.SharedMemory; +using Xunit; + +namespace CoreTests.Runtime.Stubs; + +public class using_stubs_end_to_end : IAsyncLifetime +{ + private IHost theSender; + private IHost theReceiver; + + public async Task InitializeAsync() + { + await SharedMemoryQueueManager.ClearAllAsync(); + + theSender = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(StubMessage4Handler)); + opts.UseSharedMemoryQueueing(); + + opts.PublishAllMessages().ToSharedMemoryTopic("remote"); + opts.ListenToSharedMemorySubscription("sender", "replies").UseForReplies(); + + + }).StartAsync(); + + theReceiver = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.ListenToSharedMemorySubscription("remote", "receiver"); + }).StartAsync(); + + } + + public async Task DisposeAsync() + { + await theSender.StopAsync(); + await theReceiver.StopAsync(); + } + + [Fact] + public async Task baseline_state() + { + var bus = theSender.MessageBus(); + var response = await bus.InvokeAsync(new StubMessage1("green")); + response.Id.ShouldBe("green"); + } + + [Fact] + public async Task stub_single_message() + { + theSender.WolverineStubs(stubs => + { + stubs.Stub(m => new StubResponse1(m.Id + "-1")); + + }); + + var bus = theSender.MessageBus(); + var response = await bus.InvokeAsync(new StubMessage1("green")); + response.Id.ShouldBe("green-1"); + + var response2 = await bus.InvokeAsync(new StubMessage2("green")); + response2.Id.ShouldBe("green"); + } + + [Fact] + public async Task clear_all_reverts_back_to_normal() + { + theSender.StubWolverineMessageHandling(m => new StubResponse1(m.Id + "-1")); + + theSender.ClearAllWolverineStubs(); + + var bus = theSender.MessageBus(); + var response = await bus.InvokeAsync(new StubMessage1("green")); + response.Id.ShouldBe("green"); + } + + [Fact] + public async Task clear_specific_reverts_back_to_normal() + { + theSender.StubWolverineMessageHandling(m => new StubResponse1(m.Id + "-1")); + + theSender.WolverineStubs(x => x.Clear()); + + var bus = theSender.MessageBus(); + var response = await bus.InvokeAsync(new StubMessage1("green")); + response.Id.ShouldBe("green"); + } + + [Fact] + public async Task apply_second_stub_on_same_message_type() + { + theSender.StubWolverineMessageHandling(m => new StubResponse1(m.Id + "-1")); + + var bus = theSender.MessageBus(); + var response = await bus.InvokeAsync(new StubMessage1("green")); + response.Id.ShouldBe("green-1"); + + theSender.StubWolverineMessageHandling(m => new StubResponse1(m.Id + "-2")); + + var response2 = await bus.InvokeAsync(new StubMessage1("green")); + response2.Id.ShouldBe("green-2"); + } +} + +public record StubMessage1(string Id); +public record StubMessage2(string Id); +public record StubMessage3(string Id); +public record StubMessage4(string Id); + +public record StubResponse1(string Id); +public record StubResponse2(string Id); +public record StubResponse3(string Id); +public record StubResponse4(string Id); + +public static class StubMessage1Handler +{ + public static StubResponse1 Handle(StubMessage1 m) => new(m.Id); +} + +public static class StubMessage2Handler +{ + public static StubResponse2 Handle(StubMessage2 m) => new(m.Id); +} + +public static class StubMessage3Handler +{ + public static StubResponse3 Handle(StubMessage3 m) => new(m.Id); +} + +public static class StubMessage4Handler +{ + public static StubResponse4 Handle(StubMessage4 m) => new(m.Id); +} \ No newline at end of file diff --git a/src/Wolverine/Runtime/IWolverineRuntime.cs b/src/Wolverine/Runtime/IWolverineRuntime.cs index a3bd20ad8..e1ef18d34 100644 --- a/src/Wolverine/Runtime/IWolverineRuntime.cs +++ b/src/Wolverine/Runtime/IWolverineRuntime.cs @@ -9,6 +9,7 @@ using Wolverine.Runtime.Metrics; using Wolverine.Runtime.RemoteInvocation; using Wolverine.Runtime.Routing; +using Wolverine.Runtime.Stubs; namespace Wolverine.Runtime; @@ -70,6 +71,12 @@ public interface IWolverineRuntime IMessageInvoker FindInvoker(Type messageType); void AssertHasStarted(); IMessageInvoker FindInvoker(string envelopeMessageType); + + /// + /// Use this to temporarily add message handling stubs to take the place of external systems in testing + /// that may be sending replies back to your application + /// + IStubHandlers Stubs { get; } } public record NodeDestination(Guid NodeId, Uri ControlUri) diff --git a/src/Wolverine/Runtime/Stubs/HandlerStub.cs b/src/Wolverine/Runtime/Stubs/HandlerStub.cs new file mode 100644 index 000000000..3c1cfcc05 --- /dev/null +++ b/src/Wolverine/Runtime/Stubs/HandlerStub.cs @@ -0,0 +1,16 @@ +using ImTools; +using Wolverine.Configuration; +using Wolverine.Transports.Local; + +namespace Wolverine.Runtime.Stubs; + +public abstract class HandlerStub : IHandlerStub +{ + public abstract TResponse Handle(TRequest message, IMessageContext context); + + async Task IHandlerStub.HandleAsync(TRequest message, IMessageContext context, IServiceProvider services, CancellationToken cancellation) + { + var response = Handle(message, context); + await context.PublishAsync(response); + } +} \ No newline at end of file diff --git a/src/Wolverine/Runtime/Stubs/IHandlerStub.cs b/src/Wolverine/Runtime/Stubs/IHandlerStub.cs new file mode 100644 index 000000000..931be0128 --- /dev/null +++ b/src/Wolverine/Runtime/Stubs/IHandlerStub.cs @@ -0,0 +1,6 @@ +namespace Wolverine.Runtime.Stubs; + +public interface IHandlerStub +{ + Task HandleAsync(T message, IMessageContext context, IServiceProvider services, CancellationToken cancellation); +} \ No newline at end of file diff --git a/src/Wolverine/Runtime/Stubs/IStubHandlers.cs b/src/Wolverine/Runtime/Stubs/IStubHandlers.cs new file mode 100644 index 000000000..4e38f079b --- /dev/null +++ b/src/Wolverine/Runtime/Stubs/IStubHandlers.cs @@ -0,0 +1,39 @@ +namespace Wolverine.Runtime.Stubs; + +public interface IStubHandlers +{ + /// + /// Are there any registered stub message handlers in this Wolverine application? + /// + /// + bool HasAny(); + + /// + /// Apply a complex stubbed message handling behavior for a message type T that + /// is normally handled by an external system for testing scenarios + /// + /// + /// + void Stub(Func func); + + /// + /// Apply a simple stubbed behavior for request/reply scenarios for a message type + /// TRequest that normally results in a TResponse from an external system + /// + /// + /// + /// + void Stub(Func func); + + /// + /// Clear any previously registered stub behavior for the message type T + /// + /// + void Clear(); + + /// + /// Clear any registered message stub behavior in the application + /// across all message types + /// + void ClearAll(); +} \ No newline at end of file diff --git a/src/Wolverine/Runtime/Stubs/StubMessageHandler.cs b/src/Wolverine/Runtime/Stubs/StubMessageHandler.cs new file mode 100644 index 000000000..d76cfe575 --- /dev/null +++ b/src/Wolverine/Runtime/Stubs/StubMessageHandler.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using Wolverine.Runtime.Handlers; +using Wolverine.Transports; + +namespace Wolverine.Runtime.Stubs; + +public class StubMessageHandler : IMessageHandler +{ + private readonly IServiceProvider _services; + + public StubMessageHandler(Func func, IServiceProvider services) + { + Func = func; + _services = services; + } + + // TODO -- override this! + public Func Func { get; internal set; } + + public Task HandleAsync(T message, IMessageContext context, IServiceProvider services, CancellationToken cancellation) + { + return Func(message, context, services, cancellation); + } + + public Type MessageType => typeof(T); + public LogLevel SuccessLogLevel => LogLevel.None; + public LogLevel ProcessingLogLevel => LogLevel.None; + public bool TelemetryEnabled => false; + public Task HandleAsync(MessageContext context, CancellationToken cancellation) + { + var message = (T)context.Envelope.Message; + return Func(message, context, _services, cancellation); + } +} \ No newline at end of file diff --git a/src/Wolverine/Runtime/WolverineRuntime.Routing.cs b/src/Wolverine/Runtime/WolverineRuntime.Routing.cs index 6454aa5e6..8c52c73ac 100644 --- a/src/Wolverine/Runtime/WolverineRuntime.Routing.cs +++ b/src/Wolverine/Runtime/WolverineRuntime.Routing.cs @@ -165,4 +165,9 @@ private List findRoutes(Type messageType) return null; } + + internal void ClearRoutingFor(Type messageType) + { + _messageTypeRouting = _messageTypeRouting.Remove(messageType); + } } \ No newline at end of file diff --git a/src/Wolverine/Runtime/WolverineRuntime.cs b/src/Wolverine/Runtime/WolverineRuntime.cs index 83a406517..6679be337 100644 --- a/src/Wolverine/Runtime/WolverineRuntime.cs +++ b/src/Wolverine/Runtime/WolverineRuntime.cs @@ -17,6 +17,8 @@ using Wolverine.Runtime.RemoteInvocation; using Wolverine.Runtime.Routing; using Wolverine.Runtime.Scheduled; +using Wolverine.Runtime.Stubs; +using Wolverine.Transports.Stub; namespace Wolverine.Runtime; @@ -104,6 +106,8 @@ public WolverineRuntime(WolverineOptions options, } } + public IStubHandlers Stubs => Options.Transports.GetOrCreate(); + public MetricsAccumulator MetricsAccumulator => _accumulator.Value; public IWolverineObserver Observer { get; set; } diff --git a/src/Wolverine/Tracking/HandlerStubbingExtensions.cs b/src/Wolverine/Tracking/HandlerStubbingExtensions.cs new file mode 100644 index 000000000..b4804e411 --- /dev/null +++ b/src/Wolverine/Tracking/HandlerStubbingExtensions.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Wolverine.Runtime; +using Wolverine.Runtime.Stubs; + +namespace Wolverine.Tracking; + +public static class HandlerStubbingExtensions +{ + /// + /// Configure stubbed message handlers within an application using Wolverine. This is mostly meant + /// to allow you to stub out request/reply calls to external systems, but can also be used to fake + /// internal message handling when publishing messages -- but will not work for IMessageBus.InvokeAsync()! + /// + /// + /// + /// + public static void WolverineStubs(this IServiceProvider services, Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + configure(services.GetRequiredService().Stubs); + } + + /// + /// Configure stubbed message handlers within an application using Wolverine. This is mostly meant + /// to allow you to stub out request/reply calls to external systems, but can also be used to fake + /// internal message handling when publishing messages -- but will not work for IMessageBus.InvokeAsync()! + /// + /// + /// + public static void WolverineStubs(this IHost host, Action configure) + { + host.Services.WolverineStubs(configure); + } + + /// + /// Clears out any registered message handler stubs in this Wolverine application and restores + /// the system to its original configuration + /// + /// + public static void ClearAllWolverineStubs(this IServiceProvider services) + { + services.WolverineStubs(x => x.ClearAll()); + } + + /// + /// Clears out any registered message handler stubs in this Wolverine application and restores + /// the system to its original configuration + /// + /// + public static void ClearAllWolverineStubs(this IHost host) + { + host.Services.ClearAllWolverineStubs(); + } + + /// + /// Register stubbed behavior within the Wolverine application for any messages of the TRequest type + /// Use this to efficiently replace InvokeAsync(TRequest) calls to external services inside + /// of automated tests + /// + /// + /// + /// + /// + public static void StubWolverineMessageHandling(this IServiceProvider services, + Func func) + { + services.WolverineStubs(stubs => + { + stubs.Stub(func); + }); + } + + /// + /// Register stubbed behavior within the Wolverine application for any messages of the TRequest type + /// Use this to efficiently replace InvokeAsync(TRequest) calls to external services inside + /// of automated tests + /// + /// + /// + /// + /// + public static void StubWolverineMessageHandling(this IHost host, + Func func) + { + host.Services.WolverineStubs(stubs => + { + stubs.Stub(func); + }); + } + +} \ No newline at end of file diff --git a/src/Wolverine/Tracking/WolverineHostMessageTrackingExtensions.cs b/src/Wolverine/Tracking/WolverineHostMessageTrackingExtensions.cs index f33570d9f..d4bbf9f53 100644 --- a/src/Wolverine/Tracking/WolverineHostMessageTrackingExtensions.cs +++ b/src/Wolverine/Tracking/WolverineHostMessageTrackingExtensions.cs @@ -1,6 +1,8 @@ using JasperFx.Core; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Wolverine.Runtime; +using Wolverine.Runtime.Stubs; namespace Wolverine.Tracking; @@ -375,4 +377,6 @@ internal static async Task ExecuteAndWaitValueTaskAsync(this IS return session; } + + } \ No newline at end of file diff --git a/src/Wolverine/Transports/Stub/StubTransport.cs b/src/Wolverine/Transports/Stub/StubTransport.cs index 979453c05..7cf5fef1a 100644 --- a/src/Wolverine/Transports/Stub/StubTransport.cs +++ b/src/Wolverine/Transports/Stub/StubTransport.cs @@ -1,14 +1,33 @@ +using ImTools; using JasperFx.Core; +using JasperFx.Core.Reflection; +using Wolverine.Configuration; +using Wolverine.ErrorHandling; using Wolverine.Runtime; +using Wolverine.Runtime.Handlers; +using Wolverine.Runtime.Routing; +using Wolverine.Runtime.Stubs; namespace Wolverine.Transports.Stub; -internal class StubTransport : TransportBase +internal class StubTransport : TransportBase, IStubHandlers, IMessageRouteSource, IExecutorFactory { + private WolverineRuntime _runtime; + + private readonly Dictionary _stubs = new(); + + public bool HasAny() => _stubs.Any(); + public StubTransport() : base("stub", "Stub") { Endpoints = new LightweightCache(name => new StubEndpoint(name, this)); + + Endpoints[TransportConstants.Replies].IsListener = true; + + var endpoint = Endpoints["system"]; + endpoint.Role = EndpointRole.System; + endpoint.Mode = EndpointMode.Inline; } public new LightweightCache Endpoints { get; } @@ -26,12 +45,107 @@ protected override StubEndpoint findEndpointByUri(Uri uri) public override ValueTask InitializeAsync(IWolverineRuntime runtime) { + _runtime = (WolverineRuntime)runtime; + foreach (var endpoint in Endpoints) { endpoint.Compile(runtime); - endpoint.Start(new HandlerPipeline((WolverineRuntime)runtime, (IExecutorFactory)runtime, endpoint), runtime.MessageTracking); + + if (endpoint.Uri == TransportConstants.LocalStubs) + { + endpoint.Start(new HandlerPipeline((WolverineRuntime)runtime, runtime.Options.Transports.GetOrCreate(), endpoint), runtime.MessageTracking); + } + else + { + endpoint.Start(new HandlerPipeline((WolverineRuntime)runtime, (IExecutorFactory)runtime, endpoint), runtime.MessageTracking); + } + } return ValueTask.CompletedTask; } + + IEnumerable IMessageRouteSource.FindRoutes(Type messageType, IWolverineRuntime runtime) + { + if (_stubs.ContainsKey(messageType)) + { + var sendingAgent = runtime.Endpoints.GetOrBuildSendingAgent(TransportConstants.LocalStubs, e => e.Mode = EndpointMode.BufferedInMemory); + + yield return new MessageRoute(messageType, sendingAgent.Endpoint, runtime); + } + } + + public override Endpoint? ReplyEndpoint() + { + return Endpoints[TransportConstants.Replies]; + } + + public void Stub(Func func) + { + if (_stubs.ContainsKey(typeof(T))) + { + _stubs[typeof(T)].As>().Func = func; + } + else + { + _stubs[typeof(T)] = new StubMessageHandler(func, _runtime.Services); + } + + _runtime.ClearRoutingFor(typeof(T)); + + } + + public void Stub(Func func) + { + Stub(async (message, context, _, _) => + { + var response = func(message); + await context.As().EnqueueCascadingAsync(response); + }); + } + + public void Clear() + { + _runtime.ClearRoutingFor(typeof(T)); + _stubs.Remove(typeof(T)); + } + + public void ClearAll() + { + foreach (var messageType in _stubs.Keys) + { + _runtime.ClearRoutingFor(messageType); + _stubs.Remove(messageType); + } + } + + bool IMessageRouteSource.IsAdditive => false; + + + IExecutor IExecutorFactory.BuildFor(Type messageType) + { + + if (_stubs.TryGetValue(messageType, out var handler)) + { + return new Executor(_runtime.ExecutionPool, _runtime.Logger, handler, _runtime.MessageTracking, + new FailureRuleCollection(), 10.Seconds()); + } + + throw new ArgumentOutOfRangeException(nameof(messageType), + "No registered stub for message type " + messageType.FullNameInCode()); + + + } + + IExecutor IExecutorFactory.BuildFor(Type messageType, Endpoint endpoint) + { + if (_stubs.TryGetValue(messageType, out var handler)) + { + return new Executor(_runtime.ExecutionPool, _runtime.Logger, handler, _runtime.MessageTracking, + new FailureRuleCollection(), 10.Seconds()); + } + + throw new ArgumentOutOfRangeException(nameof(messageType), + "No registered stub for message type " + messageType.FullNameInCode()); + } } \ No newline at end of file diff --git a/src/Wolverine/Transports/TransportConstants.cs b/src/Wolverine/Transports/TransportConstants.cs index 6ba16bb41..0fca0330e 100644 --- a/src/Wolverine/Transports/TransportConstants.cs +++ b/src/Wolverine/Transports/TransportConstants.cs @@ -25,5 +25,7 @@ public static class TransportConstants public static readonly Uri DurableLocalUri = "local://durable".ToUri(); public static readonly Uri LocalUri = "local://".ToUri(); + public static readonly Uri LocalStubs = "stub://system".ToUri(); + public static readonly int AnyNode = 0; } \ No newline at end of file diff --git a/src/Wolverine/WolverineOptions.cs b/src/Wolverine/WolverineOptions.cs index 53cc1d8be..46b96bd23 100644 --- a/src/Wolverine/WolverineOptions.cs +++ b/src/Wolverine/WolverineOptions.cs @@ -15,6 +15,7 @@ using Wolverine.Runtime.Scheduled; using Wolverine.Runtime.Serialization; using Wolverine.Transports.Local; +using Wolverine.Transports.Stub; [assembly: InternalsVisibleTo("Wolverine.Testing")] @@ -117,6 +118,8 @@ public WolverineOptions(string? assemblyName) Policies.Add(); MessagePartitioning = new MessagePartitioningRules(this); + + InternalRouteSources.Insert(0, Transports.GetOrCreate()); } public MetricsOptions Metrics { get; } = new();