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
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// Intentionally bespoke — PatternKit AsyncAdapter<TIn,TOut> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IChannelAdapter>();
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<IChannelAdapter>();
adapter.SendAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.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<ArgumentNullException>();
return true;
})
.AssertPassed();
}

[Scenario("Null messageSelector throws ArgumentNullException"), Fact]
public async Task NullMessageSelectorThrows()
{
var adapter = Substitute.For<IChannelAdapter>();
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<ArgumentNullException>();
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<IChannelAdapter>();
adapter.SendAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.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<IChannelAdapter>();
adapter.SendAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.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();
}
}
Original file line number Diff line number Diff line change
@@ -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<IDeadLetterStore>();
var inner = Substitute.For<IStep>();
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<IDeadLetterStore>();
var inner = Substitute.For<IStep>();
inner.ExecuteAsync(Arg.Any<IWorkflowContext>()).Returns(Task.CompletedTask);

var sut = new DeadLetterStep(store, inner);
await sut.ExecuteAsync(new WorkflowContext());

await store.DidNotReceive().SendAsync(Arg.Any<object>(), Arg.Any<string>(), Arg.Any<Exception>(), Arg.Any<CancellationToken>());

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<IDeadLetterStore>();
store.SendAsync(Arg.Any<object>(), Arg.Any<string>(), Arg.Any<Exception>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);

var inner = Substitute.For<IStep>();
inner.ExecuteAsync(Arg.Any<IWorkflowContext>())
.Returns<Task>(_ => throw new InvalidOperationException("inner failure"));

var sut = new DeadLetterStep(store, inner);
await sut.ExecuteAsync(new WorkflowContext());

await store.Received(1).SendAsync(Arg.Any<object>(), Arg.Any<string>(), Arg.Any<Exception>(), Arg.Any<CancellationToken>());

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<IDeadLetterStore>();
store.SendAsync(Arg.Any<object>(), Arg.Any<string>(), Arg.Any<Exception>(), Arg.Any<CancellationToken>())
.Returns(ci => { storedReason = (string)ci[1]; return Task.CompletedTask; });

var inner = Substitute.For<IStep>();
inner.ExecuteAsync(Arg.Any<IWorkflowContext>())
.Returns<Task>(_ => 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<IDeadLetterStore>();
store.SendAsync(Arg.Any<object>(), Arg.Any<string>(), Arg.Any<Exception>(), Arg.Any<CancellationToken>())
.Returns(ci => { storedMessage = ci[0]; return Task.CompletedTask; });

var inner = Substitute.For<IStep>();
inner.ExecuteAsync(Arg.Any<IWorkflowContext>())
.Returns<Task>(_ => 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<IDeadLetterStore>();
store.SendAsync(Arg.Any<object>(), Arg.Any<string>(), Arg.Any<Exception>(), Arg.Any<CancellationToken>())
.Returns(ci => { storedMessage = ci[0]; return Task.CompletedTask; });

var inner = Substitute.For<IStep>();
inner.ExecuteAsync(Arg.Any<IWorkflowContext>())
.Returns<Task>(_ => 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<IStep>();
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<ArgumentNullException>();
return true;
})
.AssertPassed();
}

[Scenario("Null inner step throws ArgumentNullException"), Fact]
public async Task NullInnerStepThrows()
{
var store = Substitute.For<IDeadLetterStore>();
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<ArgumentNullException>();
return true;
})
.AssertPassed();
}
}
Loading
Loading