diff --git a/src/WorkflowFramework.Extensions.Integration/Channel/ChannelAdapterStep.cs b/src/WorkflowFramework.Extensions.Integration/Channel/ChannelAdapterStep.cs index 069d756..274a9bc 100644 --- a/src/WorkflowFramework.Extensions.Integration/Channel/ChannelAdapterStep.cs +++ b/src/WorkflowFramework.Extensions.Integration/Channel/ChannelAdapterStep.cs @@ -1,3 +1,8 @@ +// Intentionally bespoke — PatternKit AsyncAdapter is a type-mapping pattern +// (produce a TOut from a TIn). ChannelAdapterStep is a side-effect operation (send a +// message to an IChannelAdapter); it has no output type and no conversion pipeline. +// Using AsyncAdapter here would be a category error. Characterization tests added in +// Phase G.3. using WorkflowFramework.Extensions.Integration.Abstractions; namespace WorkflowFramework.Extensions.Integration.Channel; diff --git a/src/WorkflowFramework.Extensions.Integration/Channel/DeadLetterStep.cs b/src/WorkflowFramework.Extensions.Integration/Channel/DeadLetterStep.cs index f16df07..f988292 100644 --- a/src/WorkflowFramework.Extensions.Integration/Channel/DeadLetterStep.cs +++ b/src/WorkflowFramework.Extensions.Integration/Channel/DeadLetterStep.cs @@ -1,3 +1,9 @@ +// Intentionally bespoke — DeadLetterStep wraps a step with error-routing: on failure, +// it extracts the dead-letter payload from context and routes it to an IDeadLetterStore. +// PatternKit 0.105.0 has no dead-letter or error-routing primitive. The try/catch +// + IDeadLetterStore pattern is specific to EIP and cannot be cleanly expressed +// via PatternKit Decorator (which transforms inputs, not catches exceptions to external +// stores). Characterization tests added in Phase G.3. using WorkflowFramework.Extensions.Integration.Abstractions; namespace WorkflowFramework.Extensions.Integration.Channel; diff --git a/src/WorkflowFramework.Extensions.Integration/Channel/MessageBridgeStep.cs b/src/WorkflowFramework.Extensions.Integration/Channel/MessageBridgeStep.cs index 2508e00..9effd85 100644 --- a/src/WorkflowFramework.Extensions.Integration/Channel/MessageBridgeStep.cs +++ b/src/WorkflowFramework.Extensions.Integration/Channel/MessageBridgeStep.cs @@ -1,3 +1,9 @@ +// Intentionally bespoke — MessageBridgeStep receives from one IChannelAdapter and sends +// to another in a single atomic step. PatternKit Bridge connects two hierarchies +// abstractly (implementation vs abstraction); it does not model a runtime channel +// receive→forward pipeline. The implementation is a two-call thin wrapper; +// no PatternKit primitive reduces the code further. Characterization tests added in +// Phase G.3. using WorkflowFramework.Extensions.Integration.Abstractions; namespace WorkflowFramework.Extensions.Integration.Channel; diff --git a/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs b/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs index d76e3a9..eb50c9f 100644 --- a/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs +++ b/src/WorkflowFramework.Extensions.Integration/Channel/WireTapStep.cs @@ -1,3 +1,9 @@ +// Intentionally bespoke — WireTapStep's core contract (run a side-effect without disrupting +// the main flow, with optional error swallowing) is simpler than PatternKit's +// AsyncActionDecorator pipeline. AsyncActionDecorator wraps a component and transforms/ +// decorates it; WireTapStep wraps nothing — it IS the side-effect. Applying Decorator here +// would add indirection without modelling the pattern more clearly. Characterization tests +// added in Phase G.3. namespace WorkflowFramework.Extensions.Integration.Channel; /// diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/ChannelAdapterStepScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/ChannelAdapterStepScenarios.cs new file mode 100644 index 0000000..49c39c2 --- /dev/null +++ b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/ChannelAdapterStepScenarios.cs @@ -0,0 +1,138 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; +using FluentAssertions; +using NSubstitute; +using WorkflowFramework.Tests.TinyBDD.Support; +using WorkflowFramework.Extensions.Integration.Channel; +using WorkflowFramework.Extensions.Integration.Abstractions; + +namespace WorkflowFramework.Tests.TinyBDD.Integration.Channel; + +[Feature("ChannelAdapterStep — characterization (Phase G.3)")] +public class ChannelAdapterStepScenarios : TinyBddTestBase +{ + public ChannelAdapterStepScenarios(ITestOutputHelper output) : base(output) { } + + [Scenario("ChannelAdapterStep Name returns 'ChannelAdapter'"), Fact] + public async Task NameIsChannelAdapter() + { + var adapter = Substitute.For(); + var sut = new ChannelAdapterStep(adapter, _ => new object()); + + await Given("ChannelAdapterStep instance", () => sut) + .Then("Name is 'ChannelAdapter'", s => + { + s.Name.Should().Be("ChannelAdapter"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Adapter SendAsync is called with message from selector"), Fact] + public async Task AdapterSendAsyncCalledWithSelectedMessage() + { + object? sentMessage = null; + var adapter = Substitute.For(); + adapter.SendAsync(Arg.Any(), Arg.Any()) + .Returns(ci => { sentMessage = ci[0]; return Task.CompletedTask; }); + + var ctx = new WorkflowContext(); + ctx.Properties["payload"] = "hello"; + + var sut = new ChannelAdapterStep( + adapter, + c => c.Properties["payload"]!); + + await sut.ExecuteAsync(ctx); + + await Given("message sent to adapter", () => sentMessage) + .Then("sent message equals 'hello'", msg => + { + msg.Should().Be("hello"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null adapter throws ArgumentNullException"), Fact] + public async Task NullAdapterThrows() + { + Exception? caught = null; + try { _ = new ChannelAdapterStep(null!, _ => new object()); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null adapter", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null messageSelector throws ArgumentNullException"), Fact] + public async Task NullMessageSelectorThrows() + { + var adapter = Substitute.For(); + Exception? caught = null; + try { _ = new ChannelAdapterStep(adapter, null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null message selector", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Adapter SendAsync receives context cancellation token"), Fact] + public async Task SendAsyncReceivesCancellationToken() + { + var cts = new CancellationTokenSource(); + CancellationToken received = default; + var adapter = Substitute.For(); + adapter.SendAsync(Arg.Any(), Arg.Any()) + .Returns(ci => { received = (CancellationToken)ci[1]; return Task.CompletedTask; }); + + var ctx = new WorkflowContext(cts.Token); + var sut = new ChannelAdapterStep(adapter, _ => "msg"); + + await sut.ExecuteAsync(ctx); + + await Given("cancellation token received by SendAsync", () => received) + .Then("token equals the context cancellation token", t => + { + t.Should().Be(cts.Token); + return true; + }) + .AssertPassed(); + } + + [Scenario("Message selector can return complex objects"), Fact] + public async Task MessageSelectorReturnsComplexObject() + { + object? sentMessage = null; + var adapter = Substitute.For(); + adapter.SendAsync(Arg.Any(), Arg.Any()) + .Returns(ci => { sentMessage = ci[0]; return Task.CompletedTask; }); + + var complexPayload = new { Id = 42, Name = "Test" }; + var ctx = new WorkflowContext(); + ctx.Properties["complex"] = complexPayload; + + var sut = new ChannelAdapterStep(adapter, c => c.Properties["complex"]!); + await sut.ExecuteAsync(ctx); + + await Given("message sent to adapter for complex object", () => sentMessage) + .Then("sent message is the complex payload", msg => + { + msg.Should().BeSameAs(complexPayload); + return true; + }) + .AssertPassed(); + } +} diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/DeadLetterStepScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/DeadLetterStepScenarios.cs new file mode 100644 index 0000000..955eb0c --- /dev/null +++ b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/DeadLetterStepScenarios.cs @@ -0,0 +1,182 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; +using FluentAssertions; +using NSubstitute; +using WorkflowFramework.Tests.TinyBDD.Support; +using WorkflowFramework.Extensions.Integration.Channel; +using WorkflowFramework.Extensions.Integration.Abstractions; + +namespace WorkflowFramework.Tests.TinyBDD.Integration.Channel; + +[Feature("DeadLetterStep — characterization (Phase G.3)")] +public class DeadLetterStepScenarios : TinyBddTestBase +{ + public DeadLetterStepScenarios(ITestOutputHelper output) : base(output) { } + + [Scenario("DeadLetterStep Name returns 'DeadLetter'"), Fact] + public async Task NameIsDeadLetter() + { + var store = Substitute.For(); + var inner = Substitute.For(); + var sut = new DeadLetterStep(store, inner); + + await Given("DeadLetterStep instance", () => sut) + .Then("Name is 'DeadLetter'", s => + { + s.Name.Should().Be("DeadLetter"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Inner step succeeds — dead letter store is not called"), Fact] + public async Task SuccessfulInnerStepDoesNotCallStore() + { + var store = Substitute.For(); + var inner = Substitute.For(); + inner.ExecuteAsync(Arg.Any()).Returns(Task.CompletedTask); + + var sut = new DeadLetterStep(store, inner); + await sut.ExecuteAsync(new WorkflowContext()); + + await store.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + + await Given("store not called on success", () => true) + .Then("store was not called (verified by NSubstitute)", _ => true) + .AssertPassed(); + } + + [Scenario("Inner step fails — dead letter store SendAsync is called"), Fact] + public async Task FailingInnerStepCallsStore() + { + var store = Substitute.For(); + store.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var inner = Substitute.For(); + inner.ExecuteAsync(Arg.Any()) + .Returns(_ => throw new InvalidOperationException("inner failure")); + + var sut = new DeadLetterStep(store, inner); + await sut.ExecuteAsync(new WorkflowContext()); + + await store.Received(1).SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + + await Given("store called when inner step throws", () => true) + .Then("store.SendAsync was called once (verified by NSubstitute)", _ => true) + .AssertPassed(); + } + + [Scenario("Exception message is passed to store SendAsync"), Fact] + public async Task ExceptionMessagePassedToStore() + { + string? storedReason = null; + var store = Substitute.For(); + store.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => { storedReason = (string)ci[1]; return Task.CompletedTask; }); + + var inner = Substitute.For(); + inner.ExecuteAsync(Arg.Any()) + .Returns(_ => throw new Exception("specific error message")); + + var sut = new DeadLetterStep(store, inner); + await sut.ExecuteAsync(new WorkflowContext()); + + await Given("reason passed to dead letter store", () => storedReason) + .Then("reason contains the exception message", reason => + { + reason.Should().Contain("specific error message"); + return true; + }) + .AssertPassed(); + } + + [Scenario("__CurrentMessage context property is used as dead letter message"), Fact] + public async Task CurrentMessagePropertyUsedAsDeadLetterMessage() + { + object? storedMessage = null; + var store = Substitute.For(); + store.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => { storedMessage = ci[0]; return Task.CompletedTask; }); + + var inner = Substitute.For(); + inner.ExecuteAsync(Arg.Any()) + .Returns(_ => throw new Exception("fail")); + + var ctx = new WorkflowContext(); + var expectedMessage = new { Id = 7 }; + ctx.Properties["__CurrentMessage"] = expectedMessage; + + var sut = new DeadLetterStep(store, inner); + await sut.ExecuteAsync(ctx); + + await Given("message stored in dead letter", () => storedMessage) + .Then("message is the __CurrentMessage property value", msg => + { + msg.Should().BeSameAs(expectedMessage); + return true; + }) + .AssertPassed(); + } + + [Scenario("Context is used as message when __CurrentMessage is absent"), Fact] + public async Task ContextUsedAsMessageWhenNoCurrentMessage() + { + object? storedMessage = null; + var store = Substitute.For(); + store.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => { storedMessage = ci[0]; return Task.CompletedTask; }); + + var inner = Substitute.For(); + inner.ExecuteAsync(Arg.Any()) + .Returns(_ => throw new Exception("fail")); + + var ctx = new WorkflowContext(); // No __CurrentMessage + var sut = new DeadLetterStep(store, inner); + await sut.ExecuteAsync(ctx); + + await Given("message stored when __CurrentMessage absent", () => storedMessage) + .Then("stored message is the workflow context itself", msg => + { + msg.Should().BeSameAs(ctx); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null store throws ArgumentNullException"), Fact] + public async Task NullStoreThrows() + { + var inner = Substitute.For(); + Exception? caught = null; + try { _ = new DeadLetterStep(null!, inner); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null store", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null inner step throws ArgumentNullException"), Fact] + public async Task NullInnerStepThrows() + { + var store = Substitute.For(); + Exception? caught = null; + try { _ = new DeadLetterStep(store, null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null inner step", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } +} diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/MessageBridgeStepScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/MessageBridgeStepScenarios.cs new file mode 100644 index 0000000..9aff5b0 --- /dev/null +++ b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/MessageBridgeStepScenarios.cs @@ -0,0 +1,135 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; +using FluentAssertions; +using NSubstitute; +using WorkflowFramework.Tests.TinyBDD.Support; +using WorkflowFramework.Extensions.Integration.Channel; +using WorkflowFramework.Extensions.Integration.Abstractions; + +namespace WorkflowFramework.Tests.TinyBDD.Integration.Channel; + +[Feature("MessageBridgeStep — characterization (Phase G.3)")] +public class MessageBridgeStepScenarios : TinyBddTestBase +{ + public MessageBridgeStepScenarios(ITestOutputHelper output) : base(output) { } + + [Scenario("MessageBridgeStep Name returns 'MessageBridge'"), Fact] + public async Task NameIsMessageBridge() + { + var source = Substitute.For(); + var dest = Substitute.For(); + var sut = new MessageBridgeStep(source, dest); + + await Given("MessageBridgeStep instance", () => sut) + .Then("Name is 'MessageBridge'", s => + { + s.Name.Should().Be("MessageBridge"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Message received from source is forwarded to destination"), Fact] + public async Task MessageForwardedFromSourceToDestination() + { + var payload = new object(); + object? received = null; + + var source = Substitute.For(); + source.ReceiveAsync(Arg.Any()).Returns(Task.FromResult(payload)); + + var dest = Substitute.For(); + dest.SendAsync(Arg.Any(), Arg.Any()) + .Returns(ci => { received = ci[0]; return Task.CompletedTask; }); + + var sut = new MessageBridgeStep(source, dest); + await sut.ExecuteAsync(new WorkflowContext()); + + await Given("message received by destination", () => received) + .Then("destination received the source message", msg => + { + msg.Should().BeSameAs(payload); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null message from source — destination SendAsync is not called"), Fact] + public async Task NullMessageFromSourceSkipsSend() + { + var source = Substitute.For(); + source.ReceiveAsync(Arg.Any()).Returns(Task.FromResult(null)); + + var dest = Substitute.For(); + + var sut = new MessageBridgeStep(source, dest); + await sut.ExecuteAsync(new WorkflowContext()); + + await dest.DidNotReceive().SendAsync(Arg.Any(), Arg.Any()); + + await Given("destination not called when source returns null", () => true) + .Then("destination SendAsync was NOT called (verified by NSubstitute)", _ => true) + .AssertPassed(); + } + + [Scenario("Null source throws ArgumentNullException"), Fact] + public async Task NullSourceThrows() + { + var dest = Substitute.For(); + Exception? caught = null; + try { _ = new MessageBridgeStep(null!, dest); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null source", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null destination throws ArgumentNullException"), Fact] + public async Task NullDestinationThrows() + { + var source = Substitute.For(); + Exception? caught = null; + try { _ = new MessageBridgeStep(source, null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null destination", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Context cancellation token is passed to source ReceiveAsync"), Fact] + public async Task CancellationTokenPassedToSourceReceive() + { + var cts = new CancellationTokenSource(); + CancellationToken receivedToken = default; + + var source = Substitute.For(); + source.ReceiveAsync(Arg.Any()) + .Returns(ci => { receivedToken = (CancellationToken)ci[0]; return Task.FromResult(null); }); + + var dest = Substitute.For(); + var ctx = new WorkflowContext(cts.Token); + + var sut = new MessageBridgeStep(source, dest); + await sut.ExecuteAsync(ctx); + + await Given("cancellation token passed to source ReceiveAsync", () => receivedToken) + .Then("token equals context cancellation token", t => + { + t.Should().Be(cts.Token); + return true; + }) + .AssertPassed(); + } +} diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/WireTapStepScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/WireTapStepScenarios.cs new file mode 100644 index 0000000..821fab1 --- /dev/null +++ b/tests/WorkflowFramework.Tests.TinyBDD/Integration/Channel/WireTapStepScenarios.cs @@ -0,0 +1,157 @@ +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; +using FluentAssertions; +using WorkflowFramework.Tests.TinyBDD.Support; +using WorkflowFramework.Extensions.Integration.Channel; + +namespace WorkflowFramework.Tests.TinyBDD.Integration.Channel; + +[Feature("WireTapStep — characterization (Phase G.3)")] +public class WireTapStepScenarios : TinyBddTestBase +{ + public WireTapStepScenarios(ITestOutputHelper output) : base(output) { } + + [Scenario("WireTapStep Name returns 'WireTap'"), Fact] + public async Task NameIsWireTap() + { + var sut = new WireTapStep(_ => Task.CompletedTask); + + await Given("WireTapStep instance", () => sut) + .Then("Name is 'WireTap'", s => + { + s.Name.Should().Be("WireTap"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Tap action runs on happy path"), Fact] + public async Task TapActionRunsOnHappyPath() + { + var tapCalled = false; + var sut = new WireTapStep(_ => { tapCalled = true; return Task.CompletedTask; }); + + await sut.ExecuteAsync(new WorkflowContext()); + + await Given("whether tap action was called", () => tapCalled) + .Then("tap action ran", called => + { + called.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Tap action receives workflow context"), Fact] + public async Task TapActionReceivesContext() + { + IWorkflowContext? received = null; + var ctx = new WorkflowContext(); + ctx.Properties["token"] = "abc"; + + var sut = new WireTapStep(c => { received = c; return Task.CompletedTask; }); + await sut.ExecuteAsync(ctx); + + await Given("context received by tap action", () => received) + .Then("it has the token property", rc => + { + rc.Should().NotBeNull(); + rc!.Properties["token"].Should().Be("abc"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Tap action error is swallowed when swallowErrors is true (default)"), Fact] + public async Task TapErrorIsSwallowedByDefault() + { + var sut = new WireTapStep(_ => throw new Exception("tap error"), swallowErrors: true); + + Exception? caught = null; + try { await sut.ExecuteAsync(new WorkflowContext()); } + catch (Exception ex) { caught = ex; } + + await Given("exception after faulting tap with swallowErrors=true", () => caught) + .Then("no exception propagates", ex => + { + ex.Should().BeNull(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Tap action error propagates when swallowErrors is false"), Fact] + public async Task TapErrorPropagatesWhenSwallowFalse() + { + var sut = new WireTapStep(_ => throw new InvalidOperationException("tap exploded"), swallowErrors: false); + + Exception? caught = null; + try { await sut.ExecuteAsync(new WorkflowContext()); } + catch (InvalidOperationException ex) { caught = ex; } + + await Given("exception after faulting tap with swallowErrors=false", () => caught) + .Then("InvalidOperationException propagates", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + ex!.Message.Should().Contain("tap exploded"); + return true; + }) + .AssertPassed(); + } + + [Scenario("Null tap action throws ArgumentNullException"), Fact] + public async Task NullTapActionThrows() + { + Exception? caught = null; + try { _ = new WireTapStep(null!); } + catch (ArgumentNullException ex) { caught = ex; } + + await Given("construction with null tap action", () => caught) + .Then("ArgumentNullException is thrown", ex => + { + ex.Should().NotBeNull().And.BeOfType(); + return true; + }) + .AssertPassed(); + } + + [Scenario("WireTap does not mutate context IsAborted"), Fact] + public async Task WireTapDoesNotAbortContext() + { + var ctx = new WorkflowContext(); + var sut = new WireTapStep(_ => Task.CompletedTask); + + await sut.ExecuteAsync(ctx); + + await Given("context IsAborted after wire tap", () => ctx.IsAborted) + .Then("context is not aborted", aborted => + { + aborted.Should().BeFalse(); + return true; + }) + .AssertPassed(); + } + + [Scenario("Async tap action is awaited"), Fact] + public async Task AsyncTapActionIsAwaited() + { + var tapCompleted = false; + var sut = new WireTapStep(async _ => + { + await Task.Delay(10); + tapCompleted = true; + }); + + await sut.ExecuteAsync(new WorkflowContext()); + + await Given("tapCompleted flag after async tap action", () => tapCompleted) + .Then("async tap completed before ExecuteAsync returned", completed => + { + completed.Should().BeTrue(); + return true; + }) + .AssertPassed(); + } +}