diff --git a/EnterpriseIntegrationPlatform/EnterpriseIntegrationPlatform.sln b/EnterpriseIntegrationPlatform/EnterpriseIntegrationPlatform.sln index 4c1043c..4563c3e 100644 --- a/EnterpriseIntegrationPlatform/EnterpriseIntegrationPlatform.sln +++ b/EnterpriseIntegrationPlatform/EnterpriseIntegrationPlatform.sln @@ -117,6 +117,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Processing.RequestReply", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Connectors", "src\Connectors\Connectors.csproj", "{7998C735-EB8F-4DBE-BB32-978E9A465433}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testing", "src\Testing\Testing.csproj", "{F13607C8-980A-4EFF-93B5-5D6FE344F08C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -751,6 +753,18 @@ Global {7998C735-EB8F-4DBE-BB32-978E9A465433}.Release|x64.Build.0 = Release|Any CPU {7998C735-EB8F-4DBE-BB32-978E9A465433}.Release|x86.ActiveCfg = Release|Any CPU {7998C735-EB8F-4DBE-BB32-978E9A465433}.Release|x86.Build.0 = Release|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Debug|x64.ActiveCfg = Debug|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Debug|x64.Build.0 = Debug|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Debug|x86.ActiveCfg = Debug|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Debug|x86.Build.0 = Debug|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Release|Any CPU.Build.0 = Release|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Release|x64.ActiveCfg = Release|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Release|x64.Build.0 = Release|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Release|x86.ActiveCfg = Release|Any CPU + {F13607C8-980A-4EFF-93B5-5D6FE344F08C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -811,5 +825,6 @@ Global {F7FBDC14-6ED2-46AC-B348-2427C14F0158} = {A1B2C3D4-0001-0001-0001-000000000002} {F8DD5966-EE52-4ADA-BE4F-D23636F424F8} = {A1B2C3D4-0001-0001-0001-000000000002} {7998C735-EB8F-4DBE-BB32-978E9A465433} = {A1B2C3D4-0001-0001-0001-000000000002} + {F13607C8-980A-4EFF-93B5-5D6FE344F08C} = {A1B2C3D4-0001-0001-0001-000000000001} EndGlobalSection EndGlobal diff --git a/EnterpriseIntegrationPlatform/src/Testing/AspireIntegrationTestHost.cs b/EnterpriseIntegrationPlatform/src/Testing/AspireIntegrationTestHost.cs new file mode 100644 index 0000000..9d08616 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/AspireIntegrationTestHost.cs @@ -0,0 +1,103 @@ +// ============================================================================ +// AspireIntegrationTestHost – DI-based test host for E2E integration testing +// ============================================================================ + +using EnterpriseIntegrationPlatform.Ingestion; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Aspire-style integration test host that wires real EIP components +/// with MockEndpoints via dependency injection. +/// +public sealed class AspireIntegrationTestHost : IAsyncDisposable +{ + private readonly IHost _host; + private readonly Dictionary _endpoints; + + internal AspireIntegrationTestHost(IHost host, Dictionary endpoints) + { + _host = host; + _endpoints = endpoints; + } + + public IServiceProvider Services => _host.Services; + + public T GetService() where T : notnull => + _host.Services.GetRequiredService(); + + public MockEndpoint GetEndpoint(string name) => _endpoints[name]; + + public IReadOnlyDictionary Endpoints => _endpoints; + + public static Builder CreateBuilder() => new(); + + public ValueTask DisposeAsync() + { + _host.Dispose(); + return ValueTask.CompletedTask; + } + + public sealed class Builder + { + private readonly HostApplicationBuilder _inner; + private readonly Dictionary _endpoints = new(); + + public Builder() + { + _inner = Host.CreateApplicationBuilder([]); + _inner.Services.AddSingleton(NullLoggerFactory.Instance); + _inner.Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>)); + } + + /// Adds a named MockEndpoint for testing. + public MockEndpoint AddMockEndpoint(string name) + { + var ep = new MockEndpoint(name); + _endpoints[name] = ep; + return ep; + } + + /// Registers a MockEndpoint as the default IMessageBrokerProducer. + public Builder UseProducer(MockEndpoint endpoint) + { + _inner.Services.AddSingleton(endpoint); + return this; + } + + /// Registers a MockEndpoint as the default IMessageBrokerConsumer. + public Builder UseConsumer(MockEndpoint endpoint) + { + _inner.Services.AddSingleton(endpoint); + return this; + } + + public Builder ConfigureServices(Action configure) + { + configure(_inner.Services); + return this; + } + + public Builder AddSingleton(TService instance) where TService : class + { + _inner.Services.AddSingleton(instance); + return this; + } + + public Builder Configure(Action configure) where TOptions : class + { + _inner.Services.Configure(configure); + return this; + } + + public AspireIntegrationTestHost Build() + { + var host = _inner.Build(); + return new AspireIntegrationTestHost(host, _endpoints); + } + } +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockActivityServices.cs b/EnterpriseIntegrationPlatform/src/Testing/MockActivityServices.cs new file mode 100644 index 0000000..3773f04 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockActivityServices.cs @@ -0,0 +1,172 @@ +// ============================================================================ +// MockActivityServices – In-memory activity services for testing +// ============================================================================ + +using System.Collections.Concurrent; +using EnterpriseIntegrationPlatform.Activities; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of +/// that returns configurable results per step name. +/// +public sealed class MockCompensationActivityService : ICompensationActivityService +{ + private readonly Dictionary _stepResults = new(); + private readonly ConcurrentQueue _calls = new(); + private bool _defaultResult = true; + + /// All compensation calls recorded. + public IReadOnlyList Calls => _calls.ToArray(); + + /// Sets the result for a specific step name. + public MockCompensationActivityService WithStepResult(string stepName, bool success) + { + _stepResults[stepName] = success; + return this; + } + + /// Sets the default result for unmatched steps. + public MockCompensationActivityService WithDefaultResult(bool success) + { + _defaultResult = success; + return this; + } + + public Task CompensateAsync(Guid correlationId, string stepName) + { + _calls.Enqueue(new CompensationCallRecord(correlationId, stepName)); + var result = _stepResults.TryGetValue(stepName, out var r) ? r : _defaultResult; + return Task.FromResult(result); + } + + public sealed record CompensationCallRecord(Guid CorrelationId, string StepName); +} + +/// +/// Real in-memory implementation of +/// that returns configurable validation results per message type. +/// +public sealed class MockMessageValidationService : IMessageValidationService +{ + private readonly Dictionary _results = new(); + private readonly ConcurrentQueue _calls = new(); + private MessageValidationResult _defaultResult = MessageValidationResult.Success; + + /// All validation calls recorded. + public IReadOnlyList Calls => _calls.ToArray(); + + /// Sets the result for a specific message type. + public MockMessageValidationService WithResult(string messageType, MessageValidationResult result) + { + _results[messageType] = result; + return this; + } + + /// Sets the default result for unmatched types. + public MockMessageValidationService WithDefaultResult(MessageValidationResult result) + { + _defaultResult = result; + return this; + } + + public Task ValidateAsync(string messageType, string payloadJson) + { + _calls.Enqueue(new ValidationCallRecord(messageType, payloadJson)); + var result = _results.TryGetValue(messageType, out var r) ? r : _defaultResult; + return Task.FromResult(result); + } + + public sealed record ValidationCallRecord(string MessageType, string PayloadJson); +} + +/// +/// Real in-memory implementation of +/// that captures all persistence calls. +/// +public sealed class MockPersistenceActivityService : IPersistenceActivityService +{ + private readonly ConcurrentQueue _calls = new(); + + /// All persistence calls recorded. + public IReadOnlyList Calls => _calls.ToArray(); + + /// Number of SaveMessage calls. + public int SaveCount => _calls.Count(c => c.Operation == "SaveMessage"); + + /// Number of UpdateDeliveryStatus calls. + public int UpdateStatusCount => _calls.Count(c => c.Operation == "UpdateDeliveryStatus"); + + /// Number of SaveFault calls. + public int SaveFaultCount => _calls.Count(c => c.Operation == "SaveFault"); + + public Task SaveMessageAsync(IntegrationPipelineInput input, CancellationToken cancellationToken = default) + { + _calls.Enqueue(new PersistenceCallRecord("SaveMessage", input.MessageId, input.MessageType, null)); + return Task.CompletedTask; + } + + public Task UpdateDeliveryStatusAsync( + Guid messageId, Guid correlationId, DateTimeOffset recordedAt, + string status, CancellationToken cancellationToken = default) + { + _calls.Enqueue(new PersistenceCallRecord("UpdateDeliveryStatus", messageId, status, null)); + return Task.CompletedTask; + } + + public Task SaveFaultAsync( + Guid messageId, Guid correlationId, string messageType, + string faultedBy, string reason, int retryCount, + CancellationToken cancellationToken = default) + { + _calls.Enqueue(new PersistenceCallRecord("SaveFault", messageId, messageType, reason)); + return Task.CompletedTask; + } + + /// Asserts that SaveMessage was called the expected number of times. + public void AssertSaveCount(int expected) => + NUnit.Framework.Assert.That(SaveCount, NUnit.Framework.Is.EqualTo(expected)); + + public void Reset() + { + while (_calls.TryDequeue(out _)) { } + } + + public sealed record PersistenceCallRecord(string Operation, Guid MessageId, string? Detail, string? Reason); +} + +/// +/// Real in-memory implementation of +/// that captures all log entries. +/// +public sealed class MockMessageLoggingService : IMessageLoggingService +{ + private readonly ConcurrentQueue _logs = new(); + + /// All log entries recorded. + public IReadOnlyList Logs => _logs.ToArray(); + + /// Number of log entries. + public int LogCount => _logs.Count; + + public Task LogAsync(Guid messageId, string messageType, string stage) + { + _logs.Enqueue(new LogRecord(messageId, messageType, stage)); + return Task.CompletedTask; + } + + /// Asserts a specific stage was logged for the given message. + public void AssertLogged(Guid messageId, string stage) => + NUnit.Framework.Assert.That( + _logs.Any(l => l.MessageId == messageId && l.Stage == stage), + NUnit.Framework.Is.True, + $"Expected log entry for message {messageId} at stage '{stage}'"); + + public void Reset() + { + while (_logs.TryDequeue(out _)) { } + } + + public sealed record LogRecord(Guid MessageId, string MessageType, string Stage); +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockAggregationStrategy.cs b/EnterpriseIntegrationPlatform/src/Testing/MockAggregationStrategy.cs new file mode 100644 index 0000000..083c452 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockAggregationStrategy.cs @@ -0,0 +1,30 @@ +// ============================================================================ +// MockAggregationStrategy – Configurable aggregation for testing +// ============================================================================ + +using EnterpriseIntegrationPlatform.Processing.Aggregator; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of +/// that applies a configurable aggregation function. +/// +public sealed class MockAggregationStrategy : IAggregationStrategy +{ + private readonly Func, TAggregate> _aggregateFunc; + private int _callCount; + + /// Creates a mock strategy with the given aggregation function. + public MockAggregationStrategy(Func, TAggregate> aggregateFunc) => + _aggregateFunc = aggregateFunc; + + /// Number of aggregation calls. + public int CallCount => _callCount; + + public TAggregate Aggregate(IReadOnlyList items) + { + Interlocked.Increment(ref _callCount); + return _aggregateFunc(items); + } +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockEndpoint.cs b/EnterpriseIntegrationPlatform/src/Testing/MockEndpoint.cs new file mode 100644 index 0000000..2677b38 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockEndpoint.cs @@ -0,0 +1,158 @@ +// ============================================================================ +// MockEndpoint – Real in-memory message broker for end-to-end integration testing +// ============================================================================ +// Captures messages published by EIP components and feeds test messages to +// consumers. Inspired by Apache Camel's MockEndpoint pattern. A real service +// implementation — not a test double. +// ============================================================================ + +using System.Collections.Concurrent; +using EnterpriseIntegrationPlatform.Contracts; +using EnterpriseIntegrationPlatform.Ingestion; +using NUnit.Framework; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory message broker endpoint for end-to-end integration testing. +/// Implements all broker interfaces so it can act as both producer (captures +/// outbound messages) and consumer (feeds inbound messages to handlers). +/// +public sealed class MockEndpoint : IMessageBrokerProducer, IMessageBrokerConsumer, + IEventDrivenConsumer, IPollingConsumer, ISelectiveConsumer, IAsyncDisposable +{ + private readonly string _name; + private readonly ConcurrentQueue _received = new(); + private readonly ConcurrentQueue _inbound = new(); + private readonly List> _handlers = new(); + + public MockEndpoint(string name) => _name = name; + + public string Name => _name; + + // ── IMessageBrokerProducer (captures outbound messages) ───────────── + + public Task PublishAsync( + IntegrationEnvelope envelope, + string topic, + CancellationToken cancellationToken = default) + { + _received.Enqueue(new ReceivedMessage(envelope!, topic, DateTimeOffset.UtcNow)); + return Task.CompletedTask; + } + + // ── IMessageBrokerConsumer (delivers test messages to handlers) ────── + + public Task SubscribeAsync( + string topic, + string consumerGroup, + Func, Task> handler, + CancellationToken cancellationToken = default) + { + _handlers.Add(obj => handler((IntegrationEnvelope)obj)); + return Task.CompletedTask; + } + + // ── IEventDrivenConsumer ──────────────────────────────────────────── + + public Task StartAsync( + string topic, + string consumerGroup, + Func, Task> handler, + CancellationToken cancellationToken = default) + { + _handlers.Add(obj => handler((IntegrationEnvelope)obj)); + return Task.CompletedTask; + } + + // ── IPollingConsumer ──────────────────────────────────────────────── + + public Task>> PollAsync( + string topic, + string consumerGroup, + int maxMessages = 10, + CancellationToken cancellationToken = default) + { + var results = new List>(); + while (results.Count < maxMessages && _inbound.TryDequeue(out var msg)) + results.Add((IntegrationEnvelope)msg); + return Task.FromResult>>(results); + } + + // ── ISelectiveConsumer ────────────────────────────────────────────── + + public Task SubscribeAsync( + string topic, + string consumerGroup, + Func, bool> predicate, + Func, Task> handler, + CancellationToken cancellationToken = default) + { + _handlers.Add(obj => + { + var env = (IntegrationEnvelope)obj; + return predicate(env) ? handler(env) : Task.CompletedTask; + }); + return Task.CompletedTask; + } + + // ── Test helpers: Send messages into the endpoint ──────────────────── + + /// + /// Sends a test message into this endpoint, triggering registered handlers. + /// Use this to feed messages into the integration pipeline under test. + /// + public async Task SendAsync(IntegrationEnvelope envelope) + { + _inbound.Enqueue(envelope!); + foreach (var handler in _handlers) + await handler(envelope!); + } + + // ── Assertions ────────────────────────────────────────────────────── + + public IReadOnlyList Received => _received.ToArray(); + + public int ReceivedCount => _received.Count; + + public IntegrationEnvelope GetReceived(int index = 0) => + (IntegrationEnvelope)_received.ElementAt(index).Envelope; + + public IReadOnlyList> GetAllReceived(string? topic = null) => + _received + .Where(r => topic is null || r.Topic == topic) + .Select(r => (IntegrationEnvelope)r.Envelope) + .ToList(); + + public IReadOnlyList GetReceivedTopics() => + _received.Select(r => r.Topic).Distinct().ToList(); + + public void AssertReceivedCount(int expected) => + Assert.That(_received.Count, Is.EqualTo(expected), + $"MockEndpoint '{_name}': expected {expected} message(s), received {_received.Count}"); + + public void AssertNoneReceived() => + Assert.That(_received.Count, Is.EqualTo(0), + $"MockEndpoint '{_name}': expected no messages, received {_received.Count}"); + + public void AssertReceivedOnTopic(string topic, int expected) => + Assert.That( + _received.Count(r => r.Topic == topic), + Is.EqualTo(expected), + $"MockEndpoint '{_name}': expected {expected} on '{topic}'"); + + public void Reset() + { + while (_received.TryDequeue(out _)) { } + while (_inbound.TryDequeue(out _)) { } + _handlers.Clear(); + } + + public ValueTask DisposeAsync() + { + Reset(); + return ValueTask.CompletedTask; + } + + public sealed record ReceivedMessage(object Envelope, string Topic, DateTimeOffset ReceivedAt); +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockEnrichmentSource.cs b/EnterpriseIntegrationPlatform/src/Testing/MockEnrichmentSource.cs new file mode 100644 index 0000000..fecbe7e --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockEnrichmentSource.cs @@ -0,0 +1,69 @@ +// ============================================================================ +// MockEnrichmentSource – Configurable enrichment lookup for testing +// ============================================================================ + +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using EnterpriseIntegrationPlatform.Processing.Transform; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of backed by +/// a configurable lookup dictionary. +/// +public sealed class MockEnrichmentSource : IEnrichmentSource +{ + private readonly Dictionary _data = new(); + private readonly ConcurrentQueue _lookups = new(); + private JsonNode? _fallback; + + /// All lookup keys requested. + public IReadOnlyList Lookups => _lookups.ToArray(); + + /// Number of lookups performed. + public int LookupCount => _lookups.Count; + + /// Adds a lookup entry. + public MockEnrichmentSource WithData(string key, string json) + { + _data[key] = JsonNode.Parse(json); + return this; + } + + /// Adds a null lookup entry (key exists but returns null). + public MockEnrichmentSource WithNull(string key) + { + _data[key] = null; + return this; + } + + /// Sets a fallback value for unknown keys. + public MockEnrichmentSource WithFallback(string? json) + { + _fallback = json is not null ? JsonNode.Parse(json) : null; + return this; + } + + /// Configures the source to return null for all unknown keys. + public MockEnrichmentSource ReturnsNullForUnknown() + { + _fallback = null; + return this; + } + + public Task FetchAsync(string lookupKey, CancellationToken ct = default) + { + _lookups.Enqueue(lookupKey); + + if (_data.TryGetValue(lookupKey, out var node)) + return Task.FromResult(node); + + return Task.FromResult(_fallback); + } + + public void Reset() + { + while (_lookups.TryDequeue(out _)) { } + } +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockEventProjection.cs b/EnterpriseIntegrationPlatform/src/Testing/MockEventProjection.cs new file mode 100644 index 0000000..5eaae76 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockEventProjection.cs @@ -0,0 +1,30 @@ +// ============================================================================ +// MockEventProjection – Configurable event projection for testing +// ============================================================================ + +using EnterpriseIntegrationPlatform.EventSourcing; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of +/// that applies a configurable projection function. +/// +public sealed class MockEventProjection : IEventProjection +{ + private readonly Func _projectFunc; + private int _callCount; + + /// Creates a mock projection with the given function. + public MockEventProjection(Func projectFunc) => + _projectFunc = projectFunc; + + /// Number of projection calls. + public int CallCount => _callCount; + + public Task ProjectAsync(TState state, EventEnvelope envelope, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _callCount); + return Task.FromResult(_projectFunc(state, envelope)); + } +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockFileSystem.cs b/EnterpriseIntegrationPlatform/src/Testing/MockFileSystem.cs new file mode 100644 index 0000000..714db13 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockFileSystem.cs @@ -0,0 +1,70 @@ +// ============================================================================ +// MockFileSystem – In-memory file system for testing +// ============================================================================ + +using System.Collections.Concurrent; +using EnterpriseIntegrationPlatform.Connector.FileSystem; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of backed by +/// a dictionary-based store. +/// +public sealed class MockFileSystem : IFileSystem +{ + private readonly ConcurrentDictionary _files = new(); + private readonly ConcurrentQueue _calls = new(); + + /// All calls recorded. + public IReadOnlyList Calls => _calls.ToArray(); + + /// In-memory file store — pre-populate for read tests. + public ConcurrentDictionary Files => _files; + + /// Gets the last written file path (excluding .meta files). + public string? LastWrittenPath => + _calls.LastOrDefault(c => c.Operation == "WriteAllBytes" && !c.Path!.EndsWith(".meta.json"))?.Path; + + public Task WriteAllBytesAsync(string path, byte[] contents, CancellationToken ct) + { + _files[path] = contents; + _calls.Enqueue(new FileSystemCallRecord("WriteAllBytes", path)); + return Task.CompletedTask; + } + + public Task ReadAllBytesAsync(string path, CancellationToken ct) + { + _calls.Enqueue(new FileSystemCallRecord("ReadAllBytes", path)); + if (_files.TryGetValue(path, out var data)) + return Task.FromResult(data); + throw new FileNotFoundException($"File not found: {path}"); + } + + public IEnumerable GetFiles(string directory, string searchPattern) + { + _calls.Enqueue(new FileSystemCallRecord("GetFiles", directory)); + return _files.Keys + .Where(k => k.StartsWith(directory, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + public bool FileExists(string path) + { + _calls.Enqueue(new FileSystemCallRecord("FileExists", path)); + return _files.ContainsKey(path); + } + + public void CreateDirectory(string path) + { + _calls.Enqueue(new FileSystemCallRecord("CreateDirectory", path)); + } + + public void Reset() + { + _files.Clear(); + while (_calls.TryDequeue(out _)) { } + } + + public sealed record FileSystemCallRecord(string Operation, string? Path); +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockHttpConnector.cs b/EnterpriseIntegrationPlatform/src/Testing/MockHttpConnector.cs new file mode 100644 index 0000000..a5db067 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockHttpConnector.cs @@ -0,0 +1,107 @@ +// ============================================================================ +// MockHttpConnector – In-memory HTTP connector for testing +// ============================================================================ + +using System.Collections.Concurrent; +using System.Text.Json; +using EnterpriseIntegrationPlatform.Connector.Http; +using EnterpriseIntegrationPlatform.Contracts; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of that captures +/// HTTP requests and returns configurable responses. +/// +public sealed class MockHttpConnector : IHttpConnector +{ + private readonly ConcurrentQueue _calls = new(); + private readonly Dictionary _responses = new(); + private object? _defaultResponse; + private Func? _failureInjector; + + /// All HTTP calls recorded. + public IReadOnlyList Calls => _calls.ToArray(); + + /// Number of calls recorded. + public int CallCount => _calls.Count; + + /// Sets the default response for any URL. + public MockHttpConnector WithDefaultResponse(TResponse response) + { + _defaultResponse = response; + return this; + } + + /// Sets a specific response for a given relative URL. + public MockHttpConnector WithResponse(string relativeUrl, TResponse response) + { + _responses[relativeUrl] = response!; + return this; + } + + /// Injects a failure for calls matching the predicate. + public MockHttpConnector WithFailure(Func failureInjector) + { + _failureInjector = failureInjector; + return this; + } + + /// Injects a failure for all calls. + public MockHttpConnector WithFailure(Exception ex) + { + _failureInjector = (_, _) => ex; + return this; + } + + public Task SendAsync( + IntegrationEnvelope envelope, + string relativeUrl, + HttpMethod method, + CancellationToken ct) + { + _calls.Enqueue(new HttpCallRecord(envelope!, relativeUrl, method, null, DateTimeOffset.UtcNow)); + + if (_failureInjector is not null) + throw _failureInjector(relativeUrl, method); + + if (_responses.TryGetValue(relativeUrl, out var specific)) + return Task.FromResult((TResponse)specific); + + if (_defaultResponse is not null) + return Task.FromResult((TResponse)_defaultResponse); + + if (typeof(TResponse) == typeof(JsonElement)) + { + var defaultJson = JsonDocument.Parse("{}").RootElement; + return Task.FromResult((TResponse)(object)defaultJson); + } + + return Task.FromResult(default(TResponse)!); + } + + public Task SendWithTokenAsync( + IntegrationEnvelope envelope, + string relativeUrl, + HttpMethod method, + string tokenEndpoint, + string tokenRequestBody, + string tokenHeaderName, + CancellationToken ct) + { + _calls.Enqueue(new HttpCallRecord(envelope!, relativeUrl, method, tokenEndpoint, DateTimeOffset.UtcNow)); + return SendAsync(envelope, relativeUrl, method, ct); + } + + public void Reset() + { + while (_calls.TryDequeue(out _)) { } + } + + public sealed record HttpCallRecord( + object Envelope, + string RelativeUrl, + HttpMethod Method, + string? TokenEndpoint, + DateTimeOffset CalledAt); +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockObservabilityServices.cs b/EnterpriseIntegrationPlatform/src/Testing/MockObservabilityServices.cs new file mode 100644 index 0000000..1ade4e5 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockObservabilityServices.cs @@ -0,0 +1,102 @@ +// ============================================================================ +// MockObservabilityServices – In-memory observability services for testing +// ============================================================================ + +using System.Collections.Concurrent; +using EnterpriseIntegrationPlatform.Observability; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of +/// that stores events in memory and supports queries. +/// +public sealed class MockObservabilityEventLog : IObservabilityEventLog +{ + private readonly ConcurrentBag _events = new(); + + /// All recorded events. + public IReadOnlyList Events => _events.ToList(); + + /// Pre-populates the event log with events. + public MockObservabilityEventLog WithEvents(params MessageEvent[] events) + { + foreach (var e in events) _events.Add(e); + return this; + } + + public Task RecordAsync(MessageEvent messageEvent, CancellationToken cancellationToken = default) + { + _events.Add(messageEvent); + return Task.CompletedTask; + } + + public Task> GetByBusinessKeyAsync( + string businessKey, CancellationToken cancellationToken = default) + { + var result = _events + .Where(e => e.BusinessKey == businessKey) + .OrderBy(e => e.RecordedAt) + .ToList(); + return Task.FromResult>(result); + } + + public Task> GetByCorrelationIdAsync( + Guid correlationId, CancellationToken cancellationToken = default) + { + var result = _events + .Where(e => e.CorrelationId == correlationId) + .OrderBy(e => e.RecordedAt) + .ToList(); + return Task.FromResult>(result); + } + + public void Reset() => _events.Clear(); +} + +/// +/// Real in-memory implementation of that returns +/// configurable analysis responses. +/// +public sealed class MockTraceAnalyzer : ITraceAnalyzer +{ + private readonly ConcurrentQueue _calls = new(); + private string _analyseResponse = "Mock trace analysis"; + private string _whereIsResponse = "Message is being processed"; + + /// All calls recorded. + public IReadOnlyList Calls => _calls.ToArray(); + + /// Sets the response for AnalyseTraceAsync. + public MockTraceAnalyzer WithAnalyseResponse(string response) + { + _analyseResponse = response; + return this; + } + + /// Sets the response for WhereIsMessageAsync. + public MockTraceAnalyzer WithWhereIsResponse(string response) + { + _whereIsResponse = response; + return this; + } + + public Task AnalyseTraceAsync(string traceContextJson, CancellationToken cancellationToken = default) + { + _calls.Enqueue(new TraceCallRecord("AnalyseTrace", traceContextJson)); + return Task.FromResult(_analyseResponse); + } + + public Task WhereIsMessageAsync(Guid correlationId, string knownState, CancellationToken cancellationToken = default) + { + _calls.Enqueue(new TraceCallRecord("WhereIsMessage", $"{correlationId}:{knownState}")); + return Task.FromResult(_whereIsResponse); + } + + public void Reset() + { + while (_calls.TryDequeue(out _)) { } + } + + public sealed record TraceCallRecord(string Operation, string Detail); +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockOllamaService.cs b/EnterpriseIntegrationPlatform/src/Testing/MockOllamaService.cs new file mode 100644 index 0000000..e352472 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockOllamaService.cs @@ -0,0 +1,83 @@ +// ============================================================================ +// MockOllamaService – In-memory Ollama/LLM service for testing +// ============================================================================ + +using System.Collections.Concurrent; +using EnterpriseIntegrationPlatform.AI.Ollama; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of that returns +/// configurable responses and captures all calls. +/// +public sealed class MockOllamaService : IOllamaService +{ + private readonly ConcurrentQueue _calls = new(); + private readonly Dictionary _generateResponses = new(); + private readonly Dictionary _analyseResponses = new(); + private string _defaultResponse = "Mock AI response"; + private bool _isHealthy = true; + + /// All calls recorded. + public IReadOnlyList Calls => _calls.ToArray(); + + /// Number of calls recorded. + public int CallCount => _calls.Count; + + /// Sets the default response for unmatched prompts. + public MockOllamaService WithDefaultResponse(string response) + { + _defaultResponse = response; + return this; + } + + /// Sets a specific response for an exact prompt. + public MockOllamaService WithGenerateResponse(string prompt, string response) + { + _generateResponses[prompt] = response; + return this; + } + + /// Sets a specific response for an analyse call matching the context. + public MockOllamaService WithAnalyseResponse(string context, string response) + { + _analyseResponses[context] = response; + return this; + } + + /// Sets the health status. + public MockOllamaService WithHealthy(bool isHealthy) + { + _isHealthy = isHealthy; + return this; + } + + public Task GenerateAsync(string prompt, string model = "llama3.2", CancellationToken cancellationToken = default) + { + _calls.Enqueue(new OllamaCallRecord("Generate", prompt, null, model)); + return Task.FromResult( + _generateResponses.TryGetValue(prompt, out var resp) + ? resp + : _defaultResponse); + } + + public Task AnalyseAsync(string systemPrompt, string context, string model = "llama3.2", CancellationToken cancellationToken = default) + { + _calls.Enqueue(new OllamaCallRecord("Analyse", context, systemPrompt, model)); + return Task.FromResult( + _analyseResponses.TryGetValue(context, out var resp) + ? resp + : _defaultResponse); + } + + public Task IsHealthyAsync(CancellationToken cancellationToken = default) => + Task.FromResult(_isHealthy); + + public void Reset() + { + while (_calls.TryDequeue(out _)) { } + } + + public sealed record OllamaCallRecord(string Operation, string Prompt, string? SystemPrompt, string Model); +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockPayloadTransform.cs b/EnterpriseIntegrationPlatform/src/Testing/MockPayloadTransform.cs new file mode 100644 index 0000000..8459d07 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockPayloadTransform.cs @@ -0,0 +1,33 @@ +// ============================================================================ +// MockPayloadTransform – Configurable payload transform for testing +// ============================================================================ + +using EnterpriseIntegrationPlatform.Processing.Translator; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of +/// that applies a configurable transform function. +/// +public sealed class MockPayloadTransform : IPayloadTransform +{ + private readonly Func _transformFunc; + private readonly List _inputs = new(); + + /// Creates a mock transform with the given function. + public MockPayloadTransform(Func transformFunc) => + _transformFunc = transformFunc; + + /// All inputs that were transformed. + public IReadOnlyList Inputs => _inputs; + + /// Number of transforms performed. + public int TransformCount => _inputs.Count; + + public TOut Transform(TIn source) + { + _inputs.Add(source); + return _transformFunc(source); + } +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockRagFlowService.cs b/EnterpriseIntegrationPlatform/src/Testing/MockRagFlowService.cs new file mode 100644 index 0000000..b092b16 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockRagFlowService.cs @@ -0,0 +1,98 @@ +// ============================================================================ +// MockRagFlowService – In-memory RAG/retrieval service for testing +// ============================================================================ + +using System.Collections.Concurrent; +using EnterpriseIntegrationPlatform.AI.RagFlow; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of that returns +/// configurable responses and captures all calls. +/// +public sealed class MockRagFlowService : IRagFlowService +{ + private readonly ConcurrentQueue _calls = new(); + private readonly Dictionary _chatResponses = new(); + private readonly List _datasets = new(); + private string _defaultRetrieveResponse = "Mock retrieval response"; + private bool _isHealthy = true; + + /// All calls recorded. + public IReadOnlyList Calls => _calls.ToArray(); + + /// Sets the default retrieve response. + public MockRagFlowService WithRetrieveResponse(string response) + { + _defaultRetrieveResponse = response; + return this; + } + + /// Sets a chat response for a specific question. + public MockRagFlowService WithChatResponse(string question, RagFlowChatResponse response) + { + _chatResponses[question] = response; + return this; + } + + /// Sets a chat response matching a conversation ID. + public MockRagFlowService WithChatResponse(string question, string? conversationId, RagFlowChatResponse response) + { + var key = $"{question}|{conversationId ?? "null"}"; + _chatResponses[key] = response; + return this; + } + + /// Adds datasets to return from ListDatasetsAsync. + public MockRagFlowService WithDatasets(params RagFlowDataset[] datasets) + { + _datasets.AddRange(datasets); + return this; + } + + /// Sets health status. + public MockRagFlowService WithHealthy(bool isHealthy) + { + _isHealthy = isHealthy; + return this; + } + + public Task RetrieveAsync(string query, IReadOnlyList? datasetIds = null, CancellationToken cancellationToken = default) + { + _calls.Enqueue(new RagFlowCallRecord("Retrieve", query, null)); + return Task.FromResult(_defaultRetrieveResponse); + } + + public Task ChatAsync(string question, string? conversationId = null, CancellationToken cancellationToken = default) + { + _calls.Enqueue(new RagFlowCallRecord("Chat", question, conversationId)); + + // Try exact match with conversation ID first + var keyWithConv = $"{question}|{conversationId ?? "null"}"; + if (_chatResponses.TryGetValue(keyWithConv, out var convResp)) + return Task.FromResult(convResp); + + // Try question-only match + if (_chatResponses.TryGetValue(question, out var resp)) + return Task.FromResult(resp); + + return Task.FromResult(new RagFlowChatResponse("Mock answer", conversationId, [])); + } + + public Task> ListDatasetsAsync(CancellationToken cancellationToken = default) + { + _calls.Enqueue(new RagFlowCallRecord("ListDatasets", null, null)); + return Task.FromResult>(_datasets.AsReadOnly()); + } + + public Task IsHealthyAsync(CancellationToken cancellationToken = default) => + Task.FromResult(_isHealthy); + + public void Reset() + { + while (_calls.TryDequeue(out _)) { } + } + + public sealed record RagFlowCallRecord(string Operation, string? Query, string? ConversationId); +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockSftpServices.cs b/EnterpriseIntegrationPlatform/src/Testing/MockSftpServices.cs new file mode 100644 index 0000000..e26ea82 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockSftpServices.cs @@ -0,0 +1,174 @@ +// ============================================================================ +// MockSftpClient + MockSftpConnectionPool – In-memory SFTP for testing +// ============================================================================ + +using System.Collections.Concurrent; +using EnterpriseIntegrationPlatform.Connector.Sftp; +using EnterpriseIntegrationPlatform.Contracts; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of backed by a +/// dictionary-based file store. +/// +public sealed class MockSftpClient : ISftpClient +{ + private readonly ConcurrentDictionary _files = new(); + private readonly ConcurrentQueue _calls = new(); + private bool _connected; + + /// All calls recorded against this client. + public IReadOnlyList Calls => _calls.ToArray(); + + /// Number of calls recorded. + public int CallCount => _calls.Count; + + /// In-memory file store — pre-populate for download tests. + public ConcurrentDictionary Files => _files; + + public bool IsConnected => _connected; + + public void Connect() + { + _connected = true; + _calls.Enqueue(new SftpCallRecord("Connect", null)); + } + + public void Disconnect() + { + _connected = false; + _calls.Enqueue(new SftpCallRecord("Disconnect", null)); + } + + public void UploadFile(Stream input, string remotePath) + { + using var ms = new MemoryStream(); + input.CopyTo(ms); + _files[remotePath] = ms.ToArray(); + _calls.Enqueue(new SftpCallRecord("UploadFile", remotePath)); + } + + public Stream DownloadFile(string remotePath) + { + _calls.Enqueue(new SftpCallRecord("DownloadFile", remotePath)); + if (_files.TryGetValue(remotePath, out var data)) + return new MemoryStream(data); + throw new FileNotFoundException($"SFTP file not found: {remotePath}"); + } + + public IEnumerable ListFiles(string remotePath) + { + _calls.Enqueue(new SftpCallRecord("ListFiles", remotePath)); + return _files.Keys + .Where(k => k.StartsWith(remotePath, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + public void DeleteFile(string remotePath) + { + _calls.Enqueue(new SftpCallRecord("DeleteFile", remotePath)); + _files.TryRemove(remotePath, out _); + } + + /// Returns the number of UploadFile calls. + public int UploadCount => _calls.Count(c => c.Operation == "UploadFile"); + + /// Returns uploaded file paths. + public IReadOnlyList UploadedPaths => + _calls.Where(c => c.Operation == "UploadFile") + .Select(c => c.Path!) + .ToList(); + + public void Reset() + { + _files.Clear(); + while (_calls.TryDequeue(out _)) { } + _connected = false; + } + + public sealed record SftpCallRecord(string Operation, string? Path); +} + +/// +/// Real in-memory implementation of that +/// wraps a . +/// +public sealed class MockSftpConnectionPool : ISftpConnectionPool +{ + private readonly MockSftpClient _client; + private int _acquireCount; + private int _releaseCount; + + public MockSftpConnectionPool(MockSftpClient client) => _client = client; + + public MockSftpClient Client => _client; + + public int AcquireCount => _acquireCount; + public int ReleaseCount => _releaseCount; + + public Task AcquireAsync(CancellationToken ct = default) + { + Interlocked.Increment(ref _acquireCount); + _client.Connect(); + return Task.FromResult(_client); + } + + public void Release(ISftpClient client) + { + Interlocked.Increment(ref _releaseCount); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + public void Reset() + { + _acquireCount = 0; + _releaseCount = 0; + _client.Reset(); + } +} + +/// +/// Real in-memory implementation of . +/// +public sealed class MockSftpConnector : ISftpConnector +{ + private readonly MockSftpClient _client; + private readonly ConcurrentQueue _calls = new(); + + public MockSftpConnector(MockSftpClient client) => _client = client; + + public IReadOnlyList Calls => _calls.ToArray(); + + public Task UploadAsync( + IntegrationEnvelope envelope, + string fileName, + Func serializer, + CancellationToken ct) + { + var bytes = serializer(envelope.Payload); + var remotePath = $"/upload/{fileName}"; + _client.UploadFile(new MemoryStream(bytes), remotePath); + _calls.Enqueue(new SftpConnectorCallRecord("Upload", remotePath)); + return Task.FromResult(remotePath); + } + + public Task DownloadAsync(string remotePath, CancellationToken ct) + { + _calls.Enqueue(new SftpConnectorCallRecord("Download", remotePath)); + using var stream = _client.DownloadFile(remotePath); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return Task.FromResult(ms.ToArray()); + } + + public Task> ListFilesAsync(string remotePath, CancellationToken ct) + { + _calls.Enqueue(new SftpConnectorCallRecord("ListFiles", remotePath)); + var files = _client.ListFiles(remotePath).ToList(); + return Task.FromResult>(files); + } + + public sealed record SftpConnectorCallRecord(string Operation, string Path); +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockSmtpClient.cs b/EnterpriseIntegrationPlatform/src/Testing/MockSmtpClient.cs new file mode 100644 index 0000000..18c66c6 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockSmtpClient.cs @@ -0,0 +1,101 @@ +// ============================================================================ +// MockSmtpClient – In-memory SMTP client for testing +// ============================================================================ + +using System.Collections.Concurrent; +using EnterpriseIntegrationPlatform.Connector.Email; +using MimeKit; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of that +/// captures emails and tracks the connect/auth/send/disconnect lifecycle. +/// +public sealed class MockSmtpClient : ISmtpClientWrapper +{ + private readonly ConcurrentQueue _calls = new(); + private readonly ConcurrentQueue _sentMessages = new(); + private bool _connected; + private Func? _authFailure; + + /// All lifecycle calls recorded in order. + public IReadOnlyList Calls => _calls.ToArray(); + + /// All captured MimeMessages sent through this client. + public IReadOnlyList SentMessages => _sentMessages.ToArray(); + + /// Number of send calls. + public int SendCount => _sentMessages.Count; + + public bool IsConnected => _connected; + + /// Injects an authentication failure. + public MockSmtpClient WithAuthFailure(Exception ex) + { + _authFailure = (_, _) => ex; + return this; + } + + /// Gets the last sent MimeMessage. + public MimeMessage? LastSentMessage => _sentMessages.LastOrDefault(); + + public Task ConnectAsync(string host, int port, bool useTls, CancellationToken ct) + { + _connected = true; + _calls.Enqueue(new SmtpCallRecord("Connect", $"{host}:{port}")); + return Task.CompletedTask; + } + + public Task AuthenticateAsync(string username, string password, CancellationToken ct) + { + if (_authFailure is not null) + { + var ex = _authFailure(username, password); + if (ex is not null) throw ex; + } + + _calls.Enqueue(new SmtpCallRecord("Authenticate", username)); + return Task.CompletedTask; + } + + public Task SendAsync(MimeMessage message, CancellationToken ct) + { + _sentMessages.Enqueue(message); + _calls.Enqueue(new SmtpCallRecord("Send", message.Subject)); + return Task.CompletedTask; + } + + public Task DisconnectAsync(bool quit, CancellationToken ct) + { + _connected = false; + _calls.Enqueue(new SmtpCallRecord("Disconnect", quit.ToString())); + return Task.CompletedTask; + } + + /// Asserts the lifecycle order: Connect → Authenticate → Send → Disconnect. + public void AssertLifecycleOrder() + { + var ops = _calls.Select(c => c.Operation).ToList(); + var connectIdx = ops.IndexOf("Connect"); + var authIdx = ops.IndexOf("Authenticate"); + var sendIdx = ops.IndexOf("Send"); + var disconnectIdx = ops.IndexOf("Disconnect"); + + NUnit.Framework.Assert.That(connectIdx, NUnit.Framework.Is.LessThan(authIdx), + "Connect must come before Authenticate"); + NUnit.Framework.Assert.That(authIdx, NUnit.Framework.Is.LessThan(sendIdx), + "Authenticate must come before Send"); + NUnit.Framework.Assert.That(sendIdx, NUnit.Framework.Is.LessThan(disconnectIdx), + "Send must come before Disconnect"); + } + + public void Reset() + { + while (_calls.TryDequeue(out _)) { } + while (_sentMessages.TryDequeue(out _)) { } + _connected = false; + } + + public sealed record SmtpCallRecord(string Operation, string? Detail); +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/MockTemporalWorkflowDispatcher.cs b/EnterpriseIntegrationPlatform/src/Testing/MockTemporalWorkflowDispatcher.cs new file mode 100644 index 0000000..8424389 --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/MockTemporalWorkflowDispatcher.cs @@ -0,0 +1,106 @@ +// ============================================================================ +// MockTemporalWorkflowDispatcher – Real in-memory pipeline executor +// ============================================================================ +// Executes the integration pipeline steps (persist → validate → ack/nack) in +// memory, providing the same all-or-nothing semantics as the real Temporal +// workflow. Captures all dispatches for test assertions. +// ============================================================================ + +using System.Collections.Concurrent; +using EnterpriseIntegrationPlatform.Activities; +using EnterpriseIntegrationPlatform.Demo.Pipeline; + +namespace EnterpriseIntegrationPlatform.Testing; + +/// +/// Real in-memory implementation of +/// that executes pipeline logic without requiring a Temporal server. +/// Captures all dispatched inputs and workflow IDs for test assertions. +/// Supports configurable validation, persistence, and failure injection. +/// +public sealed class MockTemporalWorkflowDispatcher : ITemporalWorkflowDispatcher +{ + private readonly ConcurrentQueue _dispatches = new(); + private Func? _handler; + + /// All dispatches recorded by this mock. + public IReadOnlyList Dispatches => _dispatches.ToArray(); + + /// Number of dispatches recorded. + public int DispatchCount => _dispatches.Count; + + /// + /// Configures a custom handler that processes each dispatch. + /// If not set, the dispatcher returns a success result by default. + /// + public MockTemporalWorkflowDispatcher OnDispatch( + Func handler) + { + _handler = handler; + return this; + } + + /// Configures the dispatcher to always return success. + public MockTemporalWorkflowDispatcher ReturnsSuccess() + { + _handler = (input, _) => new IntegrationPipelineResult(input.MessageId, true); + return this; + } + + /// Configures the dispatcher to always return failure with the given reason. + public MockTemporalWorkflowDispatcher ReturnsFailure(string reason = "Validation failed") + { + _handler = (input, _) => new IntegrationPipelineResult(input.MessageId, false, reason); + return this; + } + + /// + public Task DispatchAsync( + IntegrationPipelineInput input, + string workflowId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentException.ThrowIfNullOrWhiteSpace(workflowId); + + _dispatches.Enqueue(new DispatchRecord(input, workflowId, DateTimeOffset.UtcNow)); + + var result = _handler is not null + ? _handler(input, workflowId) + : new IntegrationPipelineResult(input.MessageId, true); + + return Task.FromResult(result); + } + + /// Gets the most recent dispatch input, or null if none. + public IntegrationPipelineInput? LastInput => + _dispatches.LastOrDefault()?.Input; + + /// Gets the most recent dispatch workflow ID, or null if none. + public string? LastWorkflowId => + _dispatches.LastOrDefault()?.WorkflowId; + + /// Gets the dispatch input at the specified index. + public IntegrationPipelineInput GetInput(int index = 0) => + _dispatches.ElementAt(index).Input; + + /// Gets the workflow ID at the specified index. + public string GetWorkflowId(int index = 0) => + _dispatches.ElementAt(index).WorkflowId; + + /// Asserts the total number of dispatches. + public void AssertDispatchCount(int expected) => + NUnit.Framework.Assert.That(_dispatches.Count, NUnit.Framework.Is.EqualTo(expected), + $"Expected {expected} dispatch(es), but received {_dispatches.Count}"); + + /// Resets all recorded dispatches. + public void Reset() + { + while (_dispatches.TryDequeue(out _)) { } + } + + public sealed record DispatchRecord( + IntegrationPipelineInput Input, + string WorkflowId, + DateTimeOffset DispatchedAt); +} diff --git a/EnterpriseIntegrationPlatform/src/Testing/Testing.csproj b/EnterpriseIntegrationPlatform/src/Testing/Testing.csproj new file mode 100644 index 0000000..1ae8a5a --- /dev/null +++ b/EnterpriseIntegrationPlatform/src/Testing/Testing.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Infrastructure/AspireIntegrationTestHost.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Infrastructure/AspireIntegrationTestHost.cs new file mode 100644 index 0000000..6ab12ec --- /dev/null +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Infrastructure/AspireIntegrationTestHost.cs @@ -0,0 +1,10 @@ +// ============================================================================ +// AspireIntegrationTestHost – Re-exported from Testing library +// ============================================================================ + +global using AspireIntegrationTestHost = EnterpriseIntegrationPlatform.Testing.AspireIntegrationTestHost; + +namespace TutorialLabs.Infrastructure; + +// Intentionally empty — the global using above re-exports AspireIntegrationTestHost +// from the Testing library. diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Infrastructure/MockEndpoint.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Infrastructure/MockEndpoint.cs new file mode 100644 index 0000000..0510a34 --- /dev/null +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Infrastructure/MockEndpoint.cs @@ -0,0 +1,15 @@ +// ============================================================================ +// MockEndpoint – Re-exported from EnterpriseIntegrationPlatform.Testing library +// ============================================================================ +// This file provides backward-compatible type aliases so that existing tutorials +// can continue to reference TutorialLabs.Infrastructure.MockEndpoint. +// The real implementation now lives in src/Testing/MockEndpoint.cs. +// ============================================================================ + +global using MockEndpoint = EnterpriseIntegrationPlatform.Testing.MockEndpoint; + +namespace TutorialLabs.Infrastructure; + +// Intentionally empty — the global using above re-exports MockEndpoint +// from the Testing library into the TutorialLabs.Infrastructure namespace. + diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial01/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial01/Exam.cs index dc9602e..c5d490e 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial01/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial01/Exam.cs @@ -1,97 +1,95 @@ // ============================================================================ -// Tutorial 01 – Introduction to Enterprise Integration (Exam) +// Tutorial 01 – Introduction (Exam) // ============================================================================ -// Coding challenges that test your understanding of the IntegrationEnvelope, -// message intents, causation chains, and record immutability. +// EIP Pattern: Canonical Data Model +// End-to-End: Complex envelope scenarios through MockEndpoint — domain +// objects, causation chains, and record immutability verified at output. // ============================================================================ -using EnterpriseIntegrationPlatform.Contracts; using NUnit.Framework; +using TutorialLabs.Infrastructure; +using EnterpriseIntegrationPlatform.Contracts; namespace TutorialLabs.Tutorial01; -// A simple domain record used in the exam challenges. public sealed record OrderPayload(string OrderId, string Product, int Quantity, decimal Price); [TestFixture] public sealed class Exam { - // ── Challenge 1: Wrap a Domain Object in an Envelope ──────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() + { + _output = new MockEndpoint("output"); + } + + [TearDown] + public async Task TearDown() + { + await _output.DisposeAsync(); + } [Test] - public void Challenge1_CreateEnvelopeForOrderPayload() + public async Task EndToEnd_DomainObject_AllFieldsSurviveRoundTrip() { - // Create an OrderPayload and wrap it in an IntegrationEnvelope. var order = new OrderPayload("ORD-001", "Widget", 5, 29.99m); - var envelope = IntegrationEnvelope.Create( - payload: order, - source: "OrderService", - messageType: "order.created"); - - // Verify the envelope wraps the domain object correctly. - Assert.That(envelope.Payload.OrderId, Is.EqualTo("ORD-001")); - Assert.That(envelope.Payload.Product, Is.EqualTo("Widget")); - Assert.That(envelope.Payload.Quantity, Is.EqualTo(5)); - Assert.That(envelope.Payload.Price, Is.EqualTo(29.99m)); - Assert.That(envelope.Source, Is.EqualTo("OrderService")); - Assert.That(envelope.MessageType, Is.EqualTo("order.created")); - Assert.That(envelope.MessageId, Is.Not.EqualTo(Guid.Empty)); - } + order, "OrderService", "order.created"); - // ── Challenge 2: Build a CausationId Chain ────────────────────────────── + await _output.PublishAsync(envelope, "orders"); + + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Payload.OrderId, Is.EqualTo("ORD-001")); + Assert.That(received.Payload.Price, Is.EqualTo(29.99m)); + Assert.That(received.Source, Is.EqualTo("OrderService")); + Assert.That(received.MessageId, Is.EqualTo(envelope.MessageId)); + } [Test] - public void Challenge2_CausationIdLinking_MessageBCausedByA() + public async Task EndToEnd_CausationChain_PreservedThroughPipeline() { - // Message A is the originating command. var messageA = IntegrationEnvelope.Create( - payload: "PlaceOrder", - source: "WebApp", - messageType: "order.place") with + "PlaceOrder", "WebApp", "order.place") with { Intent = MessageIntent.Command, }; - // Message B is caused by A — its CausationId points to A's MessageId - // and both share the same CorrelationId for end-to-end tracing. var messageB = IntegrationEnvelope.Create( - payload: "OrderPlaced", - source: "OrderService", - messageType: "order.placed", + "OrderPlaced", "OrderService", "order.placed", correlationId: messageA.CorrelationId, causationId: messageA.MessageId) with { Intent = MessageIntent.Event, }; - // Verify the causal link. - Assert.That(messageB.CausationId, Is.EqualTo(messageA.MessageId)); - Assert.That(messageB.CorrelationId, Is.EqualTo(messageA.CorrelationId)); - Assert.That(messageB.MessageId, Is.Not.EqualTo(messageA.MessageId)); - } + await _output.PublishAsync(messageA, "commands"); + await _output.PublishAsync(messageB, "events"); - // ── Challenge 3: Verify Envelope Immutability ─────────────────────────── + _output.AssertReceivedCount(2); + var receivedB = _output.GetReceived(1); + Assert.That(receivedB.CausationId, Is.EqualTo(messageA.MessageId)); + Assert.That(receivedB.CorrelationId, Is.EqualTo(messageA.CorrelationId)); + Assert.That(receivedB.MessageId, Is.Not.EqualTo(messageA.MessageId)); + } [Test] - public void Challenge3_RecordImmutability_WithExpressionCreatesNewInstance() + public async Task EndToEnd_ImmutableEnvelope_OriginalAndModifiedBothPreserved() { - // Records are immutable — you cannot change properties after creation. - // The `with` expression creates a shallow copy with modified values. var original = IntegrationEnvelope.Create( "original-payload", "TestService", "test.message"); - var modified = original with { Priority = MessagePriority.High }; - // The original is untouched. - Assert.That(original.Priority, Is.EqualTo(MessagePriority.Normal)); - - // The modified copy has the new priority but retains all other values. - Assert.That(modified.Priority, Is.EqualTo(MessagePriority.High)); - Assert.That(modified.MessageId, Is.EqualTo(original.MessageId)); - Assert.That(modified.Payload, Is.EqualTo(original.Payload)); + await _output.PublishAsync(original, "normal"); + await _output.PublishAsync(modified, "high"); - // They are different object references. - Assert.That(ReferenceEquals(original, modified), Is.False); + _output.AssertReceivedCount(2); + var first = _output.GetReceived(0); + var second = _output.GetReceived(1); + Assert.That(first.Priority, Is.EqualTo(MessagePriority.Normal)); + Assert.That(second.Priority, Is.EqualTo(MessagePriority.High)); + Assert.That(first.MessageId, Is.EqualTo(second.MessageId)); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial01/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial01/Lab.cs index 6b04edf..a2804d7 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial01/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial01/Lab.cs @@ -1,148 +1,123 @@ // ============================================================================ -// Tutorial 01 – Introduction to Enterprise Integration (Lab) +// Tutorial 01 – Introduction (Lab) // ============================================================================ -// This lab introduces the foundational concepts of Enterprise Integration -// Patterns (EIP) and maps them to the platform's canonical types. You will -// create IntegrationEnvelopes using the static factory method, inspect -// auto-generated fields, and explore the three message intents. +// EIP Pattern: Canonical Data Model +// End-to-End: Create envelopes, publish through MockEndpoint, verify +// canonical fields preserved at output. // ============================================================================ -using System.Reflection; -using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using NUnit.Framework; +using TutorialLabs.Infrastructure; +using EnterpriseIntegrationPlatform.Contracts; namespace TutorialLabs.Tutorial01; [TestFixture] public sealed class Lab { - // ── Creating an Envelope with the Factory Method ──────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() + { + _output = new MockEndpoint("output"); + } + + [TearDown] + public async Task TearDown() + { + await _output.DisposeAsync(); + } [Test] - public void Create_WithStringPayload_PopulatesAllRequiredFields() + public async Task EndToEnd_StringPayload_CanonicalFieldsPreserved() { - // The static factory generates MessageId, CorrelationId, and Timestamp - // automatically, so you only supply the business-relevant arguments. var envelope = IntegrationEnvelope.Create( - payload: "Hello, EIP!", - source: "Tutorial01", - messageType: "greeting.created"); - - Assert.That(envelope.MessageId, Is.Not.EqualTo(Guid.Empty)); - Assert.That(envelope.CorrelationId, Is.Not.EqualTo(Guid.Empty)); - Assert.That(envelope.Timestamp, Is.Not.EqualTo(default(DateTimeOffset))); - Assert.That(envelope.Source, Is.EqualTo("Tutorial01")); - Assert.That(envelope.MessageType, Is.EqualTo("greeting.created")); - Assert.That(envelope.Payload, Is.EqualTo("Hello, EIP!")); + "Hello, EIP!", "Tutorial01", "greeting.created"); + + await _output.PublishAsync(envelope, "greetings"); + + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Is.EqualTo("Hello, EIP!")); + Assert.That(received.Source, Is.EqualTo("Tutorial01")); + Assert.That(received.MessageType, Is.EqualTo("greeting.created")); } [Test] - public void Create_DefaultValues_AreReasonable() + public async Task EndToEnd_AutoGeneratedIds_PreservedThroughPipeline() { var envelope = IntegrationEnvelope.Create( "payload", "source", "type"); - // Defaults defined on the record - Assert.That(envelope.SchemaVersion, Is.EqualTo("1.0")); - Assert.That(envelope.Priority, Is.EqualTo(MessagePriority.Normal)); - Assert.That(envelope.CausationId, Is.Null); - Assert.That(envelope.ReplyTo, Is.Null); - Assert.That(envelope.ExpiresAt, Is.Null); - Assert.That(envelope.SequenceNumber, Is.Null); - Assert.That(envelope.TotalCount, Is.Null); - Assert.That(envelope.Intent, Is.Null); - Assert.That(envelope.Metadata, Is.Empty); + await _output.PublishAsync(envelope, "ids-topic"); + + var received = _output.GetReceived(); + Assert.That(received.MessageId, Is.EqualTo(envelope.MessageId)); + Assert.That(received.CorrelationId, Is.EqualTo(envelope.CorrelationId)); + Assert.That(received.Timestamp, Is.EqualTo(envelope.Timestamp)); } [Test] - public void Create_TimestampIsUtcAndRecent() + public async Task EndToEnd_DefaultCanonicalValues_PreservedAtOutput() { - var before = DateTimeOffset.UtcNow; - var envelope = IntegrationEnvelope.Create(42, "lab", "number"); - var after = DateTimeOffset.UtcNow; + var envelope = IntegrationEnvelope.Create( + "payload", "source", "type"); - Assert.That(envelope.Timestamp, Is.GreaterThanOrEqualTo(before)); - Assert.That(envelope.Timestamp, Is.LessThanOrEqualTo(after)); - } + await _output.PublishAsync(envelope, "defaults-topic"); - // ── Message Intents ───────────────────────────────────────────────────── + var received = _output.GetReceived(); + Assert.That(received.SchemaVersion, Is.EqualTo("1.0")); + Assert.That(received.Priority, Is.EqualTo(MessagePriority.Normal)); + Assert.That(received.Metadata, Is.Empty); + Assert.That(received.CausationId, Is.Null); + } [Test] - public void CommandIntent_RepresentsAnActionRequest() + public async Task EndToEnd_CommandIntent_PreservedAtOutput() { - // A Command Message tells the receiver to DO something. - var command = IntegrationEnvelope.Create( + var envelope = IntegrationEnvelope.Create( "PlaceOrder", "OrderService", "order.place") with { Intent = MessageIntent.Command, }; - Assert.That(command.Intent, Is.EqualTo(MessageIntent.Command)); + await _output.PublishAsync(envelope, "commands"); + + var received = _output.GetReceived(); + Assert.That(received.Intent, Is.EqualTo(MessageIntent.Command)); + Assert.That(received.Payload, Is.EqualTo("PlaceOrder")); } [Test] - public void DocumentIntent_RepresentsDataTransfer() + public async Task EndToEnd_DocumentIntent_PreservedAtOutput() { - // A Document Message carries data for the receiver to process. - var document = IntegrationEnvelope.Create( + var envelope = IntegrationEnvelope.Create( "{\"sku\":\"ABC\"}", "CatalogService", "product.catalog") with { Intent = MessageIntent.Document, }; - Assert.That(document.Intent, Is.EqualTo(MessageIntent.Document)); + await _output.PublishAsync(envelope, "documents"); + + var received = _output.GetReceived(); + Assert.That(received.Intent, Is.EqualTo(MessageIntent.Document)); + Assert.That(received.Payload, Is.EqualTo("{\"sku\":\"ABC\"}")); } [Test] - public void EventIntent_RepresentsNotification() + public async Task EndToEnd_EventIntent_PreservedAtOutput() { - // An Event Message notifies that something has already happened. - var evt = IntegrationEnvelope.Create( + var envelope = IntegrationEnvelope.Create( "OrderPlaced", "OrderService", "order.placed") with { Intent = MessageIntent.Event, }; - Assert.That(evt.Intent, Is.EqualTo(MessageIntent.Event)); - } - - // ── Mapping EIP Patterns to Platform Types ────────────────────────────── + await _output.PublishAsync(envelope, "events"); - [Test] - public void PlatformTypes_MessageChannel_ProducerInterfaceExists() - { - // EIP: Message Channel → IMessageBrokerProducer - var producerType = typeof(IMessageBrokerProducer); - Assert.That(producerType, Is.Not.Null); - Assert.That(producerType.IsInterface, Is.True); - - var publishMethod = producerType.GetMethod("PublishAsync"); - Assert.That(publishMethod, Is.Not.Null, "PublishAsync method must exist"); - } - - [Test] - public void PlatformTypes_MessageEndpoint_ConsumerInterfaceExists() - { - // EIP: Message Endpoint → IMessageBrokerConsumer - var consumerType = typeof(IMessageBrokerConsumer); - Assert.That(consumerType, Is.Not.Null); - Assert.That(consumerType.IsInterface, Is.True); - - var subscribeMethod = consumerType.GetMethod("SubscribeAsync"); - Assert.That(subscribeMethod, Is.Not.Null, "SubscribeAsync method must exist"); - } - - [Test] - public void PlatformTypes_CanonicalDataModel_EnvelopeIsRecord() - { - // EIP: Canonical Data Model → IntegrationEnvelope - // Records are classes with value-equality semantics. - var envelopeType = typeof(IntegrationEnvelope); - Assert.That(envelopeType.IsClass, Is.True); - - // Records implement IEquatable - var equatable = typeof(IEquatable>); - Assert.That(equatable.IsAssignableFrom(envelopeType), Is.True); + var received = _output.GetReceived(); + Assert.That(received.Intent, Is.EqualTo(MessageIntent.Event)); + Assert.That(received.MessageId, Is.EqualTo(envelope.MessageId)); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial02/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial02/Exam.cs index 4b394f2..6caa955 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial02/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial02/Exam.cs @@ -1,121 +1,105 @@ // ============================================================================ // Tutorial 02 – Environment Setup (Exam) // ============================================================================ -// Coding challenges that test your understanding of the platform's -// configuration types and well-known header constants. +// EIP Pattern: Service Activator +// End-to-End: Advanced DI wiring — full channel pipelines, multiple +// endpoints, and service-activated message forwarding. // ============================================================================ -using System.Reflection; +using NUnit.Framework; +using TutorialLabs.Infrastructure; using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Ingestion; -using NUnit.Framework; +using EnterpriseIntegrationPlatform.Ingestion.Channels; +using Microsoft.Extensions.DependencyInjection; namespace TutorialLabs.Tutorial02; [TestFixture] public sealed class Exam { - // ── Challenge 1: Validate BrokerOptions Properties ────────────────────── - - [Test] - public void Challenge1_BrokerOptions_HasBrokerTypeProperty() - { - var property = typeof(BrokerOptions).GetProperty("BrokerType"); - Assert.That(property, Is.Not.Null, "BrokerOptions must have a BrokerType property"); - Assert.That(property!.PropertyType, Is.EqualTo(typeof(BrokerType))); - Assert.That(property.CanRead, Is.True); - Assert.That(property.CanWrite, Is.True); - } - - [Test] - public void Challenge1_BrokerOptions_HasConnectionStringProperty() - { - var property = typeof(BrokerOptions).GetProperty("ConnectionString"); - Assert.That(property, Is.Not.Null, "BrokerOptions must have a ConnectionString property"); - Assert.That(property!.PropertyType, Is.EqualTo(typeof(string))); - } - - [Test] - public void Challenge1_BrokerOptions_HasTransactionTimeoutProperty() - { - var property = typeof(BrokerOptions).GetProperty("TransactionTimeoutSeconds"); - Assert.That(property, Is.Not.Null, - "BrokerOptions must have a TransactionTimeoutSeconds property"); - Assert.That(property!.PropertyType, Is.EqualTo(typeof(int))); - } - - [Test] - public void Challenge1_BrokerOptions_HasSectionNameConstant() - { - // The SectionName constant binds to the "Broker" configuration section. - Assert.That(BrokerOptions.SectionName, Is.EqualTo("Broker")); - } + private AspireIntegrationTestHost _host = null!; + private MockEndpoint _output = null!; - [Test] - public void Challenge1_BrokerOptions_DefaultValues_AreCorrect() + [TearDown] + public async Task TearDown() { - var options = new BrokerOptions(); - - Assert.That(options.BrokerType, Is.EqualTo(BrokerType.NatsJetStream)); - Assert.That(options.ConnectionString, Is.EqualTo(string.Empty)); - Assert.That(options.TransactionTimeoutSeconds, Is.EqualTo(30)); + if (_host is not null) await _host.DisposeAsync(); + if (_output is not null) await _output.DisposeAsync(); } - // ── Challenge 2: Verify MessageHeaders Constants ──────────────────────── - [Test] - public void Challenge2_MessageHeaders_HasExpectedTraceHeaders() + public async Task EndToEnd_FullDIPipeline_PointToPointSendsToMock() { - // Observability headers for distributed tracing. - Assert.That(MessageHeaders.TraceId, Is.EqualTo("trace-id")); - Assert.That(MessageHeaders.SpanId, Is.EqualTo("span-id")); + var builder = AspireIntegrationTestHost.CreateBuilder(); + _output = builder.AddMockEndpoint("output"); + builder.UseProducer(_output).UseConsumer(_output); + builder.ConfigureServices(services => + services.AddSingleton()); + _host = builder.Build(); + + var channel = _host.GetService(); + var envelope = IntegrationEnvelope.Create( + "DI-wired-message", "ExamService", "exam.test"); + + await channel.SendAsync(envelope, "exam-queue", CancellationToken.None); + + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Is.EqualTo("DI-wired-message")); + Assert.That(received.Source, Is.EqualTo("ExamService")); } [Test] - public void Challenge2_MessageHeaders_HasContentTypeHeader() + public async Task EndToEnd_MultipleEndpoints_IndependentMessageCapture() { - Assert.That(MessageHeaders.ContentType, Is.EqualTo("content-type")); + var builder = AspireIntegrationTestHost.CreateBuilder(); + var orders = builder.AddMockEndpoint("orders"); + var payments = builder.AddMockEndpoint("payments"); + _output = orders; + _host = builder.Build(); + + var orderEnv = IntegrationEnvelope.Create( + "new-order", "OrderService", "order.created"); + var paymentEnv = IntegrationEnvelope.Create( + "payment-received", "PaymentService", "payment.received"); + + await orders.PublishAsync(orderEnv, "orders-topic"); + await payments.PublishAsync(paymentEnv, "payments-topic"); + + orders.AssertReceivedCount(1); + payments.AssertReceivedCount(1); + Assert.That(orders.GetReceived().Payload, Is.EqualTo("new-order")); + Assert.That(payments.GetReceived().Payload, Is.EqualTo("payment-received")); } [Test] - public void Challenge2_MessageHeaders_HasSourceTopicHeader() + public async Task EndToEnd_ServiceActivator_ProcessesAndForwards() { - Assert.That(MessageHeaders.SourceTopic, Is.EqualTo("source-topic")); - } - - [Test] - public void Challenge2_MessageHeaders_HasReplayIdHeader() - { - Assert.That(MessageHeaders.ReplayId, Is.EqualTo("replay-id")); - } - - [Test] - public void Challenge2_MessageHeaders_AllConstantsAreNonEmpty() - { - // Use reflection to verify every public const string is non-empty. - var fields = typeof(MessageHeaders) - .GetFields(BindingFlags.Public | BindingFlags.Static) - .Where(f => f.IsLiteral && f.FieldType == typeof(string)) - .ToList(); - - Assert.That(fields, Is.Not.Empty, "MessageHeaders should have string constants"); - - foreach (var field in fields) - { - var value = (string?)field.GetValue(null); - Assert.That(value, Is.Not.Null.And.Not.Empty, - $"MessageHeaders.{field.Name} must not be null or empty"); - } - } - - [Test] - public void Challenge2_MessageHeaders_ContainsAtLeast15Constants() - { - // Ensure the platform defines a rich set of well-known header keys. - var constantCount = typeof(MessageHeaders) - .GetFields(BindingFlags.Public | BindingFlags.Static) - .Count(f => f.IsLiteral && f.FieldType == typeof(string)); - - Assert.That(constantCount, Is.GreaterThanOrEqualTo(15)); + var builder = AspireIntegrationTestHost.CreateBuilder(); + var input = builder.AddMockEndpoint("input"); + _output = builder.AddMockEndpoint("output"); + builder.UseProducer(_output).UseConsumer(input); + builder.ConfigureServices(services => + services.AddSingleton()); + _host = builder.Build(); + + var channel = _host.GetService(); + + // Subscribe the channel to receive on input and forward handler to output + await channel.ReceiveAsync("input-channel", "activator-group", + async msg => + { + var forwarded = msg with { Source = "Activator" }; + await _output.PublishAsync(forwarded, "output-topic"); + }, CancellationToken.None); + + // Send a message into the input endpoint + var envelope = IntegrationEnvelope.Create( + "activate-me", "Producer", "activate"); + await input.SendAsync(envelope); + + _output.AssertReceivedCount(1); + Assert.That(_output.GetReceived().Source, Is.EqualTo("Activator")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial02/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial02/Lab.cs index ca3a4f5..873a66e 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial02/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial02/Lab.cs @@ -1,142 +1,154 @@ // ============================================================================ // Tutorial 02 – Environment Setup (Lab) // ============================================================================ -// This lab verifies that your development environment is correctly configured -// by using reflection to confirm that all key platform types, enums, and -// namespaces are available and correctly structured. +// EIP Pattern: Service Activator +// End-to-End: Build AspireIntegrationTestHost, register & resolve services, +// verify DI wiring with MockEndpoints. // ============================================================================ -using System.Reflection; +using NUnit.Framework; +using TutorialLabs.Infrastructure; using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Ingestion; -using NUnit.Framework; +using EnterpriseIntegrationPlatform.Ingestion.Channels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace TutorialLabs.Tutorial02; [TestFixture] public sealed class Lab { - // ── Verify Core Types Exist ───────────────────────────────────────────── + private AspireIntegrationTestHost _host = null!; + private MockEndpoint _output = null!; - [Test] - public void IntegrationEnvelope_TypeExists() + [TearDown] + public async Task TearDown() { - var type = typeof(IntegrationEnvelope); - Assert.That(type, Is.Not.Null); - Assert.That(type.IsGenericType || type.IsClass, Is.True); + if (_host is not null) await _host.DisposeAsync(); + if (_output is not null) await _output.DisposeAsync(); } [Test] - public void IMessageBrokerProducer_InterfaceExists() + public async Task EndToEnd_HostResolvesProducer_PublishCapturedByMock() { - var type = typeof(IMessageBrokerProducer); - Assert.That(type.IsInterface, Is.True); - } + var builder = AspireIntegrationTestHost.CreateBuilder(); + _output = builder.AddMockEndpoint("output"); + builder.UseProducer(_output); + _host = builder.Build(); - [Test] - public void IMessageBrokerConsumer_InterfaceExists() - { - var type = typeof(IMessageBrokerConsumer); - Assert.That(type.IsInterface, Is.True); + var producer = _host.GetService(); + var envelope = IntegrationEnvelope.Create("hello", "lab", "test"); - // Consumer also implements IAsyncDisposable for resource cleanup. - Assert.That(typeof(IAsyncDisposable).IsAssignableFrom(type), Is.True); - } + await producer.PublishAsync(envelope, "topic"); - [Test] - public void BrokerOptions_ClassExists() - { - var type = typeof(BrokerOptions); - Assert.That(type, Is.Not.Null); - Assert.That(type.IsClass, Is.True); - Assert.That(type.IsSealed, Is.True); + _output.AssertReceivedCount(1); + Assert.That(_output.GetReceived().Payload, Is.EqualTo("hello")); } - // ── Verify BrokerType Enum ────────────────────────────────────────────── - [Test] - public void BrokerType_HasNatsJetStreamValue() + public async Task EndToEnd_HostResolvesConsumer_SubscribeReceivesMessage() { - Assert.That(Enum.IsDefined(typeof(BrokerType), BrokerType.NatsJetStream), Is.True); + var builder = AspireIntegrationTestHost.CreateBuilder(); + _output = builder.AddMockEndpoint("input"); + builder.UseConsumer(_output); + _host = builder.Build(); + + var consumer = _host.GetService(); + IntegrationEnvelope? received = null; + await consumer.SubscribeAsync("topic", "group", msg => + { + received = msg; + return Task.CompletedTask; + }); + + var envelope = IntegrationEnvelope.Create("data", "lab", "test"); + await _output.SendAsync(envelope); + + Assert.That(received, Is.Not.Null); + Assert.That(received!.Payload, Is.EqualTo("data")); } [Test] - public void BrokerType_HasKafkaValue() + public async Task EndToEnd_NamedEndpoints_RetrievedByName() { - Assert.That(Enum.IsDefined(typeof(BrokerType), BrokerType.Kafka), Is.True); + var builder = AspireIntegrationTestHost.CreateBuilder(); + var ep1 = builder.AddMockEndpoint("orders"); + var ep2 = builder.AddMockEndpoint("payments"); + builder.UseProducer(ep1); + _host = builder.Build(); + + var envelope = IntegrationEnvelope.Create("order-1", "lab", "test"); + await _host.GetEndpoint("orders").PublishAsync(envelope, "topic"); + + _host.GetEndpoint("orders").AssertReceivedCount(1); + _host.GetEndpoint("payments").AssertNoneReceived(); + _output = ep1; } [Test] - public void BrokerType_HasPulsarValue() + public async Task EndToEnd_CustomServiceRegistration_ResolvedFromHost() { - Assert.That(Enum.IsDefined(typeof(BrokerType), BrokerType.Pulsar), Is.True); + var builder = AspireIntegrationTestHost.CreateBuilder(); + _output = builder.AddMockEndpoint("output"); + builder.UseProducer(_output); + builder.ConfigureServices(services => + services.AddSingleton()); + _host = builder.Build(); + + var service = _host.GetService(); + var envelope = IntegrationEnvelope.Create( + service.Greet("World"), "lab", "greeting"); + + var producer = _host.GetService(); + await producer.PublishAsync(envelope, "greetings"); + + _output.AssertReceivedCount(1); + Assert.That(_output.GetReceived().Payload, Is.EqualTo("Hello, World!")); } [Test] - public void BrokerType_HasExactlyThreeValues() + public async Task EndToEnd_PointToPointChannel_WiredThroughDI() { - var values = Enum.GetValues(); - Assert.That(values, Has.Length.EqualTo(3)); - } + var builder = AspireIntegrationTestHost.CreateBuilder(); + _output = builder.AddMockEndpoint("output"); + builder.UseProducer(_output).UseConsumer(_output); + builder.ConfigureServices(services => + services.AddSingleton()); + _host = builder.Build(); - // ── Verify MessagePriority Enum ───────────────────────────────────────── + var channel = _host.GetService(); + var envelope = IntegrationEnvelope.Create("p2p", "lab", "test"); - [Test] - [TestCase(MessagePriority.Low, 0)] - [TestCase(MessagePriority.Normal, 1)] - [TestCase(MessagePriority.High, 2)] - [TestCase(MessagePriority.Critical, 3)] - public void MessagePriority_HasExpectedValues(MessagePriority priority, int expected) - { - Assert.That((int)priority, Is.EqualTo(expected)); - } + await channel.SendAsync(envelope, "queue", CancellationToken.None); - [Test] - public void MessagePriority_HasExactlyFourValues() - { - var values = Enum.GetValues(); - Assert.That(values, Has.Length.EqualTo(4)); + _output.AssertReceivedCount(1); + Assert.That(_output.GetReceived().Payload, Is.EqualTo("p2p")); } - // ── Verify MessageIntent Enum ─────────────────────────────────────────── - [Test] - [TestCase(MessageIntent.Command, 0)] - [TestCase(MessageIntent.Document, 1)] - [TestCase(MessageIntent.Event, 2)] - public void MessageIntent_HasExpectedValues(MessageIntent intent, int expected) + public async Task EndToEnd_PublishSubscribeChannel_WiredThroughDI() { - Assert.That((int)intent, Is.EqualTo(expected)); - } + var builder = AspireIntegrationTestHost.CreateBuilder(); + _output = builder.AddMockEndpoint("output"); + builder.UseProducer(_output).UseConsumer(_output); + builder.ConfigureServices(services => + services.AddSingleton()); + _host = builder.Build(); - // ── Verify Namespace Presence via Assembly ────────────────────────────── + var channel = _host.GetService(); + var envelope = IntegrationEnvelope.Create("pubsub", "lab", "test"); - [Test] - public void ContractsNamespace_ContainsExpectedTypes() - { - var assembly = typeof(IntegrationEnvelope<>).Assembly; - var typeNames = assembly.GetTypes() - .Where(t => t.Namespace == "EnterpriseIntegrationPlatform.Contracts") - .Select(t => t.Name) - .ToList(); - - Assert.That(typeNames, Does.Contain("MessagePriority")); - Assert.That(typeNames, Does.Contain("MessageIntent")); - Assert.That(typeNames, Does.Contain("MessageHeaders")); - } + await channel.PublishAsync(envelope, "fanout", CancellationToken.None); - [Test] - public void IngestionNamespace_ContainsExpectedTypes() - { - var assembly = typeof(IMessageBrokerProducer).Assembly; - var typeNames = assembly.GetTypes() - .Where(t => t.Namespace == "EnterpriseIntegrationPlatform.Ingestion") - .Select(t => t.Name) - .ToList(); - - Assert.That(typeNames, Does.Contain("IMessageBrokerProducer")); - Assert.That(typeNames, Does.Contain("IMessageBrokerConsumer")); - Assert.That(typeNames, Does.Contain("BrokerOptions")); - Assert.That(typeNames, Does.Contain("BrokerType")); + _output.AssertReceivedCount(1); + Assert.That(_output.GetReceived().Payload, Is.EqualTo("pubsub")); } } + +public interface IGreetingService { string Greet(string name); } +public class GreetingService : IGreetingService +{ + public string Greet(string name) => $"Hello, {name}!"; +} diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial03/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial03/Exam.cs index 6d9b1eb..bce2747 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial03/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial03/Exam.cs @@ -1,165 +1,98 @@ // ============================================================================ -// Tutorial 03 – Your First Message (Exam) +// Tutorial 03 – First Message (Exam) // ============================================================================ -// Coding challenges covering publish/consume round trips, batch correlation, -// and consumer group patterns using mocked brokers. +// EIP Pattern: Message Channel +// End-to-End: PointToPointChannel and PublishSubscribeChannel with +// MockEndpoints — real channel components delivering real messages. // ============================================================================ -using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; +using EnterpriseIntegrationPlatform.Contracts; +using EnterpriseIntegrationPlatform.Ingestion.Channels; +using Microsoft.Extensions.Logging.Abstractions; namespace TutorialLabs.Tutorial03; [TestFixture] public sealed class Exam { - // ── Challenge 1: Publish / Consume Round Trip ─────────────────────────── + private MockEndpoint _output = null!; - [Test] - public async Task Challenge1_PublishAndConsume_RoundTrip() + [SetUp] + public void SetUp() { - // Publish a message through a mocked producer, then simulate - // delivery to a consumer handler and verify the payload survives. - var producer = Substitute.For(); - var consumer = Substitute.For(); - - var payload = new OrderPayload("ORD-RT-1", "RoundTripWidget", 7); - var envelope = IntegrationEnvelope.Create( - payload, "OrderService", "order.created"); - - // Publish - await producer.PublishAsync(envelope, "orders"); - - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("orders"), - Arg.Any()); - - // Consume: simulate the broker delivering the same envelope. - IntegrationEnvelope? consumed = null; - - await consumer.SubscribeAsync( - "orders", - "order-processors", - handler: msg => - { - consumed = msg; - return Task.CompletedTask; - }); - - // Manually invoke the handler to simulate message delivery. - // In a real system the broker calls the handler; here we do it ourselves. - Func, Task> handler = msg => - { - consumed = msg; - return Task.CompletedTask; - }; - await handler(envelope); - - Assert.That(consumed, Is.Not.Null); - Assert.That(consumed!.Payload.OrderId, Is.EqualTo("ORD-RT-1")); - Assert.That(consumed.MessageId, Is.EqualTo(envelope.MessageId)); + _output = new MockEndpoint("output"); } - // ── Challenge 2: Batch Correlation ────────────────────────────────────── + [TearDown] + public async Task TearDown() + { + await _output.DisposeAsync(); + } [Test] - public async Task Challenge2_MultipleEnvelopes_ShareCorrelationId() + public async Task EndToEnd_PointToPointChannel_DeliversMessage() { - // In a batch scenario, all messages in the batch share the same - // CorrelationId so they can be traced as a single logical unit. - var batchCorrelationId = Guid.NewGuid(); - var producer = Substitute.For(); - - var items = new[] { "Item-A", "Item-B", "Item-C" }; - var envelopes = items.Select(item => - IntegrationEnvelope.Create( - payload: item, - source: "BatchService", - messageType: "batch.item", - correlationId: batchCorrelationId)) - .ToList(); - - // Publish all batch items. - foreach (var env in envelopes) - { - await producer.PublishAsync(env, "batch-topic"); - } + var channel = new PointToPointChannel( + _output, _output, NullLogger.Instance); - // Verify all share the same CorrelationId. - Assert.That(envelopes, Has.Count.EqualTo(3)); - Assert.That(envelopes.Select(e => e.CorrelationId).Distinct().Count(), Is.EqualTo(1)); - Assert.That(envelopes[0].CorrelationId, Is.EqualTo(batchCorrelationId)); + var envelope = IntegrationEnvelope.Create( + "p2p-delivery", "OrderService", "order.created"); - // Each message still has a unique MessageId. - Assert.That(envelopes.Select(e => e.MessageId).Distinct().Count(), Is.EqualTo(3)); + await channel.SendAsync(envelope, "orders-queue", CancellationToken.None); - // Verify the producer was called three times. - await producer.Received(3).PublishAsync( - Arg.Any>(), - Arg.Is("batch-topic"), - Arg.Any()); + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Is.EqualTo("p2p-delivery")); + Assert.That(received.MessageId, Is.EqualTo(envelope.MessageId)); } - // ── Challenge 3: Consumer Group Patterns ──────────────────────────────── - [Test] - public async Task Challenge3_CompetingConsumers_SameGroupName() + public async Task EndToEnd_PublishSubscribeChannel_FanOutToSubscribers() { - // Competing Consumers: multiple consumers in the SAME group. - // Each message is delivered to exactly ONE consumer in the group. - var consumer1 = Substitute.For(); - var consumer2 = Substitute.For(); - - const string sharedGroup = "order-processors"; - - await consumer1.SubscribeAsync( - "orders", sharedGroup, _ => Task.CompletedTask); - - await consumer2.SubscribeAsync( - "orders", sharedGroup, _ => Task.CompletedTask); - - // Both consumers subscribed to the same topic with the same group. - await consumer1.Received(1).SubscribeAsync( - Arg.Is("orders"), - Arg.Is(sharedGroup), - Arg.Any, Task>>(), - Arg.Any()); - - await consumer2.Received(1).SubscribeAsync( - Arg.Is("orders"), - Arg.Is(sharedGroup), - Arg.Any, Task>>(), - Arg.Any()); + var sub1 = new MockEndpoint("subscriber-1"); + var sub2 = new MockEndpoint("subscriber-2"); + + var channel1 = new PublishSubscribeChannel( + sub1, sub1, NullLogger.Instance); + var channel2 = new PublishSubscribeChannel( + sub2, sub2, NullLogger.Instance); + + var envelope = IntegrationEnvelope.Create( + "fanout-event", "EventService", "event.fired"); + + await channel1.PublishAsync(envelope, "events", CancellationToken.None); + await channel2.PublishAsync(envelope, "events", CancellationToken.None); + + sub1.AssertReceivedCount(1); + sub2.AssertReceivedCount(1); + Assert.That(sub1.GetReceived().Payload, Is.EqualTo("fanout-event")); + Assert.That(sub2.GetReceived().Payload, Is.EqualTo("fanout-event")); + + await sub1.DisposeAsync(); + await sub2.DisposeAsync(); } [Test] - public async Task Challenge3_PublishSubscribe_DifferentGroupNames() + public async Task EndToEnd_MultiTopicRouting_VerifyTopicCounts() { - // Publish-Subscribe: multiple consumers in DIFFERENT groups. - // Each message is delivered to ALL groups (fan-out). - var analyticsConsumer = Substitute.For(); - var notificationConsumer = Substitute.For(); - - await analyticsConsumer.SubscribeAsync( - "orders", "analytics-group", _ => Task.CompletedTask); - - await notificationConsumer.SubscribeAsync( - "orders", "notification-group", _ => Task.CompletedTask); - - // Verify different groups — each group gets its own copy of the message. - await analyticsConsumer.Received(1).SubscribeAsync( - Arg.Is("orders"), - Arg.Is("analytics-group"), - Arg.Any, Task>>(), - Arg.Any()); - - await notificationConsumer.Received(1).SubscribeAsync( - Arg.Is("orders"), - Arg.Is("notification-group"), - Arg.Any, Task>>(), - Arg.Any()); + var channel = new PointToPointChannel( + _output, _output, NullLogger.Instance); + + for (var i = 0; i < 3; i++) + { + var env = IntegrationEnvelope.Create($"order-{i}", "svc", "type"); + await channel.SendAsync(env, "orders", CancellationToken.None); + } + for (var i = 0; i < 2; i++) + { + var env = IntegrationEnvelope.Create($"payment-{i}", "svc", "type"); + await channel.SendAsync(env, "payments", CancellationToken.None); + } + + _output.AssertReceivedCount(5); + _output.AssertReceivedOnTopic("orders", 3); + _output.AssertReceivedOnTopic("payments", 2); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial03/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial03/Lab.cs index cc4f7ad..70ced80 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial03/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial03/Lab.cs @@ -1,152 +1,128 @@ // ============================================================================ -// Tutorial 03 – Your First Message (Lab) +// Tutorial 03 – First Message (Lab) // ============================================================================ -// This lab walks through the complete lifecycle of a message: creating an -// envelope, publishing it through a mocked broker, and consuming it on the -// other side. NSubstitute is used so no real broker is needed. +// EIP Pattern: Message Channel +// End-to-End: Use MockEndpoint as producer/consumer, send and receive +// messages, verify end-to-end delivery. // ============================================================================ -using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; +using EnterpriseIntegrationPlatform.Contracts; namespace TutorialLabs.Tutorial03; -// A simple domain payload used throughout this tutorial. public sealed record OrderPayload(string OrderId, string Product, int Quantity); [TestFixture] public sealed class Lab { - // ── Creating Your First Envelope ──────────────────────────────────────── + private MockEndpoint _output = null!; - [Test] - public void CreateEnvelope_WithStringPayload_HasValidFields() + [SetUp] + public void SetUp() { - var envelope = IntegrationEnvelope.Create( - payload: "Hello, Messaging!", - source: "Tutorial03", - messageType: "greeting"); - - Assert.That(envelope.Payload, Is.EqualTo("Hello, Messaging!")); - Assert.That(envelope.Source, Is.EqualTo("Tutorial03")); - Assert.That(envelope.MessageType, Is.EqualTo("greeting")); - Assert.That(envelope.MessageId, Is.Not.EqualTo(Guid.Empty)); - Assert.That(envelope.CorrelationId, Is.Not.EqualTo(Guid.Empty)); + _output = new MockEndpoint("output"); } - [Test] - public void CreateEnvelope_WithDomainObject_WrapsPayloadCorrectly() + [TearDown] + public async Task TearDown() { - var order = new OrderPayload("ORD-100", "Gadget", 3); - - var envelope = IntegrationEnvelope.Create( - payload: order, - source: "OrderService", - messageType: "order.created"); - - Assert.That(envelope.Payload, Is.EqualTo(order)); - Assert.That(envelope.Payload.OrderId, Is.EqualTo("ORD-100")); - Assert.That(envelope.Payload.Product, Is.EqualTo("Gadget")); - Assert.That(envelope.Payload.Quantity, Is.EqualTo(3)); + await _output.DisposeAsync(); } - // ── Publishing with a Mocked Producer ─────────────────────────────────── - [Test] - public async Task PublishAsync_WithMockedProducer_CallIsMade() + public async Task EndToEnd_PublishStringMessage_ReceivedAtOutput() { - // Arrange: create a mock producer using NSubstitute. - var producer = Substitute.For(); - var envelope = IntegrationEnvelope.Create( - "first-message", "Tutorial03", "demo.publish"); + "Hello, Messaging!", "Tutorial03", "greeting"); - // Act: publish the envelope to a topic. - await producer.PublishAsync(envelope, "demo-topic"); + await _output.PublishAsync(envelope, "greetings"); - // Assert: verify the broker received exactly one publish call. - await producer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "first-message"), - Arg.Is("demo-topic"), - Arg.Any()); + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Is.EqualTo("Hello, Messaging!")); + Assert.That(received.Source, Is.EqualTo("Tutorial03")); } [Test] - public async Task PublishAsync_WithOrderPayload_TopicIsCorrect() + public async Task EndToEnd_PublishDomainObject_PayloadPreserved() { - var producer = Substitute.For(); - - var order = new OrderPayload("ORD-200", "Widget", 1); + var order = new OrderPayload("ORD-100", "Gadget", 3); var envelope = IntegrationEnvelope.Create( order, "OrderService", "order.created"); - await producer.PublishAsync(envelope, "orders-topic"); + await _output.PublishAsync(envelope, "orders"); - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("orders-topic"), - Arg.Any()); + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Payload.OrderId, Is.EqualTo("ORD-100")); + Assert.That(received.Payload.Product, Is.EqualTo("Gadget")); } - // ── Consuming with a Mocked Consumer ──────────────────────────────────── - [Test] - public async Task SubscribeAsync_WhenHandlerInvoked_PayloadIsReceived() + public async Task EndToEnd_SubscribeAndSend_HandlerInvoked() { - // Arrange: configure the mock to capture the handler callback so we - // can invoke it manually, simulating a broker delivering a message. - var consumer = Substitute.For(); - Func, Task>? capturedHandler = null; - - consumer.SubscribeAsync( - Arg.Any(), - Arg.Any(), - Arg.Do, Task>>(h => capturedHandler = h), - Arg.Any()) - .Returns(Task.CompletedTask); - - // Act: subscribe — this triggers the Arg.Do capture above. - await consumer.SubscribeAsync( - "demo-topic", - "demo-group", - msg => Task.CompletedTask); - - // Create a message as if the broker delivered it. + IntegrationEnvelope? captured = null; + await _output.SubscribeAsync("topic", "group", msg => + { + captured = msg; + return Task.CompletedTask; + }); + var envelope = IntegrationEnvelope.Create( "consumed-payload", "Producer", "demo.event"); + await _output.SendAsync(envelope); - Assert.That(capturedHandler, Is.Not.Null, "Handler should have been captured"); + Assert.That(captured, Is.Not.Null); + Assert.That(captured!.Payload, Is.EqualTo("consumed-payload")); + } - // Simulate message delivery by invoking the captured handler. - IntegrationEnvelope? received = null; - capturedHandler = msg => + [Test] + public async Task EndToEnd_MultipleMessages_AllCaptured() + { + for (var i = 0; i < 3; i++) { - received = msg; - return Task.CompletedTask; - }; - await capturedHandler(envelope); + var envelope = IntegrationEnvelope.Create( + $"msg-{i}", "source", "type"); + await _output.PublishAsync(envelope, "topic"); + } + + _output.AssertReceivedCount(3); + Assert.That(_output.GetReceived(0).Payload, Is.EqualTo("msg-0")); + Assert.That(_output.GetReceived(2).Payload, Is.EqualTo("msg-2")); + } - // Assert: the handler processed the message. - Assert.That(received, Is.Not.Null); - Assert.That(received!.Payload, Is.EqualTo("consumed-payload")); + [Test] + public async Task EndToEnd_TopicRouting_MessagesOnCorrectTopics() + { + var orderEnv = IntegrationEnvelope.Create("order", "svc", "type"); + var paymentEnv = IntegrationEnvelope.Create("payment", "svc", "type"); + + await _output.PublishAsync(orderEnv, "orders-topic"); + await _output.PublishAsync(paymentEnv, "payments-topic"); + + _output.AssertReceivedOnTopic("orders-topic", 1); + _output.AssertReceivedOnTopic("payments-topic", 1); + Assert.That(_output.GetReceivedTopics(), Has.Count.EqualTo(2)); } [Test] - public async Task SubscribeAsync_MockVerification_SubscribeWasCalled() + public async Task EndToEnd_SendAndReceive_FullRoundTrip() { - var consumer = Substitute.For(); - - await consumer.SubscribeAsync( - "events-topic", - "my-consumer-group", - _ => Task.CompletedTask); - - await consumer.Received(1).SubscribeAsync( - Arg.Is("events-topic"), - Arg.Is("my-consumer-group"), - Arg.Any, Task>>(), - Arg.Any()); + IntegrationEnvelope? received = null; + await _output.SubscribeAsync("channel", "group", msg => + { + received = msg; + return Task.CompletedTask; + }); + + var envelope = IntegrationEnvelope.Create( + "round-trip", "Producer", "test"); + await _output.SendAsync(envelope); + + Assert.That(received, Is.Not.Null); + Assert.That(received!.MessageId, Is.EqualTo(envelope.MessageId)); + Assert.That(received.Payload, Is.EqualTo("round-trip")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial04/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial04/Exam.cs index 8bc269a..0958096 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial04/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial04/Exam.cs @@ -1,28 +1,44 @@ // ============================================================================ -// Tutorial 04 – The Integration Envelope (Exam) +// Tutorial 04 – Integration Envelope (Exam) // ============================================================================ -// Coding challenges: populate full metadata, build a multi-hop causation -// chain, and round-trip an envelope through JSON serialization. +// EIP Pattern: Envelope Wrapper +// End-to-End: Full metadata through PointToPointChannel, multi-hop causation +// chains, and split-message sequences — all verified at MockEndpoint output. // ============================================================================ -using System.Text.Json; -using EnterpriseIntegrationPlatform.Contracts; using NUnit.Framework; +using TutorialLabs.Infrastructure; +using EnterpriseIntegrationPlatform.Contracts; +using EnterpriseIntegrationPlatform.Ingestion.Channels; +using Microsoft.Extensions.Logging.Abstractions; namespace TutorialLabs.Tutorial04; [TestFixture] public sealed class Exam { - // ── Challenge 1: Envelope with Full Metadata ──────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() + { + _output = new MockEndpoint("output"); + } + + [TearDown] + public async Task TearDown() + { + await _output.DisposeAsync(); + } [Test] - public void Challenge1_FullMetadata_AllHeaderConstants() + public async Task EndToEnd_FullMetadata_ThroughPointToPointChannel() { - // Populate an envelope's Metadata dictionary with every - // MessageHeaders constant that has a sensible string value. + var channel = new PointToPointChannel( + _output, _output, NullLogger.Instance); + var envelope = IntegrationEnvelope.Create( - "full-metadata-payload", "MetadataService", "metadata.test") with + "full-metadata", "MetadataService", "metadata.test") with { Priority = MessagePriority.High, Intent = MessageIntent.Command, @@ -35,144 +51,87 @@ public void Challenge1_FullMetadata_AllHeaderConstants() [MessageHeaders.TraceId] = "trace-001", [MessageHeaders.SpanId] = "span-001", [MessageHeaders.ContentType] = "application/json", - [MessageHeaders.SchemaVersion] = "1.0", - [MessageHeaders.SourceTopic] = "commands-topic", - [MessageHeaders.ConsumerGroup] = "cmd-processors", - [MessageHeaders.LastAttemptAt] = DateTimeOffset.UtcNow.ToString("O"), - [MessageHeaders.RetryCount] = "0", - [MessageHeaders.ReplyTo] = "reply-topic", - [MessageHeaders.ExpiresAt] = DateTimeOffset.UtcNow.AddMinutes(30).ToString("O"), - [MessageHeaders.SequenceNumber] = "0", - [MessageHeaders.TotalCount] = "1", - [MessageHeaders.Intent] = "Command", - [MessageHeaders.MessageHistory] = "[]", - [MessageHeaders.ReplayId] = Guid.NewGuid().ToString(), }, }; - // Verify all 15 metadata entries are present. - Assert.That(envelope.Metadata, Has.Count.EqualTo(15)); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.TraceId), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.SpanId), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.ContentType), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.SchemaVersion), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.SourceTopic), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.ConsumerGroup), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.LastAttemptAt), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.RetryCount), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.ReplyTo), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.ExpiresAt), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.SequenceNumber), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.TotalCount), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.Intent), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.MessageHistory), Is.True); - Assert.That(envelope.Metadata.ContainsKey(MessageHeaders.ReplayId), Is.True); - } + await channel.SendAsync(envelope, "metadata-queue", CancellationToken.None); - // ── Challenge 2: Multi-Hop Causation Chain ────────────────────────────── + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Priority, Is.EqualTo(MessagePriority.High)); + Assert.That(received.ReplyTo, Is.EqualTo("reply-topic")); + Assert.That(received.Metadata[MessageHeaders.TraceId], Is.EqualTo("trace-001")); + Assert.That(received.ExpiresAt, Is.Not.Null); + } [Test] - public void Challenge2_CausationChain_A_CausesB_CausesC() + public async Task EndToEnd_MultiHopCausation_AllLinksPreserved() { - // Envelope A: the originating command. var envelopeA = IntegrationEnvelope.Create( - payload: "PlaceOrder", - source: "WebApp", - messageType: "order.place") with + "PlaceOrder", "WebApp", "order.place") with { Intent = MessageIntent.Command, }; - // Envelope B: caused by A (order placed event). var envelopeB = IntegrationEnvelope.Create( - payload: "OrderPlaced", - source: "OrderService", - messageType: "order.placed", + "OrderPlaced", "OrderService", "order.placed", correlationId: envelopeA.CorrelationId, causationId: envelopeA.MessageId) with { Intent = MessageIntent.Event, }; - // Envelope C: caused by B (invoice generated). var envelopeC = IntegrationEnvelope.Create( - payload: "InvoiceGenerated", - source: "BillingService", - messageType: "invoice.generated", + "InvoiceGenerated", "BillingService", "invoice.generated", correlationId: envelopeA.CorrelationId, causationId: envelopeB.MessageId) with { Intent = MessageIntent.Document, }; - // All three share the same CorrelationId for end-to-end tracing. - Assert.That(envelopeB.CorrelationId, Is.EqualTo(envelopeA.CorrelationId)); - Assert.That(envelopeC.CorrelationId, Is.EqualTo(envelopeA.CorrelationId)); + await _output.PublishAsync(envelopeA, "commands"); + await _output.PublishAsync(envelopeB, "events"); + await _output.PublishAsync(envelopeC, "documents"); - // The causation chain links: A → B → C. - Assert.That(envelopeA.CausationId, Is.Null, "A has no parent"); - Assert.That(envelopeB.CausationId, Is.EqualTo(envelopeA.MessageId)); - Assert.That(envelopeC.CausationId, Is.EqualTo(envelopeB.MessageId)); + _output.AssertReceivedCount(3); + var rA = _output.GetReceived(0); + var rB = _output.GetReceived(1); + var rC = _output.GetReceived(2); - // Each has a unique MessageId. - var ids = new[] { envelopeA.MessageId, envelopeB.MessageId, envelopeC.MessageId }; - Assert.That(ids.Distinct().Count(), Is.EqualTo(3)); + Assert.That(rA.CausationId, Is.Null); + Assert.That(rB.CausationId, Is.EqualTo(rA.MessageId)); + Assert.That(rC.CausationId, Is.EqualTo(rB.MessageId)); + Assert.That(rB.CorrelationId, Is.EqualTo(rA.CorrelationId)); + Assert.That(rC.CorrelationId, Is.EqualTo(rA.CorrelationId)); } - // ── Challenge 3: JSON Serialization Round-Trip ────────────────────────── - [Test] - public void Challenge3_JsonSerialization_RoundTrip() + public async Task EndToEnd_SplitSequence_AllPartsPreserved() { - var original = IntegrationEnvelope.Create( - payload: "serialize-me", - source: "SerializerService", - messageType: "test.serialize") with + var correlationId = Guid.NewGuid(); + const int total = 5; + + for (var i = 0; i < total; i++) { - SchemaVersion = "2.0", - Priority = MessagePriority.Critical, - Intent = MessageIntent.Event, - ReplyTo = "reply-channel", - ExpiresAt = DateTimeOffset.Parse("2099-12-31T23:59:59+00:00"), - SequenceNumber = 5, - TotalCount = 10, - Metadata = new Dictionary + var part = IntegrationEnvelope.Create( + $"chunk-{i}", "Splitter", "data.chunk", + correlationId: correlationId) with { - [MessageHeaders.ContentType] = "application/json", - [MessageHeaders.TraceId] = "trace-xyz", - }, - }; + SequenceNumber = i, + TotalCount = total, + }; + await _output.PublishAsync(part, "chunks"); + } - var options = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - }; + _output.AssertReceivedCount(total); + var all = _output.GetAllReceived("chunks"); - // Serialize to JSON. - var json = JsonSerializer.Serialize(original, options); - Assert.That(json, Is.Not.Null.And.Not.Empty); - - // Deserialize back. - var restored = JsonSerializer.Deserialize>(json, options); - Assert.That(restored, Is.Not.Null); - - // Verify all fields survived the round-trip. - Assert.That(restored!.MessageId, Is.EqualTo(original.MessageId)); - Assert.That(restored.CorrelationId, Is.EqualTo(original.CorrelationId)); - Assert.That(restored.CausationId, Is.EqualTo(original.CausationId)); - Assert.That(restored.Source, Is.EqualTo(original.Source)); - Assert.That(restored.MessageType, Is.EqualTo(original.MessageType)); - Assert.That(restored.SchemaVersion, Is.EqualTo("2.0")); - Assert.That(restored.Priority, Is.EqualTo(MessagePriority.Critical)); - Assert.That(restored.Intent, Is.EqualTo(MessageIntent.Event)); - Assert.That(restored.Payload, Is.EqualTo("serialize-me")); - Assert.That(restored.ReplyTo, Is.EqualTo("reply-channel")); - Assert.That(restored.SequenceNumber, Is.EqualTo(5)); - Assert.That(restored.TotalCount, Is.EqualTo(10)); - Assert.That(restored.Metadata[MessageHeaders.ContentType], - Is.EqualTo("application/json")); - Assert.That(restored.Metadata[MessageHeaders.TraceId], - Is.EqualTo("trace-xyz")); + for (var i = 0; i < total; i++) + { + Assert.That(all[i].SequenceNumber, Is.EqualTo(i)); + Assert.That(all[i].TotalCount, Is.EqualTo(total)); + Assert.That(all[i].Payload, Is.EqualTo($"chunk-{i}")); + Assert.That(all[i].CorrelationId, Is.EqualTo(correlationId)); + } } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial04/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial04/Lab.cs index 3bedf96..3b2fa99 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial04/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial04/Lab.cs @@ -1,186 +1,172 @@ // ============================================================================ -// Tutorial 04 – The Integration Envelope (Lab) +// Tutorial 04 – Integration Envelope (Lab) // ============================================================================ -// A deep dive into every property of IntegrationEnvelope. You will test -// auto-generated identifiers, message expiration, metadata headers, sequence -// numbers, and the immutable record semantics that make envelopes safe to -// pass across service boundaries. +// EIP Pattern: Envelope Wrapper +// End-to-End: Create envelopes with all fields (expiration, sequence, +// metadata, priority, causation chain), route through pipeline, verify +// wrapper fields preserved. // ============================================================================ -using EnterpriseIntegrationPlatform.Contracts; using NUnit.Framework; +using TutorialLabs.Infrastructure; +using EnterpriseIntegrationPlatform.Contracts; namespace TutorialLabs.Tutorial04; -// A rich domain payload to exercise complex envelope scenarios. public sealed record ShipmentPayload( - string ShipmentId, - string Carrier, - decimal WeightKg, - string[] Items); + string ShipmentId, string Carrier, decimal WeightKg, string[] Items); [TestFixture] public sealed class Lab { - // ── All Properties with a Complex Payload ─────────────────────────────── + private MockEndpoint _output = null!; - [Test] - public void Envelope_WithComplexPayload_AllPropertiesAccessible() + [SetUp] + public void SetUp() { - var items = new[] { "SKU-001", "SKU-002" }; - var shipment = new ShipmentPayload("SHIP-1", "FedEx", 12.5m, items); - var correlationId = Guid.NewGuid(); - - var envelope = IntegrationEnvelope.Create( - payload: shipment, - source: "WarehouseService", - messageType: "shipment.dispatched", - correlationId: correlationId) with - { - SchemaVersion = "2.0", - Priority = MessagePriority.High, - Intent = MessageIntent.Event, - ReplyTo = "shipment-replies", - ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), - SequenceNumber = 0, - TotalCount = 3, - }; - - Assert.That(envelope.Payload.ShipmentId, Is.EqualTo("SHIP-1")); - Assert.That(envelope.Payload.Carrier, Is.EqualTo("FedEx")); - Assert.That(envelope.Payload.WeightKg, Is.EqualTo(12.5m)); - Assert.That(envelope.Payload.Items, Has.Length.EqualTo(2)); - Assert.That(envelope.CorrelationId, Is.EqualTo(correlationId)); - Assert.That(envelope.SchemaVersion, Is.EqualTo("2.0")); - Assert.That(envelope.Priority, Is.EqualTo(MessagePriority.High)); - Assert.That(envelope.Intent, Is.EqualTo(MessageIntent.Event)); - Assert.That(envelope.ReplyTo, Is.EqualTo("shipment-replies")); - Assert.That(envelope.ExpiresAt, Is.Not.Null); - Assert.That(envelope.SequenceNumber, Is.EqualTo(0)); - Assert.That(envelope.TotalCount, Is.EqualTo(3)); + _output = new MockEndpoint("output"); } - // ── Unique MessageId Generation ───────────────────────────────────────── - - [Test] - public void Create_GeneratesUniqueMessageIds() + [TearDown] + public async Task TearDown() { - var ids = Enumerable.Range(0, 100) - .Select(_ => IntegrationEnvelope.Create( - "payload", "source", "type").MessageId) - .ToList(); - - Assert.That(ids.Distinct().Count(), Is.EqualTo(100), - "Each envelope must have a globally unique MessageId"); + await _output.DisposeAsync(); } [Test] - public void Create_WithoutCorrelationId_GeneratesNewOne() + public async Task EndToEnd_ExpiresAt_PreservedThroughPipeline() { - var env1 = IntegrationEnvelope.Create("a", "src", "type"); - var env2 = IntegrationEnvelope.Create("b", "src", "type"); + var expiry = DateTimeOffset.UtcNow.AddHours(1); + var envelope = IntegrationEnvelope.Create( + "expiring", "source", "type") with { ExpiresAt = expiry }; - Assert.That(env1.CorrelationId, Is.Not.EqualTo(env2.CorrelationId)); - } + await _output.PublishAsync(envelope, "topic"); - // ── IsExpired ─────────────────────────────────────────────────────────── + var received = _output.GetReceived(); + Assert.That(received.ExpiresAt, Is.EqualTo(expiry)); + Assert.That(received.IsExpired, Is.False); + } [Test] - public void IsExpired_WhenExpiresAtInPast_ReturnsTrue() + public async Task EndToEnd_SequenceNumbers_PreservedThroughPipeline() { var envelope = IntegrationEnvelope.Create( - "stale", "source", "type") with + "part-2", "Splitter", "order.part") with { - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5), + SequenceNumber = 2, + TotalCount = 5, }; - Assert.That(envelope.IsExpired, Is.True); + await _output.PublishAsync(envelope, "parts"); + + var received = _output.GetReceived(); + Assert.That(received.SequenceNumber, Is.EqualTo(2)); + Assert.That(received.TotalCount, Is.EqualTo(5)); } [Test] - public void IsExpired_WhenExpiresAtInFuture_ReturnsFalse() + public async Task EndToEnd_MetadataHeaders_PreservedThroughPipeline() { var envelope = IntegrationEnvelope.Create( - "fresh", "source", "type") with + "payload", "source", "type") with { - ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + Metadata = new Dictionary + { + [MessageHeaders.ContentType] = "application/json", + [MessageHeaders.TraceId] = "abc-123", + }, }; - Assert.That(envelope.IsExpired, Is.False); + await _output.PublishAsync(envelope, "topic"); + + var received = _output.GetReceived(); + Assert.That(received.Metadata[MessageHeaders.ContentType], + Is.EqualTo("application/json")); + Assert.That(received.Metadata[MessageHeaders.TraceId], + Is.EqualTo("abc-123")); } [Test] - public void IsExpired_WhenExpiresAtIsNull_ReturnsFalse() + public async Task EndToEnd_CriticalPriority_PreservedThroughPipeline() { - // Messages without an ExpiresAt never expire. var envelope = IntegrationEnvelope.Create( - "immortal", "source", "type"); + "urgent", "AlertService", "alert") with + { + Priority = MessagePriority.Critical, + }; - Assert.That(envelope.ExpiresAt, Is.Null); - Assert.That(envelope.IsExpired, Is.False); - } + await _output.PublishAsync(envelope, "alerts"); - // ── Metadata Dictionary ───────────────────────────────────────────────── + var received = _output.GetReceived(); + Assert.That(received.Priority, Is.EqualTo(MessagePriority.Critical)); + } [Test] - public void Metadata_AddAndReadHeaders() + public async Task EndToEnd_CausationChain_PreservedThroughPipeline() { + var parentId = Guid.NewGuid(); + var correlationId = Guid.NewGuid(); var envelope = IntegrationEnvelope.Create( - "payload", "source", "type") with - { - Metadata = new Dictionary - { - [MessageHeaders.ContentType] = "application/json", - [MessageHeaders.TraceId] = "abc-123-trace", - [MessageHeaders.SourceTopic] = "orders-topic", - }, - }; + "child", "ChildService", "child.created", + correlationId: correlationId, + causationId: parentId); - Assert.That(envelope.Metadata[MessageHeaders.ContentType], - Is.EqualTo("application/json")); - Assert.That(envelope.Metadata[MessageHeaders.TraceId], - Is.EqualTo("abc-123-trace")); - Assert.That(envelope.Metadata[MessageHeaders.SourceTopic], - Is.EqualTo("orders-topic")); - Assert.That(envelope.Metadata, Has.Count.EqualTo(3)); + await _output.PublishAsync(envelope, "topic"); + + var received = _output.GetReceived(); + Assert.That(received.CausationId, Is.EqualTo(parentId)); + Assert.That(received.CorrelationId, Is.EqualTo(correlationId)); } [Test] - public void Metadata_DefaultIsEmptyDictionary() + public async Task EndToEnd_ReplyTo_PreservedThroughPipeline() { var envelope = IntegrationEnvelope.Create( - "payload", "source", "type"); + "request", "Requester", "req") with + { + ReplyTo = "reply-channel", + }; - Assert.That(envelope.Metadata, Is.Not.Null); - Assert.That(envelope.Metadata, Is.Empty); - } + await _output.PublishAsync(envelope, "requests"); - // ── SequenceNumber and TotalCount ─────────────────────────────────────── + var received = _output.GetReceived(); + Assert.That(received.ReplyTo, Is.EqualTo("reply-channel")); + } [Test] - public void SplitMessage_SequenceNumbers_AreCorrect() + public async Task EndToEnd_AllWrapperFields_PreservedThroughPipeline() { - // Simulate a Splitter that breaks a large order into three parts. + var shipment = new ShipmentPayload("SHIP-1", "FedEx", 12.5m, + new[] { "SKU-001", "SKU-002" }); var correlationId = Guid.NewGuid(); - var parts = Enumerable.Range(0, 3) - .Select(i => IntegrationEnvelope.Create( - payload: $"Part-{i}", - source: "Splitter", - messageType: "order.part", - correlationId: correlationId) with - { - SequenceNumber = i, - TotalCount = 3, - }) - .ToList(); - Assert.That(parts, Has.Count.EqualTo(3)); - - for (var i = 0; i < 3; i++) + var envelope = IntegrationEnvelope.Create( + shipment, "Warehouse", "shipment.dispatched", + correlationId: correlationId) with { - Assert.That(parts[i].SequenceNumber, Is.EqualTo(i)); - Assert.That(parts[i].TotalCount, Is.EqualTo(3)); - Assert.That(parts[i].CorrelationId, Is.EqualTo(correlationId)); - } + SchemaVersion = "2.0", + Priority = MessagePriority.High, + Intent = MessageIntent.Event, + ReplyTo = "shipment-replies", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + SequenceNumber = 0, + TotalCount = 3, + Metadata = new Dictionary + { + [MessageHeaders.ContentType] = "application/json", + }, + }; + + await _output.PublishAsync(envelope, "shipments"); + + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Payload.ShipmentId, Is.EqualTo("SHIP-1")); + Assert.That(received.SchemaVersion, Is.EqualTo("2.0")); + Assert.That(received.Priority, Is.EqualTo(MessagePriority.High)); + Assert.That(received.Intent, Is.EqualTo(MessageIntent.Event)); + Assert.That(received.ReplyTo, Is.EqualTo("shipment-replies")); + Assert.That(received.SequenceNumber, Is.EqualTo(0)); + Assert.That(received.TotalCount, Is.EqualTo(3)); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial05/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial05/Exam.cs index e52f28e..b9419e0 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial05/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial05/Exam.cs @@ -1,31 +1,44 @@ // ============================================================================ // Tutorial 05 – Message Brokers (Exam) // ============================================================================ -// Coding challenges: multi-broker fan-out, consumer group isolation, and -// verifying message ordering via sequence numbers. +// EIP Pattern: Message Endpoint +// End-to-End: Multi-broker fan-out, selective consumer filtering, and full +// AspireIntegrationTestHost pipeline with broker configuration. // ============================================================================ +using NUnit.Framework; +using TutorialLabs.Infrastructure; using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Ingestion; -using NSubstitute; -using NUnit.Framework; namespace TutorialLabs.Tutorial05; [TestFixture] public sealed class Exam { - // ── Challenge 1: Multi-Broker Publishing ──────────────────────────────── + private MockEndpoint _nats = null!; + private MockEndpoint _kafka = null!; + private MockEndpoint _pulsar = null!; - [Test] - public async Task Challenge1_PublishSameMessage_ToDifferentBrokers() + [SetUp] + public void SetUp() + { + _nats = new MockEndpoint("nats"); + _kafka = new MockEndpoint("kafka"); + _pulsar = new MockEndpoint("pulsar"); + } + + [TearDown] + public async Task TearDown() { - // In a multi-broker architecture you might publish the same event - // to NATS (for real-time) and Kafka (for long-term retention). - var natsProducer = Substitute.For(); - var kafkaProducer = Substitute.For(); - var pulsarProducer = Substitute.For(); + await _nats.DisposeAsync(); + await _kafka.DisposeAsync(); + await _pulsar.DisposeAsync(); + } + [Test] + public async Task EndToEnd_MultiBrokerFanOut_AllEndpointsReceive() + { var envelope = IntegrationEnvelope.Create( "critical-event", "AlertService", "alert.raised") with { @@ -33,109 +46,63 @@ public async Task Challenge1_PublishSameMessage_ToDifferentBrokers() Intent = MessageIntent.Event, }; - // Publish the same envelope to all three brokers. - await natsProducer.PublishAsync(envelope, "alerts"); - await kafkaProducer.PublishAsync(envelope, "alerts"); - await pulsarProducer.PublishAsync(envelope, "alerts"); - - // Each broker received exactly one publish. - await natsProducer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "critical-event"), - Arg.Is("alerts"), - Arg.Any()); - - await kafkaProducer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "critical-event"), - Arg.Is("alerts"), - Arg.Any()); - - await pulsarProducer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "critical-event"), - Arg.Is("alerts"), - Arg.Any()); - } + await _nats.PublishAsync(envelope, "alerts"); + await _kafka.PublishAsync(envelope, "alerts"); + await _pulsar.PublishAsync(envelope, "alerts"); + + _nats.AssertReceivedCount(1); + _kafka.AssertReceivedCount(1); + _pulsar.AssertReceivedCount(1); - // ── Challenge 2: Consumer Groups with Different Group Names ───────────── + Assert.That(_nats.GetReceived().Payload, Is.EqualTo("critical-event")); + Assert.That(_kafka.GetReceived().Payload, Is.EqualTo("critical-event")); + Assert.That(_pulsar.GetReceived().Payload, Is.EqualTo("critical-event")); + } [Test] - public async Task Challenge2_DifferentConsumerGroups_ReceiveIndependently() + public async Task EndToEnd_SelectiveConsumer_FiltersMessages() { - // Three independent consumer groups on the same topic. - // Each group processes messages independently. - var consumer = Substitute.For(); + var results = new List(); + await _nats.SubscribeAsync("orders", "group", + env => env.Priority == MessagePriority.High, + msg => + { + results.Add(msg.Payload); + return Task.CompletedTask; + }); - var groups = new[] { "billing-group", "analytics-group", "audit-group" }; - const string topic = "order-events"; + var highPriority = IntegrationEnvelope.Create( + "urgent-order", "svc", "order") with { Priority = MessagePriority.High }; + var lowPriority = IntegrationEnvelope.Create( + "normal-order", "svc", "order") with { Priority = MessagePriority.Low }; - foreach (var group in groups) - { - await consumer.SubscribeAsync( - topic, group, _ => Task.CompletedTask); - } - - // Verify subscribe was called three times — once per group. - await consumer.Received(3).SubscribeAsync( - Arg.Is(topic), - Arg.Any(), - Arg.Any, Task>>(), - Arg.Any()); - - // Verify each group name was used exactly once. - foreach (var group in groups) - { - await consumer.Received(1).SubscribeAsync( - Arg.Is(topic), - Arg.Is(group), - Arg.Any, Task>>(), - Arg.Any()); - } - } + await _nats.SendAsync(highPriority); + await _nats.SendAsync(lowPriority); - // ── Challenge 3: Message Ordering via Sequence Numbers ────────────────── + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0], Is.EqualTo("urgent-order")); + } [Test] - public async Task Challenge3_SequenceNumberedMessages_MaintainOrder() + public async Task EndToEnd_FullPipeline_HostWithBrokerConfig() { - // Publish a sequence of messages and verify ordering is preserved - // by checking SequenceNumber and TotalCount on each envelope. - var producer = Substitute.For(); - var correlationId = Guid.NewGuid(); - const int totalMessages = 5; - - var envelopes = Enumerable.Range(0, totalMessages) - .Select(i => IntegrationEnvelope.Create( - payload: $"chunk-{i}", - source: "Splitter", - messageType: "data.chunk", - correlationId: correlationId) with - { - SequenceNumber = i, - TotalCount = totalMessages, - }) - .ToList(); - - // Publish all in order. - foreach (var env in envelopes) + var builder = AspireIntegrationTestHost.CreateBuilder(); + var output = builder.AddMockEndpoint("output"); + builder.UseProducer(output); + builder.Configure(opts => { - await producer.PublishAsync(env, "data-chunks"); - } + opts.BrokerType = BrokerType.NatsJetStream; + opts.ConnectionString = "nats://localhost:15222"; + }); + await using var host = builder.Build(); - // Verify the sequence numbers form an unbroken 0..N-1 range. - for (var i = 0; i < totalMessages; i++) - { - Assert.That(envelopes[i].SequenceNumber, Is.EqualTo(i)); - Assert.That(envelopes[i].TotalCount, Is.EqualTo(totalMessages)); - Assert.That(envelopes[i].Payload, Is.EqualTo($"chunk-{i}")); - } - - // All share the same CorrelationId. - Assert.That(envelopes.Select(e => e.CorrelationId).Distinct().Count(), - Is.EqualTo(1)); - - // The producer received exactly totalMessages publish calls. - await producer.Received(totalMessages).PublishAsync( - Arg.Any>(), - Arg.Is("data-chunks"), - Arg.Any()); + var producer = host.GetService(); + var envelope = IntegrationEnvelope.Create( + "host-message", "HostService", "host.event"); + + await producer.PublishAsync(envelope, "host-topic"); + + output.AssertReceivedCount(1); + Assert.That(output.GetReceived().Payload, Is.EqualTo("host-message")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial05/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial05/Lab.cs index ab9645c..4600b5e 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial05/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial05/Lab.cs @@ -1,159 +1,142 @@ // ============================================================================ // Tutorial 05 – Message Brokers (Lab) // ============================================================================ -// This lab explores the three supported message broker implementations -// (NATS JetStream, Kafka, Pulsar) through BrokerOptions configuration and -// mocked producers. You will configure each broker, publish messages to -// specific topics, and verify the interactions. +// EIP Pattern: Message Endpoint +// End-to-End: Configure BrokerOptions for NATS/Kafka/Pulsar, send through +// MockEndpoint per broker type, verify abstraction works across protocols. // ============================================================================ +using NUnit.Framework; +using TutorialLabs.Infrastructure; using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Ingestion; -using NSubstitute; -using NUnit.Framework; namespace TutorialLabs.Tutorial05; [TestFixture] public sealed class Lab { - // ── Configuring BrokerOptions for Each Broker ─────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() + { + _output = new MockEndpoint("output"); + } + + [TearDown] + public async Task TearDown() + { + await _output.DisposeAsync(); + } [Test] - public void BrokerOptions_ConfiguredForNats() + public async Task EndToEnd_NatsBrokerConfig_PublishToMockEndpoint() { var options = new BrokerOptions { BrokerType = BrokerType.NatsJetStream, ConnectionString = "nats://localhost:15222", - TransactionTimeoutSeconds = 30, }; + var envelope = IntegrationEnvelope.Create( + "nats-message", "NatsService", "nats.event"); + + await _output.PublishAsync(envelope, "nats-events"); + + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Is.EqualTo("nats-message")); Assert.That(options.BrokerType, Is.EqualTo(BrokerType.NatsJetStream)); - Assert.That(options.ConnectionString, Is.EqualTo("nats://localhost:15222")); - Assert.That(options.TransactionTimeoutSeconds, Is.EqualTo(30)); } [Test] - public void BrokerOptions_ConfiguredForKafka() + public async Task EndToEnd_KafkaBrokerConfig_PublishToMockEndpoint() { var options = new BrokerOptions { BrokerType = BrokerType.Kafka, ConnectionString = "localhost:9092", - TransactionTimeoutSeconds = 60, }; + var envelope = IntegrationEnvelope.Create( + "kafka-message", "KafkaService", "kafka.event"); + + await _output.PublishAsync(envelope, "kafka-events"); + + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Is.EqualTo("kafka-message")); Assert.That(options.BrokerType, Is.EqualTo(BrokerType.Kafka)); - Assert.That(options.ConnectionString, Is.EqualTo("localhost:9092")); - Assert.That(options.TransactionTimeoutSeconds, Is.EqualTo(60)); } [Test] - public void BrokerOptions_ConfiguredForPulsar() + public async Task EndToEnd_PulsarBrokerConfig_PublishToMockEndpoint() { var options = new BrokerOptions { BrokerType = BrokerType.Pulsar, ConnectionString = "pulsar://localhost:6650", - TransactionTimeoutSeconds = 45, }; - Assert.That(options.BrokerType, Is.EqualTo(BrokerType.Pulsar)); - Assert.That(options.ConnectionString, Is.EqualTo("pulsar://localhost:6650")); - Assert.That(options.TransactionTimeoutSeconds, Is.EqualTo(45)); - } - - // ── Publishing Through Mocked Producers ───────────────────────────────── - - [Test] - public async Task Publish_WithNatsProducer_VerifyTopicAndPayload() - { - var producer = Substitute.For(); - var envelope = IntegrationEnvelope.Create( - "nats-message", "NatsService", "nats.event"); + "pulsar-message", "PulsarService", "pulsar.event"); - await producer.PublishAsync(envelope, "nats-events"); + await _output.PublishAsync(envelope, "pulsar-events"); - await producer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "nats-message"), - Arg.Is("nats-events"), - Arg.Any()); + _output.AssertReceivedCount(1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Is.EqualTo("pulsar-message")); + Assert.That(options.BrokerType, Is.EqualTo(BrokerType.Pulsar)); } [Test] - public async Task Publish_WithKafkaProducer_VerifyTopicAndPayload() + public async Task EndToEnd_MultipleTopics_VerifyPerTopicDelivery() { - var producer = Substitute.For(); - - var envelope = IntegrationEnvelope.Create( - "kafka-message", "KafkaService", "kafka.event"); - - await producer.PublishAsync(envelope, "kafka-events"); - - await producer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "kafka-message"), - Arg.Is("kafka-events"), - Arg.Any()); + var orderEnv = IntegrationEnvelope.Create("order", "svc", "type"); + var paymentEnv = IntegrationEnvelope.Create("payment", "svc", "type"); + var shippingEnv = IntegrationEnvelope.Create("shipping", "svc", "type"); + + await _output.PublishAsync(orderEnv, "orders-topic"); + await _output.PublishAsync(paymentEnv, "payments-topic"); + await _output.PublishAsync(shippingEnv, "shipping-topic"); + + _output.AssertReceivedCount(3); + _output.AssertReceivedOnTopic("orders-topic", 1); + _output.AssertReceivedOnTopic("payments-topic", 1); + _output.AssertReceivedOnTopic("shipping-topic", 1); } [Test] - public async Task Publish_WithPulsarProducer_VerifyTopicAndPayload() + public async Task EndToEnd_EventDrivenConsumer_HandlerTriggered() { - var producer = Substitute.For(); + IntegrationEnvelope? captured = null; + await _output.StartAsync("events", "group", msg => + { + captured = msg; + return Task.CompletedTask; + }); var envelope = IntegrationEnvelope.Create( - "pulsar-message", "PulsarService", "pulsar.event"); + "event-driven", "EventSource", "event"); + await _output.SendAsync(envelope); - await producer.PublishAsync(envelope, "pulsar-events"); - - await producer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "pulsar-message"), - Arg.Is("pulsar-events"), - Arg.Any()); + Assert.That(captured, Is.Not.Null); + Assert.That(captured!.Payload, Is.EqualTo("event-driven")); } - // ── Multiple Topics ───────────────────────────────────────────────────── - [Test] - public async Task Publish_MultipleTopics_EachReceivesCorrectMessage() + public async Task EndToEnd_PollingConsumer_MessagesPolled() { - var producer = Substitute.For(); - - var orderEnvelope = IntegrationEnvelope.Create( - "new-order", "OrderService", "order.created"); - - var paymentEnvelope = IntegrationEnvelope.Create( - "payment-received", "PaymentService", "payment.received"); - - var shippingEnvelope = IntegrationEnvelope.Create( - "shipment-dispatched", "ShippingService", "shipment.dispatched"); - - await producer.PublishAsync(orderEnvelope, "orders-topic"); - await producer.PublishAsync(paymentEnvelope, "payments-topic"); - await producer.PublishAsync(shippingEnvelope, "shipping-topic"); - - // Verify each topic got exactly one message. - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("orders-topic"), - Arg.Any()); - - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("payments-topic"), - Arg.Any()); - - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("shipping-topic"), - Arg.Any()); - - // Total publish calls = 3. - await producer.Received(3).PublishAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()); + var envelope1 = IntegrationEnvelope.Create("poll-1", "svc", "type"); + var envelope2 = IntegrationEnvelope.Create("poll-2", "svc", "type"); + await _output.SendAsync(envelope1); + await _output.SendAsync(envelope2); + + var polled = await _output.PollAsync("topic", "group", 10); + + Assert.That(polled, Has.Count.EqualTo(2)); + Assert.That(polled[0].Payload, Is.EqualTo("poll-1")); + Assert.That(polled[1].Payload, Is.EqualTo("poll-2")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial06/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial06/Exam.cs index 2d63c6c..0c0c891 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial06/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial06/Exam.cs @@ -1,167 +1,92 @@ // ============================================================================ // Tutorial 06 – Messaging Channels (Exam) // ============================================================================ -// Coding challenges: build a messaging bridge, implement publish-subscribe -// fan-out, and route expired messages to a dead letter channel. +// E2E challenges: messaging bridge via Point-to-Point, pub-sub fan-out with +// verification, and type-based routing with multiple message types. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; -using NSubstitute; +using EnterpriseIntegrationPlatform.Ingestion.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial06; [TestFixture] public sealed class Exam { - // ── Challenge 1: Messaging Bridge ─────────────────────────────────────── - [Test] - public async Task Challenge1_MessagingBridge_RepublishesFromSourceToTarget() + public async Task Challenge1_Bridge_PointToPointRelay() { - // Build a messaging bridge that subscribes to a source topic and - // re-publishes every received message to a target topic. - var sourceConsumer = Substitute.For(); - var targetProducer = Substitute.For(); - - // Capture the handler that the bridge registers when it subscribes. - Func, Task>? capturedHandler = null; + await using var source = new MockEndpoint("source"); + await using var target = new MockEndpoint("target"); - await sourceConsumer.SubscribeAsync( - Arg.Is("source-topic"), - Arg.Is("bridge-group"), - Arg.Do, Task>>(h => capturedHandler = h), - Arg.Any()); + var inbound = new PointToPointChannel( + source, source, NullLogger.Instance); + var outbound = new PointToPointChannel( + target, target, NullLogger.Instance); - // Simulate the bridge subscribing to the source. - await sourceConsumer.SubscribeAsync( - "source-topic", - "bridge-group", - async envelope => - { - // Bridge logic: re-publish to the target topic. - await targetProducer.PublishAsync(envelope, "target-topic"); - }); + await inbound.ReceiveAsync("inbound-q", "bridge-group", + async msg => await outbound.SendAsync(msg, "outbound-q", CancellationToken.None), + CancellationToken.None); - // Simulate a message arriving on the source topic. var envelope = IntegrationEnvelope.Create( "bridged-payload", "SourceSystem", "source.event"); + await source.SendAsync(envelope); - // Invoke the bridge handler. - Assert.That(capturedHandler, Is.Not.Null, "Bridge handler should be registered"); - await capturedHandler!(envelope); - - // Verify the message was forwarded to the target topic. - await targetProducer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "bridged-payload"), - Arg.Is("target-topic"), - Arg.Any()); + target.AssertReceivedCount(1); + Assert.That(target.GetReceived().Payload, Is.EqualTo("bridged-payload")); + target.AssertReceivedOnTopic("outbound-q", 1); } - // ── Challenge 2: Publish-Subscribe Fan-Out with 3 Groups ──────────────── - [Test] - public async Task Challenge2_PubSubFanOut_ThreeConsumerGroupsAllReceive() + public async Task Challenge2_PubSubFanOut_ThreeSubscribersReceive() { - // Simulate a pub-sub fan-out where 3 consumer groups each receive - // the same message independently. - var producer = Substitute.For(); - var consumer = Substitute.For(); + await using var endpoint = new MockEndpoint("fanout"); + var channel = new PublishSubscribeChannel( + endpoint, endpoint, NullLogger.Instance); + + var results = new List(); + await channel.SubscribeAsync("notifications", "email", + msg => { results.Add("email"); return Task.CompletedTask; }, CancellationToken.None); + await channel.SubscribeAsync("notifications", "sms", + msg => { results.Add("sms"); return Task.CompletedTask; }, CancellationToken.None); + await channel.SubscribeAsync("notifications", "push", + msg => { results.Add("push"); return Task.CompletedTask; }, CancellationToken.None); var envelope = IntegrationEnvelope.Create( - "broadcast-event", "NotificationService", "notification.sent") with - { - Intent = MessageIntent.Event, - Priority = MessagePriority.High, - }; - - var consumerGroups = new[] { "email-service", "sms-service", "push-service" }; - var receivedPayloads = new List(); - - // Subscribe three consumer groups. - foreach (var group in consumerGroups) - { - await consumer.SubscribeAsync( - "notifications.fanout", - group, - env => - { - receivedPayloads.Add(env.Payload); - return Task.CompletedTask; - }); - } - - // Publish once — all groups should be notified. - await producer.PublishAsync(envelope, "notifications.fanout"); - - // Verify three independent subscriptions were created. - await consumer.Received(3).SubscribeAsync( - Arg.Is("notifications.fanout"), - Arg.Any(), - Arg.Any, Task>>(), - Arg.Any()); - - // Verify each group was subscribed exactly once. - foreach (var group in consumerGroups) - { - await consumer.Received(1).SubscribeAsync( - Arg.Is("notifications.fanout"), - Arg.Is(group), - Arg.Any, Task>>(), - Arg.Any()); - } + "broadcast", "NotificationService", "notification.sent"); + await endpoint.SendAsync(envelope); - // The producer published once to the fan-out topic. - await producer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "broadcast-event"), - Arg.Is("notifications.fanout"), - Arg.Any()); + Assert.That(results, Has.Count.EqualTo(3)); + Assert.That(results, Does.Contain("email")); + Assert.That(results, Does.Contain("sms")); + Assert.That(results, Does.Contain("push")); } - // ── Challenge 3: Dead Letter Routing for Expired Messages ─────────────── - [Test] - public async Task Challenge3_DeadLetterRouting_ExpiredMessagesGoToDlq() + public async Task Challenge3_DatatypeChannel_MultipleTypesRoutedCorrectly() { - // Implement dead letter routing: check IsExpired and route expired - // messages to a DLQ topic instead of the normal processing topic. - var producer = Substitute.For(); - - const string normalTopic = "orders.processing"; - const string dlqTopic = "orders.dlq"; - - var validMessage = IntegrationEnvelope.Create( - "valid-order", "OrderService", "order.created") with - { - ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), - }; - - var expiredMessage = IntegrationEnvelope.Create( - "stale-order", "OrderService", "order.created") with - { - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-10), - }; - - // Route each message: expired → DLQ, valid → normal topic. - var messagesToRoute = new[] { validMessage, expiredMessage }; - - foreach (var msg in messagesToRoute) - { - var destination = msg.IsExpired ? dlqTopic : normalTopic; - await producer.PublishAsync(msg, destination); - } - - // Verify the valid message went to the normal topic. - await producer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "valid-order"), - Arg.Is(normalTopic), - Arg.Any()); - - // Verify the expired message was routed to the DLQ. - await producer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "stale-order"), - Arg.Is(dlqTopic), - Arg.Any()); + await using var endpoint = new MockEndpoint("datatype"); + var options = Options.Create(new DatatypeChannelOptions + { TopicPrefix = "dt", Separator = "." }); + var channel = new DatatypeChannel( + endpoint, options, NullLogger.Instance); + + var orderEnv = IntegrationEnvelope.Create("o1", "svc", "order.created"); + var paymentEnv = IntegrationEnvelope.Create("p1", "svc", "payment.received"); + var inventoryEnv = IntegrationEnvelope.Create("i1", "svc", "inventory.updated"); + + await channel.PublishAsync(orderEnv, CancellationToken.None); + await channel.PublishAsync(paymentEnv, CancellationToken.None); + await channel.PublishAsync(inventoryEnv, CancellationToken.None); + + endpoint.AssertReceivedCount(3); + endpoint.AssertReceivedOnTopic("dt.order.created", 1); + endpoint.AssertReceivedOnTopic("dt.payment.received", 1); + endpoint.AssertReceivedOnTopic("dt.inventory.updated", 1); + Assert.That(endpoint.GetReceivedTopics(), Has.Count.EqualTo(3)); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial06/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial06/Lab.cs index 4ecc781..c8c6068 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial06/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial06/Lab.cs @@ -1,175 +1,160 @@ // ============================================================================ // Tutorial 06 – Messaging Channels (Lab) // ============================================================================ -// This lab explores the core channel types from Enterprise Integration Patterns: -// Point-to-Point, Publish-Subscribe, Datatype Channel, and Invalid Message -// Channel. You will use mocked producers and consumers to exercise each -// pattern and verify the behaviour. +// EIP Patterns: Point-to-Point, Publish-Subscribe, Datatype Channel, +// Invalid Message Channel. +// E2E: Wire real channel classes with MockEndpoints, send messages through +// each channel type, verify delivery patterns. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; -using NSubstitute; +using EnterpriseIntegrationPlatform.Ingestion.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial06; [TestFixture] public sealed class Lab { - // ── Point-to-Point Channel ────────────────────────────────────────────── + private MockEndpoint _endpoint = null!; + + [SetUp] + public void SetUp() => _endpoint = new MockEndpoint("lab06"); + + [TearDown] + public async Task TearDown() => await _endpoint.DisposeAsync(); + + // ── Point-to-Point Channel ────────────────────────────────────────── [Test] - public async Task PointToPoint_PublishToTopic_SingleConsumerReceives() + public async Task PointToPoint_Send_DeliversToQueueChannel() { - // In a Point-to-Point channel, only ONE consumer in a group receives - // the message. We mock the producer and verify a single publish call. - var producer = Substitute.For(); + var channel = new PointToPointChannel( + _endpoint, _endpoint, NullLogger.Instance); var envelope = IntegrationEnvelope.Create( - payload: "order-123", - source: "OrderService", - messageType: "order.created") with - { - Intent = MessageIntent.Command, - }; - - await producer.PublishAsync(envelope, "orders.point-to-point"); - - // Exactly one publish to the target topic. - await producer.Received(1).PublishAsync( - Arg.Is>(e => e.Payload == "order-123"), - Arg.Is("orders.point-to-point"), - Arg.Any()); - } + "order-123", "OrderService", "order.created"); + + await channel.SendAsync(envelope, "orders-queue", CancellationToken.None); - // ── Publish-Subscribe Channel ─────────────────────────────────────────── + _endpoint.AssertReceivedCount(1); + Assert.That(_endpoint.GetReceived().Payload, Is.EqualTo("order-123")); + _endpoint.AssertReceivedOnTopic("orders-queue", 1); + } [Test] - public async Task PubSub_MultipleConsumerGroups_EachGroupReceivesCopy() + public async Task PointToPoint_Receive_HandlerTriggeredOnSend() { - // In Publish-Subscribe, EVERY subscriber group gets a copy. - // We simulate three independent consumer groups subscribing to the same topic. - var consumer = Substitute.For(); - var producer = Substitute.For(); + var channel = new PointToPointChannel( + _endpoint, _endpoint, NullLogger.Instance); + + IntegrationEnvelope? captured = null; + await channel.ReceiveAsync("orders-queue", "worker-group", + msg => { captured = msg; return Task.CompletedTask; }, CancellationToken.None); var envelope = IntegrationEnvelope.Create( - "event-data", "EventService", "event.published") with - { - Intent = MessageIntent.Event, - }; - - // Three subscriber groups each get the same message. - var groups = new[] { "billing-group", "analytics-group", "notifications-group" }; - - foreach (var group in groups) - { - await consumer.SubscribeAsync( - "events.pubsub", group, _ => Task.CompletedTask); - } - - // Publish the message. - await producer.PublishAsync(envelope, "events.pubsub"); - - // Verify all three groups subscribed independently. - await consumer.Received(3).SubscribeAsync( - Arg.Is("events.pubsub"), - Arg.Any(), - Arg.Any, Task>>(), - Arg.Any()); - - // Each group was subscribed exactly once. - foreach (var group in groups) - { - await consumer.Received(1).SubscribeAsync( - Arg.Is("events.pubsub"), - Arg.Is(group), - Arg.Any, Task>>(), - Arg.Any()); - } + "order-456", "OrderService", "order.created"); + await _endpoint.SendAsync(envelope); + + Assert.That(captured, Is.Not.Null); + Assert.That(captured!.Payload, Is.EqualTo("order-456")); } - // ── Datatype Channel ──────────────────────────────────────────────────── + // ── Publish-Subscribe Channel ─────────────────────────────────────── [Test] - public async Task DatatypeChannel_DifferentTypes_RouteToSeparateTopics() + public async Task PubSub_Publish_DeliversToChannel() { - // A Datatype Channel routes each MessageType to its own dedicated topic, - // ensuring consumers only see messages of the type they expect. - var producer = Substitute.For(); - - var orderEnvelope = IntegrationEnvelope.Create( - "new-order", "OrderService", "order.created"); - - var paymentEnvelope = IntegrationEnvelope.Create( - "payment-received", "PaymentService", "payment.completed"); - - var inventoryEnvelope = IntegrationEnvelope.Create( - "stock-updated", "InventoryService", "inventory.adjusted"); - - // Each message type publishes to its own type-specific topic. - await producer.PublishAsync(orderEnvelope, "datatype.order.created"); - await producer.PublishAsync(paymentEnvelope, "datatype.payment.completed"); - await producer.PublishAsync(inventoryEnvelope, "datatype.inventory.adjusted"); - - // Verify three distinct topics received messages. - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("datatype.order.created"), - Arg.Any()); - - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("datatype.payment.completed"), - Arg.Any()); - - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("datatype.inventory.adjusted"), - Arg.Any()); - } + var channel = new PublishSubscribeChannel( + _endpoint, _endpoint, NullLogger.Instance); + + var envelope = IntegrationEnvelope.Create( + "event-data", "EventService", "event.fired"); + + await channel.PublishAsync(envelope, "events-topic", CancellationToken.None); - // ── Invalid Message Channel (Expired Messages) ────────────────────────── + _endpoint.AssertReceivedCount(1); + _endpoint.AssertReceivedOnTopic("events-topic", 1); + } [Test] - public void InvalidMessageChannel_ExpiredEnvelope_IsExpiredReturnsTrue() + public async Task PubSub_Subscribe_MultipleSubscribersGetUniqueGroups() { - // An expired message should be routed to the Invalid Message Channel. - // We verify the IsExpired property on an envelope with a past ExpiresAt. - var expired = IntegrationEnvelope.Create( - "stale-data", "LegacySystem", "legacy.update") with - { - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5), - }; - - // The platform uses IsExpired to detect stale messages. - Assert.That(expired.IsExpired, Is.True, - "Envelope with ExpiresAt in the past should be expired"); + var channel = new PublishSubscribeChannel( + _endpoint, _endpoint, NullLogger.Instance); + + var payloads = new List(); + await channel.SubscribeAsync("events", "sub-A", + msg => { payloads.Add(msg.Payload + "-A"); return Task.CompletedTask; }, + CancellationToken.None); + await channel.SubscribeAsync("events", "sub-B", + msg => { payloads.Add(msg.Payload + "-B"); return Task.CompletedTask; }, + CancellationToken.None); + + var envelope = IntegrationEnvelope.Create("fan-out", "svc", "type"); + await _endpoint.SendAsync(envelope); + + Assert.That(payloads, Has.Count.EqualTo(2)); + Assert.That(payloads, Does.Contain("fan-out-A")); + Assert.That(payloads, Does.Contain("fan-out-B")); } + // ── Datatype Channel ──────────────────────────────────────────────── + [Test] - public void InvalidMessageChannel_FutureExpiry_IsExpiredReturnsFalse() + public async Task DatatypeChannel_RoutesMessageByType() { - // A message with a future ExpiresAt is still valid. - var valid = IntegrationEnvelope.Create( - "fresh-data", "ModernSystem", "modern.update") with - { - ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), - }; - - Assert.That(valid.IsExpired, Is.False, - "Envelope with ExpiresAt in the future should NOT be expired"); + var options = Options.Create(new DatatypeChannelOptions + { TopicPrefix = "datatype", Separator = "." }); + var channel = new DatatypeChannel( + _endpoint, options, NullLogger.Instance); + + var envelope = IntegrationEnvelope.Create( + "order-data", "OrderService", "order.created"); + await channel.PublishAsync(envelope, CancellationToken.None); + + _endpoint.AssertReceivedCount(1); + _endpoint.AssertReceivedOnTopic("datatype.order.created", 1); } + // ── Invalid Message Channel ───────────────────────────────────────── + [Test] - public void InvalidMessageChannel_NoExpiry_IsNeverExpired() + public async Task InvalidMessageChannel_RouteInvalid_PublishesToInvalidTopic() { - // A message without an ExpiresAt never expires. - var noExpiry = IntegrationEnvelope.Create( - "persistent-data", "CoreService", "core.event"); + var options = Options.Create(new InvalidMessageChannelOptions + { InvalidMessageTopic = "invalid-msgs", Source = "TestChannel" }); + var channel = new InvalidMessageChannel( + _endpoint, options, NullLogger.Instance); - Assert.That(noExpiry.ExpiresAt, Is.Null); - Assert.That(noExpiry.IsExpired, Is.False, - "Envelope without ExpiresAt should never be expired"); + var envelope = IntegrationEnvelope.Create( + "bad-data", "LegacySystem", "legacy.event"); + await channel.RouteInvalidAsync(envelope, "Schema mismatch", CancellationToken.None); + + _endpoint.AssertReceivedCount(1); + _endpoint.AssertReceivedOnTopic("invalid-msgs", 1); + var received = _endpoint.GetReceived(); + Assert.That(received.Payload.Reason, Is.EqualTo("Schema mismatch")); + } + + [Test] + public async Task InvalidMessageChannel_RouteRawInvalid_PublishesToInvalidTopic() + { + var options = Options.Create(new InvalidMessageChannelOptions + { InvalidMessageTopic = "invalid-raw", Source = "Gateway" }); + var channel = new InvalidMessageChannel( + _endpoint, options, NullLogger.Instance); + + await channel.RouteRawInvalidAsync( + "not-json-at-all", "inbound-topic", "Parse failure", CancellationToken.None); + + _endpoint.AssertReceivedCount(1); + _endpoint.AssertReceivedOnTopic("invalid-raw", 1); + var received = _endpoint.GetReceived(); + Assert.That(received.Payload.RawData, Is.EqualTo("not-json-at-all")); + Assert.That(received.Payload.Reason, Is.EqualTo("Parse failure")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial07/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial07/Exam.cs index cd57cb3..c3ab640 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial07/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial07/Exam.cs @@ -1,167 +1,89 @@ // ============================================================================ // Tutorial 07 – Temporal Workflows (Exam) // ============================================================================ -// Coding challenges: design a workflow activity chain and test cancellation -// token propagation patterns used in workflow activity execution. +// E2E challenges: host-based orchestrator wiring, failure handling, and +// correlation ID propagation through the full pipeline dispatch. // ============================================================================ +using System.Text.Json; using EnterpriseIntegrationPlatform.Activities; -using NSubstitute; +using EnterpriseIntegrationPlatform.Contracts; +using EnterpriseIntegrationPlatform.Demo.Pipeline; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial07; [TestFixture] public sealed class Exam { - // ── Challenge 1: Design a Validate → Transform → Route Activity Chain ─── - [Test] - public async Task Challenge1_ActivityChain_ValidateThenTransformThenRoute() + public async Task Challenge1_AspireHost_OrchestratorDispatchesViaDI() { - // Design a three-step workflow activity chain: - // 1. Validate the message payload - // 2. Transform: enrich metadata (simulate by logging "Transformed") - // 3. Route: log the final routing decision - // - // Each step depends on the previous one succeeding. - var validationService = Substitute.For(); - var loggingService = Substitute.For(); - - var messageId = Guid.NewGuid(); - const string messageType = "invoice.received"; - const string payloadJson = "{\"invoiceId\": \"INV-999\", \"amount\": 1500.00}"; - - // Configure mocks. - validationService.ValidateAsync(messageType, payloadJson) - .Returns(MessageValidationResult.Success); - loggingService.LogAsync(messageId, messageType, Arg.Any()) - .Returns(Task.CompletedTask); - - // Step 1: Validate. - var result = await validationService.ValidateAsync(messageType, payloadJson); - Assert.That(result.IsValid, Is.True, "Validation must pass before transform"); - - // Step 2: Transform (log the transformation step). - await loggingService.LogAsync(messageId, messageType, "Transformed"); - - // Step 3: Route (log the routing decision). - await loggingService.LogAsync(messageId, messageType, "Routed"); - - // Verify the chain executed in order with exactly 1 call per step. - Received.InOrder(() => - { - validationService.ValidateAsync(messageType, payloadJson); - loggingService.LogAsync(messageId, messageType, "Transformed"); - loggingService.LogAsync(messageId, messageType, "Routed"); - }); + var dispatcher = new MockTemporalWorkflowDispatcher().ReturnsSuccess(); + + await using var host = AspireIntegrationTestHost.CreateBuilder() + .ConfigureServices(svc => + { + svc.AddSingleton(dispatcher); + svc.Configure(o => { o.AckSubject = "ack.di"; o.NackSubject = "nack.di"; }); + svc.AddSingleton(); + }) + .Build(); + + var orchestrator = host.GetService(); + var json = JsonSerializer.Deserialize("{\"test\":true}"); + var envelope = IntegrationEnvelope.Create(json, "DIService", "di.test"); + + await orchestrator.ProcessAsync(envelope); + + var captured = dispatcher.LastInput; + Assert.That(captured, Is.Not.Null); + Assert.That(captured!.Source, Is.EqualTo("DIService")); + Assert.That(captured.AckSubject, Is.EqualTo("ack.di")); } [Test] - public async Task Challenge1_ActivityChain_ValidationFails_StopsChain() + public async Task Challenge2_WorkflowFailure_LogsWarning() { - // When validation fails, the chain should NOT proceed to transform or route. - var validationService = Substitute.For(); - var loggingService = Substitute.For(); - - var messageId = Guid.NewGuid(); - const string messageType = "invoice.received"; - const string badPayload = ""; // Empty payload → validation fails. - - validationService.ValidateAsync(messageType, badPayload) - .Returns(MessageValidationResult.Failure("Payload is empty")); - - // Step 1: Validate — fails. - var result = await validationService.ValidateAsync(messageType, badPayload); - Assert.That(result.IsValid, Is.False); - Assert.That(result.Reason, Is.EqualTo("Payload is empty")); - - // Chain stops — transform and route are never called. - if (!result.IsValid) - { - await loggingService.LogAsync(messageId, messageType, "ValidationFailed"); - } - - // Verify: only the validation and failure log were called. - await validationService.Received(1).ValidateAsync(messageType, badPayload); - await loggingService.Received(1).LogAsync(messageId, messageType, "ValidationFailed"); - await loggingService.DidNotReceive().LogAsync(messageId, messageType, "Transformed"); - await loggingService.DidNotReceive().LogAsync(messageId, messageType, "Routed"); - } + var dispatcher = new MockTemporalWorkflowDispatcher().ReturnsFailure("Validation failed"); - // ── Challenge 2: Cancellation Token Propagation ───────────────────────── + var options = Options.Create(new PipelineOptions()); + var orchestrator = new PipelineOrchestrator( + dispatcher, options, NullLogger.Instance); - [Test] - public async Task Challenge2_CancellationToken_PropagatedToActivities() - { - // Temporal propagates a CancellationToken to each activity. Verify - // that our activity services honour the token — when cancelled, the - // operation should throw OperationCanceledException. - var persistenceService = Substitute.For(); - - using var cts = new CancellationTokenSource(); - - var input = new IntegrationPipelineInput( - MessageId: Guid.NewGuid(), - CorrelationId: Guid.NewGuid(), - CausationId: null, - Timestamp: DateTimeOffset.UtcNow, - Source: "TestService", - MessageType: "test.cancel", - SchemaVersion: "1.0", - Priority: 0, - PayloadJson: "{}", - MetadataJson: null, - AckSubject: "ack.test", - NackSubject: "nack.test"); - - // Configure the mock to throw OperationCanceledException when the token is cancelled. - persistenceService.SaveMessageAsync(input, Arg.Any()) - .Returns(callInfo => - { - var ct = callInfo.ArgAt(1); - ct.ThrowIfCancellationRequested(); - return Task.CompletedTask; - }); - - // Cancel the token BEFORE calling the activity. - cts.Cancel(); - - // The activity should respect the cancellation token. - Assert.ThrowsAsync(async () => - { - await persistenceService.SaveMessageAsync(input, cts.Token); - }); + var json = JsonSerializer.Deserialize("{\"bad\":true}"); + var envelope = IntegrationEnvelope.Create(json, "svc", "bad.type"); + + await orchestrator.ProcessAsync(envelope); + + dispatcher.AssertDispatchCount(1); + Assert.That(dispatcher.LastInput!.MessageType, Is.EqualTo("bad.type")); } [Test] - public async Task Challenge2_CancellationToken_NotCancelled_ActivityCompletes() + public async Task Challenge3_CorrelationAndCausation_PropagatedToInput() { - // When the token is NOT cancelled, the activity completes normally. - var persistenceService = Substitute.For(); - - using var cts = new CancellationTokenSource(); - - var input = new IntegrationPipelineInput( - MessageId: Guid.NewGuid(), - CorrelationId: Guid.NewGuid(), - CausationId: null, - Timestamp: DateTimeOffset.UtcNow, - Source: "TestService", - MessageType: "test.normal", - SchemaVersion: "1.0", - Priority: 0, - PayloadJson: "{\"data\": true}", - MetadataJson: null, - AckSubject: "ack.test", - NackSubject: "nack.test"); - - persistenceService.SaveMessageAsync(input, Arg.Any()) - .Returns(Task.CompletedTask); - - // Should complete without exception. - await persistenceService.SaveMessageAsync(input, cts.Token); - - await persistenceService.Received(1).SaveMessageAsync(input, Arg.Any()); + var dispatcher = new MockTemporalWorkflowDispatcher().ReturnsSuccess(); + + var options = Options.Create(new PipelineOptions()); + var orchestrator = new PipelineOrchestrator( + dispatcher, options, NullLogger.Instance); + + var correlationId = Guid.NewGuid(); + var causationId = Guid.NewGuid(); + var json = JsonSerializer.Deserialize("{}"); + var envelope = IntegrationEnvelope.Create( + json, "svc", "type", correlationId, causationId); + + await orchestrator.ProcessAsync(envelope); + + var captured = dispatcher.LastInput; + Assert.That(captured!.CorrelationId, Is.EqualTo(correlationId)); + Assert.That(captured.CausationId, Is.EqualTo(causationId)); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial07/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial07/Lab.cs index 03bb2d0..e0ef4ae 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial07/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial07/Lab.cs @@ -1,177 +1,130 @@ // ============================================================================ // Tutorial 07 – Temporal Workflows (Lab) // ============================================================================ -// This lab uses reflection to verify that the Temporal workflow infrastructure -// exists in the platform, inspects configuration types, and demonstrates a -// mocked workflow activity chain concept. +// EIP Pattern: Process Manager / Workflow Orchestration. +// E2E: Build AspireIntegrationTestHost with mocked ITemporalWorkflowDispatcher, +// wire PipelineOrchestrator, send envelope through orchestrator, verify dispatch. // ============================================================================ using System.Reflection; +using System.Text.Json; using EnterpriseIntegrationPlatform.Activities; +using EnterpriseIntegrationPlatform.Contracts; +using EnterpriseIntegrationPlatform.Demo.Pipeline; using EnterpriseIntegrationPlatform.Workflow.Temporal; -using NSubstitute; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial07; [TestFixture] public sealed class Lab { - // ── Verifying Temporal Workflow Types via Reflection ───────────────────── - [Test] - public void TemporalWorkflows_ProcessIntegrationMessage_Exists() + public void ProcessIntegrationMessageWorkflow_Exists() { - // The platform defines a ProcessIntegrationMessageWorkflow in the - // Workflow.Temporal assembly. Verify it exists via reflection. var assembly = typeof(TemporalOptions).Assembly; var workflowType = assembly.GetTypes() .FirstOrDefault(t => t.Name == "ProcessIntegrationMessageWorkflow"); - Assert.That(workflowType, Is.Not.Null, - "ProcessIntegrationMessageWorkflow should exist in the Workflow.Temporal assembly"); + Assert.That(workflowType, Is.Not.Null); Assert.That(workflowType!.IsClass, Is.True); } - [Test] - public void TemporalWorkflows_IntegrationPipelineWorkflow_Exists() - { - // The full pipeline workflow: persist → validate → ack/nack. - var assembly = typeof(TemporalOptions).Assembly; - var workflowType = assembly.GetTypes() - .FirstOrDefault(t => t.Name == "IntegrationPipelineWorkflow"); - - Assert.That(workflowType, Is.Not.Null, - "IntegrationPipelineWorkflow should exist"); - } - - [Test] - public void TemporalWorkflows_SagaCompensationWorkflow_Exists() - { - // The saga compensation workflow for rollback scenarios. - var assembly = typeof(TemporalOptions).Assembly; - var workflowType = assembly.GetTypes() - .FirstOrDefault(t => t.Name == "SagaCompensationWorkflow"); - - Assert.That(workflowType, Is.Not.Null, - "SagaCompensationWorkflow should exist"); - } - - [Test] - public void TemporalWorkflows_AtomicPipelineWorkflow_Exists() - { - // The atomic variant adds saga compensation on top of the pipeline. - var assembly = typeof(TemporalOptions).Assembly; - var workflowType = assembly.GetTypes() - .FirstOrDefault(t => t.Name == "AtomicPipelineWorkflow"); - - Assert.That(workflowType, Is.Not.Null, - "AtomicPipelineWorkflow should exist"); - } - - // ── Verifying Workflow Configuration Types ────────────────────────────── - [Test] public void TemporalOptions_HasExpectedDefaults() { - // TemporalOptions configures the Temporal worker host. var options = new TemporalOptions(); - Assert.That(options.ServerAddress, Is.EqualTo("localhost:15233")); - Assert.That(options.Namespace, Is.EqualTo("default")); Assert.That(options.TaskQueue, Is.EqualTo("integration-workflows")); + Assert.That(options.Namespace, Is.EqualTo("default")); Assert.That(TemporalOptions.SectionName, Is.EqualTo("Temporal")); } [Test] - public void TemporalOptions_CanOverrideSettings() + public async Task PipelineOrchestrator_DispatchesCorrectInput() { - var options = new TemporalOptions - { - ServerAddress = "temporal.prod.internal:7233", - Namespace = "production", - TaskQueue = "prod-integration", - }; + var dispatcher = new MockTemporalWorkflowDispatcher().ReturnsSuccess(); - Assert.That(options.ServerAddress, Is.EqualTo("temporal.prod.internal:7233")); - Assert.That(options.Namespace, Is.EqualTo("production")); - Assert.That(options.TaskQueue, Is.EqualTo("prod-integration")); - } + var options = Options.Create(new PipelineOptions + { AckSubject = "ack.test", NackSubject = "nack.test" }); + var orchestrator = new PipelineOrchestrator( + dispatcher, options, NullLogger.Instance); + + var json = JsonSerializer.Deserialize("{\"orderId\":\"ORD-1\"}"); + var envelope = IntegrationEnvelope.Create( + json, "OrderService", "order.created"); - // ── Verifying Temporal Activity Classes via Reflection ─────────────────── + await orchestrator.ProcessAsync(envelope); + + var capturedInput = dispatcher.LastInput; + Assert.That(capturedInput, Is.Not.Null); + Assert.That(capturedInput!.Source, Is.EqualTo("OrderService")); + Assert.That(capturedInput.MessageType, Is.EqualTo("order.created")); + Assert.That(capturedInput.AckSubject, Is.EqualTo("ack.test")); + Assert.That(capturedInput.NackSubject, Is.EqualTo("nack.test")); + } [Test] - public void TemporalActivities_IntegrationActivities_HasExpectedMethods() + public async Task PipelineOrchestrator_SetsWorkflowIdFromMessageId() { - // IntegrationActivities wraps validation and logging as Temporal activities. - var assembly = typeof(TemporalOptions).Assembly; - var activityType = assembly.GetTypes() - .FirstOrDefault(t => t.Name == "IntegrationActivities"); + var dispatcher = new MockTemporalWorkflowDispatcher().ReturnsSuccess(); - Assert.That(activityType, Is.Not.Null); + var options = Options.Create(new PipelineOptions()); + var orchestrator = new PipelineOrchestrator( + dispatcher, options, NullLogger.Instance); - var validateMethod = activityType!.GetMethod("ValidateMessageAsync"); - Assert.That(validateMethod, Is.Not.Null, - "ValidateMessageAsync activity should exist"); + var json = JsonSerializer.Deserialize("{}"); + var envelope = IntegrationEnvelope.Create(json, "svc", "type"); - var logMethod = activityType.GetMethod("LogProcessingStageAsync"); - Assert.That(logMethod, Is.Not.Null, - "LogProcessingStageAsync activity should exist"); + await orchestrator.ProcessAsync(envelope); + + Assert.That(dispatcher.LastWorkflowId, Is.EqualTo($"integration-{envelope.MessageId}")); } [Test] - public void TemporalActivities_PipelineActivities_HasExpectedMethods() + public async Task PipelineOrchestrator_SerializesPayloadAndMetadata() { - // PipelineActivities wraps persistence and notification as Temporal activities. - var assembly = typeof(TemporalOptions).Assembly; - var activityType = assembly.GetTypes() - .FirstOrDefault(t => t.Name == "PipelineActivities"); + var dispatcher = new MockTemporalWorkflowDispatcher().ReturnsSuccess(); - Assert.That(activityType, Is.Not.Null); + var options = Options.Create(new PipelineOptions()); + var orchestrator = new PipelineOrchestrator( + dispatcher, options, NullLogger.Instance); - var methodNames = activityType!.GetMethods(BindingFlags.Public | BindingFlags.Instance) - .Select(m => m.Name) - .ToList(); + var json = JsonSerializer.Deserialize("{\"key\":\"value\"}"); + var envelope = IntegrationEnvelope.Create(json, "svc", "type") with + { + Metadata = new Dictionary { ["tenant"] = "acme" }, + }; - Assert.That(methodNames, Does.Contain("PersistMessageAsync")); - Assert.That(methodNames, Does.Contain("UpdateDeliveryStatusAsync")); - Assert.That(methodNames, Does.Contain("SaveFaultAsync")); - Assert.That(methodNames, Does.Contain("PublishAckAsync")); - Assert.That(methodNames, Does.Contain("PublishNackAsync")); - Assert.That(methodNames, Does.Contain("LogStageAsync")); - } + await orchestrator.ProcessAsync(envelope); - // ── Mock Workflow Scenario: Activity Chain Concept ─────────────────────── + var capturedInput = dispatcher.LastInput; + Assert.That(capturedInput!.PayloadJson, Does.Contain("key")); + Assert.That(capturedInput.MetadataJson, Does.Contain("tenant")); + } [Test] - public async Task MockWorkflowScenario_ValidateTransformRoute_ChainSucceeds() + public async Task PipelineOrchestrator_MapsPriorityAsInt() { - // Demonstrate the activity chain concept that Temporal orchestrates: - // Step 1: Validate → Step 2: Log stage → Step 3: Route decision. - // We mock the services that back the activities. - var validationService = Substitute.For(); - var loggingService = Substitute.For(); - - var messageId = Guid.NewGuid(); - const string messageType = "order.created"; - const string payloadJson = "{\"orderId\": \"ORD-001\"}"; - - // Step 1: Validation succeeds. - validationService.ValidateAsync(messageType, payloadJson) - .Returns(MessageValidationResult.Success); + var dispatcher = new MockTemporalWorkflowDispatcher().ReturnsSuccess(); - // Step 2: Logging completes. - loggingService.LogAsync(messageId, messageType, Arg.Any()) - .Returns(Task.CompletedTask); + var options = Options.Create(new PipelineOptions()); + var orchestrator = new PipelineOrchestrator( + dispatcher, options, NullLogger.Instance); - // Execute the chain. - var validationResult = await validationService.ValidateAsync(messageType, payloadJson); - Assert.That(validationResult.IsValid, Is.True); + var json = JsonSerializer.Deserialize("{}"); + var envelope = IntegrationEnvelope.Create(json, "svc", "type") with + { + Priority = MessagePriority.High, + }; - await loggingService.LogAsync(messageId, messageType, "Validated"); + await orchestrator.ProcessAsync(envelope); - // Verify the chain executed in order. - await validationService.Received(1).ValidateAsync(messageType, payloadJson); - await loggingService.Received(1).LogAsync(messageId, messageType, "Validated"); + var capturedInput = dispatcher.LastInput; + Assert.That(capturedInput!.Priority, Is.EqualTo((int)MessagePriority.High)); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial08/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial08/Exam.cs index 565ec71..b312af3 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial08/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial08/Exam.cs @@ -1,165 +1,120 @@ // ============================================================================ -// Tutorial 08 – Activities and Pipeline (Exam) +// Tutorial 08 – Activities Pipeline (Exam) // ============================================================================ -// Coding challenges: build a metadata-enrichment activity and create a -// pipeline orchestrator that chains three activities together. +// E2E challenges: full pipeline with enrichment, DLQ routing on failure, +// and multi-stage pipeline with MockEndpoint verification. // ============================================================================ using EnterpriseIntegrationPlatform.Activities; using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; -using NSubstitute; +using EnterpriseIntegrationPlatform.Ingestion.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial08; [TestFixture] public sealed class Exam { - // ── Challenge 1: Metadata Enrichment Activity ─────────────────────────── - [Test] - public void Challenge1_EnrichMetadata_AddsExpectedKeys() + public async Task Challenge1_EnrichAndPublish_MetadataPreserved() { - // Build a custom "activity" that enriches an envelope's metadata - // with processing context: timestamp, processor name, and a trace ID. - var envelope = IntegrationEnvelope.Create( - "raw-data", "IngestService", "data.raw"); + var validator = new DefaultMessageValidationService(); + await using var output = new MockEndpoint("enriched"); - // Simulate an enrichment activity — adds metadata via `with` expression. - var enriched = EnrichMetadata(envelope, "MetadataEnricher", Guid.NewGuid().ToString()); - - // Verify the metadata was added without losing existing data. - Assert.That(enriched.Metadata.ContainsKey("processed-by"), Is.True); - Assert.That(enriched.Metadata["processed-by"], Is.EqualTo("MetadataEnricher")); - Assert.That(enriched.Metadata.ContainsKey("trace-id"), Is.True); - Assert.That(enriched.Metadata.ContainsKey("processed-at"), Is.True); + var envelope = IntegrationEnvelope.Create( + "{\"orderId\":\"ORD-42\"}", "OrderService", "order.created"); - // Original envelope identity is preserved. - Assert.That(enriched.MessageId, Is.EqualTo(envelope.MessageId)); - Assert.That(enriched.Payload, Is.EqualTo(envelope.Payload)); - } + var result = await validator.ValidateAsync( + envelope.MessageType, envelope.Payload); + Assert.That(result.IsValid, Is.True); - [Test] - public void Challenge1_EnrichMetadata_PreservesExistingMetadata() - { - // Metadata enrichment must NOT overwrite existing keys. - var envelope = IntegrationEnvelope.Create( - "data", "Service", "data.event") with + var enriched = envelope with { - Metadata = new Dictionary + Metadata = new Dictionary(envelope.Metadata) { - ["tenant-id"] = "T-100", - ["source-region"] = "eu-west", + ["processed-by"] = "Pipeline", + ["region"] = "us-east", }, }; - var enriched = EnrichMetadata(envelope, "Enricher", "trace-abc"); + var channel = new PointToPointChannel( + output, output, NullLogger.Instance); + await channel.SendAsync(enriched, "enriched-queue", CancellationToken.None); - Assert.That(enriched.Metadata["tenant-id"], Is.EqualTo("T-100")); - Assert.That(enriched.Metadata["source-region"], Is.EqualTo("eu-west")); - Assert.That(enriched.Metadata["processed-by"], Is.EqualTo("Enricher")); - Assert.That(enriched.Metadata["trace-id"], Is.EqualTo("trace-abc")); + output.AssertReceivedCount(1); + var received = output.GetReceived(); + Assert.That(received.Metadata["processed-by"], Is.EqualTo("Pipeline")); + Assert.That(received.Metadata["region"], Is.EqualTo("us-east")); } - /// - /// Metadata enrichment activity — adds processing context to an envelope. - /// - private static IntegrationEnvelope EnrichMetadata( - IntegrationEnvelope envelope, string processorName, string traceId) - { - var newMetadata = new Dictionary(envelope.Metadata) - { - ["processed-by"] = processorName, - ["trace-id"] = traceId, - ["processed-at"] = DateTimeOffset.UtcNow.ToString("O"), - }; - - return envelope with { Metadata = newMetadata }; - } - - // ── Challenge 2: Pipeline Orchestrator with 3 Activities ──────────────── - [Test] - public async Task Challenge2_PipelineOrchestrator_ChainsThreeActivities() + public async Task Challenge2_ValidationFailure_RoutesDlqAndSkipsOutput() { - // Build a pipeline orchestrator that chains: - // Activity 1: Validate - // Activity 2: Enrich metadata - // Activity 3: Publish to destination - // - // If validation fails, the pipeline stops and routes to a DLQ. - var validationService = Substitute.For(); - var producer = Substitute.For(); - - const string messageType = "shipment.dispatched"; - const string payloadJson = "{\"shipmentId\": \"SH-42\", \"carrier\": \"FastShip\"}"; - - validationService.ValidateAsync(messageType, payloadJson) - .Returns(MessageValidationResult.Success); + var validator = new DefaultMessageValidationService(); + await using var goodOutput = new MockEndpoint("good"); + await using var dlqOutput = new MockEndpoint("dlq"); var envelope = IntegrationEnvelope.Create( - payloadJson, "ShipmentService", messageType); + "not-json", "LegacySystem", "legacy.event"); - // --- Pipeline Execution --- + var result = await validator.ValidateAsync( + envelope.MessageType, envelope.Payload); - // Activity 1: Validate. - var validation = await validationService.ValidateAsync(messageType, payloadJson); - Assert.That(validation.IsValid, Is.True); + if (result.IsValid) + { + await goodOutput.PublishAsync(envelope, "good-topic"); + } + else + { + var invalidOpts = Options.Create(new InvalidMessageChannelOptions + { InvalidMessageTopic = "dlq-topic", Source = "Pipeline" }); + var invalidChannel = new InvalidMessageChannel( + dlqOutput, invalidOpts, NullLogger.Instance); + await invalidChannel.RouteInvalidAsync( + envelope, result.Reason!, CancellationToken.None); + } - // Activity 2: Enrich metadata. - envelope = EnrichMetadata(envelope, "PipelineOrchestrator", Guid.NewGuid().ToString()); - Assert.That(envelope.Metadata.ContainsKey("processed-by"), Is.True); - - // Activity 3: Publish to destination. - await producer.PublishAsync(envelope, "shipments.processed"); - - // Verify the full chain. - await validationService.Received(1).ValidateAsync(messageType, payloadJson); - await producer.Received(1).PublishAsync( - Arg.Is>(e => - e.Metadata.ContainsKey("processed-by") && - e.Metadata["processed-by"] == "PipelineOrchestrator"), - Arg.Is("shipments.processed"), - Arg.Any()); + goodOutput.AssertNoneReceived(); + dlqOutput.AssertReceivedCount(1); + dlqOutput.AssertReceivedOnTopic("dlq-topic", 1); } [Test] - public async Task Challenge2_PipelineOrchestrator_ValidationFails_RoutesToDlq() + public async Task Challenge3_MultiStage_PersistValidatePublishVerify() { - // When validation fails, the pipeline should route to a DLQ topic - // and NOT publish to the normal destination. - var validationService = Substitute.For(); - var producer = Substitute.For(); - - const string messageType = "shipment.dispatched"; - const string badPayload = "not-json"; - - validationService.ValidateAsync(messageType, badPayload) - .Returns(MessageValidationResult.Failure("Invalid JSON payload")); + var persistence = new MockPersistenceActivityService(); + var logging = new MockMessageLoggingService(); + var validator = new DefaultMessageValidationService(); + await using var output = new MockEndpoint("final"); + + var input = new IntegrationPipelineInput( + MessageId: Guid.NewGuid(), CorrelationId: Guid.NewGuid(), + CausationId: null, Timestamp: DateTimeOffset.UtcNow, + Source: "ExamService", MessageType: "exam.event", SchemaVersion: "1.0", + Priority: 2, PayloadJson: "{\"exam\":true}", MetadataJson: null, + AckSubject: "ack.exam", NackSubject: "nack.exam"); + + await persistence.SaveMessageAsync(input); + await logging.LogAsync(input.MessageId, input.MessageType, "Persisted"); + + var validation = await validator.ValidateAsync( + input.MessageType, input.PayloadJson); + Assert.That(validation.IsValid, Is.True); + await logging.LogAsync(input.MessageId, input.MessageType, "Validated"); var envelope = IntegrationEnvelope.Create( - badPayload, "ShipmentService", messageType); - - // Activity 1: Validate — fails. - var validation = await validationService.ValidateAsync(messageType, badPayload); - Assert.That(validation.IsValid, Is.False); - - // Pipeline stops — route to DLQ instead. - if (!validation.IsValid) - { - await producer.PublishAsync(envelope, "shipments.dlq"); - } - - // Verify: DLQ got the message, normal topic did not. - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("shipments.dlq"), - Arg.Any()); - - await producer.DidNotReceive().PublishAsync( - Arg.Any>(), - Arg.Is("shipments.processed"), - Arg.Any()); + input.PayloadJson, input.Source, input.MessageType); + await output.PublishAsync(envelope, "final-topic"); + await logging.LogAsync(input.MessageId, input.MessageType, "Published"); + + output.AssertReceivedCount(1); + persistence.AssertSaveCount(1); + logging.AssertLogged(input.MessageId, "Persisted"); + logging.AssertLogged(input.MessageId, "Validated"); + logging.AssertLogged(input.MessageId, "Published"); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial08/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial08/Lab.cs index 316e2b4..99d2b8a 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial08/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial08/Lab.cs @@ -1,181 +1,129 @@ // ============================================================================ -// Tutorial 08 – Activities and Pipeline (Lab) +// Tutorial 08 – Activities Pipeline (Lab) // ============================================================================ -// This lab verifies the platform's Activity classes, exercises the pipeline -// concept (create → validate → transform → route) using mocked services, -// and chains multiple activity calls in sequence. +// EIP Pattern: Pipes and Filters. +// E2E: Build pipeline with real DefaultMessageValidationService + mocked +// services, execute pipeline stages, verify each stage processes correctly. // ============================================================================ -using System.Reflection; using EnterpriseIntegrationPlatform.Activities; using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; -using EnterpriseIntegrationPlatform.Workflow.Temporal; -using NSubstitute; +using EnterpriseIntegrationPlatform.Ingestion.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial08; [TestFixture] public sealed class Lab { - // ── Verifying Activity Types Exist ─────────────────────────────────────── - [Test] - public void IntegrationActivities_ClassExists_WithExpectedMethods() + public async Task ValidationStage_ValidPayload_Succeeds() { - // IntegrationActivities is the Temporal activity class that wraps - // validation and logging services. - var assembly = typeof(TemporalOptions).Assembly; - var activityType = assembly.GetTypes() - .FirstOrDefault(t => t.Name == "IntegrationActivities"); + var validator = new DefaultMessageValidationService(); - Assert.That(activityType, Is.Not.Null, - "IntegrationActivities should exist in Workflow.Temporal"); + var result = await validator.ValidateAsync("order.created", "{\"orderId\":\"ORD-1\"}"); - Assert.That(activityType!.GetMethod("ValidateMessageAsync"), Is.Not.Null); - Assert.That(activityType.GetMethod("LogProcessingStageAsync"), Is.Not.Null); + Assert.That(result.IsValid, Is.True); + Assert.That(result.Reason, Is.Null); } [Test] - public void PipelineActivities_ClassExists_WithExpectedMethods() + public async Task ValidationStage_EmptyPayload_Fails() { - // PipelineActivities wraps persistence and notification as activities. - var assembly = typeof(TemporalOptions).Assembly; - var activityType = assembly.GetTypes() - .FirstOrDefault(t => t.Name == "PipelineActivities"); - - Assert.That(activityType, Is.Not.Null, - "PipelineActivities should exist in Workflow.Temporal"); - - var methodNames = activityType!.GetMethods(BindingFlags.Public | BindingFlags.Instance) - .Select(m => m.Name) - .ToList(); - - Assert.That(methodNames, Does.Contain("PersistMessageAsync")); - Assert.That(methodNames, Does.Contain("UpdateDeliveryStatusAsync")); - Assert.That(methodNames, Does.Contain("SaveFaultAsync")); - Assert.That(methodNames, Does.Contain("PublishAckAsync")); - Assert.That(methodNames, Does.Contain("PublishNackAsync")); - Assert.That(methodNames, Does.Contain("LogStageAsync")); + var validator = new DefaultMessageValidationService(); + + var result = await validator.ValidateAsync("order.created", ""); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.Reason, Does.Contain("empty")); } [Test] - public void SagaCompensationActivities_ClassExists() + public async Task ValidationStage_NonJsonPayload_Fails() { - var assembly = typeof(TemporalOptions).Assembly; - var activityType = assembly.GetTypes() - .FirstOrDefault(t => t.Name == "SagaCompensationActivities"); + var validator = new DefaultMessageValidationService(); - Assert.That(activityType, Is.Not.Null, - "SagaCompensationActivities should exist in Workflow.Temporal"); + var result = await validator.ValidateAsync("order.created", "not-json"); - Assert.That(activityType!.GetMethod("CompensateStepAsync"), Is.Not.Null); + Assert.That(result.IsValid, Is.False); + Assert.That(result.Reason, Does.Contain("JSON")); } - // ── Pipeline Concept: Create → Validate → Transform → Route ───────────── - [Test] - public async Task Pipeline_CreateValidateTransformRoute_AllStepsExecute() + public async Task PipelineChain_ValidateAndPublish_EndToEnd() { - // Simulate the full pipeline pattern using mocked services: - // 1. Create an envelope (the message entering the pipeline) - // 2. Validate the message payload - // 3. Transform: add routing metadata - // 4. Route: publish to a destination topic - var validationService = Substitute.For(); - var loggingService = Substitute.For(); - var producer = Substitute.For(); - - var messageId = Guid.NewGuid(); - const string messageType = "order.created"; - const string payloadJson = "{\"orderId\": \"ORD-500\"}"; - - // Step 1: Create envelope. + var validator = new DefaultMessageValidationService(); + await using var output = new MockEndpoint("output"); + var envelope = IntegrationEnvelope.Create( - payloadJson, "OrderService", messageType) with - { - Intent = MessageIntent.Command, - }; - - Assert.That(envelope.MessageId, Is.Not.EqualTo(Guid.Empty)); - - // Step 2: Validate. - validationService.ValidateAsync(messageType, payloadJson) - .Returns(MessageValidationResult.Success); - - var validationResult = await validationService.ValidateAsync(messageType, payloadJson); - Assert.That(validationResult.IsValid, Is.True); - - // Step 3: Transform — enrich metadata with a routing hint. - envelope = envelope with - { - Metadata = new Dictionary(envelope.Metadata) - { - ["region"] = "us-east", - ["validated"] = "true", - }, - }; - - Assert.That(envelope.Metadata["region"], Is.EqualTo("us-east")); - - // Step 4: Route — publish to destination topic. - await producer.PublishAsync(envelope, "orders.us-east"); - - await producer.Received(1).PublishAsync( - Arg.Is>( - e => e.Metadata.ContainsKey("region") && e.Metadata["region"] == "us-east"), - Arg.Is("orders.us-east"), - Arg.Any()); + "{\"item\":\"widget\"}", "FactoryService", "factory.produced"); + + var result = await validator.ValidateAsync( + envelope.MessageType, envelope.Payload); + Assert.That(result.IsValid, Is.True); + + var channel = new PointToPointChannel( + output, output, NullLogger.Instance); + await channel.SendAsync(envelope, "validated-queue", CancellationToken.None); + + output.AssertReceivedCount(1); + Assert.That(output.GetReceived().Payload, Does.Contain("widget")); } - // ── Chaining Multiple Activity Calls ──────────────────────────────────── + [Test] + public async Task PipelineChain_ValidationFails_RoutesToInvalidChannel() + { + var validator = new DefaultMessageValidationService(); + await using var output = new MockEndpoint("invalid-output"); + + var envelope = IntegrationEnvelope.Create( + "bad-data", "LegacySystem", "legacy.event"); + + var result = await validator.ValidateAsync( + envelope.MessageType, envelope.Payload); + Assert.That(result.IsValid, Is.False); + + var invalidOptions = Options.Create(new InvalidMessageChannelOptions + { InvalidMessageTopic = "invalid-msgs", Source = "Pipeline" }); + var invalidChannel = new InvalidMessageChannel( + output, invalidOptions, NullLogger.Instance); + + await invalidChannel.RouteInvalidAsync( + envelope, result.Reason!, CancellationToken.None); + + output.AssertReceivedCount(1); + output.AssertReceivedOnTopic("invalid-msgs", 1); + } [Test] - public async Task ChainedActivities_PersistLogValidateLog_InSequence() + public async Task PipelineChain_PersistThenValidateThenPublish() { - // Simulate the IntegrationPipelineWorkflow's activity chain: - // Persist → Log(Received) → Validate → Log(Validated or Failed) - var persistenceService = Substitute.For(); - var loggingService = Substitute.For(); - var validationService = Substitute.For(); + var persistence = new MockPersistenceActivityService(); + var validator = new DefaultMessageValidationService(); + await using var output = new MockEndpoint("pipeline-out"); var input = new IntegrationPipelineInput( - MessageId: Guid.NewGuid(), - CorrelationId: Guid.NewGuid(), - CausationId: null, - Timestamp: DateTimeOffset.UtcNow, - Source: "Lab08", - MessageType: "lab.pipeline", - SchemaVersion: "1.0", - Priority: 1, - PayloadJson: "{\"item\": \"widget\"}", - MetadataJson: null, - AckSubject: "ack.lab08", - NackSubject: "nack.lab08"); - - // Configure mocks. - persistenceService.SaveMessageAsync(input, Arg.Any()) - .Returns(Task.CompletedTask); - loggingService.LogAsync(input.MessageId, input.MessageType, Arg.Any()) - .Returns(Task.CompletedTask); - validationService.ValidateAsync(input.MessageType, input.PayloadJson) - .Returns(MessageValidationResult.Success); - - // Execute chain. - await persistenceService.SaveMessageAsync(input); - await loggingService.LogAsync(input.MessageId, input.MessageType, "Received"); - var result = await validationService.ValidateAsync(input.MessageType, input.PayloadJson); - await loggingService.LogAsync(input.MessageId, input.MessageType, - result.IsValid ? "Validated" : "ValidationFailed"); - - // Verify execution order. - Received.InOrder(() => - { - persistenceService.SaveMessageAsync(input, Arg.Any()); - loggingService.LogAsync(input.MessageId, input.MessageType, "Received"); - validationService.ValidateAsync(input.MessageType, input.PayloadJson); - loggingService.LogAsync(input.MessageId, input.MessageType, "Validated"); - }); + MessageId: Guid.NewGuid(), CorrelationId: Guid.NewGuid(), + CausationId: null, Timestamp: DateTimeOffset.UtcNow, + Source: "Lab08", MessageType: "lab.event", SchemaVersion: "1.0", + Priority: 1, PayloadJson: "{\"data\":true}", MetadataJson: null, + AckSubject: "ack", NackSubject: "nack"); + + await persistence.SaveMessageAsync(input); + persistence.AssertSaveCount(1); + + var validation = await validator.ValidateAsync(input.MessageType, input.PayloadJson); + Assert.That(validation.IsValid, Is.True); + + var envelope = IntegrationEnvelope.Create( + input.PayloadJson, input.Source, input.MessageType); + await output.PublishAsync(envelope, "processed-topic"); + + output.AssertReceivedCount(1); + output.AssertReceivedOnTopic("processed-topic", 1); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial09/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial09/Exam.cs index b577a49..bbc1542 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial09/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial09/Exam.cs @@ -1,222 +1,130 @@ // ============================================================================ // Tutorial 09 – Content-Based Router (Exam) // ============================================================================ -// Coding challenges: build a multi-rule e-commerce routing table, test -// priority-based rule evaluation, and implement payload-based routing. +// E2E challenges: multi-rule regional routing, payload-based routing with +// JsonElement, and multi-message batch routing verification via MockEndpoint. // ============================================================================ using System.Text.Json; using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Routing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial09; [TestFixture] public sealed class Exam { - // ── Challenge 1: E-Commerce Regional Routing ──────────────────────────── - [Test] - public async Task Challenge1_EcommerceRouting_OrdersByRegion() + public async Task Challenge1_RegionalRouting_MatchesAndFallsBack() { - // Build a multi-rule routing table for an e-commerce platform. - // Orders are routed to regional fulfilment topics based on - // the "region" metadata key. - var producer = Substitute.For(); - + await using var output = new MockEndpoint("regional"); var options = Options.Create(new RouterOptions { Rules = [ - new RoutingRule - { - Priority = 1, - FieldName = "Metadata.region", - Operator = RoutingOperator.Equals, - Value = "us-east", - TargetTopic = "fulfilment.us-east", - Name = "US-East", - }, - new RoutingRule - { - Priority = 2, - FieldName = "Metadata.region", - Operator = RoutingOperator.Equals, - Value = "eu-west", - TargetTopic = "fulfilment.eu-west", - Name = "EU-West", - }, - new RoutingRule - { - Priority = 3, - FieldName = "Metadata.region", - Operator = RoutingOperator.Equals, - Value = "ap-southeast", - TargetTopic = "fulfilment.ap-southeast", - Name = "AP-Southeast", - }, + new RoutingRule { Priority = 1, Name = "US-East", + FieldName = "Metadata.region", Operator = RoutingOperator.Equals, + Value = "us-east", TargetTopic = "fulfilment.us-east" }, + new RoutingRule { Priority = 2, Name = "EU-West", + FieldName = "Metadata.region", Operator = RoutingOperator.Equals, + Value = "eu-west", TargetTopic = "fulfilment.eu-west" }, ], DefaultTopic = "fulfilment.global", }); - - var router = new ContentBasedRouter(producer, options, NullLogger.Instance); - - // US-East order. - var usOrder = IntegrationEnvelope.Create( - "order-us", "OrderService", "order.created") with - { - Metadata = new Dictionary { ["region"] = "us-east" }, - }; - - var usDecision = await router.RouteAsync(usOrder); - Assert.That(usDecision.TargetTopic, Is.EqualTo("fulfilment.us-east")); - Assert.That(usDecision.MatchedRule!.Name, Is.EqualTo("US-East")); - - // EU-West order. - var euOrder = IntegrationEnvelope.Create( - "order-eu", "OrderService", "order.created") with - { - Metadata = new Dictionary { ["region"] = "eu-west" }, - }; - - var euDecision = await router.RouteAsync(euOrder); - Assert.That(euDecision.TargetTopic, Is.EqualTo("fulfilment.eu-west")); - - // Unknown region → global fallback. - var unknownOrder = IntegrationEnvelope.Create( - "order-unknown", "OrderService", "order.created") with - { - Metadata = new Dictionary { ["region"] = "af-south" }, - }; - - var unknownDecision = await router.RouteAsync(unknownOrder); - Assert.That(unknownDecision.TargetTopic, Is.EqualTo("fulfilment.global")); - Assert.That(unknownDecision.IsDefault, Is.True); + var router = new ContentBasedRouter( + output, options, NullLogger.Instance); + + var usOrder = IntegrationEnvelope.Create("o1", "svc", "order.created") with + { Metadata = new Dictionary { ["region"] = "us-east" } }; + var euOrder = IntegrationEnvelope.Create("o2", "svc", "order.created") with + { Metadata = new Dictionary { ["region"] = "eu-west" } }; + var unknownOrder = IntegrationEnvelope.Create("o3", "svc", "order.created") with + { Metadata = new Dictionary { ["region"] = "af-south" } }; + + Assert.That((await router.RouteAsync(usOrder)).TargetTopic, Is.EqualTo("fulfilment.us-east")); + Assert.That((await router.RouteAsync(euOrder)).TargetTopic, Is.EqualTo("fulfilment.eu-west")); + Assert.That((await router.RouteAsync(unknownOrder)).IsDefault, Is.True); + + output.AssertReceivedCount(3); + output.AssertReceivedOnTopic("fulfilment.us-east", 1); + output.AssertReceivedOnTopic("fulfilment.eu-west", 1); + output.AssertReceivedOnTopic("fulfilment.global", 1); } - // ── Challenge 2: Priority-Based Routing (Lower Number Wins) ───────────── - [Test] - public async Task Challenge2_PriorityRouting_LowerPriorityWins() + public async Task Challenge2_PayloadRouting_JsonElementField() { - // When multiple rules match, the rule with the LOWEST Priority number - // should win (first-match after sorting by priority). - var producer = Substitute.For(); - + await using var output = new MockEndpoint("payload"); var options = Options.Create(new RouterOptions { Rules = [ - // Priority 10 — broad match. - new RoutingRule - { - Priority = 10, - FieldName = "MessageType", - Operator = RoutingOperator.Contains, - Value = "order", - TargetTopic = "general-orders", - Name = "BroadOrderRule", - }, - // Priority 1 — specific match (should win). - new RoutingRule - { - Priority = 1, - FieldName = "MessageType", - Operator = RoutingOperator.Equals, - Value = "order.created", - TargetTopic = "new-orders", - Name = "SpecificOrderRule", - }, + new RoutingRule { Priority = 1, Name = "Urgent", + FieldName = "Payload.status", Operator = RoutingOperator.Equals, + Value = "urgent", TargetTopic = "urgent-processing" }, + new RoutingRule { Priority = 2, Name = "Normal", + FieldName = "Payload.status", Operator = RoutingOperator.Equals, + Value = "normal", TargetTopic = "normal-processing" }, ], - DefaultTopic = "unmatched", + DefaultTopic = "default-processing", }); + var router = new ContentBasedRouter( + output, options, NullLogger.Instance); - var router = new ContentBasedRouter(producer, options, NullLogger.Instance); + var urgentJson = JsonSerializer.Deserialize( + "{\"status\":\"urgent\",\"amount\":5000}"); + var normalJson = JsonSerializer.Deserialize( + "{\"status\":\"normal\",\"amount\":50}"); + var unknownJson = JsonSerializer.Deserialize( + "{\"status\":\"backorder\",\"amount\":10}"); - var envelope = IntegrationEnvelope.Create( - "new-order", "OrderService", "order.created"); + var d1 = await router.RouteAsync(IntegrationEnvelope.Create(urgentJson, "svc", "order")); + var d2 = await router.RouteAsync(IntegrationEnvelope.Create(normalJson, "svc", "order")); + var d3 = await router.RouteAsync(IntegrationEnvelope.Create(unknownJson, "svc", "order")); - // Both rules match, but Priority 1 wins. - var decision = await router.RouteAsync(envelope); + Assert.That(d1.TargetTopic, Is.EqualTo("urgent-processing")); + Assert.That(d2.TargetTopic, Is.EqualTo("normal-processing")); + Assert.That(d3.IsDefault, Is.True); - Assert.That(decision.TargetTopic, Is.EqualTo("new-orders")); - Assert.That(decision.MatchedRule!.Name, Is.EqualTo("SpecificOrderRule")); - Assert.That(decision.MatchedRule.Priority, Is.EqualTo(1)); + output.AssertReceivedCount(3); + output.AssertReceivedOnTopic("urgent-processing", 1); + output.AssertReceivedOnTopic("normal-processing", 1); + output.AssertReceivedOnTopic("default-processing", 1); } - // ── Challenge 3: Payload-Based Routing with JsonElement ────────────────── - [Test] - public async Task Challenge3_PayloadRouting_ByJsonField() + public async Task Challenge3_BatchRouting_MultipleMessagesVerifyTopics() { - // Route messages based on a field inside the JSON payload. - // The Payload.{path} field extraction requires the payload to be a JsonElement. - var producer = Substitute.For(); - + await using var output = new MockEndpoint("batch"); var options = Options.Create(new RouterOptions { Rules = [ - new RoutingRule - { - Priority = 1, - FieldName = "Payload.status", - Operator = RoutingOperator.Equals, - Value = "urgent", - TargetTopic = "urgent-processing", - Name = "UrgentStatus", - }, - new RoutingRule - { - Priority = 2, - FieldName = "Payload.status", - Operator = RoutingOperator.Equals, - Value = "normal", - TargetTopic = "normal-processing", - Name = "NormalStatus", - }, + new RoutingRule { Priority = 1, + FieldName = "MessageType", Operator = RoutingOperator.StartsWith, + Value = "order", TargetTopic = "orders" }, + new RoutingRule { Priority = 2, + FieldName = "MessageType", Operator = RoutingOperator.StartsWith, + Value = "payment", TargetTopic = "payments" }, ], - DefaultTopic = "default-processing", + DefaultTopic = "other", }); - - var router = new ContentBasedRouter(producer, options, NullLogger.Instance); - - // Create a JsonElement payload (required for Payload.{path} extraction). - var urgentJson = JsonSerializer.Deserialize( - "{\"orderId\": \"ORD-1\", \"status\": \"urgent\", \"amount\": 5000}"); - - var urgentEnvelope = IntegrationEnvelope.Create( - urgentJson, "OrderService", "order.submitted"); - - var urgentDecision = await router.RouteAsync(urgentEnvelope); - Assert.That(urgentDecision.TargetTopic, Is.EqualTo("urgent-processing")); - Assert.That(urgentDecision.MatchedRule!.Name, Is.EqualTo("UrgentStatus")); - - // Normal status order. - var normalJson = JsonSerializer.Deserialize( - "{\"orderId\": \"ORD-2\", \"status\": \"normal\", \"amount\": 50}"); - - var normalEnvelope = IntegrationEnvelope.Create( - normalJson, "OrderService", "order.submitted"); - - var normalDecision = await router.RouteAsync(normalEnvelope); - Assert.That(normalDecision.TargetTopic, Is.EqualTo("normal-processing")); - Assert.That(normalDecision.MatchedRule!.Name, Is.EqualTo("NormalStatus")); - - // Unknown status → default topic. - var unknownJson = JsonSerializer.Deserialize( - "{\"orderId\": \"ORD-3\", \"status\": \"backorder\", \"amount\": 10}"); - - var unknownEnvelope = IntegrationEnvelope.Create( - unknownJson, "OrderService", "order.submitted"); - - var unknownDecision = await router.RouteAsync(unknownEnvelope); - Assert.That(unknownDecision.TargetTopic, Is.EqualTo("default-processing")); - Assert.That(unknownDecision.IsDefault, Is.True); + var router = new ContentBasedRouter( + output, options, NullLogger.Instance); + + await router.RouteAsync(IntegrationEnvelope.Create("d1", "svc", "order.created")); + await router.RouteAsync(IntegrationEnvelope.Create("d2", "svc", "order.shipped")); + await router.RouteAsync(IntegrationEnvelope.Create("d3", "svc", "payment.received")); + await router.RouteAsync(IntegrationEnvelope.Create("d4", "svc", "inventory.updated")); + + output.AssertReceivedCount(4); + output.AssertReceivedOnTopic("orders", 2); + output.AssertReceivedOnTopic("payments", 1); + output.AssertReceivedOnTopic("other", 1); + Assert.That(output.GetReceivedTopics(), Has.Count.EqualTo(3)); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial09/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial09/Lab.cs index 6c63b63..b82a105 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial09/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial09/Lab.cs @@ -1,296 +1,191 @@ // ============================================================================ // Tutorial 09 – Content-Based Router (Lab) // ============================================================================ -// This lab exercises the ContentBasedRouter with various RoutingRules and -// operators. You will configure rules for MessageType, Metadata, and Regex -// matching, then verify the RoutingDecision for each scenario. +// EIP Pattern: Content-Based Router. +// E2E: Wire real ContentBasedRouter with MockEndpoint as producer, configure +// routing rules, send messages, verify delivery to correct topics. // ============================================================================ +using System.Text.Json; using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Routing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial09; [TestFixture] public sealed class Lab { - // ── Routing by MessageType (Equals Operator) ──────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("router-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Route_ByMessageType_Equals_MatchesCorrectTopic() + public async Task Route_Equals_MatchesMessageType() { - var producer = Substitute.For(); - - var options = Options.Create(new RouterOptions + var router = CreateRouter(new RoutingRule { - Rules = - [ - new RoutingRule - { - Priority = 1, - FieldName = "MessageType", - Operator = RoutingOperator.Equals, - Value = "order.created", - TargetTopic = "orders-topic", - Name = "OrderCreated", - }, - new RoutingRule - { - Priority = 2, - FieldName = "MessageType", - Operator = RoutingOperator.Equals, - Value = "payment.received", - TargetTopic = "payments-topic", - Name = "PaymentReceived", - }, - ], - DefaultTopic = "unmatched-topic", + Priority = 1, Name = "OrderRule", + FieldName = "MessageType", Operator = RoutingOperator.Equals, + Value = "order.created", TargetTopic = "orders-topic", }); - var router = new ContentBasedRouter(producer, options, NullLogger.Instance); - var envelope = IntegrationEnvelope.Create( "order-data", "OrderService", "order.created"); - var decision = await router.RouteAsync(envelope); Assert.That(decision.TargetTopic, Is.EqualTo("orders-topic")); Assert.That(decision.IsDefault, Is.False); - Assert.That(decision.MatchedRule, Is.Not.Null); - Assert.That(decision.MatchedRule!.Name, Is.EqualTo("OrderCreated")); + Assert.That(decision.MatchedRule!.Name, Is.EqualTo("OrderRule")); + _output.AssertReceivedOnTopic("orders-topic", 1); } [Test] - public async Task Route_ByMessageType_SecondRuleMatches() + public async Task Route_Contains_MatchesMetadata() { - var producer = Substitute.For(); - - var options = Options.Create(new RouterOptions + var router = CreateRouter(new RoutingRule { - Rules = - [ - new RoutingRule - { - Priority = 1, - FieldName = "MessageType", - Operator = RoutingOperator.Equals, - Value = "order.created", - TargetTopic = "orders-topic", - }, - new RoutingRule - { - Priority = 2, - FieldName = "MessageType", - Operator = RoutingOperator.Equals, - Value = "payment.received", - TargetTopic = "payments-topic", - Name = "PaymentRule", - }, - ], - DefaultTopic = "unmatched-topic", + Priority = 1, Name = "EuropeRegion", + FieldName = "Metadata.region", Operator = RoutingOperator.Contains, + Value = "europe", TargetTopic = "eu-topic", }); - var router = new ContentBasedRouter(producer, options, NullLogger.Instance); - var envelope = IntegrationEnvelope.Create( - "payment-data", "PaymentService", "payment.received"); - + "eu-data", "RegionalService", "data.regional") with + { + Metadata = new Dictionary { ["region"] = "western-europe-1" }, + }; var decision = await router.RouteAsync(envelope); - Assert.That(decision.TargetTopic, Is.EqualTo("payments-topic")); - Assert.That(decision.MatchedRule!.Name, Is.EqualTo("PaymentRule")); + Assert.That(decision.TargetTopic, Is.EqualTo("eu-topic")); + _output.AssertReceivedOnTopic("eu-topic", 1); } - // ── Routing by Metadata Field (Contains Operator) ─────────────────────── - [Test] - public async Task Route_ByMetadata_Contains_MatchesMetadataValue() + public async Task Route_StartsWith_MatchesSource() { - var producer = Substitute.For(); - - var options = Options.Create(new RouterOptions + var router = CreateRouter(new RoutingRule { - Rules = - [ - new RoutingRule - { - Priority = 1, - FieldName = "Metadata.region", - Operator = RoutingOperator.Contains, - Value = "europe", - TargetTopic = "eu-topic", - Name = "EuropeRegion", - }, - ], - DefaultTopic = "global-topic", + Priority = 1, Name = "InternalRule", + FieldName = "Source", Operator = RoutingOperator.StartsWith, + Value = "Internal", TargetTopic = "internal-topic", }); - var router = new ContentBasedRouter(producer, options, NullLogger.Instance); - var envelope = IntegrationEnvelope.Create( - "eu-data", "RegionalService", "data.regional") with - { - Metadata = new Dictionary - { - ["region"] = "western-europe-1", - }, - }; - + "data", "InternalOrderService", "order.event"); var decision = await router.RouteAsync(envelope); - Assert.That(decision.TargetTopic, Is.EqualTo("eu-topic")); - Assert.That(decision.IsDefault, Is.False); - Assert.That(decision.MatchedRule!.Name, Is.EqualTo("EuropeRegion")); + Assert.That(decision.TargetTopic, Is.EqualTo("internal-topic")); + _output.AssertReceivedOnTopic("internal-topic", 1); } - // ── Routing with Regex Operator ───────────────────────────────────────── - [Test] - public async Task Route_ByMessageType_Regex_MatchesPattern() + public async Task Route_Regex_MatchesPattern() { - var producer = Substitute.For(); - - var options = Options.Create(new RouterOptions + var router = CreateRouter(new RoutingRule { - Rules = - [ - new RoutingRule - { - Priority = 1, - FieldName = "MessageType", - Operator = RoutingOperator.Regex, - Value = @"^order\..+", - TargetTopic = "order-events", - Name = "AllOrderEvents", - }, - ], - DefaultTopic = "other-events", + Priority = 1, Name = "AllOrders", + FieldName = "MessageType", Operator = RoutingOperator.Regex, + Value = @"^order\..+", TargetTopic = "order-events", }); - var router = new ContentBasedRouter(producer, options, NullLogger.Instance); - - // "order.shipped" matches the pattern ^order\..+ var envelope = IntegrationEnvelope.Create( - "shipped-data", "OrderService", "order.shipped"); - + "shipped", "OrderService", "order.shipped"); var decision = await router.RouteAsync(envelope); Assert.That(decision.TargetTopic, Is.EqualTo("order-events")); - Assert.That(decision.MatchedRule!.Name, Is.EqualTo("AllOrderEvents")); + _output.AssertReceivedOnTopic("order-events", 1); } [Test] - public async Task Route_ByMessageType_Regex_NoMatch_UsesDefault() + public async Task Route_NoMatch_FallsToDefault() { - var producer = Substitute.For(); - - var options = Options.Create(new RouterOptions + var router = CreateRouter(new RoutingRule { - Rules = - [ - new RoutingRule - { - Priority = 1, - FieldName = "MessageType", - Operator = RoutingOperator.Regex, - Value = @"^order\..+", - TargetTopic = "order-events", - }, - ], - DefaultTopic = "other-events", + Priority = 1, + FieldName = "MessageType", Operator = RoutingOperator.Equals, + Value = "order.created", TargetTopic = "orders-topic", }); - var router = new ContentBasedRouter(producer, options, NullLogger.Instance); - - // "payment.received" does NOT match ^order\..+ var envelope = IntegrationEnvelope.Create( - "payment-data", "PaymentService", "payment.received"); - + "unknown", "UnknownService", "unknown.event"); var decision = await router.RouteAsync(envelope); - Assert.That(decision.TargetTopic, Is.EqualTo("other-events")); + Assert.That(decision.TargetTopic, Is.EqualTo("catch-all")); Assert.That(decision.IsDefault, Is.True); Assert.That(decision.MatchedRule, Is.Null); + _output.AssertReceivedOnTopic("catch-all", 1); } - // ── Default Topic Fallback ────────────────────────────────────────────── - [Test] - public async Task Route_NoRuleMatches_FallsBackToDefaultTopic() + public async Task Route_Priority_LowerNumberWins() { - var producer = Substitute.For(); - var options = Options.Create(new RouterOptions { Rules = [ new RoutingRule { - Priority = 1, - FieldName = "MessageType", - Operator = RoutingOperator.Equals, - Value = "order.created", - TargetTopic = "orders-topic", + Priority = 10, Name = "Broad", + FieldName = "MessageType", Operator = RoutingOperator.Contains, + Value = "order", TargetTopic = "general-orders", + }, + new RoutingRule + { + Priority = 1, Name = "Specific", + FieldName = "MessageType", Operator = RoutingOperator.Equals, + Value = "order.created", TargetTopic = "new-orders", }, ], - DefaultTopic = "catch-all-topic", + DefaultTopic = "unmatched", }); + var router = new ContentBasedRouter( + _output, options, NullLogger.Instance); - var router = new ContentBasedRouter(producer, options, NullLogger.Instance); - - // This message type doesn't match any rule. var envelope = IntegrationEnvelope.Create( - "unknown-data", "UnknownService", "unknown.event"); - + "new-order", "OrderService", "order.created"); var decision = await router.RouteAsync(envelope); - Assert.That(decision.TargetTopic, Is.EqualTo("catch-all-topic")); - Assert.That(decision.IsDefault, Is.True); - Assert.That(decision.MatchedRule, Is.Null); + Assert.That(decision.TargetTopic, Is.EqualTo("new-orders")); + Assert.That(decision.MatchedRule!.Name, Is.EqualTo("Specific")); + _output.AssertReceivedOnTopic("new-orders", 1); } - // ── Verify RoutingDecision Contains Correct MatchedRule ────────────────── - [Test] - public async Task Route_MatchedRule_ContainsAllRuleDetails() + public async Task Route_MatchedRule_ContainsAllDetails() { - var producer = Substitute.For(); - - var options = Options.Create(new RouterOptions + var router = CreateRouter(new RoutingRule { - Rules = - [ - new RoutingRule - { - Priority = 10, - FieldName = "Source", - Operator = RoutingOperator.Equals, - Value = "CriticalService", - TargetTopic = "critical-topic", - Name = "CriticalSource", - }, - ], - DefaultTopic = "default-topic", + Priority = 5, Name = "CriticalSource", + FieldName = "Source", Operator = RoutingOperator.Equals, + Value = "CriticalService", TargetTopic = "critical-topic", }); - var router = new ContentBasedRouter(producer, options, NullLogger.Instance); - var envelope = IntegrationEnvelope.Create( - "critical-payload", "CriticalService", "alert.triggered"); - + "alert", "CriticalService", "alert.fired"); var decision = await router.RouteAsync(envelope); - Assert.That(decision.MatchedRule, Is.Not.Null); - Assert.That(decision.MatchedRule!.Priority, Is.EqualTo(10)); + Assert.That(decision.MatchedRule!.Priority, Is.EqualTo(5)); Assert.That(decision.MatchedRule.FieldName, Is.EqualTo("Source")); Assert.That(decision.MatchedRule.Operator, Is.EqualTo(RoutingOperator.Equals)); - Assert.That(decision.MatchedRule.Value, Is.EqualTo("CriticalService")); Assert.That(decision.MatchedRule.TargetTopic, Is.EqualTo("critical-topic")); - Assert.That(decision.MatchedRule.Name, Is.EqualTo("CriticalSource")); + } + + private ContentBasedRouter CreateRouter(RoutingRule rule) + { + var options = Options.Create(new RouterOptions + { + Rules = [rule], + DefaultTopic = "catch-all", + }); + return new ContentBasedRouter( + _output, options, NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial10/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial10/Exam.cs index 1632e55..52d4ecf 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial10/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial10/Exam.cs @@ -1,35 +1,27 @@ // ============================================================================ // Tutorial 10 – Message Filter (Exam) // ============================================================================ -// Coding challenges: build a spam filter, a priority-based filter, and a -// metadata-based filter using the MessageFilter and RuleCondition types. +// E2E challenges: spam filter with In operator, priority-based filter, and +// multi-condition AND filter — all verified via MockEndpoint topic counts. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Routing; using EnterpriseIntegrationPlatform.RuleEngine; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial10; [TestFixture] public sealed class Exam { - // ── Challenge 1: Spam Filter — Reject Messages from Specific Sources ──── - [Test] - public async Task Challenge1_SpamFilter_RejectsUntrustedSources() + public async Task Challenge1_SpamFilter_AcceptsTrustedRejectsOthers() { - // Build a filter that ONLY accepts messages from "TrustedPartnerA" - // or "TrustedPartnerB". All other sources are discarded to a DLQ. - // - // Using the "In" operator with comma-separated trusted values. - var producer = Substitute.For(); - + await using var output = new MockEndpoint("spam"); var options = Options.Create(new MessageFilterOptions { Conditions = @@ -42,68 +34,28 @@ public async Task Challenge1_SpamFilter_RejectsUntrustedSources() }, ], Logic = RuleLogicOperator.And, - OutputTopic = "legitimate-messages", - DiscardTopic = "spam-quarantine", + OutputTopic = "legitimate", + DiscardTopic = "quarantine", }); + var filter = new MessageFilter( + output, options, NullLogger.Instance); - var filter = new MessageFilter(producer, options, NullLogger.Instance); - - // Trusted source passes. - var trustedEnvelope = IntegrationEnvelope.Create( - "partner-data", "TrustedPartnerA", "partner.update"); + var trusted = IntegrationEnvelope.Create( + "data", "TrustedPartnerA", "partner.update"); + var spam = IntegrationEnvelope.Create( + "spam", "MaliciousBot", "spam.broadcast"); - var passResult = await filter.FilterAsync(trustedEnvelope); - Assert.That(passResult.Passed, Is.True); - Assert.That(passResult.OutputTopic, Is.EqualTo("legitimate-messages")); + Assert.That((await filter.FilterAsync(trusted)).Passed, Is.True); + Assert.That((await filter.FilterAsync(spam)).Passed, Is.False); - // Untrusted (spam) source is rejected. - var spamEnvelope = IntegrationEnvelope.Create( - "spam-payload", "MaliciousBot", "spam.broadcast"); - - var rejectResult = await filter.FilterAsync(spamEnvelope); - Assert.That(rejectResult.Passed, Is.False); - Assert.That(rejectResult.OutputTopic, Is.EqualTo("spam-quarantine")); + output.AssertReceivedOnTopic("legitimate", 1); + output.AssertReceivedOnTopic("quarantine", 1); } [Test] - public async Task Challenge1_SpamFilter_SecondTrustedPartnerAlsoAccepted() + public async Task Challenge2_PriorityFilter_OnlyHighCriticalPass() { - var producer = Substitute.For(); - - var options = Options.Create(new MessageFilterOptions - { - Conditions = - [ - new RuleCondition - { - FieldName = "Source", - Operator = RuleConditionOperator.In, - Value = "TrustedPartnerA,TrustedPartnerB", - }, - ], - Logic = RuleLogicOperator.And, - OutputTopic = "legitimate-messages", - DiscardTopic = "spam-quarantine", - }); - - var filter = new MessageFilter(producer, options, NullLogger.Instance); - - var partnerB = IntegrationEnvelope.Create( - "b-data", "TrustedPartnerB", "partner.sync"); - - var result = await filter.FilterAsync(partnerB); - Assert.That(result.Passed, Is.True); - } - - // ── Challenge 2: Priority Filter — Only High/Critical Pass ────────────── - - [Test] - public async Task Challenge2_PriorityFilter_OnlyHighAndCriticalPass() - { - // Create a filter that only accepts messages with Priority "High" or "Critical". - // The Priority field on the envelope is extracted as its enum string representation. - var producer = Substitute.For(); - + await using var output = new MockEndpoint("priority"); var options = Options.Create(new MessageFilterOptions { Conditions = @@ -117,65 +69,33 @@ public async Task Challenge2_PriorityFilter_OnlyHighAndCriticalPass() ], Logic = RuleLogicOperator.And, OutputTopic = "priority-processing", - DiscardTopic = "low-priority-archive", + DiscardTopic = "low-archive", }); - - var filter = new MessageFilter(producer, options, NullLogger.Instance); - - // High priority passes. - var highPriority = IntegrationEnvelope.Create( - "urgent-data", "AlertService", "alert.fired") with - { - Priority = MessagePriority.High, - }; - - var highResult = await filter.FilterAsync(highPriority); - Assert.That(highResult.Passed, Is.True); - Assert.That(highResult.OutputTopic, Is.EqualTo("priority-processing")); - - // Critical priority passes. - var criticalPriority = IntegrationEnvelope.Create( - "critical-data", "AlertService", "alert.critical") with - { - Priority = MessagePriority.Critical, - }; - - var criticalResult = await filter.FilterAsync(criticalPriority); - Assert.That(criticalResult.Passed, Is.True); - - // Normal priority is rejected. - var normalPriority = IntegrationEnvelope.Create( - "normal-data", "ReportService", "report.generated") with - { - Priority = MessagePriority.Normal, - }; - - var normalResult = await filter.FilterAsync(normalPriority); - Assert.That(normalResult.Passed, Is.False); - Assert.That(normalResult.OutputTopic, Is.EqualTo("low-priority-archive")); - - // Low priority is rejected. - var lowPriority = IntegrationEnvelope.Create( - "background-data", "BatchService", "batch.completed") with - { - Priority = MessagePriority.Low, - }; - - var lowResult = await filter.FilterAsync(lowPriority); - Assert.That(lowResult.Passed, Is.False); + var filter = new MessageFilter( + output, options, NullLogger.Instance); + + var high = IntegrationEnvelope.Create("d", "svc", "ev") with + { Priority = MessagePriority.High }; + var critical = IntegrationEnvelope.Create("d", "svc", "ev") with + { Priority = MessagePriority.Critical }; + var normal = IntegrationEnvelope.Create("d", "svc", "ev") with + { Priority = MessagePriority.Normal }; + var low = IntegrationEnvelope.Create("d", "svc", "ev") with + { Priority = MessagePriority.Low }; + + Assert.That((await filter.FilterAsync(high)).Passed, Is.True); + Assert.That((await filter.FilterAsync(critical)).Passed, Is.True); + Assert.That((await filter.FilterAsync(normal)).Passed, Is.False); + Assert.That((await filter.FilterAsync(low)).Passed, Is.False); + + output.AssertReceivedOnTopic("priority-processing", 2); + output.AssertReceivedOnTopic("low-archive", 2); } - // ── Challenge 3: Metadata-Based Filter with Multiple Conditions ───────── - [Test] - public async Task Challenge3_MetadataFilter_RequiresTenantAndEnvironment() + public async Task Challenge3_MetadataFilter_AndLogic_BothConditionsRequired() { - // Build a filter that requires BOTH conditions (AND logic): - // 1. Metadata.tenant must equal "acme-corp" - // 2. Metadata.environment must equal "production" - // Messages missing either metadata key are rejected. - var producer = Substitute.For(); - + await using var output = new MockEndpoint("metadata"); var options = Options.Create(new MessageFilterOptions { Conditions = @@ -197,121 +117,30 @@ public async Task Challenge3_MetadataFilter_RequiresTenantAndEnvironment() OutputTopic = "production-acme", DiscardTopic = "non-prod-discard", }); + var filter = new MessageFilter( + output, options, NullLogger.Instance); - var filter = new MessageFilter(producer, options, NullLogger.Instance); - - // Both conditions met — passes. - var validEnvelope = IntegrationEnvelope.Create( - "prod-data", "AcmeService", "data.sync") with + var valid = IntegrationEnvelope.Create("d", "svc", "ev") with { Metadata = new Dictionary - { - ["tenant"] = "acme-corp", - ["environment"] = "production", - }, + { ["tenant"] = "acme-corp", ["environment"] = "production" }, }; - - var passResult = await filter.FilterAsync(validEnvelope); - Assert.That(passResult.Passed, Is.True); - Assert.That(passResult.OutputTopic, Is.EqualTo("production-acme")); - - // Wrong tenant — rejected. - var wrongTenant = IntegrationEnvelope.Create( - "other-data", "OtherService", "data.sync") with - { - Metadata = new Dictionary - { - ["tenant"] = "other-corp", - ["environment"] = "production", - }, - }; - - var rejectTenant = await filter.FilterAsync(wrongTenant); - Assert.That(rejectTenant.Passed, Is.False); - - // Wrong environment — rejected. - var wrongEnv = IntegrationEnvelope.Create( - "staging-data", "AcmeService", "data.sync") with - { - Metadata = new Dictionary - { - ["tenant"] = "acme-corp", - ["environment"] = "staging", - }, - }; - - var rejectEnv = await filter.FilterAsync(wrongEnv); - Assert.That(rejectEnv.Passed, Is.False); - } - - [Test] - public async Task Challenge3_MetadataFilter_OrLogic_EitherConditionSuffices() - { - // With OR logic, matching ANY condition is enough to pass. - var producer = Substitute.For(); - - var options = Options.Create(new MessageFilterOptions - { - Conditions = - [ - new RuleCondition - { - FieldName = "Metadata.priority-override", - Operator = RuleConditionOperator.Equals, - Value = "true", - }, - new RuleCondition - { - FieldName = "Metadata.vip-customer", - Operator = RuleConditionOperator.Equals, - Value = "true", - }, - ], - Logic = RuleLogicOperator.Or, - OutputTopic = "fast-lane", - DiscardTopic = "standard-lane", - }); - - var filter = new MessageFilter(producer, options, NullLogger.Instance); - - // Only priority-override set — passes (OR logic). - var priorityOverride = IntegrationEnvelope.Create( - "rush-order", "OrderService", "order.rush") with + var wrongTenant = IntegrationEnvelope.Create("d", "svc", "ev") with { Metadata = new Dictionary - { - ["priority-override"] = "true", - }, + { ["tenant"] = "other-corp", ["environment"] = "production" }, }; - - var result1 = await filter.FilterAsync(priorityOverride); - Assert.That(result1.Passed, Is.True); - - // Only vip-customer set — also passes. - var vipOrder = IntegrationEnvelope.Create( - "vip-order", "OrderService", "order.vip") with + var wrongEnv = IntegrationEnvelope.Create("d", "svc", "ev") with { Metadata = new Dictionary - { - ["vip-customer"] = "true", - }, + { ["tenant"] = "acme-corp", ["environment"] = "staging" }, }; - var result2 = await filter.FilterAsync(vipOrder); - Assert.That(result2.Passed, Is.True); - - // Neither condition met — rejected. - var normalOrder = IntegrationEnvelope.Create( - "normal-order", "OrderService", "order.standard") with - { - Metadata = new Dictionary - { - ["customer-tier"] = "bronze", - }, - }; + Assert.That((await filter.FilterAsync(valid)).Passed, Is.True); + Assert.That((await filter.FilterAsync(wrongTenant)).Passed, Is.False); + Assert.That((await filter.FilterAsync(wrongEnv)).Passed, Is.False); - var result3 = await filter.FilterAsync(normalOrder); - Assert.That(result3.Passed, Is.False); - Assert.That(result3.OutputTopic, Is.EqualTo("standard-lane")); + output.AssertReceivedOnTopic("production-acme", 1); + output.AssertReceivedOnTopic("non-prod-discard", 2); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial10/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial10/Lab.cs index 45cbdde..5b51d59 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial10/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial10/Lab.cs @@ -1,141 +1,81 @@ // ============================================================================ // Tutorial 10 – Message Filter (Lab) // ============================================================================ -// This lab exercises the MessageFilter with various RuleCondition predicates. -// You will configure accept/reject filters, test default behaviour when no -// condition matches, and verify the MessageFilterResult for each scenario. +// EIP Pattern: Message Filter. +// E2E: Wire real MessageFilter with MockEndpoint, configure accept/reject +// conditions, verify messages arrive at correct output/discard topics. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Routing; using EnterpriseIntegrationPlatform.RuleEngine; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial10; [TestFixture] public sealed class Lab { - // ── Accept Filter: Message Passes Through ─────────────────────────────── + private MockEndpoint _output = null!; - [Test] - public async Task Filter_Accept_MessagePassesWhenPredicateMatches() - { - var producer = Substitute.For(); + [SetUp] + public void SetUp() => _output = new MockEndpoint("filter-out"); - // Only messages of type "order.created" pass through. - var options = Options.Create(new MessageFilterOptions - { - Conditions = - [ - new RuleCondition - { - FieldName = "MessageType", - Operator = RuleConditionOperator.Equals, - Value = "order.created", - }, - ], - Logic = RuleLogicOperator.And, - OutputTopic = "orders-accepted", - DiscardTopic = "orders-rejected", - }); + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); - var filter = new MessageFilter(producer, options, NullLogger.Instance); + [Test] + public async Task Filter_Accept_PublishesToOutputTopic() + { + var filter = CreateFilter("order.created", "orders-accepted", "orders-rejected"); var envelope = IntegrationEnvelope.Create( "valid-order", "OrderService", "order.created"); - var result = await filter.FilterAsync(envelope); Assert.That(result.Passed, Is.True); Assert.That(result.OutputTopic, Is.EqualTo("orders-accepted")); - Assert.That(result.Reason, Is.EqualTo("Predicate matched")); - - // Verify the message was published to the output topic. - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("orders-accepted"), - Arg.Any()); + _output.AssertReceivedOnTopic("orders-accepted", 1); } - // ── Reject Filter: Message is Filtered Out ────────────────────────────── - [Test] - public async Task Filter_Reject_MessageDiscardedWhenPredicateFails() + public async Task Filter_Reject_PublishesToDiscardTopic() { - var producer = Substitute.For(); - - var options = Options.Create(new MessageFilterOptions - { - Conditions = - [ - new RuleCondition - { - FieldName = "MessageType", - Operator = RuleConditionOperator.Equals, - Value = "order.created", - }, - ], - Logic = RuleLogicOperator.And, - OutputTopic = "orders-accepted", - DiscardTopic = "orders-rejected", - }); + var filter = CreateFilter("order.created", "orders-accepted", "orders-rejected"); - var filter = new MessageFilter(producer, options, NullLogger.Instance); - - // This message type does NOT match — it will be rejected. var envelope = IntegrationEnvelope.Create( - "unknown-data", "UnknownService", "unknown.event"); - + "unknown", "UnknownService", "unknown.event"); var result = await filter.FilterAsync(envelope); Assert.That(result.Passed, Is.False); Assert.That(result.OutputTopic, Is.EqualTo("orders-rejected")); - Assert.That(result.Reason, Does.Contain("discard")); - - // Verify the message was published to the discard topic. - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("orders-rejected"), - Arg.Any()); + _output.AssertReceivedOnTopic("orders-rejected", 1); } - // ── Default Action: No Conditions = Pass Through ──────────────────────── - [Test] - public async Task Filter_NoConditions_DefaultPassThrough() + public async Task Filter_NoConditions_PassThrough() { - var producer = Substitute.For(); - - // When no conditions are configured, the filter passes everything. var options = Options.Create(new MessageFilterOptions { Conditions = [], - OutputTopic = "pass-through-topic", + OutputTopic = "pass-through", }); + var filter = new MessageFilter( + _output, options, NullLogger.Instance); - var filter = new MessageFilter(producer, options, NullLogger.Instance); - - var envelope = IntegrationEnvelope.Create( - "any-data", "AnyService", "any.event"); - + var envelope = IntegrationEnvelope.Create("any", "svc", "any.type"); var result = await filter.FilterAsync(envelope); Assert.That(result.Passed, Is.True); - Assert.That(result.OutputTopic, Is.EqualTo("pass-through-topic")); + _output.AssertReceivedOnTopic("pass-through", 1); } - // ── Silent Discard: No DiscardTopic Configured ────────────────────────── - [Test] - public async Task Filter_NoDiscardTopic_SilentlyDiscards() + public async Task Filter_SilentDiscard_NoPublish() { - var producer = Substitute.For(); - var options = Options.Create(new MessageFilterOptions { Conditions = @@ -149,34 +89,23 @@ public async Task Filter_NoDiscardTopic_SilentlyDiscards() ], Logic = RuleLogicOperator.And, OutputTopic = "output-topic", - // No DiscardTopic — silent discard. }); - - var filter = new MessageFilter(producer, options, NullLogger.Instance); + var filter = new MessageFilter( + _output, options, NullLogger.Instance); var envelope = IntegrationEnvelope.Create( - "wrong-data", "Service", "wrong.type"); - + "wrong", "svc", "wrong.type"); var result = await filter.FilterAsync(envelope); Assert.That(result.Passed, Is.False); Assert.That(result.OutputTopic, Is.Null); Assert.That(result.Reason, Does.Contain("silently discarded")); - - // No publish calls at all — the message was silently dropped. - await producer.DidNotReceive().PublishAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()); + _output.AssertNoneReceived(); } - // ── Verify FilterResult Contains Correct Details ──────────────────────── - [Test] - public async Task Filter_Result_ContainsCorrectReasonAndTopic() + public async Task Filter_BySource_AcceptsAndRejects() { - var producer = Substitute.For(); - var options = Options.Create(new MessageFilterOptions { Conditions = @@ -189,27 +118,116 @@ public async Task Filter_Result_ContainsCorrectReasonAndTopic() }, ], Logic = RuleLogicOperator.And, - OutputTopic = "trusted-output", + OutputTopic = "trusted-out", DiscardTopic = "untrusted-dlq", }); + var filter = new MessageFilter( + _output, options, NullLogger.Instance); - var filter = new MessageFilter(producer, options, NullLogger.Instance); - - // Matching message. var trusted = IntegrationEnvelope.Create( - "trusted-data", "TrustedService", "data.event"); + "data", "TrustedService", "data.event"); + var untrusted = IntegrationEnvelope.Create( + "data", "UntrustedService", "data.event"); - var passResult = await filter.FilterAsync(trusted); - Assert.That(passResult.Passed, Is.True); - Assert.That(passResult.Reason, Is.EqualTo("Predicate matched")); + Assert.That((await filter.FilterAsync(trusted)).Passed, Is.True); + Assert.That((await filter.FilterAsync(untrusted)).Passed, Is.False); - // Non-matching message. - var untrusted = IntegrationEnvelope.Create( - "untrusted-data", "UntrustedService", "data.event"); + _output.AssertReceivedOnTopic("trusted-out", 1); + _output.AssertReceivedOnTopic("untrusted-dlq", 1); + } + + [Test] + public async Task Filter_InOperator_MultipleSources() + { + var options = Options.Create(new MessageFilterOptions + { + Conditions = + [ + new RuleCondition + { + FieldName = "Source", + Operator = RuleConditionOperator.In, + Value = "PartnerA,PartnerB", + }, + ], + Logic = RuleLogicOperator.And, + OutputTopic = "partners", + DiscardTopic = "rejected", + }); + var filter = new MessageFilter( + _output, options, NullLogger.Instance); + + var a = IntegrationEnvelope.Create("d", "PartnerA", "ev"); + var b = IntegrationEnvelope.Create("d", "PartnerB", "ev"); + var c = IntegrationEnvelope.Create("d", "Unknown", "ev"); + + Assert.That((await filter.FilterAsync(a)).Passed, Is.True); + Assert.That((await filter.FilterAsync(b)).Passed, Is.True); + Assert.That((await filter.FilterAsync(c)).Passed, Is.False); + + _output.AssertReceivedOnTopic("partners", 2); + _output.AssertReceivedOnTopic("rejected", 1); + } + + [Test] + public async Task Filter_OrLogic_EitherConditionSuffices() + { + var options = Options.Create(new MessageFilterOptions + { + Conditions = + [ + new RuleCondition + { + FieldName = "Metadata.priority-override", + Operator = RuleConditionOperator.Equals, + Value = "true", + }, + new RuleCondition + { + FieldName = "Metadata.vip", + Operator = RuleConditionOperator.Equals, + Value = "true", + }, + ], + Logic = RuleLogicOperator.Or, + OutputTopic = "fast-lane", + DiscardTopic = "standard", + }); + var filter = new MessageFilter( + _output, options, NullLogger.Instance); + + var overrideMsg = IntegrationEnvelope.Create("d", "svc", "ev") with + { Metadata = new Dictionary { ["priority-override"] = "true" } }; + var vipMsg = IntegrationEnvelope.Create("d", "svc", "ev") with + { Metadata = new Dictionary { ["vip"] = "true" } }; + var normalMsg = IntegrationEnvelope.Create("d", "svc", "ev") with + { Metadata = new Dictionary { ["tier"] = "bronze" } }; + + Assert.That((await filter.FilterAsync(overrideMsg)).Passed, Is.True); + Assert.That((await filter.FilterAsync(vipMsg)).Passed, Is.True); + Assert.That((await filter.FilterAsync(normalMsg)).Passed, Is.False); + + _output.AssertReceivedOnTopic("fast-lane", 2); + _output.AssertReceivedOnTopic("standard", 1); + } - var failResult = await filter.FilterAsync(untrusted); - Assert.That(failResult.Passed, Is.False); - Assert.That(failResult.OutputTopic, Is.EqualTo("untrusted-dlq")); - Assert.That(failResult.Reason, Does.Contain("discard")); + private MessageFilter CreateFilter(string acceptType, string outputTopic, string discardTopic) + { + var options = Options.Create(new MessageFilterOptions + { + Conditions = + [ + new RuleCondition + { + FieldName = "MessageType", + Operator = RuleConditionOperator.Equals, + Value = acceptType, + }, + ], + Logic = RuleLogicOperator.And, + OutputTopic = outputTopic, + DiscardTopic = discardTopic, + }); + return new MessageFilter(_output, options, NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial11/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial11/Exam.cs index 44b1fd8..eb3af02 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial11/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial11/Exam.cs @@ -1,147 +1,100 @@ // ============================================================================ // Tutorial 11 – Dynamic Router (Exam) // ============================================================================ -// Coding challenges: build a self-registering microservice topology, test -// route replacement semantics, and verify control-channel thread-safety. +// E2E challenges: multi-participant topology, route replacement semantics, +// and case-insensitive routing verification via MockEndpoint. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Routing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial11; [TestFixture] public sealed class Exam { - // ── Challenge 1: Multi-Service Dynamic Registration ───────────────────── - [Test] - public async Task Challenge1_MultiServiceRegistration_EachServiceGetsItsOwnRoute() + public async Task Challenge1_MultiParticipantTopology_RoutesCorrectly() { - // Simulate three microservices registering their preferred message types. - // After registration, route messages of each type and verify they reach - // the correct destination. - var producer = Substitute.For(); - - var options = Options.Create(new DynamicRouterOptions - { - ConditionField = "MessageType", - FallbackTopic = "dead-letter", - }); - - var router = new DynamicRouter(producer, options, NullLogger.Instance); - - // Three services register at runtime. - await router.RegisterAsync("order.created", "orders-topic", "OrderService"); - await router.RegisterAsync("payment.received", "payments-topic", "PaymentService"); - await router.RegisterAsync("shipment.dispatched", "shipping-topic", "ShippingService"); - - // Verify routing for each service. - var orderEnvelope = IntegrationEnvelope.Create( - "order-1", "Gateway", "order.created"); - - var orderDecision = await router.RouteAsync(orderEnvelope); - Assert.That(orderDecision.Destination, Is.EqualTo("orders-topic")); - Assert.That(orderDecision.MatchedEntry!.ParticipantId, Is.EqualTo("OrderService")); - - var paymentEnvelope = IntegrationEnvelope.Create( - "payment-1", "Gateway", "payment.received"); + await using var output = new MockEndpoint("multi-participant"); + var router = CreateRouter(output); + + await router.RegisterAsync("order.created", "order-svc-topic", "order-service"); + await router.RegisterAsync("payment.received", "payment-svc-topic", "payment-service"); + await router.RegisterAsync("shipment.dispatched", "shipment-svc-topic", "shipment-service"); + + var e1 = IntegrationEnvelope.Create("o1", "svc", "order.created"); + var e2 = IntegrationEnvelope.Create("p1", "svc", "payment.received"); + var e3 = IntegrationEnvelope.Create("s1", "svc", "shipment.dispatched"); + var e4 = IntegrationEnvelope.Create("u1", "svc", "unknown.event"); + + var d1 = await router.RouteAsync(e1); + var d2 = await router.RouteAsync(e2); + var d3 = await router.RouteAsync(e3); + var d4 = await router.RouteAsync(e4); + + Assert.That(d1.Destination, Is.EqualTo("order-svc-topic")); + Assert.That(d2.Destination, Is.EqualTo("payment-svc-topic")); + Assert.That(d3.Destination, Is.EqualTo("shipment-svc-topic")); + Assert.That(d4.IsFallback, Is.True); + + output.AssertReceivedCount(4); + output.AssertReceivedOnTopic("order-svc-topic", 1); + output.AssertReceivedOnTopic("payment-svc-topic", 1); + output.AssertReceivedOnTopic("shipment-svc-topic", 1); + } - var paymentDecision = await router.RouteAsync(paymentEnvelope); - Assert.That(paymentDecision.Destination, Is.EqualTo("payments-topic")); - Assert.That(paymentDecision.MatchedEntry!.ParticipantId, Is.EqualTo("PaymentService")); + [Test] + public async Task Challenge2_RouteReplacement_NewParticipantOverrides() + { + await using var output = new MockEndpoint("replacement"); + var router = CreateRouter(output); - var shipmentEnvelope = IntegrationEnvelope.Create( - "shipment-1", "Gateway", "shipment.dispatched"); + await router.RegisterAsync("order.created", "old-handler", "participant-v1"); + await router.RegisterAsync("order.created", "new-handler", "participant-v2"); - var shipmentDecision = await router.RouteAsync(shipmentEnvelope); - Assert.That(shipmentDecision.Destination, Is.EqualTo("shipping-topic")); + var table = router.GetRoutingTable(); + Assert.That(table["order.created"].Destination, Is.EqualTo("new-handler")); + Assert.That(table["order.created"].ParticipantId, Is.EqualTo("participant-v2")); - // Unknown type falls to dead-letter. - var unknownEnvelope = IntegrationEnvelope.Create( - "unknown-1", "Gateway", "refund.issued"); + var envelope = IntegrationEnvelope.Create("data", "svc", "order.created"); + var decision = await router.RouteAsync(envelope); - var unknownDecision = await router.RouteAsync(unknownEnvelope); - Assert.That(unknownDecision.IsFallback, Is.True); - Assert.That(unknownDecision.Destination, Is.EqualTo("dead-letter")); + Assert.That(decision.Destination, Is.EqualTo("new-handler")); + Assert.That(decision.MatchedEntry!.ParticipantId, Is.EqualTo("participant-v2")); + output.AssertReceivedOnTopic("new-handler", 1); } - // ── Challenge 2: Route Replacement — Re-Register Overwrites ───────────── - [Test] - public async Task Challenge2_RouteReplacement_LatestRegistrationWins() + public async Task Challenge3_CaseInsensitive_MatchesRegardlessOfCase() { - // When a participant re-registers for the same condition key, the old - // destination is replaced. Verify that only the latest destination - // is used and that the routing table has exactly one entry. - var producer = Substitute.For(); - - var options = Options.Create(new DynamicRouterOptions - { - ConditionField = "MessageType", - FallbackTopic = "fallback", - }); - - var router = new DynamicRouter(producer, options, NullLogger.Instance); - - // Version 1 of the order service registers. - await router.RegisterAsync("order.created", "orders-v1-topic", "OrderService-v1"); + await using var output = new MockEndpoint("case-insensitive"); + var router = CreateRouter(output); - // Version 2 replaces the registration. - await router.RegisterAsync("order.created", "orders-v2-topic", "OrderService-v2"); + await router.RegisterAsync("Order.Created", "orders-topic"); - // Routing table should have exactly one entry. - var table = router.GetRoutingTable(); - Assert.That(table, Has.Count.EqualTo(1)); - Assert.That(table["order.created"].Destination, Is.EqualTo("orders-v2-topic")); - Assert.That(table["order.created"].ParticipantId, Is.EqualTo("OrderService-v2")); - - // Messages route to the v2 destination. var envelope = IntegrationEnvelope.Create( - "order-data", "Gateway", "order.created"); - + "data", "svc", "order.created"); var decision = await router.RouteAsync(envelope); - Assert.That(decision.Destination, Is.EqualTo("orders-v2-topic")); - } - // ── Challenge 3: Unregister Non-Existent Key Returns False ────────────── + Assert.That(decision.Destination, Is.EqualTo("orders-topic")); + Assert.That(decision.IsFallback, Is.False); + output.AssertReceivedOnTopic("orders-topic", 1); + } - [Test] - public async Task Challenge3_UnregisterNonExistent_ReturnsFalse() + private static DynamicRouter CreateRouter(MockEndpoint output) { - // Unregistering a condition key that was never registered should return - // false and leave the routing table unchanged. - var producer = Substitute.For(); - var options = Options.Create(new DynamicRouterOptions { ConditionField = "MessageType", - FallbackTopic = "fallback", + FallbackTopic = "unmatched", + CaseInsensitive = true, }); - - var router = new DynamicRouter(producer, options, NullLogger.Instance); - - await router.RegisterAsync("order.created", "orders-topic"); - - // Try to unregister a key that doesn't exist. - var removed = await router.UnregisterAsync("payment.received"); - Assert.That(removed, Is.False); - - // Original entry is still intact. - var table = router.GetRoutingTable(); - Assert.That(table, Has.Count.EqualTo(1)); - Assert.That(table.ContainsKey("order.created"), Is.True); - - // Route still works. - var envelope = IntegrationEnvelope.Create( - "order-data", "Gateway", "order.created"); - - var decision = await router.RouteAsync(envelope); - Assert.That(decision.Destination, Is.EqualTo("orders-topic")); + return new DynamicRouter( + output, options, NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial11/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial11/Lab.cs index eb8be31..d7fa07d 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial11/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial11/Lab.cs @@ -1,220 +1,143 @@ // ============================================================================ // Tutorial 11 – Dynamic Router (Lab) // ============================================================================ -// This lab exercises the DynamicRouter pattern — a router whose routing table -// is updated at runtime by downstream participants via a control channel. -// You will register and unregister routes, verify routing decisions, test -// case-insensitive matching, and confirm fallback behaviour. +// EIP Pattern: Dynamic Router +// E2E: Wire real DynamicRouter with MockEndpoint as producer, register/ +// unregister routes at runtime, verify routing decisions and message delivery. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Routing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial11; [TestFixture] public sealed class Lab { - // ── Register a Route and Route a Matching Message ─────────────────────── + private MockEndpoint _output = null!; - [Test] - public async Task Route_RegisteredCondition_RoutesToRegisteredDestination() - { - var producer = Substitute.For(); - - var options = Options.Create(new DynamicRouterOptions - { - ConditionField = "MessageType", - FallbackTopic = "unmatched-topic", - }); + [SetUp] + public void SetUp() => _output = new MockEndpoint("dynamic-router-out"); - var router = new DynamicRouter(producer, options, NullLogger.Instance); + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); - // Register a dynamic route for "order.created" messages. - await router.RegisterAsync("order.created", "orders-topic", "OrderService"); + [Test] + public async Task Route_RegisteredKey_RoutesToDestination() + { + var router = CreateRouter(); + await router.RegisterAsync("order.created", "orders-topic"); var envelope = IntegrationEnvelope.Create( "order-data", "OrderService", "order.created"); - var decision = await router.RouteAsync(envelope); Assert.That(decision.Destination, Is.EqualTo("orders-topic")); Assert.That(decision.IsFallback, Is.False); Assert.That(decision.MatchedEntry, Is.Not.Null); - Assert.That(decision.MatchedEntry!.ParticipantId, Is.EqualTo("OrderService")); Assert.That(decision.ConditionValue, Is.EqualTo("order.created")); + _output.AssertReceivedOnTopic("orders-topic", 1); } - // ── Unmatched Message Falls Back to FallbackTopic ─────────────────────── - [Test] - public async Task Route_NoMatchingRoute_UsesFallbackTopic() + public async Task Route_UnregisteredKey_FallsBackToDefault() { - var producer = Substitute.For(); - - var options = Options.Create(new DynamicRouterOptions - { - ConditionField = "MessageType", - FallbackTopic = "catch-all-topic", - }); - - var router = new DynamicRouter(producer, options, NullLogger.Instance); + var router = CreateRouter(fallback: "dead-letter"); + await router.RegisterAsync("order.created", "orders-topic"); - // No routes registered — everything falls back. var envelope = IntegrationEnvelope.Create( - "unknown-data", "UnknownService", "unknown.event"); - + "unknown", "Svc", "unknown.event"); var decision = await router.RouteAsync(envelope); - Assert.That(decision.Destination, Is.EqualTo("catch-all-topic")); + Assert.That(decision.Destination, Is.EqualTo("dead-letter")); Assert.That(decision.IsFallback, Is.True); Assert.That(decision.MatchedEntry, Is.Null); + _output.AssertReceivedOnTopic("dead-letter", 1); } - // ── Unregister Removes Route Entry ────────────────────────────────────── - [Test] - public async Task Unregister_RemovesRoute_SubsequentMessageUsesFallback() + public async Task Route_NoMatchNoFallback_ThrowsInvalidOperation() { - var producer = Substitute.For(); + var router = CreateRouter(fallback: null); - var options = Options.Create(new DynamicRouterOptions - { - ConditionField = "MessageType", - FallbackTopic = "fallback-topic", - }); - - var router = new DynamicRouter(producer, options, NullLogger.Instance); - - await router.RegisterAsync("order.created", "orders-topic"); - - // Unregister the route. - var removed = await router.UnregisterAsync("order.created"); - Assert.That(removed, Is.True); - - // Now routing should fall back. var envelope = IntegrationEnvelope.Create( - "order-data", "OrderService", "order.created"); + "data", "Svc", "no.match"); - var decision = await router.RouteAsync(envelope); - Assert.That(decision.IsFallback, Is.True); - Assert.That(decision.Destination, Is.EqualTo("fallback-topic")); + Assert.ThrowsAsync( + async () => await router.RouteAsync(envelope)); + _output.AssertNoneReceived(); } - // ── Case-Insensitive Routing (Default) ────────────────────────────────── - [Test] - public async Task Route_CaseInsensitive_MatchesRegardlessOfCase() + public async Task Register_UpdatesExistingRoute() { - var producer = Substitute.For(); + var router = CreateRouter(); + await router.RegisterAsync("order.created", "old-topic"); + await router.RegisterAsync("order.created", "new-topic"); - var options = Options.Create(new DynamicRouterOptions - { - ConditionField = "MessageType", - FallbackTopic = "fallback", - CaseInsensitive = true, - }); + var envelope = IntegrationEnvelope.Create( + "data", "Svc", "order.created"); + var decision = await router.RouteAsync(envelope); - var router = new DynamicRouter(producer, options, NullLogger.Instance); + Assert.That(decision.Destination, Is.EqualTo("new-topic")); + _output.AssertReceivedOnTopic("new-topic", 1); + } - // Register with lowercase key. + [Test] + public async Task Unregister_RemovesRoute_FallsBack() + { + var router = CreateRouter(fallback: "fallback-topic"); await router.RegisterAsync("order.created", "orders-topic"); + var removed = await router.UnregisterAsync("order.created"); - // Route with mixed case — should still match. - var envelope = IntegrationEnvelope.Create( - "data", "Service", "Order.Created"); + Assert.That(removed, Is.True); + var envelope = IntegrationEnvelope.Create( + "data", "Svc", "order.created"); var decision = await router.RouteAsync(envelope); - Assert.That(decision.Destination, Is.EqualTo("orders-topic")); - Assert.That(decision.IsFallback, Is.False); + Assert.That(decision.IsFallback, Is.True); + _output.AssertReceivedOnTopic("fallback-topic", 1); } - // ── GetRoutingTable Returns Current Snapshot ───────────────────────────── - [Test] - public async Task GetRoutingTable_ReturnsAllRegisteredEntries() + public async Task Unregister_NonExistentKey_ReturnsFalse() { - var producer = Substitute.For(); + var router = CreateRouter(); - var options = Options.Create(new DynamicRouterOptions - { - ConditionField = "MessageType", - FallbackTopic = "fallback", - }); + var removed = await router.UnregisterAsync("no-such-key"); - var router = new DynamicRouter(producer, options, NullLogger.Instance); + Assert.That(removed, Is.False); + } - await router.RegisterAsync("order.created", "orders-topic", "OrderService"); - await router.RegisterAsync("payment.received", "payments-topic", "PaymentService"); + [Test] + public async Task GetRoutingTable_ReturnsSnapshot() + { + var router = CreateRouter(); + await router.RegisterAsync("order.created", "orders-topic", "participant-1"); + await router.RegisterAsync("payment.received", "payments-topic", "participant-2"); var table = router.GetRoutingTable(); Assert.That(table, Has.Count.EqualTo(2)); Assert.That(table.ContainsKey("order.created"), Is.True); - Assert.That(table.ContainsKey("payment.received"), Is.True); Assert.That(table["order.created"].Destination, Is.EqualTo("orders-topic")); - Assert.That(table["payment.received"].Destination, Is.EqualTo("payments-topic")); + Assert.That(table["order.created"].ParticipantId, Is.EqualTo("participant-1")); } - // ── No Fallback Configured Throws ─────────────────────────────────────── - - [Test] - public void Route_NoFallbackConfigured_ThrowsInvalidOperationException() + private DynamicRouter CreateRouter(string? fallback = "catch-all") { - var producer = Substitute.For(); - - // FallbackTopic is null — no safety net. var options = Options.Create(new DynamicRouterOptions { ConditionField = "MessageType", - FallbackTopic = null, - }); - - var router = new DynamicRouter(producer, options, NullLogger.Instance); - - var envelope = IntegrationEnvelope.Create( - "data", "Service", "unknown.event"); - - Assert.ThrowsAsync( - () => router.RouteAsync(envelope)); - } - - // ── Routing by Metadata Field ─────────────────────────────────────────── - - [Test] - public async Task Route_ByMetadataField_MatchesDynamicEntry() - { - var producer = Substitute.For(); - - var options = Options.Create(new DynamicRouterOptions - { - ConditionField = "Metadata.region", - FallbackTopic = "global-topic", + FallbackTopic = fallback, + CaseInsensitive = true, }); - - var router = new DynamicRouter(producer, options, NullLogger.Instance); - - await router.RegisterAsync("eu-west", "eu-west-topic", "EUService"); - - var envelope = IntegrationEnvelope.Create( - "eu-data", "RegionalService", "data.sync") with - { - Metadata = new Dictionary - { - ["region"] = "eu-west", - }, - }; - - var decision = await router.RouteAsync(envelope); - - Assert.That(decision.Destination, Is.EqualTo("eu-west-topic")); - Assert.That(decision.IsFallback, Is.False); - Assert.That(decision.MatchedEntry!.ParticipantId, Is.EqualTo("EUService")); + return new DynamicRouter( + _output, options, NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial12/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial12/Exam.cs index acffb64..2760f06 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial12/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial12/Exam.cs @@ -1,177 +1,148 @@ // ============================================================================ // Tutorial 12 – Recipient List (Exam) // ============================================================================ -// Coding challenges: build an event notification system, combine rule-based -// and metadata-based recipient resolution, and handle cross-rule dedup. +// E2E challenges: event notification fan-out, combined rule + metadata +// resolution, and cross-rule deduplication verification via MockEndpoint. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Routing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial12; [TestFixture] public sealed class Exam { - // ── Challenge 1: Event Notification Fan-Out ───────────────────────────── - [Test] - public async Task Challenge1_EventNotification_FansOutToAllSubscribers() + public async Task Challenge1_EventNotificationFanOut_RoutesToAllSubscribers() { - // Build a recipient list that routes order events to three departments: - // - Warehouse (fulfilment-topic) - // - Finance (billing-topic) - // - Analytics (analytics-topic) - // All three should receive a copy of every order.created message. - var producer = Substitute.For(); - + await using var output = new MockEndpoint("notification"); var options = Options.Create(new RecipientListOptions { Rules = [ new RecipientListRule { + Name = "OrderNotify", FieldName = "MessageType", Operator = RoutingOperator.Equals, Value = "order.created", - Destinations = ["fulfilment-topic", "billing-topic", "analytics-topic"], - Name = "OrderNotification", + Destinations = ["email-svc", "sms-svc", "push-svc"], + }, + new RecipientListRule + { + Name = "HighPriorityAlert", + FieldName = "Metadata.priority", + Operator = RoutingOperator.Equals, + Value = "high", + Destinations = ["pager-svc"], }, ], }); - - var router = new RecipientListRouter(producer, options, NullLogger.Instance); + var router = new RecipientListRouter( + output, options, NullLogger.Instance); var envelope = IntegrationEnvelope.Create( - "new-order", "OrderService", "order.created"); - + "order-1", "svc", "order.created") with + { + Metadata = new Dictionary { ["priority"] = "high" }, + }; var result = await router.RouteAsync(envelope); - Assert.That(result.ResolvedCount, Is.EqualTo(3)); - Assert.That(result.Destinations, Contains.Item("fulfilment-topic")); - Assert.That(result.Destinations, Contains.Item("billing-topic")); - Assert.That(result.Destinations, Contains.Item("analytics-topic")); - - // Non-order message should not match. - var paymentEnvelope = IntegrationEnvelope.Create( - "payment-data", "PaymentService", "payment.received"); - - var paymentResult = await router.RouteAsync(paymentEnvelope); - Assert.That(paymentResult.ResolvedCount, Is.EqualTo(0)); + Assert.That(result.ResolvedCount, Is.EqualTo(4)); + output.AssertReceivedOnTopic("email-svc", 1); + output.AssertReceivedOnTopic("sms-svc", 1); + output.AssertReceivedOnTopic("push-svc", 1); + output.AssertReceivedOnTopic("pager-svc", 1); } - // ── Challenge 2: Rule + Metadata Combined Resolution ──────────────────── - [Test] - public async Task Challenge2_RuleAndMetadataCombined_AllDestinationsReached() + public async Task Challenge2_RulesAndMetadataCombined_MergesDestinations() { - // Combine rule-based routing (audit-topic for all messages from OrderService) - // with metadata-based routing (extra destinations in the "notify" key). - // Verify that all destinations — from rules AND metadata — are resolved - // and deduplicated. - var producer = Substitute.For(); - + await using var output = new MockEndpoint("combined"); var options = Options.Create(new RecipientListOptions { Rules = [ new RecipientListRule { - FieldName = "Source", - Operator = RoutingOperator.Equals, - Value = "OrderService", - Destinations = ["audit-topic", "compliance-topic"], - Name = "OrderAudit", + Name = "AuditAll", + FieldName = "MessageType", + Operator = RoutingOperator.Contains, + Value = "order", + Destinations = ["audit-log"], }, ], - MetadataRecipientsKey = "notify", + MetadataRecipientsKey = "extra-recipients", }); - - var router = new RecipientListRouter(producer, options, NullLogger.Instance); + var router = new RecipientListRouter( + output, options, NullLogger.Instance); var envelope = IntegrationEnvelope.Create( - "order-data", "OrderService", "order.created") with + "data", "svc", "order.created") with { Metadata = new Dictionary { - // Extra recipients from metadata — "audit-topic" is a duplicate. - ["notify"] = "analytics-topic,audit-topic", + ["extra-recipients"] = "webhook-svc,reporting-svc", }, }; - var result = await router.RouteAsync(envelope); - // Rule: audit-topic, compliance-topic. Metadata: analytics-topic, audit-topic. - // Deduplication removes one "audit-topic". Assert.That(result.ResolvedCount, Is.EqualTo(3)); - Assert.That(result.DuplicatesRemoved, Is.EqualTo(1)); - Assert.That(result.Destinations, Contains.Item("audit-topic")); - Assert.That(result.Destinations, Contains.Item("compliance-topic")); - Assert.That(result.Destinations, Contains.Item("analytics-topic")); + output.AssertReceivedOnTopic("audit-log", 1); + output.AssertReceivedOnTopic("webhook-svc", 1); + output.AssertReceivedOnTopic("reporting-svc", 1); } - // ── Challenge 3: Regex-Based Recipient Matching ───────────────────────── - [Test] - public async Task Challenge3_RegexRouting_MatchesPatternBasedDestinations() + public async Task Challenge3_CrossRuleDedup_RemovesDuplicateDestinations() { - // Use the Regex operator to route all "order.*" message types to one set - // of recipients and all "payment.*" types to another. - var producer = Substitute.For(); - + await using var output = new MockEndpoint("dedup"); var options = Options.Create(new RecipientListOptions { Rules = [ new RecipientListRule { + Name = "TypeRule", FieldName = "MessageType", - Operator = RoutingOperator.Regex, - Value = @"^order\..+", - Destinations = ["order-audit", "order-analytics"], - Name = "AllOrderEvents", + Operator = RoutingOperator.StartsWith, + Value = "order", + Destinations = ["shared-topic", "orders-topic"], }, new RecipientListRule { - FieldName = "MessageType", - Operator = RoutingOperator.Regex, - Value = @"^payment\..+", - Destinations = ["payment-audit", "payment-ledger"], - Name = "AllPaymentEvents", + Name = "SourceRule", + FieldName = "Source", + Operator = RoutingOperator.Equals, + Value = "OrderService", + Destinations = ["shared-topic", "source-audit"], }, ], + MetadataRecipientsKey = "recipients", }); + var router = new RecipientListRouter( + output, options, NullLogger.Instance); - var router = new RecipientListRouter(producer, options, NullLogger.Instance); - - // An order message. - var orderEnvelope = IntegrationEnvelope.Create( - "order-data", "OrderService", "order.shipped"); - - var orderResult = await router.RouteAsync(orderEnvelope); - Assert.That(orderResult.ResolvedCount, Is.EqualTo(2)); - Assert.That(orderResult.Destinations, Contains.Item("order-audit")); - Assert.That(orderResult.Destinations, Contains.Item("order-analytics")); - - // A payment message. - var paymentEnvelope = IntegrationEnvelope.Create( - "payment-data", "PaymentService", "payment.confirmed"); - - var paymentResult = await router.RouteAsync(paymentEnvelope); - Assert.That(paymentResult.ResolvedCount, Is.EqualTo(2)); - Assert.That(paymentResult.Destinations, Contains.Item("payment-audit")); - Assert.That(paymentResult.Destinations, Contains.Item("payment-ledger")); - - // A refund message matches neither. - var refundEnvelope = IntegrationEnvelope.Create( - "refund-data", "RefundService", "refund.issued"); + var envelope = IntegrationEnvelope.Create( + "data", "OrderService", "order.created") with + { + Metadata = new Dictionary + { + ["recipients"] = "shared-topic,extra-topic", + }, + }; + var result = await router.RouteAsync(envelope); - var refundResult = await router.RouteAsync(refundEnvelope); - Assert.That(refundResult.ResolvedCount, Is.EqualTo(0)); + Assert.That(result.DuplicatesRemoved, Is.GreaterThanOrEqualTo(2)); + Assert.That(result.Destinations, Does.Contain("shared-topic")); + Assert.That(result.Destinations, Does.Contain("orders-topic")); + Assert.That(result.Destinations, Does.Contain("source-audit")); + Assert.That(result.Destinations, Does.Contain("extra-topic")); + output.AssertReceivedCount(result.ResolvedCount); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial12/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial12/Lab.cs index 250e22f..827cced 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial12/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial12/Lab.cs @@ -1,282 +1,205 @@ // ============================================================================ // Tutorial 12 – Recipient List (Lab) // ============================================================================ -// This lab exercises the RecipientListRouter — a pattern that fans out a single -// message to multiple destinations based on matching rules and metadata-based -// recipient resolution. You will configure rules, verify deduplication, and -// confirm that all resolved recipients receive the message. +// EIP Pattern: Recipient List +// E2E: Wire real RecipientListRouter with MockEndpoint as producer, configure +// fan-out rules, send messages, verify delivery to multiple destinations. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Routing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial12; [TestFixture] public sealed class Lab { - // ── Single Rule Matches — Fan-out to Multiple Destinations ────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("recipient-list-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Route_SingleRuleMatches_PublishesToAllDestinations() + public async Task Route_SingleRuleMatch_FansOutToAllDestinations() { - var producer = Substitute.For(); - - var options = Options.Create(new RecipientListOptions + var router = CreateRouter(new RecipientListRule { - Rules = - [ - new RecipientListRule - { - FieldName = "MessageType", - Operator = RoutingOperator.Equals, - Value = "order.created", - Destinations = ["audit-topic", "analytics-topic", "fulfilment-topic"], - Name = "OrderFanOut", - }, - ], + Name = "OrderEvents", + FieldName = "MessageType", + Operator = RoutingOperator.Equals, + Value = "order.created", + Destinations = ["orders-topic", "audit-topic", "analytics-topic"], }); - var router = new RecipientListRouter(producer, options, NullLogger.Instance); - var envelope = IntegrationEnvelope.Create( "order-data", "OrderService", "order.created"); - var result = await router.RouteAsync(envelope); Assert.That(result.ResolvedCount, Is.EqualTo(3)); - Assert.That(result.Destinations, Contains.Item("audit-topic")); - Assert.That(result.Destinations, Contains.Item("analytics-topic")); - Assert.That(result.Destinations, Contains.Item("fulfilment-topic")); + Assert.That(result.Destinations, Has.Count.EqualTo(3)); + Assert.That(result.DuplicatesRemoved, Is.EqualTo(0)); + _output.AssertReceivedCount(3); + _output.AssertReceivedOnTopic("orders-topic", 1); + _output.AssertReceivedOnTopic("audit-topic", 1); + _output.AssertReceivedOnTopic("analytics-topic", 1); } - // ── Multiple Rules Match — Destinations Are Merged ────────────────────── - [Test] - public async Task Route_MultipleRulesMatch_MergesAllDestinations() + public async Task Route_NoRuleMatch_ReturnsEmptyResult() { - var producer = Substitute.For(); - - var options = Options.Create(new RecipientListOptions + var router = CreateRouter(new RecipientListRule { - Rules = - [ - new RecipientListRule - { - FieldName = "MessageType", - Operator = RoutingOperator.Contains, - Value = "order", - Destinations = ["audit-topic"], - Name = "AuditAll", - }, - new RecipientListRule - { - FieldName = "Source", - Operator = RoutingOperator.Equals, - Value = "OrderService", - Destinations = ["order-analytics"], - Name = "OrderAnalytics", - }, - ], + Name = "OrderEvents", + FieldName = "MessageType", + Operator = RoutingOperator.Equals, + Value = "order.created", + Destinations = ["orders-topic"], }); - var router = new RecipientListRouter(producer, options, NullLogger.Instance); - var envelope = IntegrationEnvelope.Create( - "order-data", "OrderService", "order.created"); - + "data", "Svc", "payment.received"); var result = await router.RouteAsync(envelope); - // Both rules match → destinations are merged. - Assert.That(result.ResolvedCount, Is.EqualTo(2)); - Assert.That(result.Destinations, Contains.Item("audit-topic")); - Assert.That(result.Destinations, Contains.Item("order-analytics")); + Assert.That(result.ResolvedCount, Is.EqualTo(0)); + Assert.That(result.Destinations, Is.Empty); + _output.AssertNoneReceived(); } - // ── Duplicate Destinations Are Removed ────────────────────────────────── - [Test] - public async Task Route_DuplicateDestinations_AreDeduplicated() + public async Task Route_MultipleRulesMatch_CombinesDestinations() { - var producer = Substitute.For(); - var options = Options.Create(new RecipientListOptions { Rules = [ new RecipientListRule { + Name = "TypeRule", FieldName = "MessageType", - Operator = RoutingOperator.Contains, + Operator = RoutingOperator.StartsWith, Value = "order", - Destinations = ["audit-topic", "analytics-topic"], + Destinations = ["orders-topic"], }, new RecipientListRule { + Name = "SourceRule", FieldName = "Source", - Operator = RoutingOperator.Equals, - Value = "OrderService", - Destinations = ["audit-topic", "fulfilment-topic"], + Operator = RoutingOperator.Contains, + Value = "Critical", + Destinations = ["alert-topic"], }, ], }); - - var router = new RecipientListRouter(producer, options, NullLogger.Instance); + var router = new RecipientListRouter( + _output, options, NullLogger.Instance); var envelope = IntegrationEnvelope.Create( - "order-data", "OrderService", "order.created"); - + "data", "CriticalOrderService", "order.created"); var result = await router.RouteAsync(envelope); - // "audit-topic" appears in both rules but should be deduplicated. - Assert.That(result.ResolvedCount, Is.EqualTo(3)); - Assert.That(result.DuplicatesRemoved, Is.EqualTo(1)); - Assert.That(result.Destinations, Contains.Item("audit-topic")); - Assert.That(result.Destinations, Contains.Item("analytics-topic")); - Assert.That(result.Destinations, Contains.Item("fulfilment-topic")); + Assert.That(result.ResolvedCount, Is.EqualTo(2)); + _output.AssertReceivedOnTopic("orders-topic", 1); + _output.AssertReceivedOnTopic("alert-topic", 1); } - // ── No Rule Matches — Empty Result ────────────────────────────────────── - [Test] - public async Task Route_NoRuleMatches_ReturnsEmptyDestinations() + public async Task Route_DuplicateDestinations_AreDeduplicated() { - var producer = Substitute.For(); - var options = Options.Create(new RecipientListOptions { Rules = [ new RecipientListRule { + Name = "Rule1", FieldName = "MessageType", - Operator = RoutingOperator.Equals, - Value = "order.created", - Destinations = ["orders-topic"], + Operator = RoutingOperator.StartsWith, + Value = "order", + Destinations = ["shared-topic", "orders-topic"], + }, + new RecipientListRule + { + Name = "Rule2", + FieldName = "Source", + Operator = RoutingOperator.Contains, + Value = "Service", + Destinations = ["shared-topic", "audit-topic"], }, ], }); + var router = new RecipientListRouter( + _output, options, NullLogger.Instance); - var router = new RecipientListRouter(producer, options, NullLogger.Instance); - - // This message type doesn't match any rule. var envelope = IntegrationEnvelope.Create( - "payment-data", "PaymentService", "payment.received"); - + "data", "OrderService", "order.created"); var result = await router.RouteAsync(envelope); - Assert.That(result.ResolvedCount, Is.EqualTo(0)); - Assert.That(result.Destinations, Is.Empty); + Assert.That(result.DuplicatesRemoved, Is.GreaterThan(0)); + Assert.That(result.ResolvedCount, Is.EqualTo(3)); + _output.AssertReceivedCount(3); } - // ── Metadata-Based Recipient Resolution ───────────────────────────────── - [Test] - public async Task Route_MetadataRecipients_AddsExtraDestinations() + public async Task Route_MetadataRecipients_AddsDestinations() { - var producer = Substitute.For(); - var options = Options.Create(new RecipientListOptions { Rules = [], MetadataRecipientsKey = "recipients", }); + var router = new RecipientListRouter( + _output, options, NullLogger.Instance); - var router = new RecipientListRouter(producer, options, NullLogger.Instance); - - // Destinations specified in the envelope metadata. var envelope = IntegrationEnvelope.Create( - "data", "Service", "event.occurred") with + "data", "Svc", "event.fired") with { Metadata = new Dictionary { ["recipients"] = "topic-a,topic-b,topic-c", }, }; - var result = await router.RouteAsync(envelope); Assert.That(result.ResolvedCount, Is.EqualTo(3)); - Assert.That(result.Destinations, Contains.Item("topic-a")); - Assert.That(result.Destinations, Contains.Item("topic-b")); - Assert.That(result.Destinations, Contains.Item("topic-c")); + _output.AssertReceivedOnTopic("topic-a", 1); + _output.AssertReceivedOnTopic("topic-b", 1); + _output.AssertReceivedOnTopic("topic-c", 1); } - // ── StartsWith Operator ───────────────────────────────────────────────── - [Test] - public async Task Route_StartsWithOperator_MatchesPrefixes() + public async Task Route_RegexRule_MatchesPattern() { - var producer = Substitute.For(); - - var options = Options.Create(new RecipientListOptions + var router = CreateRouter(new RecipientListRule { - Rules = - [ - new RecipientListRule - { - FieldName = "MessageType", - Operator = RoutingOperator.StartsWith, - Value = "order.", - Destinations = ["order-events-topic"], - Name = "AllOrderEvents", - }, - ], + Name = "AllOrders", + FieldName = "MessageType", + Operator = RoutingOperator.Regex, + Value = @"^order\..+", + Destinations = ["order-events"], }); - var router = new RecipientListRouter(producer, options, NullLogger.Instance); - var envelope = IntegrationEnvelope.Create( - "data", "OrderService", "order.shipped"); - + "shipped", "Svc", "order.shipped"); var result = await router.RouteAsync(envelope); Assert.That(result.ResolvedCount, Is.EqualTo(1)); - Assert.That(result.Destinations, Contains.Item("order-events-topic")); + _output.AssertReceivedOnTopic("order-events", 1); } - // ── Verify Producer Receives All Publish Calls ────────────────────────── - - [Test] - public async Task Route_PublishCalledForEachDestination() + private RecipientListRouter CreateRouter(RecipientListRule rule) { - var producer = Substitute.For(); - var options = Options.Create(new RecipientListOptions { - Rules = - [ - new RecipientListRule - { - FieldName = "MessageType", - Operator = RoutingOperator.Equals, - Value = "order.created", - Destinations = ["topic-a", "topic-b"], - }, - ], + Rules = [rule], }); - - var router = new RecipientListRouter(producer, options, NullLogger.Instance); - - var envelope = IntegrationEnvelope.Create( - "data", "OrderService", "order.created"); - - await router.RouteAsync(envelope); - - // Verify publish was called for each destination. - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("topic-a"), - Arg.Any()); - - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("topic-b"), - Arg.Any()); + return new RecipientListRouter( + _output, options, NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial13/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial13/Exam.cs index 4ba6a34..be698b9 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial13/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial13/Exam.cs @@ -1,172 +1,149 @@ // ============================================================================ // Tutorial 13 – Routing Slip (Exam) // ============================================================================ -// Coding challenges: build a multi-step processing pipeline, handle partial -// failure mid-slip, and verify step-by-step forwarding to destination topics. +// E2E challenges: multi-step pipeline execution, partial failure mid-slip, +// and step-by-step forwarding verification via MockEndpoint. // ============================================================================ using System.Text.Json; using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Routing; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial13; [TestFixture] public sealed class Exam { - // ── Challenge 1: Three-Step Pipeline — Validate → Transform → Deliver ─── - [Test] - public async Task Challenge1_ThreeStepPipeline_ExecutesFirstStepAndAdvances() + public async Task Challenge1_FullPipeline_ExecutesAllStepsSequentially() { - // Build a routing slip with three steps: Validate → Transform → Deliver. - // Execute the first step (Validate), verify it succeeds, and confirm - // the remaining slip has two steps. - var producer = Substitute.For(); - - var validateHandler = Substitute.For(); - validateHandler.StepName.Returns("Validate"); - validateHandler.HandleAsync( - Arg.Any>(), - Arg.Any?>(), - Arg.Any()) - .Returns(true); - - var transformHandler = Substitute.For(); - transformHandler.StepName.Returns("Transform"); - - var deliverHandler = Substitute.For(); - deliverHandler.StepName.Returns("Deliver"); - + await using var output = new MockEndpoint("pipeline"); + var handlers = new IRoutingSlipStepHandler[] + { + new TrackingHandler("Validate"), + new TrackingHandler("Transform"), + new TrackingHandler("Deliver"), + }; var router = new RoutingSlipRouter( - [validateHandler, transformHandler, deliverHandler], - producer, - NullLogger.Instance); - - var slip = new RoutingSlip([ - new RoutingSlipStep("Validate"), - new RoutingSlipStep("Transform", "transform-topic"), - new RoutingSlipStep("Deliver", "delivery-topic"), - ]); + handlers, output, NullLogger.Instance); + + var envelope = CreateEnvelopeWithSlip( + new RoutingSlipStep("Validate", "step1-out"), + new RoutingSlipStep("Transform", "step2-out"), + new RoutingSlipStep("Deliver", "step3-out")); + + var r1 = await router.ExecuteCurrentStepAsync(envelope); + Assert.That(r1.Succeeded, Is.True); + Assert.That(r1.StepName, Is.EqualTo("Validate")); + Assert.That(r1.RemainingSlip.Steps, Has.Count.EqualTo(2)); + + var r2 = await router.ExecuteCurrentStepAsync(envelope); + Assert.That(r2.Succeeded, Is.True); + Assert.That(r2.StepName, Is.EqualTo("Transform")); + Assert.That(r2.RemainingSlip.Steps, Has.Count.EqualTo(1)); + + var r3 = await router.ExecuteCurrentStepAsync(envelope); + Assert.That(r3.Succeeded, Is.True); + Assert.That(r3.StepName, Is.EqualTo("Deliver")); + Assert.That(r3.RemainingSlip.IsComplete, Is.True); + + output.AssertReceivedCount(3); + output.AssertReceivedOnTopic("step1-out", 1); + output.AssertReceivedOnTopic("step2-out", 1); + output.AssertReceivedOnTopic("step3-out", 1); + } - var envelope = IntegrationEnvelope.Create( - "order-data", "OrderService", "order.created") with + [Test] + public async Task Challenge2_PartialFailure_StopsAtFailedStep() + { + await using var output = new MockEndpoint("partial-fail"); + var handlers = new IRoutingSlipStepHandler[] { - Metadata = new Dictionary - { - [RoutingSlip.MetadataKey] = JsonSerializer.Serialize(slip.Steps), - }, + new TrackingHandler("Validate"), + new FailingHandler("Transform"), + new TrackingHandler("Deliver"), }; + var router = new RoutingSlipRouter( + handlers, output, NullLogger.Instance); - var result = await router.ExecuteCurrentStepAsync(envelope); + var envelope = CreateEnvelopeWithSlip( + new RoutingSlipStep("Validate", "step1-out"), + new RoutingSlipStep("Transform", "step2-out"), + new RoutingSlipStep("Deliver", "step3-out")); - Assert.That(result.StepName, Is.EqualTo("Validate")); - Assert.That(result.Succeeded, Is.True); - Assert.That(result.RemainingSlip.Steps, Has.Count.EqualTo(2)); - Assert.That(result.RemainingSlip.CurrentStep!.StepName, Is.EqualTo("Transform")); - } + var r1 = await router.ExecuteCurrentStepAsync(envelope); + Assert.That(r1.Succeeded, Is.True); + output.AssertReceivedOnTopic("step1-out", 1); + + var r2 = await router.ExecuteCurrentStepAsync(envelope); + Assert.That(r2.Succeeded, Is.False); + Assert.That(r2.StepName, Is.EqualTo("Transform")); + Assert.That(r2.FailureReason, Is.Not.Null); - // ── Challenge 2: Mid-Pipeline Failure Halts Processing ────────────────── + // Only step1 was forwarded; Transform failed so step2/step3 not reached + output.AssertReceivedCount(1); + } [Test] - public async Task Challenge2_MidPipelineFailure_HaltsAtFailedStep() + public async Task Challenge3_MissingSlip_ThrowsInvalidOperation() { - // In a two-step slip (Validate → Enrich), if Validate fails, the - // remaining slip should still contain both steps (no advancement). - var producer = Substitute.For(); - - var validateHandler = Substitute.For(); - validateHandler.StepName.Returns("Validate"); - validateHandler.HandleAsync( - Arg.Any>(), - Arg.Any?>(), - Arg.Any()) - .Returns(false); // Validation fails. - - var enrichHandler = Substitute.For(); - enrichHandler.StepName.Returns("Enrich"); - + await using var output = new MockEndpoint("no-slip"); var router = new RoutingSlipRouter( - [validateHandler, enrichHandler], - producer, - NullLogger.Instance); - - var slip = new RoutingSlip([ - new RoutingSlipStep("Validate"), - new RoutingSlipStep("Enrich", "enrich-topic"), - ]); + Array.Empty(), + output, NullLogger.Instance); var envelope = IntegrationEnvelope.Create( - "bad-data", "Service", "event.type") with - { - Metadata = new Dictionary - { - [RoutingSlip.MetadataKey] = JsonSerializer.Serialize(slip.Steps), - }, - }; - - var result = await router.ExecuteCurrentStepAsync(envelope); + "data", "svc", "test.event"); - Assert.That(result.Succeeded, Is.False); - Assert.That(result.StepName, Is.EqualTo("Validate")); - // Slip was NOT advanced — both steps remain. - Assert.That(result.RemainingSlip.Steps, Has.Count.EqualTo(2)); - Assert.That(result.ForwardedToTopic, Is.Null); - - // Producer was NOT called — no forwarding on failure. - await producer.DidNotReceive().PublishAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any()); + Assert.ThrowsAsync( + async () => await router.ExecuteCurrentStepAsync(envelope)); + output.AssertNoneReceived(); } - // ── Challenge 3: Step with Destination Topic Forwards Message ──────────── + // ── Helpers ───────────────────────────────────────────────────────── - [Test] - public async Task Challenge3_StepForwarding_PublishesToDestinationTopic() + private static IntegrationEnvelope CreateEnvelopeWithSlip( + params RoutingSlipStep[] steps) { - // When a step has a DestinationTopic and succeeds, the router should - // publish the envelope to that topic. - var producer = Substitute.For(); - - var handler = Substitute.For(); - handler.StepName.Returns("Deliver"); - handler.HandleAsync( - Arg.Any>(), - Arg.Any?>(), - Arg.Any()) - .Returns(true); - - var router = new RoutingSlipRouter( - [handler], producer, NullLogger.Instance); - - var slip = new RoutingSlip([ - new RoutingSlipStep("Deliver", "final-destination-topic"), - ]); + var slip = new RoutingSlip(steps.ToList().AsReadOnly()); + var slipJson = JsonSerializer.Serialize(slip.Steps, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }); - var envelope = IntegrationEnvelope.Create( - "payload", "Service", "event.type") with + return IntegrationEnvelope.Create("test-payload", "TestSvc", "test.event") with { Metadata = new Dictionary { - [RoutingSlip.MetadataKey] = JsonSerializer.Serialize(slip.Steps), + [RoutingSlip.MetadataKey] = slipJson, }, }; + } - var result = await router.ExecuteCurrentStepAsync(envelope); - - Assert.That(result.Succeeded, Is.True); - Assert.That(result.ForwardedToTopic, Is.EqualTo("final-destination-topic")); - Assert.That(result.RemainingSlip.IsComplete, Is.True); + private sealed class TrackingHandler : IRoutingSlipStepHandler + { + public TrackingHandler(string stepName) => StepName = stepName; + public string StepName { get; } + + public Task HandleAsync( + IntegrationEnvelope envelope, + IReadOnlyDictionary? parameters, + CancellationToken cancellationToken = default) => + Task.FromResult(true); + } - // Verify the producer published to the correct topic. - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("final-destination-topic"), - Arg.Any()); + private sealed class FailingHandler : IRoutingSlipStepHandler + { + public FailingHandler(string stepName) => StepName = stepName; + public string StepName { get; } + + public Task HandleAsync( + IntegrationEnvelope envelope, + IReadOnlyDictionary? parameters, + CancellationToken cancellationToken = default) => + Task.FromResult(false); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial13/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial13/Lab.cs index ef3e9a5..0c86356 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial13/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial13/Lab.cs @@ -1,281 +1,196 @@ // ============================================================================ // Tutorial 13 – Routing Slip (Lab) // ============================================================================ -// This lab exercises the RoutingSlipRouter — a pattern where each message -// carries its own processing itinerary. Steps are executed sequentially; -// after each step the slip is advanced and the message may be forwarded -// to a destination topic. +// EIP Pattern: Routing Slip +// E2E: Wire real RoutingSlipRouter with test step handlers + MockEndpoint, +// execute steps sequentially, verify forwarding to destination topics. // ============================================================================ using System.Text.Json; using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Routing; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial13; [TestFixture] public sealed class Lab { - // ── Execute a Single Step Successfully ─────────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("routing-slip-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Execute_SingleStep_SucceedsAndAdvancesSlip() + public async Task ExecuteStep_SingleStep_SucceedsAndForwards() { - var producer = Substitute.For(); - - // Create a handler that always succeeds. - var handler = Substitute.For(); - handler.StepName.Returns("Validate"); - handler.HandleAsync( - Arg.Any>(), - Arg.Any?>(), - Arg.Any()) - .Returns(true); - - var router = new RoutingSlipRouter( - [handler], producer, NullLogger.Instance); - - // Build an envelope with a routing slip in metadata. - var slip = new RoutingSlip([new RoutingSlipStep("Validate", "output-topic")]); - var envelope = IntegrationEnvelope.Create( - "payload", "Service", "event.type") with - { - Metadata = new Dictionary - { - [RoutingSlip.MetadataKey] = JsonSerializer.Serialize(slip.Steps), - }, - }; + var router = CreateRouter(new AlwaysSucceedHandler("Validate")); + var envelope = CreateEnvelopeWithSlip( + new RoutingSlipStep("Validate", "validated-topic")); var result = await router.ExecuteCurrentStepAsync(envelope); Assert.That(result.StepName, Is.EqualTo("Validate")); Assert.That(result.Succeeded, Is.True); Assert.That(result.FailureReason, Is.Null); + Assert.That(result.ForwardedToTopic, Is.EqualTo("validated-topic")); Assert.That(result.RemainingSlip.IsComplete, Is.True); - Assert.That(result.ForwardedToTopic, Is.EqualTo("output-topic")); + _output.AssertReceivedOnTopic("validated-topic", 1); } - // ── Step Fails — Handler Returns False ────────────────────────────────── - [Test] - public async Task Execute_StepFails_ResultIndicatesFailure() + public async Task ExecuteStep_NoDestination_CompletesInProcess() { - var producer = Substitute.For(); - - var handler = Substitute.For(); - handler.StepName.Returns("Validate"); - handler.HandleAsync( - Arg.Any>(), - Arg.Any?>(), - Arg.Any()) - .Returns(false); - - var router = new RoutingSlipRouter( - [handler], producer, NullLogger.Instance); - - var slip = new RoutingSlip([new RoutingSlipStep("Validate", "output-topic")]); - var envelope = IntegrationEnvelope.Create( - "payload", "Service", "event.type") with - { - Metadata = new Dictionary - { - [RoutingSlip.MetadataKey] = JsonSerializer.Serialize(slip.Steps), - }, - }; + var router = CreateRouter(new AlwaysSucceedHandler("Enrich")); + var envelope = CreateEnvelopeWithSlip( + new RoutingSlipStep("Enrich")); var result = await router.ExecuteCurrentStepAsync(envelope); - Assert.That(result.Succeeded, Is.False); - Assert.That(result.FailureReason, Is.Not.Null); + Assert.That(result.Succeeded, Is.True); Assert.That(result.ForwardedToTopic, Is.Null); + _output.AssertNoneReceived(); } - // ── No Handler Registered — Step Fails ────────────────────────────────── - [Test] - public async Task Execute_NoHandlerForStep_FailsWithReason() + public async Task ExecuteStep_HandlerFails_ReturnsFalseResult() { - var producer = Substitute.For(); - - // Register a handler for "Transform" but the slip calls "Validate". - var handler = Substitute.For(); - handler.StepName.Returns("Transform"); - - var router = new RoutingSlipRouter( - [handler], producer, NullLogger.Instance); - - var slip = new RoutingSlip([new RoutingSlipStep("Validate")]); - var envelope = IntegrationEnvelope.Create( - "payload", "Service", "event.type") with - { - Metadata = new Dictionary - { - [RoutingSlip.MetadataKey] = JsonSerializer.Serialize(slip.Steps), - }, - }; + var router = CreateRouter(new AlwaysFailHandler("Transform")); + var envelope = CreateEnvelopeWithSlip( + new RoutingSlipStep("Transform", "transformed-topic")); var result = await router.ExecuteCurrentStepAsync(envelope); + Assert.That(result.StepName, Is.EqualTo("Transform")); Assert.That(result.Succeeded, Is.False); - Assert.That(result.FailureReason, Does.Contain("Validate")); + Assert.That(result.FailureReason, Is.Not.Null); + _output.AssertNoneReceived(); } - // ── Multi-Step Slip — Advance Through Steps ───────────────────────────── - [Test] - public async Task Execute_MultiStepSlip_AdvancesToNextStep() + public async Task ExecuteStep_NoHandlerRegistered_FailsGracefully() { - var producer = Substitute.For(); - - var validateHandler = Substitute.For(); - validateHandler.StepName.Returns("Validate"); - validateHandler.HandleAsync( - Arg.Any>(), - Arg.Any?>(), - Arg.Any()) - .Returns(true); - - var router = new RoutingSlipRouter( - [validateHandler], producer, NullLogger.Instance); - - // Slip with two steps: Validate (no forwarding) → Transform. - var slip = new RoutingSlip([ - new RoutingSlipStep("Validate"), - new RoutingSlipStep("Transform", "transform-topic"), - ]); - - var envelope = IntegrationEnvelope.Create( - "payload", "Service", "event.type") with - { - Metadata = new Dictionary - { - [RoutingSlip.MetadataKey] = JsonSerializer.Serialize(slip.Steps), - }, - }; + var router = CreateRouter(new AlwaysSucceedHandler("Other")); + var envelope = CreateEnvelopeWithSlip( + new RoutingSlipStep("NonExistent", "dest-topic")); var result = await router.ExecuteCurrentStepAsync(envelope); - // After executing "Validate", one step remains. - Assert.That(result.StepName, Is.EqualTo("Validate")); - Assert.That(result.Succeeded, Is.True); - Assert.That(result.RemainingSlip.Steps, Has.Count.EqualTo(1)); - Assert.That(result.RemainingSlip.CurrentStep!.StepName, Is.EqualTo("Transform")); - Assert.That(result.ForwardedToTopic, Is.Null); // No destination on Validate step. + Assert.That(result.Succeeded, Is.False); + Assert.That(result.FailureReason, Does.Contain("NonExistent")); + _output.AssertNoneReceived(); } - // ── Step with Parameters ──────────────────────────────────────────────── - [Test] - public async Task Execute_StepWithParameters_PassesParametersToHandler() + public async Task ExecuteStep_MultiStepSlip_AdvancesCorrectly() { - var producer = Substitute.For(); - - IReadOnlyDictionary? receivedParams = null; + var router = CreateRouter( + new AlwaysSucceedHandler("Step1"), + new AlwaysSucceedHandler("Step2")); - var handler = Substitute.For(); - handler.StepName.Returns("Enrich"); - handler.HandleAsync( - Arg.Any>(), - Arg.Any?>(), - Arg.Any()) - .Returns(ci => - { - receivedParams = ci.ArgAt?>(1); - return true; - }); + var envelope = CreateEnvelopeWithSlip( + new RoutingSlipStep("Step1", "step1-out"), + new RoutingSlipStep("Step2", "step2-out")); - var router = new RoutingSlipRouter( - [handler], producer, NullLogger.Instance); + var result1 = await router.ExecuteCurrentStepAsync(envelope); - var parameters = new Dictionary - { - ["lookupUrl"] = "https://api.example.com/enrich", - ["timeout"] = "30", - }; + Assert.That(result1.StepName, Is.EqualTo("Step1")); + Assert.That(result1.Succeeded, Is.True); + Assert.That(result1.RemainingSlip.Steps, Has.Count.EqualTo(1)); + Assert.That(result1.RemainingSlip.CurrentStep!.StepName, Is.EqualTo("Step2")); + _output.AssertReceivedOnTopic("step1-out", 1); + } - var slip = new RoutingSlip([ - new RoutingSlipStep("Enrich", null, parameters), - ]); + [Test] + public async Task ExecuteStep_WithParameters_PassesParametersToHandler() + { + var handler = new ParameterCapturingHandler("Configure"); + var router = CreateRouter(handler); - var envelope = IntegrationEnvelope.Create( - "payload", "Service", "event.type") with + var parameters = new Dictionary { - Metadata = new Dictionary - { - [RoutingSlip.MetadataKey] = JsonSerializer.Serialize(slip.Steps), - }, + ["format"] = "json", + ["compress"] = "true", }; + var envelope = CreateEnvelopeWithSlip( + new RoutingSlipStep("Configure", "configured-topic", parameters)); var result = await router.ExecuteCurrentStepAsync(envelope); Assert.That(result.Succeeded, Is.True); - Assert.That(receivedParams, Is.Not.Null); - Assert.That(receivedParams!["lookupUrl"], Is.EqualTo("https://api.example.com/enrich")); - Assert.That(receivedParams["timeout"], Is.EqualTo("30")); + Assert.That(handler.CapturedParameters, Is.Not.Null); + Assert.That(handler.CapturedParameters!["format"], Is.EqualTo("json")); + Assert.That(handler.CapturedParameters["compress"], Is.EqualTo("true")); + _output.AssertReceivedOnTopic("configured-topic", 1); } - // ── Handler Throws Exception — Step Fails Gracefully ──────────────────── + // ── Helpers ───────────────────────────────────────────────────────── - [Test] - public async Task Execute_HandlerThrows_ResultIndicatesFailureWithMessage() + private RoutingSlipRouter CreateRouter(params IRoutingSlipStepHandler[] handlers) => + new(handlers, _output, NullLogger.Instance); + + private static IntegrationEnvelope CreateEnvelopeWithSlip( + params RoutingSlipStep[] steps) { - var producer = Substitute.For(); - - var handler = Substitute.For(); - handler.StepName.Returns("RiskyStep"); - handler.HandleAsync( - Arg.Any>(), - Arg.Any?>(), - Arg.Any()) - .Returns(_ => throw new InvalidOperationException("Connection timed out")); - - var router = new RoutingSlipRouter( - [handler], producer, NullLogger.Instance); - - var slip = new RoutingSlip([new RoutingSlipStep("RiskyStep", "output-topic")]); - var envelope = IntegrationEnvelope.Create( - "payload", "Service", "event.type") with + var slip = new RoutingSlip(steps.ToList().AsReadOnly()); + var slipJson = JsonSerializer.Serialize(slip.Steps, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }); + + return IntegrationEnvelope.Create("test-payload", "TestSvc", "test.event") with { Metadata = new Dictionary { - [RoutingSlip.MetadataKey] = JsonSerializer.Serialize(slip.Steps), + [RoutingSlip.MetadataKey] = slipJson, }, }; - - var result = await router.ExecuteCurrentStepAsync(envelope); - - Assert.That(result.Succeeded, Is.False); - Assert.That(result.FailureReason, Does.Contain("Connection timed out")); - Assert.That(result.ForwardedToTopic, Is.Null); } - // ── RoutingSlip Contract Tests ────────────────────────────────────────── + // ── Test step handlers ────────────────────────────────────────────── - [Test] - public void RoutingSlip_Advance_ConsumesCurrentStep() + private sealed class AlwaysSucceedHandler : IRoutingSlipStepHandler { - var slip = new RoutingSlip([ - new RoutingSlipStep("Step1"), - new RoutingSlipStep("Step2"), - new RoutingSlipStep("Step3"), - ]); - - Assert.That(slip.IsComplete, Is.False); - Assert.That(slip.CurrentStep!.StepName, Is.EqualTo("Step1")); - - var advanced = slip.Advance(); - Assert.That(advanced.CurrentStep!.StepName, Is.EqualTo("Step2")); - Assert.That(advanced.Steps, Has.Count.EqualTo(2)); + public AlwaysSucceedHandler(string stepName) => StepName = stepName; + public string StepName { get; } + + public Task HandleAsync( + IntegrationEnvelope envelope, + IReadOnlyDictionary? parameters, + CancellationToken cancellationToken = default) => + Task.FromResult(true); + } - var advanced2 = advanced.Advance(); - Assert.That(advanced2.CurrentStep!.StepName, Is.EqualTo("Step3")); + private sealed class AlwaysFailHandler : IRoutingSlipStepHandler + { + public AlwaysFailHandler(string stepName) => StepName = stepName; + public string StepName { get; } + + public Task HandleAsync( + IntegrationEnvelope envelope, + IReadOnlyDictionary? parameters, + CancellationToken cancellationToken = default) => + Task.FromResult(false); + } - var completed = advanced2.Advance(); - Assert.That(completed.IsComplete, Is.True); - Assert.That(completed.CurrentStep, Is.Null); + private sealed class ParameterCapturingHandler : IRoutingSlipStepHandler + { + public ParameterCapturingHandler(string stepName) => StepName = stepName; + public string StepName { get; } + public IReadOnlyDictionary? CapturedParameters { get; private set; } + + public Task HandleAsync( + IntegrationEnvelope envelope, + IReadOnlyDictionary? parameters, + CancellationToken cancellationToken = default) + { + CapturedParameters = parameters; + return Task.FromResult(true); + } } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial14/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial14/Exam.cs index c308f3a..7bfe49f 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial14/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial14/Exam.cs @@ -1,7 +1,7 @@ // ============================================================================ // Tutorial 14 – Process Manager (Exam) // ============================================================================ -// Coding challenges: verify metadata serialisation, test envelope-to-input +// E2E challenges: verify metadata serialisation, test envelope-to-input // priority mapping, and validate idempotent workflow ID generation. // ============================================================================ @@ -9,9 +9,9 @@ using EnterpriseIntegrationPlatform.Activities; using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Demo.Pipeline; +using EnterpriseIntegrationPlatform.Testing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; namespace TutorialLabs.Tutorial14; @@ -19,174 +19,77 @@ namespace TutorialLabs.Tutorial14; [TestFixture] public sealed class Exam { - // ── Challenge 1: Metadata Serialisation ────────────────────────────────── - [Test] - public async Task Challenge1_MetadataSerialisation_NullWhenEmpty() + public async Task Challenge1_PriorityMapping_CastsEnumToInt() { - // When the envelope has no metadata entries, MetadataJson in the - // pipeline input should be null (not an empty JSON object). - IntegrationPipelineInput? capturedInput = null; - - var dispatcher = Substitute.For(); - dispatcher.DispatchAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(ci => - { - capturedInput = ci.ArgAt(0); - return new IntegrationPipelineResult(capturedInput.MessageId, IsSuccess: true); - }); - - var options = Options.Create(new PipelineOptions - { - AckSubject = "ack", - NackSubject = "nack", - }); + var dispatcher = new MockTemporalWorkflowDispatcher(); + var orchestrator = CreateOrchestrator(dispatcher); - var orchestrator = new PipelineOrchestrator( - dispatcher, options, NullLogger.Instance); - - var json = JsonSerializer.Deserialize("{}"); + var json = JsonSerializer.Deserialize("{\"item\":\"widget\"}"); var envelope = IntegrationEnvelope.Create( - json, "Service", "event.type"); - // Ensure metadata is empty (default). + json, "Svc", "order.created") with + { + Priority = MessagePriority.High, + }; + dispatcher.ReturnsSuccess(); await orchestrator.ProcessAsync(envelope); - Assert.That(capturedInput, Is.Not.Null); - Assert.That(capturedInput!.MetadataJson, Is.Null); + var captured = dispatcher.LastInput; + Assert.That(captured!.Priority, Is.EqualTo((int)MessagePriority.High)); } [Test] - public async Task Challenge1_MetadataSerialisation_PopulatedWhenPresent() + public async Task Challenge2_IdempotentWorkflowId_DeterministicFromMessageId() { - IntegrationPipelineInput? capturedInput = null; - - var dispatcher = Substitute.For(); - dispatcher.DispatchAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(ci => - { - capturedInput = ci.ArgAt(0); - return new IntegrationPipelineResult(capturedInput.MessageId, IsSuccess: true); - }); + var dispatcher = new MockTemporalWorkflowDispatcher(); + var orchestrator = CreateOrchestrator(dispatcher); - var options = Options.Create(new PipelineOptions - { - AckSubject = "ack", - NackSubject = "nack", - }); - - var orchestrator = new PipelineOrchestrator( - dispatcher, options, NullLogger.Instance); - - var json = JsonSerializer.Deserialize("{}"); + var json = JsonSerializer.Deserialize("{\"data\":1}"); var envelope = IntegrationEnvelope.Create( - json, "Service", "event.type") with - { - Metadata = new Dictionary - { - ["region"] = "us-east", - ["tenant"] = "acme", - }, - }; + json, "Svc", "test.type"); + dispatcher.ReturnsSuccess(); await orchestrator.ProcessAsync(envelope); - Assert.That(capturedInput, Is.Not.Null); - Assert.That(capturedInput!.MetadataJson, Is.Not.Null); - Assert.That(capturedInput.MetadataJson, Does.Contain("us-east")); - Assert.That(capturedInput.MetadataJson, Does.Contain("acme")); + var expectedId = $"integration-{envelope.MessageId}"; + Assert.That(dispatcher.LastWorkflowId, Is.EqualTo(expectedId)); } - // ── Challenge 2: Priority Mapping — Enum to Int ───────────────────────── - [Test] - public async Task Challenge2_PriorityMapping_EnumCastsToInt() + public async Task Challenge3_CausationIdAndTimestamp_PreservedInInput() { - // The PipelineOrchestrator maps MessagePriority enum to int. - // Verify that High (2) and Critical (3) map correctly. - IntegrationPipelineInput? capturedInput = null; - - var dispatcher = Substitute.For(); - dispatcher.DispatchAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(ci => - { - capturedInput = ci.ArgAt(0); - return new IntegrationPipelineResult(capturedInput.MessageId, IsSuccess: true); - }); + var dispatcher = new MockTemporalWorkflowDispatcher(); + var orchestrator = CreateOrchestrator(dispatcher); - var options = Options.Create(new PipelineOptions - { - AckSubject = "ack", - NackSubject = "nack", - }); - - var orchestrator = new PipelineOrchestrator( - dispatcher, options, NullLogger.Instance); - - var json = JsonSerializer.Deserialize("{}"); - - // Test Critical priority mapping. - var criticalEnvelope = IntegrationEnvelope.Create( - json, "Service", "alert.critical") with + var causationId = Guid.NewGuid(); + var timestamp = DateTimeOffset.UtcNow.AddMinutes(-5); + var json = JsonSerializer.Deserialize("{\"v\":1}"); + var envelope = IntegrationEnvelope.Create( + json, "Svc", "test.type") with { - Priority = MessagePriority.Critical, + CausationId = causationId, + Timestamp = timestamp, }; - await orchestrator.ProcessAsync(criticalEnvelope); + dispatcher.ReturnsSuccess(); + await orchestrator.ProcessAsync(envelope); - Assert.That(capturedInput, Is.Not.Null); - Assert.That(capturedInput!.Priority, Is.EqualTo((int)MessagePriority.Critical)); + var captured = dispatcher.LastInput; + Assert.That(captured!.CausationId, Is.EqualTo(causationId)); + Assert.That(captured.Timestamp, Is.EqualTo(timestamp)); + Assert.That(captured.SchemaVersion, Is.EqualTo(envelope.SchemaVersion)); } - // ── Challenge 3: Idempotent Workflow IDs ──────────────────────────────── - - [Test] - public async Task Challenge3_IdempotentWorkflowId_SameMessageProducesSameId() + private static PipelineOrchestrator CreateOrchestrator( + MockTemporalWorkflowDispatcher dispatcher) { - // Processing the same envelope twice should produce the same workflow ID, - // enabling Temporal's idempotency guarantees. - var capturedIds = new List(); - - var dispatcher = Substitute.For(); - dispatcher.DispatchAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(ci => - { - capturedIds.Add(ci.ArgAt(1)); - var input = ci.ArgAt(0); - return new IntegrationPipelineResult(input.MessageId, IsSuccess: true); - }); - var options = Options.Create(new PipelineOptions { - AckSubject = "ack", - NackSubject = "nack", + AckSubject = "integration.ack", + NackSubject = "integration.nack", }); - - var orchestrator = new PipelineOrchestrator( + return new PipelineOrchestrator( dispatcher, options, NullLogger.Instance); - - var json = JsonSerializer.Deserialize("{}"); - var envelope = IntegrationEnvelope.Create( - json, "Service", "event.type"); - - // Process the same envelope twice. - await orchestrator.ProcessAsync(envelope); - await orchestrator.ProcessAsync(envelope); - - Assert.That(capturedIds, Has.Count.EqualTo(2)); - Assert.That(capturedIds[0], Is.EqualTo(capturedIds[1])); - Assert.That(capturedIds[0], Is.EqualTo($"integration-{envelope.MessageId}")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial14/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial14/Lab.cs index ba72ee4..9116499 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial14/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial14/Lab.cs @@ -1,19 +1,19 @@ // ============================================================================ // Tutorial 14 – Process Manager (Lab) // ============================================================================ -// This lab exercises the PipelineOrchestrator — the Process Manager pattern -// that converts an IntegrationEnvelope into an IntegrationPipelineInput and -// dispatches it to a Temporal workflow. You will verify input mapping, mock -// the Temporal dispatcher, and validate success/failure paths. +// EIP Pattern: Process Manager +// E2E: PipelineOrchestrator converts IntegrationEnvelope to pipeline input +// and dispatches to Temporal. Uses MockTemporalWorkflowDispatcher since +// Temporal requires a real server. // ============================================================================ using System.Text.Json; using EnterpriseIntegrationPlatform.Activities; using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Demo.Pipeline; +using EnterpriseIntegrationPlatform.Testing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; namespace TutorialLabs.Tutorial14; @@ -21,219 +21,123 @@ namespace TutorialLabs.Tutorial14; [TestFixture] public sealed class Lab { - // ── Successful Dispatch — Workflow Returns Success ─────────────────────── + private MockTemporalWorkflowDispatcher _dispatcher = null!; + + [SetUp] + public void SetUp() => _dispatcher = new MockTemporalWorkflowDispatcher(); [Test] - public async Task Process_SuccessfulWorkflow_CompletesWithoutError() + public async Task ProcessAsync_DispatchesCorrectWorkflowId() { - var dispatcher = Substitute.For(); - dispatcher.DispatchAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(ci => new IntegrationPipelineResult( - ci.ArgAt(0).MessageId, - IsSuccess: true)); + var orchestrator = CreateOrchestrator(); + var envelope = CreateEnvelope("order-data", "OrderService", "order.created"); - var options = Options.Create(new PipelineOptions - { - AckSubject = "integration.ack", - NackSubject = "integration.nack", - }); - - var orchestrator = new PipelineOrchestrator( - dispatcher, options, NullLogger.Instance); + _dispatcher.ReturnsSuccess(); + await orchestrator.ProcessAsync(envelope); - var json = JsonSerializer.Deserialize( - """{"orderId": "ORD-1", "amount": 100}"""); + Assert.That(_dispatcher.LastWorkflowId, Is.EqualTo($"integration-{envelope.MessageId}")); + } - var envelope = IntegrationEnvelope.Create( - json, "OrderService", "order.created"); + [Test] + public async Task ProcessAsync_MapsEnvelopeFieldsToInput() + { + var orchestrator = CreateOrchestrator(); + var envelope = CreateEnvelope("payload-data", "TestSource", "test.type"); - // Should complete without throwing. + _dispatcher.ReturnsSuccess(); await orchestrator.ProcessAsync(envelope); - // Verify the dispatcher was called exactly once. - await dispatcher.Received(1).DispatchAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()); + var capturedInput = _dispatcher.LastInput; + Assert.That(capturedInput, Is.Not.Null); + Assert.That(capturedInput!.MessageId, Is.EqualTo(envelope.MessageId)); + Assert.That(capturedInput.CorrelationId, Is.EqualTo(envelope.CorrelationId)); + Assert.That(capturedInput.Source, Is.EqualTo("TestSource")); + Assert.That(capturedInput.MessageType, Is.EqualTo("test.type")); } - // ── Input Mapping — Envelope Fields Map to Pipeline Input ──────────────── - [Test] - public async Task Process_InputMapping_AllFieldsCorrectlyMapped() + public async Task ProcessAsync_SerializesPayloadAsJson() { - IntegrationPipelineInput? capturedInput = null; - - var dispatcher = Substitute.For(); - dispatcher.DispatchAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(ci => - { - capturedInput = ci.ArgAt(0); - return new IntegrationPipelineResult(capturedInput.MessageId, IsSuccess: true); - }); - - var options = Options.Create(new PipelineOptions - { - AckSubject = "test.ack", - NackSubject = "test.nack", - }); + var orchestrator = CreateOrchestrator(); + var json = JsonSerializer.Deserialize("{\"key\":\"value\"}"); + var envelope = IntegrationEnvelope.Create( + json, "Svc", "test.type"); - var orchestrator = new PipelineOrchestrator( - dispatcher, options, NullLogger.Instance); + _dispatcher.ReturnsSuccess(); + await orchestrator.ProcessAsync(envelope); - var json = JsonSerializer.Deserialize( - """{"key": "value"}"""); + var capturedInput = _dispatcher.LastInput; + Assert.That(capturedInput!.PayloadJson, Does.Contain("key")); + Assert.That(capturedInput.PayloadJson, Does.Contain("value")); + } - var envelope = IntegrationEnvelope.Create( - json, "TestService", "test.event") with + [Test] + public async Task ProcessAsync_WithMetadata_SerializesMetadataJson() + { + var orchestrator = CreateOrchestrator(); + var envelope = CreateEnvelope("data", "Svc", "test.type") with { - Priority = MessagePriority.High, - SchemaVersion = "2.0", Metadata = new Dictionary { + ["region"] = "us-east", ["tenant"] = "acme", }, }; + _dispatcher.ReturnsSuccess(); await orchestrator.ProcessAsync(envelope); - Assert.That(capturedInput, Is.Not.Null); - Assert.That(capturedInput!.MessageId, Is.EqualTo(envelope.MessageId)); - Assert.That(capturedInput.CorrelationId, Is.EqualTo(envelope.CorrelationId)); - Assert.That(capturedInput.Source, Is.EqualTo("TestService")); - Assert.That(capturedInput.MessageType, Is.EqualTo("test.event")); - Assert.That(capturedInput.SchemaVersion, Is.EqualTo("2.0")); - Assert.That(capturedInput.Priority, Is.EqualTo((int)MessagePriority.High)); - Assert.That(capturedInput.AckSubject, Is.EqualTo("test.ack")); - Assert.That(capturedInput.NackSubject, Is.EqualTo("test.nack")); - Assert.That(capturedInput.PayloadJson, Does.Contain("value")); - Assert.That(capturedInput.MetadataJson, Does.Contain("acme")); + var capturedInput = _dispatcher.LastInput; + Assert.That(capturedInput!.MetadataJson, Is.Not.Null); + Assert.That(capturedInput.MetadataJson, Does.Contain("region")); + Assert.That(capturedInput.MetadataJson, Does.Contain("us-east")); } - // ── Workflow ID Derived from MessageId ─────────────────────────────────── - [Test] - public async Task Process_WorkflowId_DerivedFromMessageId() + public async Task ProcessAsync_EmptyMetadata_SetsMetadataJsonNull() { - string? capturedWorkflowId = null; - - var dispatcher = Substitute.For(); - dispatcher.DispatchAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(ci => - { - capturedWorkflowId = ci.ArgAt(1); - var input = ci.ArgAt(0); - return new IntegrationPipelineResult(input.MessageId, IsSuccess: true); - }); - - var options = Options.Create(new PipelineOptions - { - AckSubject = "ack", - NackSubject = "nack", - }); - - var orchestrator = new PipelineOrchestrator( - dispatcher, options, NullLogger.Instance); - - var json = JsonSerializer.Deserialize("{}"); - var envelope = IntegrationEnvelope.Create( - json, "Service", "event.type"); + var orchestrator = CreateOrchestrator(); + var envelope = CreateEnvelope("data", "Svc", "test.type"); + _dispatcher.ReturnsSuccess(); await orchestrator.ProcessAsync(envelope); - Assert.That(capturedWorkflowId, Is.Not.Null); - Assert.That(capturedWorkflowId, Is.EqualTo($"integration-{envelope.MessageId}")); + var capturedInput = _dispatcher.LastInput; + Assert.That(capturedInput!.MetadataJson, Is.Null); } - // ── Failed Workflow — Completes Without Throwing ───────────────────────── - [Test] - public async Task Process_FailedWorkflow_CompletesWithoutThrowing() + public async Task ProcessAsync_SetsAckAndNackSubjectsFromOptions() { - var dispatcher = Substitute.For(); - dispatcher.DispatchAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(ci => new IntegrationPipelineResult( - ci.ArgAt(0).MessageId, - IsSuccess: false, - FailureReason: "Validation failed")); - - var options = Options.Create(new PipelineOptions - { - AckSubject = "ack", - NackSubject = "nack", - }); + var orchestrator = CreateOrchestrator(); + var envelope = CreateEnvelope("data", "Svc", "test.type"); - var orchestrator = new PipelineOrchestrator( - dispatcher, options, NullLogger.Instance); - - var json = JsonSerializer.Deserialize("{}"); - var envelope = IntegrationEnvelope.Create( - json, "Service", "event.type"); - - // ProcessAsync should not throw even when the workflow fails. - Assert.DoesNotThrowAsync(() => orchestrator.ProcessAsync(envelope)); - } - - // ── IntegrationPipelineInput Record Shape ─────────────────────────────── + _dispatcher.ReturnsSuccess(); + await orchestrator.ProcessAsync(envelope); - [Test] - public void PipelineInput_Record_HasExpectedProperties() - { - // Verify the IntegrationPipelineInput record has the expected shape. - var input = new IntegrationPipelineInput( - MessageId: Guid.NewGuid(), - CorrelationId: Guid.NewGuid(), - CausationId: null, - Timestamp: DateTimeOffset.UtcNow, - Source: "TestSource", - MessageType: "test.type", - SchemaVersion: "1.0", - Priority: 0, - PayloadJson: "{}", - MetadataJson: null, - AckSubject: "ack", - NackSubject: "nack"); - - Assert.That(input.Source, Is.EqualTo("TestSource")); - Assert.That(input.MessageType, Is.EqualTo("test.type")); - Assert.That(input.PayloadJson, Is.EqualTo("{}")); - Assert.That(input.MetadataJson, Is.Null); - Assert.That(input.NotificationsEnabled, Is.False); + var capturedInput = _dispatcher.LastInput; + Assert.That(capturedInput!.AckSubject, Is.EqualTo("integration.ack")); + Assert.That(capturedInput.NackSubject, Is.EqualTo("integration.nack")); } - // ── IntegrationPipelineResult Record Shape ────────────────────────────── + // ── Helpers ───────────────────────────────────────────────────────── - [Test] - public void PipelineResult_Success_HasCorrectProperties() + private PipelineOrchestrator CreateOrchestrator() { - var messageId = Guid.NewGuid(); - var result = new IntegrationPipelineResult(messageId, IsSuccess: true); - - Assert.That(result.MessageId, Is.EqualTo(messageId)); - Assert.That(result.IsSuccess, Is.True); - Assert.That(result.FailureReason, Is.Null); + var options = Options.Create(new PipelineOptions + { + AckSubject = "integration.ack", + NackSubject = "integration.nack", + }); + return new PipelineOrchestrator( + _dispatcher, options, NullLogger.Instance); } - [Test] - public void PipelineResult_Failure_HasReasonPopulated() + private static IntegrationEnvelope CreateEnvelope( + string payload, string source, string messageType) { - var messageId = Guid.NewGuid(); - var result = new IntegrationPipelineResult( - messageId, IsSuccess: false, FailureReason: "Timeout exceeded"); - - Assert.That(result.IsSuccess, Is.False); - Assert.That(result.FailureReason, Is.EqualTo("Timeout exceeded")); + var json = JsonSerializer.Deserialize( + $"{{\"data\":\"{payload}\"}}"); + return IntegrationEnvelope.Create(json, source, messageType); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial15/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial15/Exam.cs index a4460bb..81ea3a0 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial15/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial15/Exam.cs @@ -1,155 +1,112 @@ // ============================================================================ // Tutorial 15 – Message Translator (Exam) // ============================================================================ -// Coding challenges: build a type-converting translator, verify metadata -// preservation, and implement a multi-field transformation pipeline. +// E2E challenges: type-converting translator, metadata preservation chain, +// and multi-field transformation verification via MockEndpoint. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Translator; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial15; -// Simple DTOs used for type-conversion translation tests. -file sealed record OrderDto(string OrderId, decimal Amount, string Currency); -file sealed record OrderSummary(string Reference, string Total); - [TestFixture] public sealed class Exam { - // ── Challenge 1: Type-Converting Translator ───────────────────────────── - [Test] - public async Task Challenge1_TypeConversion_OrderDtoToOrderSummary() + public async Task Challenge1_TypeConversion_StringToInt() { - // Translate an OrderDto payload into an OrderSummary payload. - // The translator should: - // - Map OrderId → Reference - // - Format Amount + Currency → Total (e.g. "100.50 USD") - // - Preserve CorrelationId and set CausationId - var producer = Substitute.For(); - - var transform = new FuncPayloadTransform(order => - new OrderSummary( - Reference: order.OrderId, - Total: $"{order.Amount} {order.Currency}")); + await using var output = new MockEndpoint("type-convert"); + var transform = new MockPayloadTransform(input => int.Parse(input)); var options = Options.Create(new TranslatorOptions { - TargetTopic = "order-summaries", - TargetMessageType = "order.summary", + TargetTopic = "int-topic", + TargetMessageType = "number.parsed", }); + var translator = new MessageTranslator( + transform, output, options, + NullLogger>.Instance); - var translator = new MessageTranslator( - transform, producer, options, - NullLogger>.Instance); - - var source = IntegrationEnvelope.Create( - new OrderDto("ORD-1", 250.75m, "EUR"), - "OrderService", - "order.created"); - - var result = await translator.TranslateAsync(source); + var envelope = IntegrationEnvelope.Create( + "42", "parser-svc", "string.input"); + var result = await translator.TranslateAsync(envelope); - Assert.That(result.TranslatedEnvelope.Payload.Reference, Is.EqualTo("ORD-1")); - Assert.That(result.TranslatedEnvelope.Payload.Total, Is.EqualTo("250.75 EUR")); - Assert.That(result.TranslatedEnvelope.MessageType, Is.EqualTo("order.summary")); - Assert.That(result.TranslatedEnvelope.CorrelationId, Is.EqualTo(source.CorrelationId)); - Assert.That(result.TranslatedEnvelope.CausationId, Is.EqualTo(source.MessageId)); - Assert.That(result.TargetTopic, Is.EqualTo("order-summaries")); + Assert.That(result.TranslatedEnvelope.Payload, Is.EqualTo(42)); + Assert.That(result.TranslatedEnvelope.MessageType, Is.EqualTo("number.parsed")); + Assert.That(result.TranslatedEnvelope.CausationId, Is.EqualTo(envelope.MessageId)); + output.AssertReceivedOnTopic("int-topic", 1); } - // ── Challenge 2: Metadata Preservation ────────────────────────────────── - [Test] - public async Task Challenge2_MetadataPreservation_AllMetadataCopied() + public async Task Challenge2_MetadataPreservationChain_TwoTranslations() { - // Verify that the translator copies ALL metadata from the source envelope - // to the translated envelope, including custom keys. - var producer = Substitute.For(); - var transform = new FuncPayloadTransform(s => $"translated:{s}"); + await using var output1 = new MockEndpoint("stage1"); + await using var output2 = new MockEndpoint("stage2"); - var options = Options.Create(new TranslatorOptions - { - TargetTopic = "output-topic", - }); + var transform1 = new MockPayloadTransform(input => input.ToUpperInvariant()); - var translator = new MessageTranslator( - transform, producer, options, + var transform2 = new MockPayloadTransform(input => $"[{input}]"); + + var translator1 = new MessageTranslator( + transform1, output1, Options.Create(new TranslatorOptions { TargetTopic = "stage1-topic" }), NullLogger>.Instance); - var source = IntegrationEnvelope.Create( - "data", "Service", "event.type") with + var translator2 = new MessageTranslator( + transform2, output2, Options.Create(new TranslatorOptions { TargetTopic = "stage2-topic" }), + NullLogger>.Instance); + + var original = IntegrationEnvelope.Create( + "hello", "origin", "raw.text") with { - Priority = MessagePriority.High, - SchemaVersion = "3.0", - Metadata = new Dictionary - { - ["tenant"] = "acme-corp", - ["region"] = "eu-west", - ["trace-id"] = "abc-123", - }, + Metadata = new Dictionary { ["trace"] = "abc123" }, }; - var result = await translator.TranslateAsync(source); + var r1 = await translator1.TranslateAsync(original); + var r2 = await translator2.TranslateAsync(r1.TranslatedEnvelope); - // Payload is transformed. - Assert.That(result.TranslatedEnvelope.Payload, Is.EqualTo("translated:data")); + Assert.That(r2.TranslatedEnvelope.Payload, Is.EqualTo("[HELLO]")); + Assert.That(r2.TranslatedEnvelope.Metadata["trace"], Is.EqualTo("abc123")); + Assert.That(r2.TranslatedEnvelope.CorrelationId, Is.EqualTo(original.CorrelationId)); + Assert.That(r1.TranslatedEnvelope.CausationId, Is.EqualTo(original.MessageId)); + Assert.That(r2.TranslatedEnvelope.CausationId, Is.EqualTo(r1.TranslatedEnvelope.MessageId)); - // Metadata is preserved. - Assert.That(result.TranslatedEnvelope.Metadata["tenant"], Is.EqualTo("acme-corp")); - Assert.That(result.TranslatedEnvelope.Metadata["region"], Is.EqualTo("eu-west")); - Assert.That(result.TranslatedEnvelope.Metadata["trace-id"], Is.EqualTo("abc-123")); - - // Priority and SchemaVersion are preserved. - Assert.That(result.TranslatedEnvelope.Priority, Is.EqualTo(MessagePriority.High)); - Assert.That(result.TranslatedEnvelope.SchemaVersion, Is.EqualTo("3.0")); + output1.AssertReceivedOnTopic("stage1-topic", 1); + output2.AssertReceivedOnTopic("stage2-topic", 1); } - // ── Challenge 3: FuncPayloadTransform Convenience ─────────────────────── - [Test] - public async Task Challenge3_FuncPayloadTransform_SupportsComplexTransformations() + public async Task Challenge3_PreservesSourceWhenNoOverride() { - // Use FuncPayloadTransform to implement a transformation that: - // - Splits a comma-separated string into the count of elements - // - Returns the count as a string (e.g. "a,b,c" → "3") - // Demonstrates that FuncPayloadTransform can wrap arbitrary logic. - var producer = Substitute.For(); - - var transform = new FuncPayloadTransform(csv => - csv.Split(',', StringSplitOptions.RemoveEmptyEntries).Length); + await using var output = new MockEndpoint("preserve"); + var transform = new MockPayloadTransform(_ => "out"); var options = Options.Create(new TranslatorOptions { - TargetTopic = "counts-topic", - TargetMessageType = "item.count", - TargetSource = "CounterService", + TargetTopic = "dest-topic", }); + var translator = new MessageTranslator( + transform, output, options, + NullLogger>.Instance); - var translator = new MessageTranslator( - transform, producer, options, - NullLogger>.Instance); - - var source = IntegrationEnvelope.Create( - "apple,banana,cherry,date", "InventoryService", "inventory.list"); - - var result = await translator.TranslateAsync(source); + var envelope = IntegrationEnvelope.Create( + "data", "OriginalSource", "original.type") with + { + Priority = MessagePriority.High, + SchemaVersion = "2.0", + }; - Assert.That(result.TranslatedEnvelope.Payload, Is.EqualTo(4)); - Assert.That(result.TranslatedEnvelope.MessageType, Is.EqualTo("item.count")); - Assert.That(result.TranslatedEnvelope.Source, Is.EqualTo("CounterService")); - Assert.That(result.TargetTopic, Is.EqualTo("counts-topic")); + var result = await translator.TranslateAsync(envelope); - // Verify publish. - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("counts-topic"), - Arg.Any()); + Assert.That(result.TranslatedEnvelope.Source, Is.EqualTo("OriginalSource")); + Assert.That(result.TranslatedEnvelope.MessageType, Is.EqualTo("original.type")); + Assert.That(result.TranslatedEnvelope.Priority, Is.EqualTo(MessagePriority.High)); + Assert.That(result.TranslatedEnvelope.SchemaVersion, Is.EqualTo("2.0")); + output.AssertReceivedOnTopic("dest-topic", 1); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial15/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial15/Lab.cs index 6bdb9db..d49f821 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial15/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial15/Lab.cs @@ -1,204 +1,146 @@ // ============================================================================ // Tutorial 15 – Message Translator (Lab) // ============================================================================ -// This lab exercises the MessageTranslator — the pattern that converts a -// message from one format to another. You will test payload transformation, -// envelope field preservation (CorrelationId, Priority, CausationId chain), -// and verify that the translated envelope is published to the target topic. +// EIP Pattern: Message Translator +// E2E: Wire real MessageTranslator with MockPayloadTransform and +// MockEndpoint, verify payload transformation and envelope publishing. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Translator; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial15; [TestFixture] public sealed class Lab { - // ── Basic Translation — String to String ──────────────────────────────── + private MockEndpoint _output = null!; - [Test] - public async Task Translate_StringToString_ProducesTranslatedEnvelope() - { - var producer = Substitute.For(); - var transform = new FuncPayloadTransform(s => s.ToUpperInvariant()); + [SetUp] + public void SetUp() => _output = new MockEndpoint("translator-out"); - var options = Options.Create(new TranslatorOptions - { - TargetTopic = "translated-topic", - }); + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); - var translator = new MessageTranslator( - transform, producer, options, - NullLogger>.Instance); + [Test] + public async Task Translate_TransformsPayload_PublishesToTarget() + { + var transform = new MockPayloadTransform(input => input.ToUpperInvariant()); - var source = IntegrationEnvelope.Create( - "hello world", "SourceService", "greeting.event"); + var translator = CreateTranslator(transform, "translated-topic"); + var envelope = IntegrationEnvelope.Create( + "hello", "SourceSvc", "input.type"); - var result = await translator.TranslateAsync(source); + var result = await translator.TranslateAsync(envelope); - Assert.That(result.TranslatedEnvelope.Payload, Is.EqualTo("HELLO WORLD")); + Assert.That(result.TranslatedEnvelope.Payload, Is.EqualTo("HELLO")); Assert.That(result.TargetTopic, Is.EqualTo("translated-topic")); - Assert.That(result.SourceMessageId, Is.EqualTo(source.MessageId)); + Assert.That(result.SourceMessageId, Is.EqualTo(envelope.MessageId)); + _output.AssertReceivedOnTopic("translated-topic", 1); } - // ── CorrelationId Is Preserved ────────────────────────────────────────── - [Test] public async Task Translate_PreservesCorrelationId() { - var producer = Substitute.For(); - var transform = new FuncPayloadTransform(s => s); + var transform = new MockPayloadTransform(_ => "out"); - var options = Options.Create(new TranslatorOptions - { - TargetTopic = "output-topic", - }); + var translator = CreateTranslator(transform, "target"); + var envelope = IntegrationEnvelope.Create("in", "Svc", "type"); - var translator = new MessageTranslator( - transform, producer, options, - NullLogger>.Instance); - - var source = IntegrationEnvelope.Create( - "data", "Service", "event.type"); + var result = await translator.TranslateAsync(envelope); - var result = await translator.TranslateAsync(source); - - Assert.That(result.TranslatedEnvelope.CorrelationId, Is.EqualTo(source.CorrelationId)); + Assert.That(result.TranslatedEnvelope.CorrelationId, + Is.EqualTo(envelope.CorrelationId)); } - // ── CausationId Set to Source MessageId ────────────────────────────────── - [Test] - public async Task Translate_CausationId_SetToSourceMessageId() + public async Task Translate_SetsCausationIdToSourceMessageId() { - var producer = Substitute.For(); - var transform = new FuncPayloadTransform(s => s); - - var options = Options.Create(new TranslatorOptions - { - TargetTopic = "output-topic", - }); - - var translator = new MessageTranslator( - transform, producer, options, - NullLogger>.Instance); + var transform = new MockPayloadTransform(_ => "out"); - var source = IntegrationEnvelope.Create( - "data", "Service", "event.type"); + var translator = CreateTranslator(transform, "target"); + var envelope = IntegrationEnvelope.Create("in", "Svc", "type"); - var result = await translator.TranslateAsync(source); + var result = await translator.TranslateAsync(envelope); - Assert.That(result.TranslatedEnvelope.CausationId, Is.EqualTo(source.MessageId)); - Assert.That(result.TranslatedEnvelope.MessageId, Is.Not.EqualTo(source.MessageId)); + Assert.That(result.TranslatedEnvelope.CausationId, + Is.EqualTo(envelope.MessageId)); } - // ── TargetMessageType Override ────────────────────────────────────────── - [Test] - public async Task Translate_TargetMessageTypeOverride_ChangesMessageType() + public async Task Translate_OverridesSourceAndMessageType() { - var producer = Substitute.For(); - var transform = new FuncPayloadTransform(s => s); + var transform = new MockPayloadTransform(_ => "out"); var options = Options.Create(new TranslatorOptions { - TargetTopic = "output-topic", - TargetMessageType = "translated.event", + TargetTopic = "target", + TargetSource = "NewSource", + TargetMessageType = "new.type", }); - var translator = new MessageTranslator( - transform, producer, options, + transform, _output, options, NullLogger>.Instance); - var source = IntegrationEnvelope.Create( - "data", "Service", "original.event"); - - var result = await translator.TranslateAsync(source); + var envelope = IntegrationEnvelope.Create("in", "OldSource", "old.type"); + var result = await translator.TranslateAsync(envelope); - Assert.That(result.TranslatedEnvelope.MessageType, Is.EqualTo("translated.event")); + Assert.That(result.TranslatedEnvelope.Source, Is.EqualTo("NewSource")); + Assert.That(result.TranslatedEnvelope.MessageType, Is.EqualTo("new.type")); + _output.AssertReceivedOnTopic("target", 1); } - // ── TargetSource Override ─────────────────────────────────────────────── - [Test] - public async Task Translate_TargetSourceOverride_ChangesSource() + public async Task Translate_PreservesMetadata() { - var producer = Substitute.For(); - var transform = new FuncPayloadTransform(s => s); + var transform = new MockPayloadTransform(_ => "out"); - var options = Options.Create(new TranslatorOptions + var translator = CreateTranslator(transform, "target"); + var envelope = IntegrationEnvelope.Create("in", "Svc", "type") with { - TargetTopic = "output-topic", - TargetSource = "TranslatorService", - }); + Metadata = new Dictionary + { + ["region"] = "us-east", + ["tenant"] = "acme", + }, + }; - var translator = new MessageTranslator( - transform, producer, options, - NullLogger>.Instance); - - var source = IntegrationEnvelope.Create( - "data", "OriginalService", "event.type"); + var result = await translator.TranslateAsync(envelope); - var result = await translator.TranslateAsync(source); - - Assert.That(result.TranslatedEnvelope.Source, Is.EqualTo("TranslatorService")); + Assert.That(result.TranslatedEnvelope.Metadata["region"], Is.EqualTo("us-east")); + Assert.That(result.TranslatedEnvelope.Metadata["tenant"], Is.EqualTo("acme")); } - // ── No TargetTopic Configured — Throws ────────────────────────────────── - [Test] - public void Translate_NoTargetTopic_ThrowsInvalidOperationException() + public async Task Translate_NoTargetTopic_ThrowsInvalidOperation() { - var producer = Substitute.For(); - var transform = new FuncPayloadTransform(s => s); - - var options = Options.Create(new TranslatorOptions - { - TargetTopic = "", // Empty — not configured. - }); - + var transform = new MockPayloadTransform(_ => "out"); + var options = Options.Create(new TranslatorOptions { TargetTopic = "" }); var translator = new MessageTranslator( - transform, producer, options, + transform, _output, options, NullLogger>.Instance); - var source = IntegrationEnvelope.Create( - "data", "Service", "event.type"); + var envelope = IntegrationEnvelope.Create("in", "Svc", "type"); Assert.ThrowsAsync( - () => translator.TranslateAsync(source)); + async () => await translator.TranslateAsync(envelope)); + _output.AssertNoneReceived(); } - // ── Verify Producer PublishAsync Called ────────────────────────────────── - - [Test] - public async Task Translate_PublishesToTargetTopic() + private MessageTranslator CreateTranslator( + IPayloadTransform transform, string targetTopic) { - var producer = Substitute.For(); - var transform = new FuncPayloadTransform(s => s); - var options = Options.Create(new TranslatorOptions { - TargetTopic = "translated-topic", + TargetTopic = targetTopic, }); - - var translator = new MessageTranslator( - transform, producer, options, + return new MessageTranslator( + transform, _output, options, NullLogger>.Instance); - - var source = IntegrationEnvelope.Create( - "data", "Service", "event.type"); - - await translator.TranslateAsync(source); - - await producer.Received(1).PublishAsync( - Arg.Any>(), - Arg.Is("translated-topic"), - Arg.Any()); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial16/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial16/Exam.cs index abc4b78..f44cde0 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial16/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial16/Exam.cs @@ -1,94 +1,83 @@ // ============================================================================ // Tutorial 16 – Transform Pipeline (Exam) // ============================================================================ -// Coding challenges: build a JSON→XML→JSON round-trip pipeline, compose a -// regex-replace pipeline, and exercise concrete transform steps end-to-end. +// E2E challenges: regex replace step, JsonPathFilter step, and multi-step +// pipeline with metadata verification via MockEndpoint. // ============================================================================ -using System.Text.Json; +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.Transform; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial16; [TestFixture] public sealed class Exam { - // ── Challenge 1: JSON → XML Round-Trip ────────────────────────────────── - [Test] - public async Task Challenge1_JsonToXmlStep_ProducesValidXml() + public async Task Challenge1_RegexReplace_MasksPhoneNumbers() { - // Use the real JsonToXmlStep to convert a simple JSON object to XML. - var step = new JsonToXmlStep("Order"); - var options = Options.Create(new TransformOptions()); + var step = new RegexReplaceStep(@"\d{3}-\d{4}", "***-****"); + var options = Options.Create(new TransformOptions { Enabled = true }); var pipeline = new TransformPipeline( new ITransformStep[] { step }, options, NullLogger.Instance); - var json = """{"orderId":"ORD-1","amount":"250"}"""; - - var result = await pipeline.ExecuteAsync(json, "application/json"); + var result = await pipeline.ExecuteAsync( + "Call 555-1234 or 555-5678", "text/plain"); - Assert.That(result.ContentType, Is.EqualTo("application/xml")); - Assert.That(result.Payload, Does.Contain("")); - Assert.That(result.Payload, Does.Contain("ORD-1")); - Assert.That(result.Payload, Does.Contain("250")); + Assert.That(result.Payload, Is.EqualTo("Call ***-**** or ***-****")); Assert.That(result.StepsApplied, Is.EqualTo(1)); - Assert.That(result.Metadata.ContainsKey("Step.JsonToXml.Applied"), Is.True); } - // ── Challenge 2: Regex Replace Pipeline ───────────────────────────────── - [Test] - public async Task Challenge2_RegexReplacePipeline_SanitisesPayload() + public async Task Challenge2_JsonPathFilter_RetainsOnlySpecifiedPaths() { - // Build a two-step pipeline that first masks credit card numbers, then - // redacts email addresses from a plain-text payload. - var maskCards = new RegexReplaceStep( - @"\b\d{4}-\d{4}-\d{4}-\d{4}\b", "****-****-****-****"); - var redactEmails = new RegexReplaceStep( - @"[\w.+-]+@[\w-]+\.[\w.]+", "[REDACTED]"); - - var options = Options.Create(new TransformOptions()); + var step = new JsonPathFilterStep(new[] { "order.id", "customer.name" }); + var options = Options.Create(new TransformOptions { Enabled = true }); var pipeline = new TransformPipeline( - new ITransformStep[] { maskCards, redactEmails }, options, + new ITransformStep[] { step }, options, NullLogger.Instance); - var input = "Card: 1234-5678-9012-3456, Email: alice@example.com"; - - var result = await pipeline.ExecuteAsync(input, "text/plain"); + var payload = """{"order":{"id":"ORD-1","total":99.99},"customer":{"name":"Alice","email":"a@b.com"},"internal":"secret"}"""; + var result = await pipeline.ExecuteAsync(payload, "application/json"); - Assert.That(result.Payload, Does.Contain("****-****-****-****")); - Assert.That(result.Payload, Does.Contain("[REDACTED]")); - Assert.That(result.Payload, Does.Not.Contain("1234-5678-9012-3456")); - Assert.That(result.Payload, Does.Not.Contain("alice@example.com")); - Assert.That(result.StepsApplied, Is.EqualTo(2)); + Assert.That(result.ContentType, Is.EqualTo("application/json")); + Assert.That(result.Payload, Does.Contain("ORD-1")); + Assert.That(result.Payload, Does.Contain("Alice")); + Assert.That(result.Payload, Does.Not.Contain("secret")); + Assert.That(result.Payload, Does.Not.Contain("a@b.com")); } - // ── Challenge 3: XmlToJson Step End-to-End ────────────────────────────── - [Test] - public async Task Challenge3_XmlToJsonStep_ConvertsXmlToJson() + public async Task Challenge3_MultiStep_TransformAndPublish() { - // Use the real XmlToJsonStep to convert an XML document to JSON. - var step = new XmlToJsonStep(); - var options = Options.Create(new TransformOptions()); + await using var output = new MockEndpoint("exam-transform"); + + var steps = new ITransformStep[] + { + new RegexReplaceStep(@"\bfoo\b", "bar"), + new UpperCaseStep(), + new PrefixStep("[PROCESSED] "), + }; + var options = Options.Create(new TransformOptions { Enabled = true }); var pipeline = new TransformPipeline( - new ITransformStep[] { step }, options, - NullLogger.Instance); + steps, options, NullLogger.Instance); - var xml = "Alice30"; + var result = await pipeline.ExecuteAsync("the foo is here", "text/plain"); - var result = await pipeline.ExecuteAsync(xml, "application/xml"); + Assert.That(result.Payload, Is.EqualTo("[PROCESSED] THE BAR IS HERE")); + Assert.That(result.StepsApplied, Is.EqualTo(3)); - Assert.That(result.ContentType, Is.EqualTo("application/json")); - Assert.That(result.StepsApplied, Is.EqualTo(1)); + var envelope = IntegrationEnvelope.Create( + result.Payload, "TransformSvc", "transform.done"); + await output.PublishAsync(envelope, "processed-topic", CancellationToken.None); - using var doc = JsonDocument.Parse(result.Payload); - Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Alice")); - Assert.That(doc.RootElement.GetProperty("age").GetString(), Is.EqualTo("30")); + output.AssertReceivedOnTopic("processed-topic", 1); + var received = output.GetReceived(); + Assert.That(received.Payload, Is.EqualTo("[PROCESSED] THE BAR IS HERE")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial16/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial16/Lab.cs index 5cee5be..6d4ce22 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial16/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial16/Lab.cs @@ -1,169 +1,169 @@ // ============================================================================ // Tutorial 16 – Transform Pipeline (Lab) // ============================================================================ -// This lab exercises the TransformPipeline — the pattern that chains an -// ordered sequence of ITransformStep instances. You will verify step -// execution order, disabled pipeline passthrough, payload size limits, -// stop-on-failure behaviour, and metadata accumulation. +// EIP Pattern: Pipes and Filters (Transform variant). +// E2E: TransformPipeline with real ITransformStep implementations, verify +// transformed payload, step count, metadata, and publish results via MockEndpoint. // ============================================================================ +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.Transform; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; -using NSubstitute.ExceptionExtensions; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial16; +/// Test step that converts the payload to upper-case. +internal sealed class UpperCaseStep : ITransformStep +{ + public string Name => "UpperCase"; + + public Task ExecuteAsync( + TransformContext context, CancellationToken cancellationToken = default) + { + var result = context.WithPayload(context.Payload.ToUpperInvariant()); + result.Metadata[$"Step.{Name}.Applied"] = "true"; + return Task.FromResult(result); + } +} + +/// Test step that prepends a configurable prefix to the payload. +internal sealed class PrefixStep : ITransformStep +{ + private readonly string _prefix; + public PrefixStep(string prefix) => _prefix = prefix; + public string Name => "Prefix"; + + public Task ExecuteAsync( + TransformContext context, CancellationToken cancellationToken = default) + { + var result = context.WithPayload($"{_prefix}{context.Payload}"); + result.Metadata[$"Step.{Name}.Applied"] = "true"; + return Task.FromResult(result); + } +} + +/// Test step that always throws to verify error-handling behaviour. +internal sealed class FailingStep : ITransformStep +{ + public string Name => "Failing"; + + public Task ExecuteAsync( + TransformContext context, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("Intentional step failure"); +} + [TestFixture] public sealed class Lab { - // ── Basic Pipeline Execution ──────────────────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("transform-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Execute_SingleStep_AppliesTransformation() + public async Task Pipeline_SingleStep_TransformsPayload() { - var step = Substitute.For(); - step.Name.Returns("Upper"); - step.ExecuteAsync(Arg.Any(), Arg.Any()) - .Returns(ci => - { - var ctx = ci.Arg(); - return ctx.WithPayload(ctx.Payload.ToUpperInvariant()); - }); - - var options = Options.Create(new TransformOptions()); - var pipeline = new TransformPipeline( - new[] { step }, options, NullLogger.Instance); + var pipeline = CreatePipeline(new ITransformStep[] { new UpperCaseStep() }); - var result = await pipeline.ExecuteAsync("hello", "text/plain"); + var result = await pipeline.ExecuteAsync("hello world", "text/plain"); - Assert.That(result.Payload, Is.EqualTo("HELLO")); + Assert.That(result.Payload, Is.EqualTo("HELLO WORLD")); Assert.That(result.StepsApplied, Is.EqualTo(1)); Assert.That(result.ContentType, Is.EqualTo("text/plain")); } [Test] - public async Task Execute_MultipleSteps_AppliedInOrder() + public async Task Pipeline_MultipleSteps_ChainsTransformations() { - var step1 = Substitute.For(); - step1.Name.Returns("Append-A"); - step1.ExecuteAsync(Arg.Any(), Arg.Any()) - .Returns(ci => ci.Arg().WithPayload(ci.Arg().Payload + "A")); + var pipeline = CreatePipeline(new ITransformStep[] + { + new UpperCaseStep(), + new PrefixStep("[TRANSFORMED] "), + }); - var step2 = Substitute.For(); - step2.Name.Returns("Append-B"); - step2.ExecuteAsync(Arg.Any(), Arg.Any()) - .Returns(ci => ci.Arg().WithPayload(ci.Arg().Payload + "B")); - - var options = Options.Create(new TransformOptions()); - var pipeline = new TransformPipeline( - new[] { step1, step2 }, options, NullLogger.Instance); + var result = await pipeline.ExecuteAsync("order data", "text/plain"); - var result = await pipeline.ExecuteAsync("X", "text/plain"); - - Assert.That(result.Payload, Is.EqualTo("XAB")); + Assert.That(result.Payload, Is.EqualTo("[TRANSFORMED] ORDER DATA")); Assert.That(result.StepsApplied, Is.EqualTo(2)); } - // ── Disabled Pipeline ─────────────────────────────────────────────────── - [Test] - public async Task Execute_DisabledPipeline_ReturnsInputUnchanged() + public async Task Pipeline_Disabled_ReturnsInputUnchanged() { - var step = Substitute.For(); - var options = Options.Create(new TransformOptions { Enabled = false }); var pipeline = new TransformPipeline( - new[] { step }, options, NullLogger.Instance); + new ITransformStep[] { new UpperCaseStep() }, options, + NullLogger.Instance); - var result = await pipeline.ExecuteAsync("{\"id\":1}", "application/json"); + var result = await pipeline.ExecuteAsync("keep me", "text/plain"); - Assert.That(result.Payload, Is.EqualTo("{\"id\":1}")); + Assert.That(result.Payload, Is.EqualTo("keep me")); Assert.That(result.StepsApplied, Is.EqualTo(0)); - await step.DidNotReceive() - .ExecuteAsync(Arg.Any(), Arg.Any()); } - // ── Max Payload Size ──────────────────────────────────────────────────── - [Test] - public void Execute_PayloadExceedsMaxSize_ThrowsInvalidOperationException() + public async Task Pipeline_StepFailure_SkippedWhenNotStopOnFailure() { - var options = Options.Create(new TransformOptions { MaxPayloadSizeBytes = 10 }); + var options = Options.Create(new TransformOptions + { + Enabled = true, + StopOnStepFailure = false, + }); var pipeline = new TransformPipeline( - Array.Empty(), options, NullLogger.Instance); + new ITransformStep[] { new FailingStep(), new UpperCaseStep() }, + options, NullLogger.Instance); - var largePayload = new string('x', 50); + var result = await pipeline.ExecuteAsync("hello", "text/plain"); - Assert.ThrowsAsync( - () => pipeline.ExecuteAsync(largePayload, "text/plain")); + Assert.That(result.Payload, Is.EqualTo("HELLO")); + Assert.That(result.StepsApplied, Is.EqualTo(1)); } - // ── Stop On Step Failure ──────────────────────────────────────────────── - [Test] - public async Task Execute_StepFails_StopOnFailureFalse_ContinuesExecution() + public void Pipeline_MaxPayloadSize_RejectsOversized() { - var failingStep = Substitute.For(); - failingStep.Name.Returns("Failing"); - failingStep.ExecuteAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new InvalidOperationException("step error")); - - var goodStep = Substitute.For(); - goodStep.Name.Returns("Good"); - goodStep.ExecuteAsync(Arg.Any(), Arg.Any()) - .Returns(ci => ci.Arg().WithPayload("done")); - - var options = Options.Create(new TransformOptions { StopOnStepFailure = false }); + var options = Options.Create(new TransformOptions + { + Enabled = true, + MaxPayloadSizeBytes = 5, + }); var pipeline = new TransformPipeline( - new[] { failingStep, goodStep }, options, NullLogger.Instance); + new ITransformStep[] { new UpperCaseStep() }, options, + NullLogger.Instance); - var result = await pipeline.ExecuteAsync("input", "text/plain"); - - Assert.That(result.Payload, Is.EqualTo("done")); - Assert.That(result.StepsApplied, Is.EqualTo(1)); + Assert.ThrowsAsync( + () => pipeline.ExecuteAsync("this is too long", "text/plain")); } [Test] - public void Execute_StepFails_StopOnFailureTrue_Throws() + public async Task Pipeline_E2E_PublishTransformedToMockEndpoint() { - var failingStep = Substitute.For(); - failingStep.Name.Returns("Failing"); - failingStep.ExecuteAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new InvalidOperationException("boom")); + var pipeline = CreatePipeline(new ITransformStep[] + { + new UpperCaseStep(), + new PrefixStep("MSG:"), + }); - var options = Options.Create(new TransformOptions { StopOnStepFailure = true }); - var pipeline = new TransformPipeline( - new[] { failingStep }, options, NullLogger.Instance); + var result = await pipeline.ExecuteAsync("{\"name\":\"test\"}", "application/json"); - Assert.ThrowsAsync( - () => pipeline.ExecuteAsync("input", "text/plain")); - } + var envelope = IntegrationEnvelope.Create( + result.Payload, "TransformService", "transform.completed"); + await _output.PublishAsync(envelope, "transformed-topic", CancellationToken.None); - // ── Metadata Accumulation ─────────────────────────────────────────────── + _output.AssertReceivedOnTopic("transformed-topic", 1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Does.StartWith("MSG:")); + } - [Test] - public async Task Execute_StepsWriteMetadata_MetadataAccumulatedInResult() + private static TransformPipeline CreatePipeline(ITransformStep[] steps) { - var step = Substitute.For(); - step.Name.Returns("MetaStep"); - step.ExecuteAsync(Arg.Any(), Arg.Any()) - .Returns(ci => - { - var ctx = ci.Arg(); - ctx.Metadata["custom-key"] = "custom-value"; - return ctx; - }); - - var options = Options.Create(new TransformOptions()); - var pipeline = new TransformPipeline( - new[] { step }, options, NullLogger.Instance); - - var result = await pipeline.ExecuteAsync("data", "text/plain"); - - Assert.That(result.Metadata.ContainsKey("custom-key"), Is.True); - Assert.That(result.Metadata["custom-key"], Is.EqualTo("custom-value")); + var options = Options.Create(new TransformOptions { Enabled = true }); + return new TransformPipeline(steps, options, NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial17/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial17/Exam.cs index c40c58b..04f62c2 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial17/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial17/Exam.cs @@ -1,107 +1,87 @@ // ============================================================================ // Tutorial 17 – Normalizer (Exam) // ============================================================================ -// Coding challenges: normalise a multi-format message stream, verify XML -// with nested elements, and test CSV-without-headers mode. +// E2E challenges: XML with repeated elements (arrays), CSV with custom +// delimiter, and multi-format batch normalization via MockEndpoint. // ============================================================================ -using System.Text.Json; +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.Transform; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial17; [TestFixture] public sealed class Exam { - // ── Challenge 1: Multi-Format Stream ──────────────────────────────────── - - [Test] - public async Task Challenge1_MultiFormat_AllNormaliseToJson() - { - // Normalise three payloads (JSON, XML, CSV) using one normalizer and - // verify they all produce valid JSON output. - var options = Options.Create(new NormalizerOptions()); - var normalizer = new MessageNormalizer(options, NullLogger.Instance); - - var jsonPayload = """{"product":"Widget","qty":5}"""; - var xmlPayload = "Gadget10"; - var csvPayload = "product,qty\nGizmo,3"; - - var jsonResult = await normalizer.NormalizeAsync(jsonPayload, "application/json"); - var xmlResult = await normalizer.NormalizeAsync(xmlPayload, "application/xml"); - var csvResult = await normalizer.NormalizeAsync(csvPayload, "text/csv"); - - // All results should be parsable JSON. - Assert.DoesNotThrow(() => JsonDocument.Parse(jsonResult.Payload)); - Assert.DoesNotThrow(() => JsonDocument.Parse(xmlResult.Payload)); - Assert.DoesNotThrow(() => JsonDocument.Parse(csvResult.Payload)); - - Assert.That(jsonResult.WasTransformed, Is.False); - Assert.That(xmlResult.WasTransformed, Is.True); - Assert.That(csvResult.WasTransformed, Is.True); - } - - // ── Challenge 2: Nested XML Conversion ────────────────────────────────── - [Test] - public async Task Challenge2_NestedXml_ConvertedToNestedJson() + public async Task Challenge1_XmlRepeatedElements_ProducesJsonArrays() { - // XML with nested elements should produce nested JSON objects. - var options = Options.Create(new NormalizerOptions()); - var normalizer = new MessageNormalizer(options, NullLogger.Instance); - - var xml = """ - - ORD-42 - - Alice - alice@example.com - - 150.00 - - """; + var normalizer = new MessageNormalizer( + Options.Create(new NormalizerOptions()), + NullLogger.Instance); + var xml = "ABC"; var result = await normalizer.NormalizeAsync(xml, "application/xml"); Assert.That(result.DetectedFormat, Is.EqualTo("XML")); Assert.That(result.WasTransformed, Is.True); - - using var doc = JsonDocument.Parse(result.Payload); - Assert.That(doc.RootElement.GetProperty("id").GetString(), Is.EqualTo("ORD-42")); - Assert.That( - doc.RootElement.GetProperty("customer").GetProperty("name").GetString(), - Is.EqualTo("Alice")); - Assert.That( - doc.RootElement.GetProperty("customer").GetProperty("email").GetString(), - Is.EqualTo("alice@example.com")); + // Repeated sibling elements become JSON arrays + Assert.That(result.Payload, Does.Contain("[")); + Assert.That(result.Payload, Does.Contain("A")); + Assert.That(result.Payload, Does.Contain("B")); + Assert.That(result.Payload, Does.Contain("C")); } - // ── Challenge 3: CSV Without Headers ──────────────────────────────────── - [Test] - public async Task Challenge3_CsvWithoutHeaders_ProducesArrayOfArrays() + public async Task Challenge2_CsvCustomDelimiter_ParsesCorrectly() { - // When CsvHasHeaders is false, each row should be a JSON array of values - // rather than an object with named properties. - var options = Options.Create(new NormalizerOptions { CsvHasHeaders = false }); - var normalizer = new MessageNormalizer(options, NullLogger.Instance); - - var csv = "Alice,30\nBob,25\nCharlie,35"; - + var normalizer = new MessageNormalizer( + Options.Create(new NormalizerOptions + { + CsvDelimiter = ';', + CsvHasHeaders = true, + }), + NullLogger.Instance); + + var csv = "product;price\nWidget;9.99\nGadget;19.99"; var result = await normalizer.NormalizeAsync(csv, "text/csv"); + Assert.That(result.DetectedFormat, Is.EqualTo("CSV")); Assert.That(result.WasTransformed, Is.True); + Assert.That(result.Payload, Does.Contain("Widget")); + Assert.That(result.Payload, Does.Contain("9.99")); + Assert.That(result.Payload, Does.Contain("Gadget")); + } - using var doc = JsonDocument.Parse(result.Payload); - var array = doc.RootElement.GetProperty("Root"); - Assert.That(array.GetArrayLength(), Is.EqualTo(3)); - - // Each row is an array of string values. - Assert.That(array[0].GetArrayLength(), Is.EqualTo(2)); - Assert.That(array[0][0].GetString(), Is.EqualTo("Alice")); - Assert.That(array[0][1].GetString(), Is.EqualTo("30")); + [Test] + public async Task Challenge3_MultiformatBatch_NormalizeAndPublish() + { + await using var output = new MockEndpoint("exam-normalizer"); + var normalizer = new MessageNormalizer( + Options.Create(new NormalizerOptions()), + NullLogger.Instance); + + var jsonResult = await normalizer.NormalizeAsync( + """{"status":"ok"}""", "application/json"); + var xmlResult = await normalizer.NormalizeAsync( + "ok", "application/xml"); + var csvResult = await normalizer.NormalizeAsync( + "status\nok\ndone", "text/csv"); + + foreach (var r in new[] { jsonResult, xmlResult, csvResult }) + { + var envelope = IntegrationEnvelope.Create( + r.Payload, "NormSvc", "normalized"); + await output.PublishAsync(envelope, "canonical-json", CancellationToken.None); + } + + output.AssertReceivedOnTopic("canonical-json", 3); + Assert.That(jsonResult.DetectedFormat, Is.EqualTo("JSON")); + Assert.That(xmlResult.DetectedFormat, Is.EqualTo("XML")); + Assert.That(csvResult.DetectedFormat, Is.EqualTo("CSV")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial17/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial17/Lab.cs index 2009af9..6169c41 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial17/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial17/Lab.cs @@ -1,145 +1,112 @@ // ============================================================================ // Tutorial 17 – Normalizer (Lab) // ============================================================================ -// This lab exercises the MessageNormalizer — the pattern that detects the -// incoming payload format (JSON, XML, CSV) and converts it to canonical -// JSON. You will test format detection, JSON passthrough, XML-to-JSON -// conversion, CSV-to-JSON conversion, and strict content-type enforcement. +// EIP Pattern: Normalizer. +// E2E: MessageNormalizer detecting JSON/XML/CSV and converting to canonical +// JSON. Publish normalized results via MockEndpoint. // ============================================================================ -using System.Text.Json; +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.Transform; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial17; [TestFixture] public sealed class Lab { - // ── JSON Passthrough ──────────────────────────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("normalizer-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Normalize_JsonPayload_PassesThroughUnchanged() + public async Task Normalize_Json_PassesThroughUnchanged() { - var options = Options.Create(new NormalizerOptions()); - var normalizer = new MessageNormalizer(options, NullLogger.Instance); + var normalizer = CreateNormalizer(); - var json = """{"name":"Alice","age":30}"""; - - var result = await normalizer.NormalizeAsync(json, "application/json"); + var result = await normalizer.NormalizeAsync( + """{"name":"Alice","age":30}""", "application/json"); Assert.That(result.DetectedFormat, Is.EqualTo("JSON")); Assert.That(result.WasTransformed, Is.False); - Assert.That(result.OriginalContentType, Is.EqualTo("application/json")); - - using var doc = JsonDocument.Parse(result.Payload); - Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Alice")); + Assert.That(result.Payload, Does.Contain("Alice")); } - // ── XML to JSON Conversion ────────────────────────────────────────────── - [Test] - public async Task Normalize_XmlPayload_ConvertsToJson() + public async Task Normalize_Xml_ConvertsToJson() { - var options = Options.Create(new NormalizerOptions()); - var normalizer = new MessageNormalizer(options, NullLogger.Instance); - - var xml = "ORD-199.50"; + var normalizer = CreateNormalizer(); + var xml = "Bob25"; var result = await normalizer.NormalizeAsync(xml, "application/xml"); Assert.That(result.DetectedFormat, Is.EqualTo("XML")); Assert.That(result.WasTransformed, Is.True); - - using var doc = JsonDocument.Parse(result.Payload); - Assert.That(doc.RootElement.GetProperty("id").GetString(), Is.EqualTo("ORD-1")); - Assert.That(doc.RootElement.GetProperty("total").GetString(), Is.EqualTo("99.50")); + Assert.That(result.Payload, Does.Contain("Bob")); + Assert.That(result.Payload, Does.Contain("25")); + Assert.That(result.OriginalContentType, Is.EqualTo("application/xml")); } - // ── CSV to JSON Conversion ────────────────────────────────────────────── - [Test] - public async Task Normalize_CsvPayload_ConvertsToJsonArray() + public async Task Normalize_Csv_ConvertsToJsonArray() { - var options = Options.Create(new NormalizerOptions()); - var normalizer = new MessageNormalizer(options, NullLogger.Instance); + var normalizer = CreateNormalizer(); var csv = "name,age\nAlice,30\nBob,25"; - var result = await normalizer.NormalizeAsync(csv, "text/csv"); Assert.That(result.DetectedFormat, Is.EqualTo("CSV")); Assert.That(result.WasTransformed, Is.True); - - using var doc = JsonDocument.Parse(result.Payload); - var array = doc.RootElement.GetProperty("Root"); - Assert.That(array.GetArrayLength(), Is.EqualTo(2)); - Assert.That(array[0].GetProperty("name").GetString(), Is.EqualTo("Alice")); - Assert.That(array[1].GetProperty("name").GetString(), Is.EqualTo("Bob")); + Assert.That(result.Payload, Does.Contain("Alice")); + Assert.That(result.Payload, Does.Contain("Bob")); } - // ── Strict Content Type Enforcement ───────────────────────────────────── - [Test] - public void Normalize_UnknownContentType_StrictMode_Throws() + public async Task Normalize_StrictContentType_ThrowsForUnknown() { - var options = Options.Create(new NormalizerOptions { StrictContentType = true }); - var normalizer = new MessageNormalizer(options, NullLogger.Instance); + var normalizer = CreateNormalizer(strict: true); Assert.ThrowsAsync( - () => normalizer.NormalizeAsync("{}", "application/octet-stream")); + () => normalizer.NormalizeAsync("some data", "application/octet-stream")); } - // ── Best-Effort Detection (Non-Strict) ────────────────────────────────── - [Test] - public async Task Normalize_UnknownContentType_NonStrict_DetectsJson() + public async Task Normalize_NonStrict_DetectsJsonByPayload() { - var options = Options.Create(new NormalizerOptions { StrictContentType = false }); - var normalizer = new MessageNormalizer(options, NullLogger.Instance); - - var json = """{"key":"value"}"""; + var normalizer = CreateNormalizer(strict: false); - var result = await normalizer.NormalizeAsync(json, "application/octet-stream"); + var result = await normalizer.NormalizeAsync( + """{"key":"value"}""", "application/octet-stream"); Assert.That(result.DetectedFormat, Is.EqualTo("JSON")); Assert.That(result.WasTransformed, Is.False); } [Test] - public async Task Normalize_UnknownContentType_NonStrict_DetectsXml() + public async Task Normalize_E2E_PublishNormalizedToMockEndpoint() { - var options = Options.Create(new NormalizerOptions { StrictContentType = false }); - var normalizer = new MessageNormalizer(options, NullLogger.Instance); + var normalizer = CreateNormalizer(); - var xml = "42"; + var xml = "ORD-199"; + var result = await normalizer.NormalizeAsync(xml, "application/xml"); - var result = await normalizer.NormalizeAsync(xml, "application/octet-stream"); + var envelope = IntegrationEnvelope.Create( + result.Payload, "NormalizerService", "payload.normalized"); + await _output.PublishAsync(envelope, "normalized-topic", CancellationToken.None); - Assert.That(result.DetectedFormat, Is.EqualTo("XML")); - Assert.That(result.WasTransformed, Is.True); + _output.AssertReceivedOnTopic("normalized-topic", 1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Does.Contain("ORD-1")); } - // ── Custom CSV Delimiter ──────────────────────────────────────────────── - - [Test] - public async Task Normalize_CsvWithCustomDelimiter_ParsesCorrectly() - { - var options = Options.Create(new NormalizerOptions { CsvDelimiter = ';' }); - var normalizer = new MessageNormalizer(options, NullLogger.Instance); - - var csv = "name;age\nAlice;30"; - - var result = await normalizer.NormalizeAsync(csv, "text/csv"); - - Assert.That(result.DetectedFormat, Is.EqualTo("CSV")); - Assert.That(result.WasTransformed, Is.True); - - using var doc = JsonDocument.Parse(result.Payload); - var array = doc.RootElement.GetProperty("Root"); - Assert.That(array[0].GetProperty("name").GetString(), Is.EqualTo("Alice")); - Assert.That(array[0].GetProperty("age").GetString(), Is.EqualTo("30")); - } + private static MessageNormalizer CreateNormalizer(bool strict = true) => + new(Options.Create(new NormalizerOptions { StrictContentType = strict }), + NullLogger.Instance); } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial18/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial18/Exam.cs index ff8fbd0..a0d84f2 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial18/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial18/Exam.cs @@ -1,129 +1,110 @@ // ============================================================================ // Tutorial 18 – Content Enricher (Exam) // ============================================================================ -// Coding challenges: enrich an order with customer details, test fallback -// on enrichment failure, and merge data at a nested target path. +// E2E challenges: deep nested merge path, enrichment with numeric lookup key, +// and multi-message enrichment batch verification via MockEndpoint. // ============================================================================ -using System.Text.Json; using System.Text.Json.Nodes; +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.Transform; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; -using NSubstitute.ExceptionExtensions; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial18; [TestFixture] public sealed class Exam { - // ── Challenge 1: Full Order Enrichment ────────────────────────────────── - [Test] - public async Task Challenge1_EnrichOrder_MergesCustomerDetails() + public async Task Challenge1_DeepNestedMerge_EnrichesAtNestedPath() { - // An order payload contains a customerId. The enricher should fetch - // the customer details and merge them under the "customer" property. - var source = Substitute.For(); - source.FetchAsync("C-42", Arg.Any()) - .Returns(JsonNode.Parse( - """{"name":"Bob","email":"bob@example.com","tier":"Platinum"}""")); - - var options = Options.Create(new ContentEnricherOptions - { - EndpointUrlTemplate = "https://api.example.com/customers/{key}", - LookupKeyPath = "customerId", - MergeTargetPath = "customer", - }); + var source = new MockEnrichmentSource() + .WithData("WH-1", """{"location":"NYC","capacity":5000}"""); + var options = new ContentEnricherOptions + { + EndpointUrlTemplate = "https://api.example.com/wh/{key}", + LookupKeyPath = "shipment.warehouseId", + MergeTargetPath = "shipment.warehouseDetails", + }; var enricher = new ContentEnricher( - source, options, NullLogger.Instance); - - var payload = """{"orderId":"ORD-99","customerId":"C-42","items":3,"total":450}"""; + source, Options.Create(options), + NullLogger.Instance); + var payload = """{"shipment":{"warehouseId":"WH-1","items":3}}"""; var result = await enricher.EnrichAsync(payload, Guid.NewGuid()); - using var doc = JsonDocument.Parse(result); - var root = doc.RootElement; - - // Original fields preserved. - Assert.That(root.GetProperty("orderId").GetString(), Is.EqualTo("ORD-99")); - Assert.That(root.GetProperty("items").GetInt32(), Is.EqualTo(3)); - Assert.That(root.GetProperty("total").GetInt32(), Is.EqualTo(450)); - - // Enriched customer data merged. - var customer = root.GetProperty("customer"); - Assert.That(customer.GetProperty("name").GetString(), Is.EqualTo("Bob")); - Assert.That(customer.GetProperty("email").GetString(), Is.EqualTo("bob@example.com")); - Assert.That(customer.GetProperty("tier").GetString(), Is.EqualTo("Platinum")); + Assert.That(result, Does.Contain("NYC")); + Assert.That(result, Does.Contain("5000")); + Assert.That(result, Does.Contain("WH-1")); } - // ── Challenge 2: Fallback on Source Failure ───────────────────────────── - [Test] - public async Task Challenge2_SourceThrows_FallbackEnabled_UsesFallbackValue() + public async Task Challenge2_NumericLookupKey_ExtractsCorrectly() { - // When the enrichment source throws an exception but FallbackOnFailure - // is enabled, the enricher should merge the configured FallbackValue. - var source = Substitute.For(); - source.FetchAsync(Arg.Any(), Arg.Any()) - .ThrowsAsync(new HttpRequestException("Service unavailable")); + var source = new MockEnrichmentSource() + .WithData("42", """{"status":"active","plan":"enterprise"}"""); - var options = Options.Create(new ContentEnricherOptions + var options = new ContentEnricherOptions { - EndpointUrlTemplate = "https://api.example.com/{key}", - LookupKeyPath = "userId", - MergeTargetPath = "profile", - FallbackOnFailure = true, - FallbackValue = """{"name":"Unknown","status":"fallback"}""", - }); - + EndpointUrlTemplate = "https://api.example.com/accounts/{key}", + LookupKeyPath = "accountId", + MergeTargetPath = "account", + }; var enricher = new ContentEnricher( - source, options, NullLogger.Instance); - - var payload = """{"userId":"U-1","action":"login"}"""; + source, Options.Create(options), + NullLogger.Instance); + var payload = """{"accountId":42,"action":"upgrade"}"""; var result = await enricher.EnrichAsync(payload, Guid.NewGuid()); - using var doc = JsonDocument.Parse(result); - Assert.That( - doc.RootElement.GetProperty("profile").GetProperty("status").GetString(), - Is.EqualTo("fallback")); - Assert.That(doc.RootElement.GetProperty("action").GetString(), Is.EqualTo("login")); + Assert.That(result, Does.Contain("active")); + Assert.That(result, Does.Contain("enterprise")); } - // ── Challenge 3: Nested Merge Target Path ─────────────────────────────── - [Test] - public async Task Challenge3_NestedMergeTarget_CreatesIntermediateObjects() + public async Task Challenge3_BatchEnrichment_MultipleMessagesPublished() { - // The merge target path can be a nested path like "metadata.enrichment". - // The enricher should create intermediate JSON objects as needed. - var source = Substitute.For(); - source.FetchAsync("REF-5", Arg.Any()) - .Returns(JsonNode.Parse("""{"source":"external-api","timestamp":"2024-01-01"}""")); + await using var output = new MockEndpoint("exam-enricher"); + + var source = new MockEnrichmentSource() + .WithData("C-1", """{"name":"Alice"}""") + .WithData("C-2", """{"name":"Bob"}""") + .WithData("C-3", """{"name":"Charlie"}"""); - var options = Options.Create(new ContentEnricherOptions + var options = new ContentEnricherOptions { EndpointUrlTemplate = "https://api.example.com/{key}", - LookupKeyPath = "refId", - MergeTargetPath = "metadata.enrichment", - }); - + LookupKeyPath = "customerId", + MergeTargetPath = "customer", + }; var enricher = new ContentEnricher( - source, options, NullLogger.Instance); - - var payload = """{"refId":"REF-5","data":"important"}"""; + source, Options.Create(options), + NullLogger.Instance); - var result = await enricher.EnrichAsync(payload, Guid.NewGuid()); + var payloads = new[] + { + """{"customerId":"C-1","amount":100}""", + """{"customerId":"C-2","amount":200}""", + """{"customerId":"C-3","amount":300}""", + }; - using var doc = JsonDocument.Parse(result); - var enrichment = doc.RootElement - .GetProperty("metadata") - .GetProperty("enrichment"); - Assert.That(enrichment.GetProperty("source").GetString(), Is.EqualTo("external-api")); - Assert.That(enrichment.GetProperty("timestamp").GetString(), Is.EqualTo("2024-01-01")); + foreach (var p in payloads) + { + var enriched = await enricher.EnrichAsync(p, Guid.NewGuid()); + var envelope = IntegrationEnvelope.Create( + enriched, "EnricherSvc", "enriched"); + await output.PublishAsync(envelope, "enriched-orders", CancellationToken.None); + } + + output.AssertReceivedOnTopic("enriched-orders", 3); + var all = output.GetAllReceived(); + Assert.That(all[0].Payload, Does.Contain("Alice")); + Assert.That(all[1].Payload, Does.Contain("Bob")); + Assert.That(all[2].Payload, Does.Contain("Charlie")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial18/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial18/Lab.cs index 8470c54..7e76144 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial18/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial18/Lab.cs @@ -1,193 +1,164 @@ // ============================================================================ // Tutorial 18 – Content Enricher (Lab) // ============================================================================ -// This lab exercises the ContentEnricher — the pattern that augments a -// message payload with data fetched from an external source. You will -// mock IEnrichmentSource to return supplementary data and verify lookup- -// key extraction, data merging, fallback behaviour, and missing-key paths. +// EIP Pattern: Content Enricher. +// E2E: ContentEnricher with MockEnrichmentSource, verify enriched +// JSON payload, fallback behaviour, and publish via MockEndpoint. // ============================================================================ -using System.Text.Json; using System.Text.Json.Nodes; +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.Transform; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial18; [TestFixture] public sealed class Lab { - // ── Basic Enrichment ──────────────────────────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("enricher-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Enrich_MergesExternalDataAtTargetPath() + public async Task Enrich_MergesExternalData() { - var source = Substitute.For(); - source.FetchAsync("CUST-1", Arg.Any()) - .Returns(JsonNode.Parse("""{"name":"Alice","tier":"Gold"}""")); + var source = new MockEnrichmentSource() + .WithData("C-100", """{"name":"Alice","tier":"Gold"}"""); - var options = Options.Create(new ContentEnricherOptions - { - EndpointUrlTemplate = "https://api.example.com/customers/{key}", - LookupKeyPath = "customerId", - MergeTargetPath = "customer", - }); + var enricher = CreateEnricher(source, "order.customerId", "customer"); - var enricher = new ContentEnricher( - source, options, NullLogger.Instance); + var payload = """{"order":{"customerId":"C-100","total":250}}"""; + var result = await enricher.EnrichAsync(payload, Guid.NewGuid()); + + Assert.That(result, Does.Contain("Alice")); + Assert.That(result, Does.Contain("Gold")); + Assert.That(result, Does.Contain("C-100")); + } + + [Test] + public async Task Enrich_NestedLookup_ExtractsCorrectKey() + { + var source = new MockEnrichmentSource() + .WithData("P-200", """{"sku":"Widget","warehouse":"WH-1"}"""); - var payload = """{"orderId":"ORD-1","customerId":"CUST-1","total":100}"""; + var enricher = CreateEnricher(source, "line.productId", "product"); + var payload = """{"line":{"productId":"P-200","qty":5}}"""; var result = await enricher.EnrichAsync(payload, Guid.NewGuid()); - using var doc = JsonDocument.Parse(result); - Assert.That(doc.RootElement.GetProperty("orderId").GetString(), Is.EqualTo("ORD-1")); - Assert.That( - doc.RootElement.GetProperty("customer").GetProperty("name").GetString(), - Is.EqualTo("Alice")); - Assert.That( - doc.RootElement.GetProperty("customer").GetProperty("tier").GetString(), - Is.EqualTo("Gold")); + Assert.That(result, Does.Contain("Widget")); + Assert.That(result, Does.Contain("WH-1")); } - // ── Nested Lookup Key ─────────────────────────────────────────────────── - [Test] - public async Task Enrich_NestedLookupKeyPath_ExtractsCorrectValue() + public async Task Enrich_SourceReturnsNull_UsesFallback() { - var source = Substitute.For(); - source.FetchAsync("ADDR-7", Arg.Any()) - .Returns(JsonNode.Parse("""{"city":"Seattle","zip":"98101"}""")); + var source = new MockEnrichmentSource() + .ReturnsNullForUnknown(); - var options = Options.Create(new ContentEnricherOptions + var options = new ContentEnricherOptions { - EndpointUrlTemplate = "https://api.example.com/addresses/{key}", - LookupKeyPath = "order.addressId", - MergeTargetPath = "shippingAddress", - }); - + EndpointUrlTemplate = "https://api.example.com/{key}", + LookupKeyPath = "order.customerId", + MergeTargetPath = "customer", + FallbackOnFailure = true, + FallbackValue = """{"name":"Unknown","tier":"None"}""", + }; var enricher = new ContentEnricher( - source, options, NullLogger.Instance); - - var payload = """{"order":{"id":"ORD-2","addressId":"ADDR-7"}}"""; + source, Options.Create(options), + NullLogger.Instance); + var payload = """{"order":{"customerId":"C-999","total":10}}"""; var result = await enricher.EnrichAsync(payload, Guid.NewGuid()); - using var doc = JsonDocument.Parse(result); - Assert.That( - doc.RootElement.GetProperty("shippingAddress").GetProperty("city").GetString(), - Is.EqualTo("Seattle")); + Assert.That(result, Does.Contain("Unknown")); + Assert.That(result, Does.Contain("None")); } - // ── Missing Lookup Key — Fallback ─────────────────────────────────────── - [Test] - public async Task Enrich_MissingLookupKey_FallbackEnabled_ReturnsOriginal() + public async Task Enrich_MissingLookupKey_FallsBack() { - var source = Substitute.For(); - - var options = Options.Create(new ContentEnricherOptions + var source = new MockEnrichmentSource() + .ReturnsNullForUnknown(); + var options = new ContentEnricherOptions { EndpointUrlTemplate = "https://api.example.com/{key}", - LookupKeyPath = "nonExistentField", - MergeTargetPath = "extra", + LookupKeyPath = "order.customerId", + MergeTargetPath = "customer", FallbackOnFailure = true, - }); - + FallbackValue = """{"name":"Fallback"}""", + }; var enricher = new ContentEnricher( - source, options, NullLogger.Instance); - - var payload = """{"id":"X"}"""; + source, Options.Create(options), + NullLogger.Instance); + var payload = """{"order":{"total":50}}"""; var result = await enricher.EnrichAsync(payload, Guid.NewGuid()); - using var doc = JsonDocument.Parse(result); - Assert.That(doc.RootElement.GetProperty("id").GetString(), Is.EqualTo("X")); - await source.DidNotReceive().FetchAsync(Arg.Any(), Arg.Any()); + Assert.That(result, Does.Contain("Fallback")); } - // ── Missing Lookup Key — No Fallback ──────────────────────────────────── - [Test] - public void Enrich_MissingLookupKey_NoFallback_Throws() + public async Task Enrich_MissingLookupKey_ThrowsWhenNoFallback() { - var source = Substitute.For(); - - var options = Options.Create(new ContentEnricherOptions + var source = new MockEnrichmentSource() + .ReturnsNullForUnknown(); + var options = new ContentEnricherOptions { EndpointUrlTemplate = "https://api.example.com/{key}", - LookupKeyPath = "missingKey", - MergeTargetPath = "extra", + LookupKeyPath = "order.customerId", + MergeTargetPath = "customer", FallbackOnFailure = false, - }); - + }; var enricher = new ContentEnricher( - source, options, NullLogger.Instance); + source, Options.Create(options), + NullLogger.Instance); + var payload = """{"order":{"total":50}}"""; Assert.ThrowsAsync( - () => enricher.EnrichAsync("""{"id":1}""", Guid.NewGuid())); + () => enricher.EnrichAsync(payload, Guid.NewGuid())); } - // ── Source Returns Null — Fallback Value ──────────────────────────────── - [Test] - public async Task Enrich_SourceReturnsNull_FallbackValue_MergesFallback() + public async Task Enrich_E2E_PublishEnrichedToMockEndpoint() { - var source = Substitute.For(); - source.FetchAsync("KEY-1", Arg.Any()) - .Returns((JsonNode?)null); + var source = new MockEnrichmentSource() + .WithData("C-100", """{"name":"Alice"}"""); - var options = Options.Create(new ContentEnricherOptions - { - EndpointUrlTemplate = "https://api.example.com/{key}", - LookupKeyPath = "key", - MergeTargetPath = "extra", - FallbackOnFailure = true, - FallbackValue = """{"status":"unknown"}""", - }); + var enricher = CreateEnricher(source, "order.customerId", "customer"); - var enricher = new ContentEnricher( - source, options, NullLogger.Instance); + var payload = """{"order":{"customerId":"C-100","total":100}}"""; + var enriched = await enricher.EnrichAsync(payload, Guid.NewGuid()); - var payload = """{"key":"KEY-1"}"""; + var envelope = IntegrationEnvelope.Create( + enriched, "EnricherService", "payload.enriched"); + await _output.PublishAsync(envelope, "enriched-topic", CancellationToken.None); - var result = await enricher.EnrichAsync(payload, Guid.NewGuid()); - - using var doc = JsonDocument.Parse(result); - Assert.That( - doc.RootElement.GetProperty("extra").GetProperty("status").GetString(), - Is.EqualTo("unknown")); + _output.AssertReceivedOnTopic("enriched-topic", 1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Does.Contain("Alice")); } - // ── Enrichment Preserves Existing Fields ──────────────────────────────── - - [Test] - public async Task Enrich_PreservesAllExistingPayloadFields() + private static ContentEnricher CreateEnricher( + IEnrichmentSource source, string lookupPath, string mergePath) { - var source = Substitute.For(); - source.FetchAsync("C-1", Arg.Any()) - .Returns(JsonNode.Parse("""{"loyalty":true}""")); - - var options = Options.Create(new ContentEnricherOptions + var options = new ContentEnricherOptions { EndpointUrlTemplate = "https://api.example.com/{key}", - LookupKeyPath = "cid", - MergeTargetPath = "loyalty", - }); - - var enricher = new ContentEnricher( - source, options, NullLogger.Instance); - - var payload = """{"cid":"C-1","amount":50,"currency":"USD"}"""; - - var result = await enricher.EnrichAsync(payload, Guid.NewGuid()); - - using var doc = JsonDocument.Parse(result); - Assert.That(doc.RootElement.GetProperty("cid").GetString(), Is.EqualTo("C-1")); - Assert.That(doc.RootElement.GetProperty("amount").GetInt32(), Is.EqualTo(50)); - Assert.That(doc.RootElement.GetProperty("currency").GetString(), Is.EqualTo("USD")); + LookupKeyPath = lookupPath, + MergeTargetPath = mergePath, + }; + return new ContentEnricher( + source, Options.Create(options), + NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial19/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial19/Exam.cs index 0f860f9..85286af 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial19/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial19/Exam.cs @@ -1,121 +1,77 @@ // ============================================================================ // Tutorial 19 – Content Filter (Exam) // ============================================================================ -// Coding challenges: build a PII-stripping filter, compose filter + regex -// pipeline, and test the standalone ContentFilter with deeply nested JSON. +// E2E challenges: JsonPathFilterStep in a pipeline, filter with deeply nested +// data, and multi-message filter batch verification via MockEndpoint. // ============================================================================ -using System.Text.Json; +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.Transform; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial19; [TestFixture] public sealed class Exam { - // ── Challenge 1: PII Stripping Filter ─────────────────────────────────── - [Test] - public async Task Challenge1_PiiStripping_RemovesSensitiveFields() + public async Task Challenge1_JsonPathFilterStep_FiltersInPipeline() { - // Given a payload with PII fields (email, ssn, phone), use a - // JsonPathFilterStep to retain only the non-sensitive fields. - var step = new JsonPathFilterStep(new[] - { - "order.id", "order.total", "order.currency", - }); - - var payload = """ - { - "order": {"id": "ORD-77", "total": 500, "currency": "USD"}, - "customer": {"name": "Alice", "email": "alice@secret.com", "ssn": "123-45-6789"}, - "internal": {"traceId": "abc-123"} - } - """; + var step = new JsonPathFilterStep(new[] { "order.id", "customer.name" }); + var options = Options.Create(new TransformOptions { Enabled = true }); + var pipeline = new TransformPipeline( + new ITransformStep[] { step }, options, + NullLogger.Instance); - var context = new TransformContext(payload, "application/json"); - var result = await step.ExecuteAsync(context); + var payload = """{"order":{"id":"ORD-1","total":99},"customer":{"name":"Alice","email":"a@b.com"},"internal":"x"}"""; + var result = await pipeline.ExecuteAsync(payload, "application/json"); - using var doc = JsonDocument.Parse(result.Payload); - Assert.That(doc.RootElement.GetProperty("order").GetProperty("id").GetString(), - Is.EqualTo("ORD-77")); - Assert.That(doc.RootElement.GetProperty("order").GetProperty("total").GetInt32(), - Is.EqualTo(500)); - Assert.That(doc.RootElement.TryGetProperty("customer", out _), Is.False); - Assert.That(doc.RootElement.TryGetProperty("internal", out _), Is.False); + Assert.That(result.Payload, Does.Contain("ORD-1")); + Assert.That(result.Payload, Does.Contain("Alice")); + Assert.That(result.Payload, Does.Not.Contain("internal")); + Assert.That(result.StepsApplied, Is.EqualTo(1)); } - // ── Challenge 2: Filter + Regex Pipeline ──────────────────────────────── - [Test] - public async Task Challenge2_FilterThenRegex_CombinedPipeline() + public async Task Challenge2_DeeplyNestedFilter_ExtractsCorrectly() { - // First filter to keep only "message" and "level", then regex-replace - // to redact any numeric sequences longer than 4 digits. - var filterStep = new JsonPathFilterStep(new[] { "message", "level" }); - var regexStep = new RegexReplaceStep(@"\d{5,}", "[REDACTED]"); - - var options = Options.Create(new TransformOptions()); - var pipeline = new TransformPipeline( - new ITransformStep[] { filterStep, regexStep }, options, - NullLogger.Instance); - - var payload = """ - {"message":"Error code 123456 occurred","level":"error","secret":"password123"} - """.Trim(); + var filter = new ContentFilter(NullLogger.Instance); - var result = await pipeline.ExecuteAsync(payload, "application/json"); + var payload = """{"level1":{"level2":{"level3":{"target":"found","other":"skip"}},"sibling":"also-skip"}}"""; + var result = await filter.FilterAsync(payload, new[] { "level1.level2.level3.target" }); - Assert.That(result.Payload, Does.Contain("[REDACTED]")); - Assert.That(result.Payload, Does.Not.Contain("123456")); - Assert.That(result.Payload, Does.Not.Contain("secret")); - Assert.That(result.Payload, Does.Not.Contain("password123")); - Assert.That(result.StepsApplied, Is.EqualTo(2)); + Assert.That(result, Does.Contain("found")); + Assert.That(result, Does.Not.Contain("skip")); } - // ── Challenge 3: Deeply Nested Extraction ─────────────────────────────── - [Test] - public async Task Challenge3_DeeplyNestedPaths_ExtractedCorrectly() + public async Task Challenge3_BatchFilter_MultipleMessagesPublished() { - // Use the standalone ContentFilter to extract deeply nested paths from - // a complex JSON payload. + await using var output = new MockEndpoint("exam-filter"); var filter = new ContentFilter(NullLogger.Instance); - var payload = """ - { - "company": { - "name": "Acme Corp", - "address": { - "street": "123 Main St", - "city": "Springfield", - "zip": "62701" - }, - "ceo": "Jane Doe" - }, - "revenue": 1000000, - "confidential": {"salaries": [100,200,300]} - } - """; - - var result = await filter.FilterAsync(payload, new[] + var payloads = new[] { - "company.name", - "company.address.city", - "revenue", - }); + """{"user":"Alice","role":"admin","salary":100000}""", + """{"user":"Bob","role":"viewer","salary":60000}""", + """{"user":"Charlie","role":"editor","salary":80000}""", + }; - using var doc = JsonDocument.Parse(result); - Assert.That( - doc.RootElement.GetProperty("company").GetProperty("name").GetString(), - Is.EqualTo("Acme Corp")); - Assert.That( - doc.RootElement.GetProperty("company").GetProperty("address").GetProperty("city").GetString(), - Is.EqualTo("Springfield")); - Assert.That(doc.RootElement.GetProperty("revenue").GetInt32(), Is.EqualTo(1000000)); - Assert.That(doc.RootElement.TryGetProperty("confidential", out _), Is.False); + foreach (var p in payloads) + { + var filtered = await filter.FilterAsync(p, new[] { "user", "role" }); + var envelope = IntegrationEnvelope.Create( + filtered, "FilterSvc", "filtered"); + await output.PublishAsync(envelope, "safe-data", CancellationToken.None); + } + + output.AssertReceivedOnTopic("safe-data", 3); + var all = output.GetAllReceived(); + Assert.That(all[0].Payload, Does.Not.Contain("100000")); + Assert.That(all[1].Payload, Does.Not.Contain("60000")); + Assert.That(all[2].Payload, Does.Contain("Charlie")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial19/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial19/Lab.cs index f2dd396..507fdb0 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial19/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial19/Lab.cs @@ -1,140 +1,108 @@ // ============================================================================ // Tutorial 19 – Content Filter (Lab) // ============================================================================ -// This lab exercises the JsonPathFilterStep and the ContentFilter — the -// pattern that strips a message down to only the fields the next consumer -// needs. You will test path-based filtering, missing-path handling, -// nested-property extraction, and pipeline integration. +// EIP Pattern: Content Filter. +// E2E: ContentFilter keeping only specified JSON paths, verify filtered +// payload, and publish results via MockEndpoint. // ============================================================================ -using System.Text.Json; +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.Transform; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial19; [TestFixture] public sealed class Lab { - // ── Basic JsonPathFilterStep ──────────────────────────────────────────── + private MockEndpoint _output = null!; - [Test] - public async Task FilterStep_RetainsOnlySpecifiedPaths() - { - var step = new JsonPathFilterStep(new[] { "name", "age" }); - var context = new TransformContext( - """{"name":"Alice","age":30,"email":"a@b.com","role":"admin"}""", - "application/json"); - - var result = await step.ExecuteAsync(context); - - using var doc = JsonDocument.Parse(result.Payload); - Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.True); - Assert.That(doc.RootElement.TryGetProperty("age", out _), Is.True); - Assert.That(doc.RootElement.TryGetProperty("email", out _), Is.False); - Assert.That(doc.RootElement.TryGetProperty("role", out _), Is.False); - } + [SetUp] + public void SetUp() => _output = new MockEndpoint("filter-out"); - // ── Nested Property Extraction ────────────────────────────────────────── + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task FilterStep_NestedPath_ExtractsNestedProperty() + public async Task Filter_RetainsSpecifiedPaths() { - var step = new JsonPathFilterStep(new[] { "order.id", "customer.name" }); - var payload = """ - { - "order": {"id": "ORD-1", "total": 100}, - "customer": {"name": "Bob", "email": "bob@test.com"}, - "internal": "secret" - } - """; - - var context = new TransformContext(payload, "application/json"); - var result = await step.ExecuteAsync(context); - - using var doc = JsonDocument.Parse(result.Payload); - Assert.That(doc.RootElement.GetProperty("order").GetProperty("id").GetString(), - Is.EqualTo("ORD-1")); - Assert.That(doc.RootElement.GetProperty("customer").GetProperty("name").GetString(), - Is.EqualTo("Bob")); - Assert.That(doc.RootElement.TryGetProperty("internal", out _), Is.False); - } + var filter = CreateFilter(); - // ── Missing Paths Are Silently Skipped ────────────────────────────────── + var payload = """{"order":{"id":"ORD-1","total":99.99},"customer":{"name":"Alice","email":"a@b.com"},"internal":"secret"}"""; + var result = await filter.FilterAsync(payload, new[] { "order.id", "customer.name" }); + + Assert.That(result, Does.Contain("ORD-1")); + Assert.That(result, Does.Contain("Alice")); + Assert.That(result, Does.Not.Contain("secret")); + Assert.That(result, Does.Not.Contain("a@b.com")); + Assert.That(result, Does.Not.Contain("99.99")); + } [Test] - public async Task FilterStep_MissingPath_SilentlySkipped() + public async Task Filter_MissingPath_SkippedSilently() { - var step = new JsonPathFilterStep(new[] { "name", "nonexistent" }); - var context = new TransformContext( - """{"name":"Alice","age":30}""", "application/json"); + var filter = CreateFilter(); - var result = await step.ExecuteAsync(context); + var payload = """{"name":"Alice","age":30}"""; + var result = await filter.FilterAsync(payload, new[] { "name", "nonexistent" }); - using var doc = JsonDocument.Parse(result.Payload); - Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.True); - Assert.That(doc.RootElement.TryGetProperty("nonexistent", out _), Is.False); + Assert.That(result, Does.Contain("Alice")); + Assert.That(result, Does.Not.Contain("30")); } - // ── Metadata Written by Step ──────────────────────────────────────────── - [Test] - public async Task FilterStep_SetsAppliedMetadata() + public async Task Filter_NestedPaths_PreservesStructure() { - var step = new JsonPathFilterStep(new[] { "id" }); - var context = new TransformContext("""{"id":1,"extra":"x"}""", "application/json"); + var filter = CreateFilter(); - var result = await step.ExecuteAsync(context); + var payload = """{"address":{"city":"NYC","zip":"10001","street":"5th Ave"},"phone":"555-0123"}"""; + var result = await filter.FilterAsync(payload, new[] { "address.city", "address.zip" }); - Assert.That(result.Metadata.ContainsKey("Step.JsonPathFilter.Applied"), Is.True); - Assert.That(result.Metadata["Step.JsonPathFilter.Applied"], Is.EqualTo("true")); + Assert.That(result, Does.Contain("NYC")); + Assert.That(result, Does.Contain("10001")); + Assert.That(result, Does.Not.Contain("5th Ave")); + Assert.That(result, Does.Not.Contain("555-0123")); } - // ── Pipeline Integration ──────────────────────────────────────────────── - [Test] - public async Task FilterStep_InPipeline_FiltersPayload() + public void Filter_EmptyKeepPaths_ThrowsArgumentException() { - var filterStep = new JsonPathFilterStep(new[] { "order.id", "order.total" }); - var options = Options.Create(new TransformOptions()); - var pipeline = new TransformPipeline( - new ITransformStep[] { filterStep }, options, - NullLogger.Instance); - - var payload = """ - {"order":{"id":"ORD-5","total":250,"items":3},"customer":{"name":"Eve"}} - """.Trim(); - - var result = await pipeline.ExecuteAsync(payload, "application/json"); - - using var doc = JsonDocument.Parse(result.Payload); - Assert.That(doc.RootElement.GetProperty("order").GetProperty("id").GetString(), - Is.EqualTo("ORD-5")); - Assert.That(doc.RootElement.GetProperty("order").GetProperty("total").GetInt32(), - Is.EqualTo(250)); - Assert.That(doc.RootElement.TryGetProperty("customer", out _), Is.False); - Assert.That(result.StepsApplied, Is.EqualTo(1)); + var filter = CreateFilter(); + + Assert.ThrowsAsync( + () => filter.FilterAsync("""{"a":1}""", Array.Empty())); } - // ── ContentFilter Class (Direct Usage) ────────────────────────────────── + [Test] + public void Filter_NonJsonObject_ThrowsInvalidOperation() + { + var filter = CreateFilter(); + + Assert.ThrowsAsync( + () => filter.FilterAsync("[1,2,3]", new[] { "a" })); + } [Test] - public async Task ContentFilter_RetainsOnlyKeepPaths() + public async Task Filter_E2E_PublishFilteredToMockEndpoint() { - var filter = new ContentFilter(NullLogger.Instance); + var filter = CreateFilter(); - var payload = """ - {"user":"Alice","age":30,"email":"a@b.com","role":"admin","secret":"x"} - """.Trim(); + var payload = """{"order":{"id":"ORD-5","total":500,"status":"shipped"},"audit":{"user":"admin"}}"""; + var filtered = await filter.FilterAsync(payload, new[] { "order.id", "order.status" }); - var result = await filter.FilterAsync(payload, new[] { "user", "age" }); + var envelope = IntegrationEnvelope.Create( + filtered, "FilterService", "payload.filtered"); + await _output.PublishAsync(envelope, "filtered-topic", CancellationToken.None); - using var doc = JsonDocument.Parse(result); - Assert.That(doc.RootElement.TryGetProperty("user", out _), Is.True); - Assert.That(doc.RootElement.TryGetProperty("age", out _), Is.True); - Assert.That(doc.RootElement.TryGetProperty("email", out _), Is.False); - Assert.That(doc.RootElement.TryGetProperty("secret", out _), Is.False); + _output.AssertReceivedOnTopic("filtered-topic", 1); + var received = _output.GetReceived(); + Assert.That(received.Payload, Does.Contain("ORD-5")); + Assert.That(received.Payload, Does.Contain("shipped")); + Assert.That(received.Payload, Does.Not.Contain("admin")); } + + private static ContentFilter CreateFilter() => + new(NullLogger.Instance); } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial20/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial20/Exam.cs index 3e37862..97495a6 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial20/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial20/Exam.cs @@ -1,151 +1,116 @@ // ============================================================================ // Tutorial 20 – Splitter (Exam) // ============================================================================ -// Coding challenges: split a JSON object with a named array property, -// verify metadata/priority preservation across split envelopes, and use -// TargetMessageType override. +// E2E challenges: split with custom message type override, metadata +// preservation across splits, and large batch split with MockEndpoint +// verification. // ============================================================================ -using System.Text.Json; using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Splitter; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial20; [TestFixture] public sealed class Exam { - // ── Challenge 1: Named Array Property Split ───────────────────────────── - [Test] - public async Task Challenge1_NamedArrayProperty_SplitsCorrectly() + public async Task Challenge1_TargetMessageTypeOverride_AppliedToAll() { - // The payload is a JSON object with an "items" array property. - // Use JsonArraySplitStrategy with ArrayPropertyName set to split only - // the items array into individual envelopes. - var producer = Substitute.For(); - var splitOptions = Options.Create(new SplitterOptions + await using var output = new MockEndpoint("exam-splitter-1"); + + var strategy = new FuncSplitStrategy( + composite => composite.Split(',').ToList()); + var options = Options.Create(new SplitterOptions { - TargetTopic = "order-items", - ArrayPropertyName = "items", + TargetTopic = "items-topic", + TargetMessageType = "item.split", + TargetSource = "SplitterService", }); + var splitter = new MessageSplitter( + strategy, output, options, + NullLogger>.Instance); - var strategy = new JsonArraySplitStrategy(splitOptions); - var splitter = new MessageSplitter( - strategy, producer, splitOptions, - NullLogger>.Instance); - - var payload = JsonSerializer.Deserialize(""" - { - "orderId": "ORD-1", - "items": [ - {"sku": "SKU-A", "qty": 2}, - {"sku": "SKU-B", "qty": 5}, - {"sku": "SKU-C", "qty": 1} - ] - } - """); - - var source = IntegrationEnvelope.Create( - payload, "OrderService", "order.batch"); - + var source = IntegrationEnvelope.Create( + "X,Y,Z", "OriginalSvc", "batch.original"); var result = await splitter.SplitAsync(source); - Assert.That(result.ItemCount, Is.EqualTo(3)); - Assert.That(result.SplitEnvelopes[0].Payload.GetProperty("sku").GetString(), - Is.EqualTo("SKU-A")); - Assert.That(result.SplitEnvelopes[1].Payload.GetProperty("qty").GetInt32(), - Is.EqualTo(5)); - Assert.That(result.SplitEnvelopes[2].Payload.GetProperty("sku").GetString(), - Is.EqualTo("SKU-C")); - } + foreach (var env in result.SplitEnvelopes) + { + Assert.That(env.MessageType, Is.EqualTo("item.split")); + Assert.That(env.Source, Is.EqualTo("SplitterService")); + } - // ── Challenge 2: Metadata and Priority Preservation ───────────────────── + output.AssertReceivedOnTopic("items-topic", 3); + } [Test] - public async Task Challenge2_MetadataAndPriority_PreservedInSplitEnvelopes() + public async Task Challenge2_MetadataPreserved_AcrossSplitEnvelopes() { - // Verify that metadata, priority, and schema version from the source - // envelope are copied to every split envelope. - var producer = Substitute.For(); - var strategy = new FuncSplitStrategy( - composite => composite.Split(';').ToList()); + await using var output = new MockEndpoint("exam-splitter-2"); - var options = Options.Create(new SplitterOptions { TargetTopic = "split-out" }); + var strategy = new FuncSplitStrategy( + composite => composite.Split('|').ToList()); + var options = Options.Create(new SplitterOptions { TargetTopic = "meta-topic" }); var splitter = new MessageSplitter( - strategy, producer, options, + strategy, output, options, NullLogger>.Instance); var source = IntegrationEnvelope.Create( - "A;B;C", "BatchService", "batch.items") with + "A|B", "Svc", "batch") with { - Priority = MessagePriority.High, - SchemaVersion = "2.0", Metadata = new Dictionary { - ["tenant"] = "acme", ["region"] = "us-east", + ["priority"] = "high", }, + Priority = MessagePriority.High, + SchemaVersion = "2.0", }; var result = await splitter.SplitAsync(source); - Assert.That(result.ItemCount, Is.EqualTo(3)); - foreach (var env in result.SplitEnvelopes) { + Assert.That(env.Metadata["region"], Is.EqualTo("us-east")); + Assert.That(env.Metadata["priority"], Is.EqualTo("high")); Assert.That(env.Priority, Is.EqualTo(MessagePriority.High)); Assert.That(env.SchemaVersion, Is.EqualTo("2.0")); - Assert.That(env.Metadata["tenant"], Is.EqualTo("acme")); - Assert.That(env.Metadata["region"], Is.EqualTo("us-east")); - Assert.That(env.CorrelationId, Is.EqualTo(source.CorrelationId)); - Assert.That(env.CausationId, Is.EqualTo(source.MessageId)); } - } - // ── Challenge 3: TargetMessageType Override ───────────────────────────── + output.AssertReceivedOnTopic("meta-topic", 2); + } [Test] - public async Task Challenge3_TargetMessageTypeOverride_AppliedToSplitEnvelopes() + public async Task Challenge3_LargeBatch_AllItemsPublished() { - // When TargetMessageType is configured, all split envelopes should use - // the overridden message type instead of the source's message type. - var producer = Substitute.For(); - var strategy = new FuncSplitStrategy(s => s.Split(',').ToList()); - - var options = Options.Create(new SplitterOptions - { - TargetTopic = "individual-items", - TargetMessageType = "item.created", - TargetSource = "SplitterService", - }); + await using var output = new MockEndpoint("exam-splitter-3"); + var items = Enumerable.Range(1, 50).Select(i => $"item-{i}").ToList(); + var strategy = new FuncSplitStrategy( + composite => composite.Split(',').ToList()); + var options = Options.Create(new SplitterOptions { TargetTopic = "bulk-topic" }); var splitter = new MessageSplitter( - strategy, producer, options, + strategy, output, options, NullLogger>.Instance); var source = IntegrationEnvelope.Create( - "X,Y", "BatchService", "batch.submitted"); - + string.Join(",", items), "BulkSvc", "batch.large"); var result = await splitter.SplitAsync(source); - Assert.That(result.ItemCount, Is.EqualTo(2)); - Assert.That(result.TargetTopic, Is.EqualTo("individual-items")); + Assert.That(result.ItemCount, Is.EqualTo(50)); + output.AssertReceivedOnTopic("bulk-topic", 50); - foreach (var env in result.SplitEnvelopes) - { - Assert.That(env.MessageType, Is.EqualTo("item.created")); - Assert.That(env.Source, Is.EqualTo("SplitterService")); - } + // Verify sequence numbers span 0..49 + Assert.That(result.SplitEnvelopes.First().SequenceNumber, Is.EqualTo(0)); + Assert.That(result.SplitEnvelopes.Last().SequenceNumber, Is.EqualTo(49)); - await producer.Received(2).PublishAsync( - Arg.Any>(), - Arg.Is("individual-items"), - Arg.Any()); + // Verify TotalCount on every envelope + foreach (var env in result.SplitEnvelopes) + Assert.That(env.TotalCount, Is.EqualTo(50)); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial20/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial20/Lab.cs index 765de16..259ccbb 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial20/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial20/Lab.cs @@ -1,169 +1,138 @@ // ============================================================================ // Tutorial 20 – Splitter (Lab) // ============================================================================ -// This lab exercises the MessageSplitter — the pattern that decomposes a -// composite message into individual messages, each published separately. -// You will test FuncSplitStrategy, JsonArraySplitStrategy, envelope field -// preservation, and error handling for unconfigured target topics. +// EIP Pattern: Splitter. +// E2E: MessageSplitter with FuncSplitStrategy + MockEndpoint to capture +// split messages, verify SequenceNumber, TotalCount, and CausationId. // ============================================================================ -using System.Text.Json; using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Splitter; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial20; [TestFixture] public sealed class Lab { - // ── Basic Split with FuncSplitStrategy ─────────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("splitter-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Split_FuncStrategy_SplitsIntoIndividualEnvelopes() + public async Task Split_ProducesCorrectItemCount() { - var producer = Substitute.For(); - var strategy = new FuncSplitStrategy( - composite => composite.Split(',').ToList()); - - var options = Options.Create(new SplitterOptions { TargetTopic = "items-topic" }); - var splitter = new MessageSplitter( - strategy, producer, options, - NullLogger>.Instance); + var splitter = CreateStringSplitter(","); var source = IntegrationEnvelope.Create( - "apple,banana,cherry", "InventoryService", "batch.items"); - + "A,B,C", "OrderService", "batch.created"); var result = await splitter.SplitAsync(source); Assert.That(result.ItemCount, Is.EqualTo(3)); - Assert.That(result.TargetTopic, Is.EqualTo("items-topic")); - Assert.That(result.SourceMessageId, Is.EqualTo(source.MessageId)); - Assert.That(result.SplitEnvelopes[0].Payload, Is.EqualTo("apple")); - Assert.That(result.SplitEnvelopes[1].Payload, Is.EqualTo("banana")); - Assert.That(result.SplitEnvelopes[2].Payload, Is.EqualTo("cherry")); + Assert.That(result.SplitEnvelopes, Has.Count.EqualTo(3)); + _output.AssertReceivedOnTopic("split-topic", 3); } - // ── CorrelationId and CausationId Preservation ────────────────────────── - [Test] - public async Task Split_PreservesCorrelationId_SetsCausationId() + public async Task Split_PreservesCorrelationId() { - var producer = Substitute.For(); - var strategy = new FuncSplitStrategy(s => new[] { s }); - - var options = Options.Create(new SplitterOptions { TargetTopic = "topic" }); - var splitter = new MessageSplitter( - strategy, producer, options, - NullLogger>.Instance); + var splitter = CreateStringSplitter(","); var source = IntegrationEnvelope.Create( - "payload", "Service", "event.type"); - + "X,Y", "Svc", "batch"); var result = await splitter.SplitAsync(source); - var splitEnv = result.SplitEnvelopes[0]; - Assert.That(splitEnv.CorrelationId, Is.EqualTo(source.CorrelationId)); - Assert.That(splitEnv.CausationId, Is.EqualTo(source.MessageId)); - Assert.That(splitEnv.MessageId, Is.Not.EqualTo(source.MessageId)); + Assert.That(result.SplitEnvelopes[0].CorrelationId, Is.EqualTo(source.CorrelationId)); + Assert.That(result.SplitEnvelopes[1].CorrelationId, Is.EqualTo(source.CorrelationId)); } - // ── Publisher Called for Each Split Envelope ───────────────────────────── - [Test] - public async Task Split_PublishesEachEnvelopeToTargetTopic() + public async Task Split_SetsCausationIdToSourceMessageId() { - var producer = Substitute.For(); - var strategy = new FuncSplitStrategy( - s => s.Split('|').ToList()); - - var options = Options.Create(new SplitterOptions { TargetTopic = "split-topic" }); - var splitter = new MessageSplitter( - strategy, producer, options, - NullLogger>.Instance); + var splitter = CreateStringSplitter(","); var source = IntegrationEnvelope.Create( - "A|B", "Service", "batch"); - - await splitter.SplitAsync(source); + "A,B", "Svc", "batch"); + var result = await splitter.SplitAsync(source); - await producer.Received(2).PublishAsync( - Arg.Any>(), - Arg.Is("split-topic"), - Arg.Any()); + Assert.That(result.SplitEnvelopes[0].CausationId, Is.EqualTo(source.MessageId)); + Assert.That(result.SplitEnvelopes[1].CausationId, Is.EqualTo(source.MessageId)); } - // ── No Target Topic Configured — Throws ───────────────────────────────── + [Test] + public async Task Split_SequenceNumbers_AreZeroBased() + { + var splitter = CreateStringSplitter(","); + + var source = IntegrationEnvelope.Create( + "A,B,C", "Svc", "batch"); + var result = await splitter.SplitAsync(source); + + Assert.That(result.SplitEnvelopes[0].SequenceNumber, Is.EqualTo(0)); + Assert.That(result.SplitEnvelopes[1].SequenceNumber, Is.EqualTo(1)); + Assert.That(result.SplitEnvelopes[2].SequenceNumber, Is.EqualTo(2)); + } [Test] - public void Split_NoTargetTopic_ThrowsInvalidOperationException() + public async Task Split_TotalCount_MatchesItemCount() { - var producer = Substitute.For(); - var strategy = new FuncSplitStrategy(s => new[] { s }); + var splitter = CreateStringSplitter(","); - var options = Options.Create(new SplitterOptions { TargetTopic = "" }); - var splitter = new MessageSplitter( - strategy, producer, options, - NullLogger>.Instance); + var source = IntegrationEnvelope.Create( + "A,B,C,D", "Svc", "batch"); + var result = await splitter.SplitAsync(source); - var source = IntegrationEnvelope.Create("data", "Svc", "evt"); + foreach (var env in result.SplitEnvelopes) + Assert.That(env.TotalCount, Is.EqualTo(4)); - Assert.ThrowsAsync( - () => splitter.SplitAsync(source)); + Assert.That(result.ItemCount, Is.EqualTo(4)); } - // ── Zero Items After Split ────────────────────────────────────────────── - [Test] - public async Task Split_ZeroItems_ReturnsEmptyResult_NoPublish() + public async Task Split_EmptyResult_ReturnsZeroItems() { - var producer = Substitute.For(); var strategy = new FuncSplitStrategy(_ => Array.Empty()); - - var options = Options.Create(new SplitterOptions { TargetTopic = "topic" }); + var options = Options.Create(new SplitterOptions { TargetTopic = "split-topic" }); var splitter = new MessageSplitter( - strategy, producer, options, + strategy, _output, options, NullLogger>.Instance); - var source = IntegrationEnvelope.Create("empty", "Svc", "evt"); - + var source = IntegrationEnvelope.Create( + "empty", "Svc", "batch"); var result = await splitter.SplitAsync(source); Assert.That(result.ItemCount, Is.EqualTo(0)); Assert.That(result.SplitEnvelopes, Is.Empty); - await producer.DidNotReceive() - .PublishAsync(Arg.Any>(), - Arg.Any(), Arg.Any()); + _output.AssertNoneReceived(); } - // ── JsonArraySplitStrategy ────────────────────────────────────────────── - [Test] - public async Task Split_JsonArrayStrategy_SplitsTopLevelArray() + public async Task Split_SourceMessageId_CapturedInResult() { - var producer = Substitute.For(); - var splitOptions = Options.Create(new SplitterOptions { TargetTopic = "json-items" }); - var strategy = new JsonArraySplitStrategy(splitOptions); - - var splitter = new MessageSplitter( - strategy, producer, splitOptions, - NullLogger>.Instance); - - var jsonArray = JsonSerializer.Deserialize( - """[{"id":1},{"id":2},{"id":3}]"""); - - var source = IntegrationEnvelope.Create( - jsonArray, "BatchService", "batch.created"); + var splitter = CreateStringSplitter(","); + var source = IntegrationEnvelope.Create( + "A,B", "Svc", "batch"); var result = await splitter.SplitAsync(source); - Assert.That(result.ItemCount, Is.EqualTo(3)); - Assert.That(result.SplitEnvelopes[0].Payload.GetProperty("id").GetInt32(), Is.EqualTo(1)); - Assert.That(result.SplitEnvelopes[1].Payload.GetProperty("id").GetInt32(), Is.EqualTo(2)); - Assert.That(result.SplitEnvelopes[2].Payload.GetProperty("id").GetInt32(), Is.EqualTo(3)); + Assert.That(result.SourceMessageId, Is.EqualTo(source.MessageId)); + Assert.That(result.TargetTopic, Is.EqualTo("split-topic")); + } + + private MessageSplitter CreateStringSplitter(string delimiter) + { + var strategy = new FuncSplitStrategy( + composite => composite.Split(delimiter).ToList()); + var options = Options.Create(new SplitterOptions { TargetTopic = "split-topic" }); + return new MessageSplitter( + strategy, _output, options, + NullLogger>.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial21/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial21/Exam.cs index 676bfc8..571a639 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial21/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial21/Exam.cs @@ -1,126 +1,106 @@ // ============================================================================ // Tutorial 21 – Aggregator (Exam) // ============================================================================ -// Coding challenges: accumulate order line items into a batch, verify -// idempotent deduplication, and confirm that TargetSource overrides the -// first envelope's source in the aggregate output. +// E2E challenges: multi-group interleaved aggregation, metadata override on +// key conflict, and idempotent duplicate rejection. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Aggregator; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial21; [TestFixture] public sealed class Exam { - // ── Challenge 1: Order Line Aggregation ────────────────────────────────── + [Test] + public async Task Challenge1_InterleavedGroups_CompleteIndependently() + { + await using var output = new MockEndpoint("exam-agg"); + var aggregator = CreateAggregator(output, expectedCount: 2); + + var corrA = Guid.NewGuid(); + var corrB = Guid.NewGuid(); + + var a1 = IntegrationEnvelope.Create("a1", "svc", "t", corrA); + var b1 = IntegrationEnvelope.Create("b1", "svc", "t", corrB); + var a2 = IntegrationEnvelope.Create("a2", "svc", "t", corrA); + var b2 = IntegrationEnvelope.Create("b2", "svc", "t", corrB); + + Assert.That((await aggregator.AggregateAsync(a1)).IsComplete, Is.False); + Assert.That((await aggregator.AggregateAsync(b1)).IsComplete, Is.False); + Assert.That((await aggregator.AggregateAsync(a2)).IsComplete, Is.True); + Assert.That((await aggregator.AggregateAsync(b2)).IsComplete, Is.True); + + output.AssertReceivedOnTopic("aggregated-topic", 2); + } [Test] - public async Task Challenge1_AggregateThreeLineItems_IntoSingleBatch() + public async Task Challenge2_MetadataConflict_LaterOverridesEarlier() { - // Aggregate 3 order line items into a single comma-separated batch. - var store = new InMemoryMessageAggregateStore(); - var completion = new CountCompletionStrategy(3); - var aggregation = Substitute.For>(); - aggregation - .Aggregate(Arg.Any>()) - .Returns(ci => - { - var items = ci.Arg>(); - return string.Join(";", items); - }); - - var producer = Substitute.For(); - var options = Options.Create(new AggregatorOptions + await using var output = new MockEndpoint("exam-meta"); + var aggregator = CreateAggregator(output, expectedCount: 2); + var correlationId = Guid.NewGuid(); + + var e1 = IntegrationEnvelope.Create("a", "svc", "t", correlationId) with { - TargetTopic = "order-batches", - TargetMessageType = "order.batch", - ExpectedCount = 3, - }); + Metadata = new Dictionary { ["key"] = "first" }, + }; + var e2 = IntegrationEnvelope.Create("b", "svc", "t", correlationId) with + { + Metadata = new Dictionary { ["key"] = "second" }, + }; - var aggregator = new MessageAggregator( - store, completion, aggregation, producer, options, - NullLogger>.Instance); + await aggregator.AggregateAsync(e1); + var result = await aggregator.AggregateAsync(e2); - var correlationId = Guid.NewGuid(); - var e1 = IntegrationEnvelope.Create("SKU-A", "OrderSvc", "line", correlationId: correlationId); - var e2 = IntegrationEnvelope.Create("SKU-B", "OrderSvc", "line", correlationId: correlationId); - var e3 = IntegrationEnvelope.Create("SKU-C", "OrderSvc", "line", correlationId: correlationId); - - var r1 = await aggregator.AggregateAsync(e1); - var r2 = await aggregator.AggregateAsync(e2); - var r3 = await aggregator.AggregateAsync(e3); - - Assert.That(r1.IsComplete, Is.False); - Assert.That(r1.ReceivedCount, Is.EqualTo(1)); - Assert.That(r2.IsComplete, Is.False); - Assert.That(r2.ReceivedCount, Is.EqualTo(2)); - Assert.That(r3.IsComplete, Is.True); - Assert.That(r3.ReceivedCount, Is.EqualTo(3)); - Assert.That(r3.AggregateEnvelope!.Payload, Is.EqualTo("SKU-A;SKU-B;SKU-C")); - - await producer.Received(1).PublishAsync( - Arg.Any>(), - "order-batches", - Arg.Any()); + Assert.That(result.AggregateEnvelope!.Metadata["key"], Is.EqualTo("second")); + output.AssertReceivedOnTopic("aggregated-topic", 1); } - // ── Challenge 2: Deduplication Via InMemoryStore ───────────────────────── - [Test] - public async Task Challenge2_DuplicateMessageId_IsIgnoredByStore() + public async Task Challenge3_DuplicateMessage_IsIdempotent() { - // The InMemoryMessageAggregateStore should ignore a duplicate MessageId - // so the group size stays at 1 despite adding the same envelope twice. - var store = new InMemoryMessageAggregateStore(); + await using var output = new MockEndpoint("exam-dup"); + var aggregator = CreateAggregator(output, expectedCount: 2); var correlationId = Guid.NewGuid(); - var envelope = IntegrationEnvelope.Create( - "payload", "Svc", "type", correlationId: correlationId); + var e1 = IntegrationEnvelope.Create("a", "svc", "t", correlationId); + var e2 = IntegrationEnvelope.Create("b", "svc", "t", correlationId); - var group1 = await store.AddAsync(envelope); - var group2 = await store.AddAsync(envelope); + await aggregator.AggregateAsync(e1); + // Resend e1 — duplicate by MessageId should be ignored + var dupResult = await aggregator.AggregateAsync(e1); + Assert.That(dupResult.IsComplete, Is.False); + Assert.That(dupResult.ReceivedCount, Is.EqualTo(1)); - Assert.That(group1.Count, Is.EqualTo(1)); - Assert.That(group2.Count, Is.EqualTo(1)); - } + var final = await aggregator.AggregateAsync(e2); + Assert.That(final.IsComplete, Is.True); + Assert.That(final.ReceivedCount, Is.EqualTo(2)); - // ── Challenge 3: TargetSource Overrides First Envelope Source ──────────── + output.AssertReceivedOnTopic("aggregated-topic", 1); + } - [Test] - public async Task Challenge3_TargetSource_OverridesEnvelopeSource() + private static MessageAggregator CreateAggregator( + MockEndpoint output, int expectedCount) { - // When AggregatorOptions.TargetSource is set, the aggregate envelope - // should use that source instead of the first envelope's source. var store = new InMemoryMessageAggregateStore(); - var completion = new CountCompletionStrategy(1); - var aggregation = Substitute.For>(); - aggregation.Aggregate(Arg.Any>()).Returns("agg"); + var completion = new CountCompletionStrategy(expectedCount); + var strategy = new MockAggregationStrategy(items => string.Join(",", items)); - var producer = Substitute.For(); var options = Options.Create(new AggregatorOptions { - TargetTopic = "out", - TargetSource = "AggregatorService", - ExpectedCount = 1, + TargetTopic = "aggregated-topic", + ExpectedCount = expectedCount, }); - var aggregator = new MessageAggregator( - store, completion, aggregation, producer, options, + return new MessageAggregator( + store, completion, strategy, output, options, NullLogger>.Instance); - - var envelope = IntegrationEnvelope.Create( - "data", "OriginalService", "msg.type"); - - var result = await aggregator.AggregateAsync(envelope); - - Assert.That(result.IsComplete, Is.True); - Assert.That(result.AggregateEnvelope!.Source, Is.EqualTo("AggregatorService")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial21/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial21/Lab.cs index a40325e..2d75006 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial21/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial21/Lab.cs @@ -1,211 +1,179 @@ // ============================================================================ // Tutorial 21 – Aggregator (Lab) // ============================================================================ -// This lab exercises the MessageAggregator with InMemoryMessageAggregateStore, -// CountCompletionStrategy, and mock IAggregationStrategy. You will verify -// accumulation behaviour, completion conditions, and aggregate publishing. +// EIP Pattern: Aggregator. +// E2E: Wire real MessageAggregator with InMemoryMessageAggregateStore, +// CountCompletionStrategy, MockAggregationStrategy, and MockEndpoint. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Aggregator; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial21; [TestFixture] public sealed class Lab { - // ── InMemoryMessageAggregateStore Basics ───────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("agg-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Store_AddAsync_ReturnsSingleItemGroup() + public async Task Aggregate_SingleMessage_GroupNotComplete() { - var store = new InMemoryMessageAggregateStore(); + var aggregator = CreateAggregator(expectedCount: 3); + var envelope = IntegrationEnvelope.Create("item1", "svc", "order.line"); - var envelope = IntegrationEnvelope.Create( - "item-1", "TestService", "order.line"); - - var group = await store.AddAsync(envelope); + var result = await aggregator.AggregateAsync(envelope); - Assert.That(group.Count, Is.EqualTo(1)); - Assert.That(group[0].Payload, Is.EqualTo("item-1")); + Assert.That(result.IsComplete, Is.False); + Assert.That(result.ReceivedCount, Is.EqualTo(1)); + Assert.That(result.AggregateEnvelope, Is.Null); + _output.AssertNoneReceived(); } [Test] - public async Task Store_AddAsync_GroupsBySameCorrelationId() + public async Task Aggregate_ReachesCount_CompletesAndPublishes() { - var store = new InMemoryMessageAggregateStore(); var correlationId = Guid.NewGuid(); + var aggregator = CreateAggregator(expectedCount: 2); - var e1 = IntegrationEnvelope.Create( - "item-1", "Svc", "line", correlationId: correlationId); - var e2 = IntegrationEnvelope.Create( - "item-2", "Svc", "line", correlationId: correlationId); + var e1 = IntegrationEnvelope.Create("a", "svc", "line", correlationId); + var e2 = IntegrationEnvelope.Create("b", "svc", "line", correlationId); - await store.AddAsync(e1); - var group = await store.AddAsync(e2); + await aggregator.AggregateAsync(e1); + var result = await aggregator.AggregateAsync(e2); - Assert.That(group.Count, Is.EqualTo(2)); - Assert.That(group[0].Payload, Is.EqualTo("item-1")); - Assert.That(group[1].Payload, Is.EqualTo("item-2")); + Assert.That(result.IsComplete, Is.True); + Assert.That(result.ReceivedCount, Is.EqualTo(2)); + Assert.That(result.AggregateEnvelope, Is.Not.Null); + _output.AssertReceivedOnTopic("aggregated-topic", 1); } - // ── CountCompletionStrategy ───────────────────────────────────────────── - [Test] - public void CountCompletion_NotComplete_WhenBelowExpected() + public async Task Aggregate_PreservesCorrelationId() { - var strategy = new CountCompletionStrategy(3); - var envelopes = new[] - { - IntegrationEnvelope.Create("a", "Svc", "t"), - IntegrationEnvelope.Create("b", "Svc", "t"), - }; + var correlationId = Guid.NewGuid(); + var aggregator = CreateAggregator(expectedCount: 2); - Assert.That(strategy.IsComplete(envelopes), Is.False); - } + var e1 = IntegrationEnvelope.Create("a", "svc", "line", correlationId); + var e2 = IntegrationEnvelope.Create("b", "svc", "line", correlationId); - [Test] - public void CountCompletion_Complete_WhenCountReached() - { - var strategy = new CountCompletionStrategy(2); - var envelopes = new[] - { - IntegrationEnvelope.Create("a", "Svc", "t"), - IntegrationEnvelope.Create("b", "Svc", "t"), - }; + await aggregator.AggregateAsync(e1); + var result = await aggregator.AggregateAsync(e2); - Assert.That(strategy.IsComplete(envelopes), Is.True); + Assert.That(result.CorrelationId, Is.EqualTo(correlationId)); + Assert.That(result.AggregateEnvelope!.CorrelationId, Is.EqualTo(correlationId)); } - // ── MessageAggregator – Incomplete Group ──────────────────────────────── - [Test] - public async Task Aggregator_ReturnsIncomplete_WhenGroupNotReady() + public async Task Aggregate_DifferentCorrelationIds_FormSeparateGroups() { - var store = new InMemoryMessageAggregateStore(); - var completion = new CountCompletionStrategy(3); - var aggregation = Substitute.For>(); - var producer = Substitute.For(); + var corr1 = Guid.NewGuid(); + var corr2 = Guid.NewGuid(); + var aggregator = CreateAggregator(expectedCount: 2); - var options = Options.Create(new AggregatorOptions - { - TargetTopic = "aggregated-topic", - ExpectedCount = 3, - }); + var e1a = IntegrationEnvelope.Create("a1", "svc", "line", corr1); + var e2a = IntegrationEnvelope.Create("a2", "svc", "line", corr2); - var aggregator = new MessageAggregator( - store, completion, aggregation, producer, options, - NullLogger>.Instance); + var r1 = await aggregator.AggregateAsync(e1a); + var r2 = await aggregator.AggregateAsync(e2a); + + Assert.That(r1.IsComplete, Is.False); + Assert.That(r2.IsComplete, Is.False); + Assert.That(r1.ReceivedCount, Is.EqualTo(1)); + Assert.That(r2.ReceivedCount, Is.EqualTo(1)); + _output.AssertNoneReceived(); + } + [Test] + public async Task Aggregate_CountCompletion_ExactThreshold() + { var correlationId = Guid.NewGuid(); - var envelope = IntegrationEnvelope.Create( - "item-1", "Svc", "line", correlationId: correlationId); + var aggregator = CreateAggregator(expectedCount: 3); - var result = await aggregator.AggregateAsync(envelope); + var e1 = IntegrationEnvelope.Create("x", "svc", "t", correlationId); + var e2 = IntegrationEnvelope.Create("y", "svc", "t", correlationId); + var e3 = IntegrationEnvelope.Create("z", "svc", "t", correlationId); - Assert.That(result.IsComplete, Is.False); - Assert.That(result.AggregateEnvelope, Is.Null); - Assert.That(result.ReceivedCount, Is.EqualTo(1)); - Assert.That(result.CorrelationId, Is.EqualTo(correlationId)); - } + var r1 = await aggregator.AggregateAsync(e1); + var r2 = await aggregator.AggregateAsync(e2); + var r3 = await aggregator.AggregateAsync(e3); - // ── MessageAggregator – Complete Group & Publish ───────────────────────── + Assert.That(r1.IsComplete, Is.False); + Assert.That(r2.IsComplete, Is.False); + Assert.That(r3.IsComplete, Is.True); + Assert.That(r3.ReceivedCount, Is.EqualTo(3)); + _output.AssertReceivedOnTopic("aggregated-topic", 1); + } [Test] - public async Task Aggregator_CompletesAndPublishes_WhenCountReached() + public async Task Aggregate_MergesMetadata_FromAllEnvelopes() { - var store = new InMemoryMessageAggregateStore(); - var completion = new CountCompletionStrategy(2); - var aggregation = Substitute.For>(); - aggregation - .Aggregate(Arg.Any>()) - .Returns(ci => - { - var items = ci.Arg>(); - return string.Join(",", items); - }); - - var producer = Substitute.For(); + var correlationId = Guid.NewGuid(); + var aggregator = CreateAggregator(expectedCount: 2); - var options = Options.Create(new AggregatorOptions + var e1 = IntegrationEnvelope.Create("a", "svc", "t", correlationId) with { - TargetTopic = "agg-out", - TargetMessageType = "order.batch", - ExpectedCount = 2, - }); - - var aggregator = new MessageAggregator( - store, completion, aggregation, producer, options, - NullLogger>.Instance); - - var correlationId = Guid.NewGuid(); - var e1 = IntegrationEnvelope.Create( - "A", "Svc", "line", correlationId: correlationId); - var e2 = IntegrationEnvelope.Create( - "B", "Svc", "line", correlationId: correlationId); + Metadata = new Dictionary { ["region"] = "us-east" }, + }; + var e2 = IntegrationEnvelope.Create("b", "svc", "t", correlationId) with + { + Metadata = new Dictionary { ["tier"] = "premium" }, + }; await aggregator.AggregateAsync(e1); var result = await aggregator.AggregateAsync(e2); - Assert.That(result.IsComplete, Is.True); - Assert.That(result.ReceivedCount, Is.EqualTo(2)); - Assert.That(result.AggregateEnvelope, Is.Not.Null); - Assert.That(result.AggregateEnvelope!.Payload, Is.EqualTo("A,B")); - Assert.That(result.AggregateEnvelope.MessageType, Is.EqualTo("order.batch")); - Assert.That(result.AggregateEnvelope.CorrelationId, Is.EqualTo(correlationId)); - - await producer.Received(1).PublishAsync( - Arg.Any>(), - "agg-out", - Arg.Any()); + var meta = result.AggregateEnvelope!.Metadata; + Assert.That(meta["region"], Is.EqualTo("us-east")); + Assert.That(meta["tier"], Is.EqualTo("premium")); } - // ── MessageAggregator – Metadata Merging ──────────────────────────────── - [Test] - public async Task Aggregator_MergesMetadata_FromAllEnvelopes() + public async Task Aggregate_UsesHighestPriority() { - var store = new InMemoryMessageAggregateStore(); - var completion = new CountCompletionStrategy(2); - var aggregation = Substitute.For>(); - aggregation - .Aggregate(Arg.Any>()) - .Returns("merged"); - - var producer = Substitute.For(); - - var options = Options.Create(new AggregatorOptions - { - TargetTopic = "merged-topic", - ExpectedCount = 2, - }); - - var aggregator = new MessageAggregator( - store, completion, aggregation, producer, options, - NullLogger>.Instance); - var correlationId = Guid.NewGuid(); + var aggregator = CreateAggregator(expectedCount: 2); - var e1 = IntegrationEnvelope.Create( - "A", "Svc", "line", correlationId: correlationId) with + var e1 = IntegrationEnvelope.Create("a", "svc", "t", correlationId) with { - Metadata = new Dictionary { ["key1"] = "val1" }, + Priority = MessagePriority.Low, }; - var e2 = IntegrationEnvelope.Create( - "B", "Svc", "line", correlationId: correlationId) with + var e2 = IntegrationEnvelope.Create("b", "svc", "t", correlationId) with { - Metadata = new Dictionary { ["key2"] = "val2" }, + Priority = MessagePriority.High, }; await aggregator.AggregateAsync(e1); var result = await aggregator.AggregateAsync(e2); - Assert.That(result.AggregateEnvelope!.Metadata, Contains.Key("key1")); - Assert.That(result.AggregateEnvelope.Metadata, Contains.Key("key2")); + Assert.That(result.AggregateEnvelope!.Priority, Is.EqualTo(MessagePriority.High)); + } + + private MessageAggregator CreateAggregator(int expectedCount) + { + var store = new InMemoryMessageAggregateStore(); + var completion = new CountCompletionStrategy(expectedCount); + var strategy = new MockAggregationStrategy(items => string.Join(",", items)); + + var options = Options.Create(new AggregatorOptions + { + TargetTopic = "aggregated-topic", + ExpectedCount = expectedCount, + }); + + return new MessageAggregator( + store, completion, strategy, _output, options, + NullLogger>.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial22/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial22/Exam.cs index ac3a8a7..9446e63 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial22/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial22/Exam.cs @@ -1,133 +1,102 @@ // ============================================================================ // Tutorial 22 – Scatter-Gather (Exam) // ============================================================================ -// Coding challenges: multi-recipient gather with mixed success/error -// responses, timeout behaviour with partial results, and duplicate -// correlation ID rejection. +// E2E challenges: mixed success/failure responses, duration tracking, +// and concurrent scatter-gather operations. // ============================================================================ -using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.ScatterGather; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial22; [TestFixture] public sealed class Exam { - // ── Challenge 1: Mixed Success and Error Responses ─────────────────────── - [Test] - public async Task Challenge1_GatherMixedResponses_AllIncludedInResult() + public async Task Challenge1_MixedResponses_SuccessAndFailure() { - // Scatter to 2 recipients. One succeeds, one fails. - // Both responses should appear in the result. - var producer = Substitute.For(); - var options = Options.Create(new ScatterGatherOptions { TimeoutMs = 10_000 }); - - var sg = new ScatterGatherer( - producer, options, - NullLogger>.Instance); - + await using var output = new MockEndpoint("exam-sg"); + var sg = CreateScatterGatherer(output, timeoutMs: 500); var correlationId = Guid.NewGuid(); - var request = new ScatterRequest( - correlationId, "compute", - new List { "svc-fast", "svc-flaky" }); - - var scatterTask = sg.ScatterGatherAsync(request); - - await Task.Delay(100); + var request = new ScatterRequest(correlationId, "req", + new[] { "ok-svc", "fail-svc" }); + var task = sg.ScatterGatherAsync(request); await sg.SubmitResponseAsync(correlationId, - new GatherResponse("svc-fast", "ok", DateTimeOffset.UtcNow, true, null)); - + new GatherResponse("ok-svc", "result", DateTimeOffset.UtcNow, true, null)); await sg.SubmitResponseAsync(correlationId, - new GatherResponse("svc-flaky", "", DateTimeOffset.UtcNow, false, "Internal error")); - - var result = await scatterTask; + new GatherResponse("fail-svc", default!, DateTimeOffset.UtcNow, false, "Timeout")); + var result = await task; Assert.That(result.Responses.Count, Is.EqualTo(2)); + Assert.That(result.Responses.Count(r => r.IsSuccess), Is.EqualTo(1)); + Assert.That(result.Responses.Count(r => !r.IsSuccess), Is.EqualTo(1)); Assert.That(result.TimedOut, Is.False); - - var successResp = result.Responses.First(r => r.Recipient == "svc-fast"); - Assert.That(successResp.IsSuccess, Is.True); - - var errorResp = result.Responses.First(r => r.Recipient == "svc-flaky"); - Assert.That(errorResp.IsSuccess, Is.False); - Assert.That(errorResp.ErrorMessage, Is.EqualTo("Internal error")); + output.AssertReceivedCount(2); } - // ── Challenge 2: Timeout Returns Partial Responses ────────────────────── - [Test] - public async Task Challenge2_Timeout_ReturnsPartialResponses() + public async Task Challenge2_Duration_IsTracked() { - // Scatter to 2 recipients with a short timeout. Only 1 responds in time. - var producer = Substitute.For(); - var options = Options.Create(new ScatterGatherOptions { TimeoutMs = 500 }); - - var sg = new ScatterGatherer( - producer, options, - NullLogger>.Instance); - + await using var output = new MockEndpoint("exam-dur"); + var sg = CreateScatterGatherer(output, timeoutMs: 500); var correlationId = Guid.NewGuid(); - var request = new ScatterRequest( - correlationId, "urgent", - new List { "svc-quick", "svc-slow" }); + var request = new ScatterRequest(correlationId, "req", + new[] { "topic-1" }); - var scatterTask = sg.ScatterGatherAsync(request); - - await Task.Delay(50); + var task = sg.ScatterGatherAsync(request); await sg.SubmitResponseAsync(correlationId, - new GatherResponse("svc-quick", "done", DateTimeOffset.UtcNow, true, null)); - - // svc-slow never responds — the timeout expires. - var result = await scatterTask; + new GatherResponse("topic-1", "done", DateTimeOffset.UtcNow, true, null)); + var result = await task; - Assert.That(result.TimedOut, Is.True); - Assert.That(result.Responses.Count, Is.EqualTo(1)); - Assert.That(result.Responses[0].Recipient, Is.EqualTo("svc-quick")); Assert.That(result.Duration, Is.GreaterThan(TimeSpan.Zero)); + Assert.That(result.TimedOut, Is.False); } - // ── Challenge 3: Duplicate CorrelationId Throws ───────────────────────── - [Test] - public async Task Challenge3_DuplicateCorrelationId_ThrowsInvalidOperation() + public async Task Challenge3_ConcurrentOperations_IsolateByCorrelation() { - // Starting two scatter-gather operations with the same CorrelationId - // should throw InvalidOperationException on the second call. - var producer = Substitute.For(); - var options = Options.Create(new ScatterGatherOptions { TimeoutMs = 5000 }); + await using var output = new MockEndpoint("exam-conc"); + var sg = CreateScatterGatherer(output, timeoutMs: 1000); + + var corr1 = Guid.NewGuid(); + var corr2 = Guid.NewGuid(); + var req1 = new ScatterRequest(corr1, "req1", new[] { "svc-a" }); + var req2 = new ScatterRequest(corr2, "req2", new[] { "svc-b" }); + + var task1 = sg.ScatterGatherAsync(req1); + var task2 = sg.ScatterGatherAsync(req2); + + await sg.SubmitResponseAsync(corr2, + new GatherResponse("svc-b", "r2", DateTimeOffset.UtcNow, true, null)); + await sg.SubmitResponseAsync(corr1, + new GatherResponse("svc-a", "r1", DateTimeOffset.UtcNow, true, null)); + + var result1 = await task1; + var result2 = await task2; + + Assert.That(result1.CorrelationId, Is.EqualTo(corr1)); + Assert.That(result2.CorrelationId, Is.EqualTo(corr2)); + Assert.That(result1.Responses[0].Payload, Is.EqualTo("r1")); + Assert.That(result2.Responses[0].Payload, Is.EqualTo("r2")); + output.AssertReceivedCount(2); + } - var sg = new ScatterGatherer( - producer, options, + private static ScatterGatherer CreateScatterGatherer( + MockEndpoint output, int timeoutMs) + { + var options = Options.Create(new ScatterGatherOptions + { + TimeoutMs = timeoutMs, + MaxRecipients = 50, + }); + + return new ScatterGatherer( + output, options, NullLogger>.Instance); - - var correlationId = Guid.NewGuid(); - var request = new ScatterRequest( - correlationId, "first", - new List { "svc-a" }); - - // First call starts gathering (will block waiting for response). - var firstTask = sg.ScatterGatherAsync(request); - await Task.Delay(100); - - // Second call with the same correlationId should throw. - var secondRequest = new ScatterRequest( - correlationId, "second", - new List { "svc-b" }); - - Assert.ThrowsAsync( - () => sg.ScatterGatherAsync(secondRequest)); - - // Complete the first task by submitting a response. - await sg.SubmitResponseAsync(correlationId, - new GatherResponse("svc-a", "done", DateTimeOffset.UtcNow, true, null)); - await firstTask; } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial22/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial22/Lab.cs index 128a346..a3ff027 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial22/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial22/Lab.cs @@ -1,180 +1,141 @@ // ============================================================================ // Tutorial 22 – Scatter-Gather (Lab) // ============================================================================ -// This lab exercises the ScatterGatherer: empty recipients, max-recipient -// validation, scatter publishing, response submission, and result assembly. +// EIP Pattern: Scatter-Gather. +// E2E: Wire real ScatterGatherer with MockEndpoint as producer. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.ScatterGather; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial22; [TestFixture] public sealed class Lab { - // ── Empty Recipients Returns Immediately ───────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("scatter-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Scatter_EmptyRecipients_ReturnsEmptyResult() + public async Task Scatter_PublishesToAllRecipients() { - var producer = Substitute.For(); - var options = Options.Create(new ScatterGatherOptions { TimeoutMs = 5000 }); + var sg = CreateScatterGatherer(timeoutMs: 500); + var correlationId = Guid.NewGuid(); + var request = new ScatterRequest(correlationId, "quote-request", + new[] { "supplier-a", "supplier-b", "supplier-c" }); - var sg = new ScatterGatherer( - producer, options, - NullLogger>.Instance); + // Start scatter-gather in background; submit responses immediately + var task = sg.ScatterGatherAsync(request); - var request = new ScatterRequest( - Guid.NewGuid(), "ping", new List()); + await sg.SubmitResponseAsync(correlationId, + new GatherResponse("supplier-a", "price-a", DateTimeOffset.UtcNow, true, null)); + await sg.SubmitResponseAsync(correlationId, + new GatherResponse("supplier-b", "price-b", DateTimeOffset.UtcNow, true, null)); + await sg.SubmitResponseAsync(correlationId, + new GatherResponse("supplier-c", "price-c", DateTimeOffset.UtcNow, true, null)); - var result = await sg.ScatterGatherAsync(request); + var result = await task; - Assert.That(result.Responses, Is.Empty); + Assert.That(result.Responses.Count, Is.EqualTo(3)); Assert.That(result.TimedOut, Is.False); - Assert.That(result.Duration, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(1))); + _output.AssertReceivedOnTopic("supplier-a", 1); + _output.AssertReceivedOnTopic("supplier-b", 1); + _output.AssertReceivedOnTopic("supplier-c", 1); } - // ── Max Recipients Exceeded Throws ─────────────────────────────────────── - [Test] - public void Scatter_ExceedsMaxRecipients_ThrowsArgumentException() + public async Task Scatter_EmptyRecipients_ReturnsImmediately() { - var producer = Substitute.For(); - var options = Options.Create(new ScatterGatherOptions - { - MaxRecipients = 2, - TimeoutMs = 5000, - }); - - var sg = new ScatterGatherer( - producer, options, - NullLogger>.Instance); + var sg = CreateScatterGatherer(timeoutMs: 500); + var request = new ScatterRequest(Guid.NewGuid(), "data", Array.Empty()); - var request = new ScatterRequest( - Guid.NewGuid(), "payload", - new List { "t1", "t2", "t3" }); + var result = await sg.ScatterGatherAsync(request); - Assert.ThrowsAsync(() => sg.ScatterGatherAsync(request)); + Assert.That(result.Responses.Count, Is.EqualTo(0)); + Assert.That(result.TimedOut, Is.False); + _output.AssertNoneReceived(); } - // ── Scatter Publishes To All Recipients ────────────────────────────────── - [Test] - public async Task Scatter_PublishesToEachRecipientTopic() + public async Task Gather_TimesOut_ReturnsPartialResponses() { - var producer = Substitute.For(); - var options = Options.Create(new ScatterGatherOptions { TimeoutMs = 500 }); - - var sg = new ScatterGatherer( - producer, options, - NullLogger>.Instance); + var sg = CreateScatterGatherer(timeoutMs: 200); + var correlationId = Guid.NewGuid(); + var request = new ScatterRequest(correlationId, "req", + new[] { "fast", "slow" }); - var recipients = new List { "svc-a", "svc-b" }; - var request = new ScatterRequest( - Guid.NewGuid(), "hello", recipients); + var task = sg.ScatterGatherAsync(request); - // Scatter will publish to both topics then time out waiting for responses. - await sg.ScatterGatherAsync(request); + // Only fast responds + await sg.SubmitResponseAsync(correlationId, + new GatherResponse("fast", "done", DateTimeOffset.UtcNow, true, null)); - await producer.Received(1).PublishAsync( - Arg.Any>(), - "svc-a", - Arg.Any()); + var result = await task; - await producer.Received(1).PublishAsync( - Arg.Any>(), - "svc-b", - Arg.Any()); + Assert.That(result.TimedOut, Is.True); + Assert.That(result.Responses.Count, Is.EqualTo(1)); + Assert.That(result.Responses[0].Recipient, Is.EqualTo("fast")); } - // ── SubmitResponse For Unknown CorrelationId Returns False ──────────────── - [Test] - public async Task SubmitResponse_UnknownCorrelation_ReturnsFalse() + public async Task Gather_PreservesCorrelationId() { - var producer = Substitute.For(); - var options = Options.Create(new ScatterGatherOptions { TimeoutMs = 5000 }); - - var sg = new ScatterGatherer( - producer, options, - NullLogger>.Instance); + var sg = CreateScatterGatherer(timeoutMs: 500); + var correlationId = Guid.NewGuid(); + var request = new ScatterRequest(correlationId, "data", + new[] { "topic-1" }); - var response = new GatherResponse( - "svc-a", "pong", DateTimeOffset.UtcNow, true, null); + var task = sg.ScatterGatherAsync(request); + await sg.SubmitResponseAsync(correlationId, + new GatherResponse("topic-1", "resp", DateTimeOffset.UtcNow, true, null)); - var accepted = await sg.SubmitResponseAsync(Guid.NewGuid(), response); + var result = await task; - Assert.That(accepted, Is.False); + Assert.That(result.CorrelationId, Is.EqualTo(correlationId)); } - // ── Full Scatter-Gather With Submitted Responses ───────────────────────── - [Test] - public async Task Scatter_ReceivesAllResponses_CompletesBeforeTimeout() + public async Task SubmitResponse_UnknownCorrelation_ReturnsFalse() { - var producer = Substitute.For(); - var options = Options.Create(new ScatterGatherOptions { TimeoutMs = 10_000 }); - - var sg = new ScatterGatherer( - producer, options, - NullLogger>.Instance); - - var correlationId = Guid.NewGuid(); - var request = new ScatterRequest( - correlationId, "query", new List { "svc-a" }); - - // Start scatter-gather on a background task. - var scatterTask = sg.ScatterGatherAsync(request); + var sg = CreateScatterGatherer(timeoutMs: 500); - // Give scatter time to publish, then submit a response. - await Task.Delay(100); - var submitted = await sg.SubmitResponseAsync( - correlationId, - new GatherResponse("svc-a", "answer", DateTimeOffset.UtcNow, true, null)); + var accepted = await sg.SubmitResponseAsync(Guid.NewGuid(), + new GatherResponse("x", "data", DateTimeOffset.UtcNow, true, null)); - var result = await scatterTask; - - Assert.That(submitted, Is.True); - Assert.That(result.Responses.Count, Is.EqualTo(1)); - Assert.That(result.Responses[0].Payload, Is.EqualTo("answer")); - Assert.That(result.TimedOut, Is.False); + Assert.That(accepted, Is.False); } - // ── ScatterGatherResult Preserves CorrelationId ────────────────────────── - [Test] - public async Task Result_CorrelationId_MatchesRequest() + public async Task Scatter_ExceedsMaxRecipients_Throws() { - var producer = Substitute.For(); - var options = Options.Create(new ScatterGatherOptions { TimeoutMs = 500 }); + var sg = CreateScatterGatherer(timeoutMs: 500, maxRecipients: 2); + var request = new ScatterRequest(Guid.NewGuid(), "data", + new[] { "a", "b", "c" }); - var sg = new ScatterGatherer( - producer, options, - NullLogger>.Instance); - - var correlationId = Guid.NewGuid(); - var request = new ScatterRequest( - correlationId, "payload", new List()); - - var result = await sg.ScatterGatherAsync(request); - - Assert.That(result.CorrelationId, Is.EqualTo(correlationId)); + Assert.ThrowsAsync(async () => + await sg.ScatterGatherAsync(request)); } - // ── Options Default Values ────────────────────────────────────────────── - - [Test] - public void Options_DefaultValues_AreCorrect() + private ScatterGatherer CreateScatterGatherer( + int timeoutMs, int maxRecipients = 50) { - var opts = new ScatterGatherOptions(); + var options = Options.Create(new ScatterGatherOptions + { + TimeoutMs = timeoutMs, + MaxRecipients = maxRecipients, + }); - Assert.That(opts.TimeoutMs, Is.EqualTo(30_000)); - Assert.That(opts.MaxRecipients, Is.EqualTo(50)); + return new ScatterGatherer( + _output, options, + NullLogger>.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial23/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial23/Exam.cs index af40546..796297e 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial23/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial23/Exam.cs @@ -1,98 +1,100 @@ // ============================================================================ // Tutorial 23 – Request-Reply (Exam) // ============================================================================ -// Coding challenges: validate that empty ReplyTopic throws, verify the -// correlator subscribes on the reply topic before publishing, and test -// the generated correlationId flow when none is provided. +// E2E challenges: request envelope intent/replyTo, concurrent requests with +// different correlation IDs, and timeout duration accuracy. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.RequestReply; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial23; [TestFixture] public sealed class Exam { - // ── Challenge 1: Empty ReplyTopic Throws ───────────────────────────────── + [Test] + public async Task Challenge1_RequestEnvelope_HasIntentAndReplyTo() + { + await using var producer = new MockEndpoint("exam-prod"); + await using var consumer = new MockEndpoint("exam-cons"); + var correlator = CreateCorrelator(producer, consumer, timeoutMs: 200); + var correlationId = Guid.NewGuid(); + var request = new RequestReplyMessage( + "payload", "req-topic", "rep-topic", "source", "type", correlationId); + + await correlator.SendAndReceiveAsync(request); + + var sent = producer.GetReceived(0); + Assert.That(sent.ReplyTo, Is.EqualTo("rep-topic")); + Assert.That(sent.Intent, Is.EqualTo(MessageIntent.Command)); + Assert.That(sent.CorrelationId, Is.EqualTo(correlationId)); + producer.AssertReceivedOnTopic("req-topic", 1); + } [Test] - public void Challenge1_EmptyReplyTopic_ThrowsArgumentException() + public async Task Challenge2_ConcurrentRequests_CorrelateCorrectly() { - // When ReplyTopic is empty or whitespace, the correlator should throw - // before publishing anything. - var producer = Substitute.For(); - var consumer = Substitute.For(); - var options = Options.Create(new RequestReplyOptions { TimeoutMs = 500 }); + await using var producer = new MockEndpoint("exam-conc-prod"); + await using var consumer = new MockEndpoint("exam-conc-cons"); + var correlator = CreateCorrelator(producer, consumer, timeoutMs: 2000); - var correlator = new RequestReplyCorrelator( - producer, consumer, options, - NullLogger>.Instance); + var corr1 = Guid.NewGuid(); + var corr2 = Guid.NewGuid(); + var req1 = new RequestReplyMessage( + "r1", "req-topic", "rep-topic", "svc", "type", corr1); + var req2 = new RequestReplyMessage( + "r2", "req-topic", "rep-topic", "svc", "type", corr2); - var msg = new RequestReplyMessage( - "data", "cmd-topic", " ", "Svc", "type"); + var task1 = correlator.SendAndReceiveAsync(req1); - Assert.ThrowsAsync( - () => correlator.SendAndReceiveAsync(msg)); - } + // Give the first request time to subscribe + await Task.Delay(50); + + // Send reply for corr1 + var reply1 = IntegrationEnvelope.Create("ans1", "be", "resp", corr1); + await consumer.SendAsync(reply1); - // ── Challenge 2: Consumer Subscribes On Reply Topic ───────────────────── + var result1 = await task1; + + Assert.That(result1.TimedOut, Is.False); + Assert.That(result1.Reply!.Payload, Is.EqualTo("ans1")); + Assert.That(result1.CorrelationId, Is.EqualTo(corr1)); + } [Test] - public async Task Challenge2_Correlator_SubscribesOnReplyTopic() + public async Task Challenge3_Timeout_DurationIsReasonable() { - // Verify the correlator subscribes to the correct reply topic with - // the consumer group from options. - var producer = Substitute.For(); - var consumer = Substitute.For(); - var options = Options.Create(new RequestReplyOptions - { - TimeoutMs = 300, - ConsumerGroup = "my-group", - }); + await using var producer = new MockEndpoint("exam-to-prod"); + await using var consumer = new MockEndpoint("exam-to-cons"); + var correlator = CreateCorrelator(producer, consumer, timeoutMs: 300); - var correlator = new RequestReplyCorrelator( - producer, consumer, options, - NullLogger>.Instance); - - var msg = new RequestReplyMessage( - "payload", "commands", "my-replies", "Svc", "cmd.ping"); + var request = new RequestReplyMessage( + "data", "req", "rep", "svc", "type"); - await correlator.SendAndReceiveAsync(msg); + var result = await correlator.SendAndReceiveAsync(request); - await consumer.Received(1).SubscribeAsync( - "my-replies", - "my-group", - Arg.Any, Task>>(), - Arg.Any()); + Assert.That(result.TimedOut, Is.True); + Assert.That(result.Reply, Is.Null); + Assert.That(result.Duration.TotalMilliseconds, Is.GreaterThan(200)); + Assert.That(result.Duration.TotalMilliseconds, Is.LessThan(2000)); } - // ── Challenge 3: Auto-Generated CorrelationId On Result ───────────────── - - [Test] - public async Task Challenge3_NullCorrelationId_GeneratesNewOne() + private static RequestReplyCorrelator CreateCorrelator( + MockEndpoint producer, MockEndpoint consumer, int timeoutMs) { - // When no CorrelationId is provided in the message, the correlator - // generates a new one. The result should carry a non-empty CorrelationId. - var producer = Substitute.For(); - var consumer = Substitute.For(); - var options = Options.Create(new RequestReplyOptions { TimeoutMs = 300 }); + var options = Options.Create(new RequestReplyOptions + { + TimeoutMs = timeoutMs, + ConsumerGroup = "exam-group", + }); - var correlator = new RequestReplyCorrelator( + return new RequestReplyCorrelator( producer, consumer, options, NullLogger>.Instance); - - var msg = new RequestReplyMessage( - "data", "topic-a", "reply-a", "Svc", "type", CorrelationId: null); - - var result = await correlator.SendAndReceiveAsync(msg); - - Assert.That(result.CorrelationId, Is.Not.EqualTo(Guid.Empty)); - Assert.That(result.TimedOut, Is.True); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial23/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial23/Lab.cs index cff802a..c5c4f1a 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial23/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial23/Lab.cs @@ -1,160 +1,149 @@ // ============================================================================ // Tutorial 23 – Request-Reply (Lab) // ============================================================================ -// This lab exercises the RequestReplyCorrelator using mocked broker -// interfaces. You will verify request publishing with ReplyTo, reply -// correlation, timeout behaviour, and option defaults. +// EIP Pattern: Request-Reply. +// E2E: Wire real RequestReplyCorrelator with MockEndpoints for both producer +// and consumer. Simulate reply delivery via MockEndpoint.SendAsync. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.RequestReply; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial23; [TestFixture] public sealed class Lab { - // ── Options Default Values ────────────────────────────────────────────── + private MockEndpoint _producer = null!; + private MockEndpoint _consumer = null!; - [Test] - public void Options_DefaultValues_AreCorrect() + [SetUp] + public void SetUp() { - var opts = new RequestReplyOptions(); - - Assert.That(opts.TimeoutMs, Is.EqualTo(30_000)); - Assert.That(opts.ConsumerGroup, Is.EqualTo("request-reply")); + _producer = new MockEndpoint("req-producer"); + _consumer = new MockEndpoint("req-consumer"); } - // ── RequestReplyMessage Construction ───────────────────────────────────── - - [Test] - public void Message_RecordProperties_AreCorrect() + [TearDown] + public async Task TearDown() { - var correlationId = Guid.NewGuid(); - var msg = new RequestReplyMessage( - "payload", "req-topic", "reply-topic", "TestSvc", "cmd.ping", correlationId); - - Assert.That(msg.Payload, Is.EqualTo("payload")); - Assert.That(msg.RequestTopic, Is.EqualTo("req-topic")); - Assert.That(msg.ReplyTopic, Is.EqualTo("reply-topic")); - Assert.That(msg.Source, Is.EqualTo("TestSvc")); - Assert.That(msg.MessageType, Is.EqualTo("cmd.ping")); - Assert.That(msg.CorrelationId, Is.EqualTo(correlationId)); + await _producer.DisposeAsync(); + await _consumer.DisposeAsync(); } - // ── Correlator Publishes Request With ReplyTo ──────────────────────────── - [Test] - public async Task Correlator_PublishesRequest_WithReplyToSet() + public async Task SendAndReceive_PublishesRequestToTopic() { - var producer = Substitute.For(); - var consumer = Substitute.For(); - var options = Options.Create(new RequestReplyOptions { TimeoutMs = 500 }); - - var correlator = new RequestReplyCorrelator( - producer, consumer, options, - NullLogger>.Instance); - - var msg = new RequestReplyMessage( - "ping", "commands", "replies", "TestSvc", "cmd.ping"); + var correlator = CreateCorrelator(timeoutMs: 500); + var correlationId = Guid.NewGuid(); + var request = new RequestReplyMessage( + "get-price", "requests-topic", "replies-topic", "PriceSvc", "PriceRequest", correlationId); - // Will time out since no reply is submitted, but request should be published. - await correlator.SendAndReceiveAsync(msg); + // Start request — will timeout since no reply, but request must be published + var result = await correlator.SendAndReceiveAsync(request); - await producer.Received(1).PublishAsync( - Arg.Is>(e => e.ReplyTo == "replies"), - "commands", - Arg.Any()); + _producer.AssertReceivedOnTopic("requests-topic", 1); + var sent = _producer.GetReceived(0); + Assert.That(sent.CorrelationId, Is.EqualTo(correlationId)); + Assert.That(sent.ReplyTo, Is.EqualTo("replies-topic")); } - // ── Correlator Sets Intent To Command ──────────────────────────────────── - [Test] - public async Task Correlator_SetsIntentToCommand() + public async Task SendAndReceive_ReceivesCorrelatedReply() { - var producer = Substitute.For(); - var consumer = Substitute.For(); - var options = Options.Create(new RequestReplyOptions { TimeoutMs = 500 }); - - var correlator = new RequestReplyCorrelator( - producer, consumer, options, - NullLogger>.Instance); + var correlator = CreateCorrelator(timeoutMs: 2000); + var correlationId = Guid.NewGuid(); + var request = new RequestReplyMessage( + "get-price", "requests-topic", "replies-topic", "PriceSvc", "PriceReq", correlationId); - var msg = new RequestReplyMessage( - "data", "req", "rep", "Svc", "cmd.do"); + // Simulate reply arrival after a short delay + _ = Task.Run(async () => + { + await Task.Delay(100); + var reply = IntegrationEnvelope.Create( + "$42.00", "PriceBackend", "PriceReply", correlationId); + await _consumer.SendAsync(reply); + }); - await correlator.SendAndReceiveAsync(msg); + var result = await correlator.SendAndReceiveAsync(request); - await producer.Received(1).PublishAsync( - Arg.Is>(e => e.Intent == MessageIntent.Command), - "req", - Arg.Any()); + Assert.That(result.TimedOut, Is.False); + Assert.That(result.Reply, Is.Not.Null); + Assert.That(result.Reply!.Payload, Is.EqualTo("$42.00")); + Assert.That(result.CorrelationId, Is.EqualTo(correlationId)); } - // ── Timeout Returns TimedOut Result ────────────────────────────────────── - [Test] - public async Task Correlator_Timeout_ReturnsTimedOutResult() + public async Task SendAndReceive_TimesOut_ReturnsNullReply() { - var producer = Substitute.For(); - var consumer = Substitute.For(); - var options = Options.Create(new RequestReplyOptions { TimeoutMs = 300 }); + var correlator = CreateCorrelator(timeoutMs: 200); + var request = new RequestReplyMessage( + "req", "requests-topic", "replies-topic", "svc", "type"); - var correlator = new RequestReplyCorrelator( - producer, consumer, options, - NullLogger>.Instance); - - var msg = new RequestReplyMessage( - "request-data", "cmd-topic", "reply-topic", "Svc", "cmd.type"); - - var result = await correlator.SendAndReceiveAsync(msg); + var result = await correlator.SendAndReceiveAsync(request); Assert.That(result.TimedOut, Is.True); Assert.That(result.Reply, Is.Null); - Assert.That(result.Duration, Is.GreaterThan(TimeSpan.Zero)); } - // ── RequestReplyResult Record ──────────────────────────────────────────── - [Test] - public void ResultRecord_Properties_AreCorrectlySet() + public async Task SendAndReceive_DurationIsTracked() { + var correlator = CreateCorrelator(timeoutMs: 2000); var correlationId = Guid.NewGuid(); - var reply = IntegrationEnvelope.Create( - "pong", "ReplySvc", "reply.type", correlationId: correlationId); + var request = new RequestReplyMessage( + "req", "req-topic", "reply-topic", "svc", "type", correlationId); - var result = new RequestReplyResult( - correlationId, reply, false, TimeSpan.FromMilliseconds(42)); + _ = Task.Run(async () => + { + await Task.Delay(50); + var reply = IntegrationEnvelope.Create( + "ok", "backend", "reply", correlationId); + await _consumer.SendAsync(reply); + }); - Assert.That(result.CorrelationId, Is.EqualTo(correlationId)); - Assert.That(result.Reply, Is.Not.Null); - Assert.That(result.Reply!.Payload, Is.EqualTo("pong")); + var result = await correlator.SendAndReceiveAsync(request); + + Assert.That(result.Duration, Is.GreaterThan(TimeSpan.Zero)); Assert.That(result.TimedOut, Is.False); - Assert.That(result.Duration.TotalMilliseconds, Is.EqualTo(42)); } - // ── Empty RequestTopic Throws ──────────────────────────────────────────── - [Test] - public void Correlator_EmptyRequestTopic_ThrowsArgumentException() + public async Task SendAndReceive_EmptyRequestTopic_Throws() { - var producer = Substitute.For(); - var consumer = Substitute.For(); - var options = Options.Create(new RequestReplyOptions { TimeoutMs = 500 }); + var correlator = CreateCorrelator(timeoutMs: 500); + var request = new RequestReplyMessage( + "data", "", "replies", "svc", "type"); - var correlator = new RequestReplyCorrelator( - producer, consumer, options, - NullLogger>.Instance); + Assert.ThrowsAsync(async () => + await correlator.SendAndReceiveAsync(request)); + } - var msg = new RequestReplyMessage( - "data", "", "reply-topic", "Svc", "type"); + [Test] + public async Task SendAndReceive_EmptyReplyTopic_Throws() + { + var correlator = CreateCorrelator(timeoutMs: 500); + var request = new RequestReplyMessage( + "data", "requests", "", "svc", "type"); - Assert.ThrowsAsync( - () => correlator.SendAndReceiveAsync(msg)); + Assert.ThrowsAsync(async () => + await correlator.SendAndReceiveAsync(request)); + } + + private RequestReplyCorrelator CreateCorrelator(int timeoutMs) + { + var options = Options.Create(new RequestReplyOptions + { + TimeoutMs = timeoutMs, + ConsumerGroup = "test-group", + }); + + return new RequestReplyCorrelator( + _producer, _consumer, options, + NullLogger>.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial24/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial24/Exam.cs index ee211a1..603fdd1 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial24/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial24/Exam.cs @@ -1,105 +1,118 @@ // ============================================================================ // Tutorial 24 – Retry Framework (Exam) // ============================================================================ -// Coding challenges: verify exactly MaxAttempts invocations, test that a -// single retry recovery carries the correct attempt count, and validate -// that the retry policy respects max-attempts = 1 (no retries). +// E2E challenges: retry with exception chain, cancellation mid-retry, +// and retry-then-dead-letter flow via MockEndpoint. // ============================================================================ +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.Retry; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial24; [TestFixture] public sealed class Exam { - private static ExponentialBackoffRetryPolicy CreatePolicy( - int maxAttempts = 3) => - new( - Options.Create(new RetryOptions - { - MaxAttempts = maxAttempts, - InitialDelayMs = 100, - MaxDelayMs = 1000, - BackoffMultiplier = 2.0, - UseJitter = false, - }), - NullLogger.Instance, - delayFunc: (_, _) => Task.CompletedTask); - - // ── Challenge 1: Exactly MaxAttempts Invocations ───────────────────────── - [Test] - public async Task Challenge1_OperationCalledExactlyMaxAttemptsTimes() + public async Task Challenge1_ExhaustRetries_CapturesLastException() { - // When every attempt throws, the operation should be invoked exactly - // MaxAttempts times — no more, no less. - var policy = CreatePolicy(maxAttempts: 4); - var callCount = 0; + await using var output = new MockEndpoint("exam-retry"); + var policy = CreatePolicy(maxAttempts: 3); + var attempt = 0; - var result = await policy.ExecuteAsync( - _ => - { - callCount++; - throw new InvalidOperationException("boom"); - }, - CancellationToken.None); + var result = await policy.ExecuteAsync(_ => + { + attempt++; + throw new InvalidOperationException($"fail-{attempt}"); + }, CancellationToken.None); Assert.That(result.IsSucceeded, Is.False); - Assert.That(callCount, Is.EqualTo(4)); - Assert.That(result.Attempts, Is.EqualTo(4)); - } + Assert.That(result.Attempts, Is.EqualTo(3)); + Assert.That(result.LastException!.Message, Is.EqualTo("fail-3")); - // ── Challenge 2: Recover On Second Attempt ────────────────────────────── + // Publish failure info to dead-letter via MockEndpoint + var envelope = IntegrationEnvelope.Create( + result.LastException!.Message, "svc", "retry.exhausted"); + await output.PublishAsync(envelope, "dlq-topic", CancellationToken.None); + output.AssertReceivedOnTopic("dlq-topic", 1); + } [Test] - public async Task Challenge2_RecoverOnSecondAttempt_ReportsCorrectAttempts() + public async Task Challenge2_CancellationDuringRetry_ThrowsOperationCanceled() { - // The operation fails once, then succeeds. Verify the result records - // exactly 2 attempts with the correct return value. var policy = CreatePolicy(maxAttempts: 5); - var callCount = 0; + using var cts = new CancellationTokenSource(); + var attempt = 0; - var result = await policy.ExecuteAsync( - _ => + // Create a policy with a delayFunc that cancels on 2nd attempt + var optionsValue = Options.Create(new RetryOptions + { + MaxAttempts = 5, + InitialDelayMs = 100, + BackoffMultiplier = 2.0, + MaxDelayMs = 5000, + UseJitter = false, + }); + var cancellablePolicy = new ExponentialBackoffRetryPolicy( + optionsValue, + NullLogger.Instance, + delayFunc: (_, ct) => { - callCount++; - if (callCount == 1) - throw new TimeoutException("first attempt timeout"); - return Task.FromResult("recovered"); - }, - CancellationToken.None); + cts.Cancel(); + return Task.CompletedTask; + }); - Assert.That(result.IsSucceeded, Is.True); - Assert.That(result.Attempts, Is.EqualTo(2)); - Assert.That(result.Result, Is.EqualTo("recovered")); - Assert.That(result.LastException, Is.Null); + Assert.ThrowsAsync(async () => + await cancellablePolicy.ExecuteAsync(_ => + { + attempt++; + throw new Exception("transient"); + }, cts.Token)); } - // ── Challenge 3: MaxAttempts = 1 Means No Retries ─────────────────────── - [Test] - public async Task Challenge3_MaxAttemptsOne_NoRetryOnFailure() + public async Task Challenge3_RetrySuccessThenPublish_FullPipeline() { - // With MaxAttempts = 1, a single failure should result in immediate - // failure with no retries. - var policy = CreatePolicy(maxAttempts: 1); - var callCount = 0; + await using var output = new MockEndpoint("exam-pipeline"); + var policy = CreatePolicy(maxAttempts: 4); + var attempt = 0; - var result = await policy.ExecuteAsync( - _ => - { - callCount++; - throw new ApplicationException("fatal"); - }, - CancellationToken.None); + var result = await policy.ExecuteAsync(_ => + { + attempt++; + if (attempt < 3) throw new Exception("not yet"); + return Task.FromResult("final-value"); + }, CancellationToken.None); - Assert.That(result.IsSucceeded, Is.False); - Assert.That(callCount, Is.EqualTo(1)); - Assert.That(result.Attempts, Is.EqualTo(1)); - Assert.That(result.LastException, Is.TypeOf()); + Assert.That(result.IsSucceeded, Is.True); + Assert.That(result.Attempts, Is.EqualTo(3)); + Assert.That(result.Result, Is.EqualTo("final-value")); + + var envelope = IntegrationEnvelope.Create( + result.Result!, "pipeline-svc", "order.processed"); + await output.PublishAsync(envelope, "orders-out", CancellationToken.None); + output.AssertReceivedOnTopic("orders-out", 1); + output.AssertReceivedCount(1); + } + + private static ExponentialBackoffRetryPolicy CreatePolicy(int maxAttempts) + { + var options = Options.Create(new RetryOptions + { + MaxAttempts = maxAttempts, + InitialDelayMs = 100, + BackoffMultiplier = 2.0, + MaxDelayMs = 5000, + UseJitter = false, + }); + + return new ExponentialBackoffRetryPolicy( + options, + NullLogger.Instance, + delayFunc: (_, _) => Task.CompletedTask); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial24/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial24/Lab.cs index 9c96f82..c31044e 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial24/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial24/Lab.cs @@ -1,149 +1,149 @@ // ============================================================================ // Tutorial 24 – Retry Framework (Lab) // ============================================================================ -// This lab exercises the ExponentialBackoffRetryPolicy with a no-delay -// override. You will verify success on first attempt, retry on transient -// failures, max-attempt exhaustion, and the void-returning overload. +// EIP Pattern: Retry / Guaranteed Delivery. +// E2E: Wire real ExponentialBackoffRetryPolicy with no-delay override, +// then publish success to MockEndpoint. // ============================================================================ +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.Retry; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial24; [TestFixture] public sealed class Lab { - private static ExponentialBackoffRetryPolicy CreatePolicy( - int maxAttempts = 3, - int initialDelayMs = 100, - double multiplier = 2.0) => - new( - Options.Create(new RetryOptions - { - MaxAttempts = maxAttempts, - InitialDelayMs = initialDelayMs, - MaxDelayMs = 5000, - BackoffMultiplier = multiplier, - UseJitter = false, - }), - NullLogger.Instance, - delayFunc: (_, _) => Task.CompletedTask); + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("retry-out"); - // ── Success On First Attempt ───────────────────────────────────────────── + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Execute_SuccessOnFirstAttempt_ReturnsResult() + public async Task Execute_SucceedsFirstAttempt_ReturnsResult() { - var policy = CreatePolicy(); + var policy = CreatePolicy(maxAttempts: 3); - var result = await policy.ExecuteAsync( - _ => Task.FromResult(42), CancellationToken.None); + var result = await policy.ExecuteAsync( + _ => Task.FromResult("ok"), CancellationToken.None); Assert.That(result.IsSucceeded, Is.True); Assert.That(result.Attempts, Is.EqualTo(1)); - Assert.That(result.Result, Is.EqualTo(42)); - Assert.That(result.LastException, Is.Null); - } + Assert.That(result.Result, Is.EqualTo("ok")); - // ── Retry Succeeds After Transient Failure ─────────────────────────────── + // Publish success to MockEndpoint + var envelope = IntegrationEnvelope.Create(result.Result!, "svc", "retry.success"); + await _output.PublishAsync(envelope, "success-topic", CancellationToken.None); + _output.AssertReceivedOnTopic("success-topic", 1); + } [Test] public async Task Execute_FailsThenSucceeds_RetriesCorrectly() { - var policy = CreatePolicy(maxAttempts: 5); - var callCount = 0; + var policy = CreatePolicy(maxAttempts: 3); + var attempts = 0; - var result = await policy.ExecuteAsync( - _ => - { - callCount++; - if (callCount < 3) - throw new InvalidOperationException("transient"); - return Task.FromResult("ok"); - }, - CancellationToken.None); + var result = await policy.ExecuteAsync(_ => + { + attempts++; + if (attempts < 3) + throw new InvalidOperationException("transient failure"); + return Task.FromResult("recovered"); + }, CancellationToken.None); Assert.That(result.IsSucceeded, Is.True); Assert.That(result.Attempts, Is.EqualTo(3)); - Assert.That(result.Result, Is.EqualTo("ok")); + Assert.That(result.Result, Is.EqualTo("recovered")); } - // ── All Attempts Exhausted ─────────────────────────────────────────────── - [Test] - public async Task Execute_AllAttemptsFail_ReturnsFailureWithException() + public async Task Execute_AllAttemptsFail_ReturnsFailure() { var policy = CreatePolicy(maxAttempts: 3); var result = await policy.ExecuteAsync( - _ => throw new TimeoutException("always fails"), + _ => throw new InvalidOperationException("permanent"), CancellationToken.None); Assert.That(result.IsSucceeded, Is.False); Assert.That(result.Attempts, Is.EqualTo(3)); - Assert.That(result.LastException, Is.TypeOf()); - Assert.That(result.Result, Is.Null); + Assert.That(result.LastException, Is.Not.Null); + Assert.That(result.LastException, Is.InstanceOf()); } - // ── Void Overload Returns True On Success ──────────────────────────────── - [Test] - public async Task ExecuteVoid_SuccessOnFirst_ReturnsTrueResult() + public async Task Execute_VoidOverload_ReturnsRetryResultBool() { - var policy = CreatePolicy(); + var policy = CreatePolicy(maxAttempts: 2); + var called = false; - var result = await policy.ExecuteAsync( - _ => Task.CompletedTask, CancellationToken.None); + var result = await policy.ExecuteAsync(_ => + { + called = true; + return Task.CompletedTask; + }, CancellationToken.None); Assert.That(result.IsSucceeded, Is.True); Assert.That(result.Attempts, Is.EqualTo(1)); - Assert.That(result.Result, Is.True); + Assert.That(called, Is.True); } - // ── Void Overload Retries And Fails ────────────────────────────────────── - [Test] - public async Task ExecuteVoid_AllFail_ReturnsFailure() + public async Task Execute_RetryThenPublish_EndToEnd() { - var policy = CreatePolicy(maxAttempts: 2); + var policy = CreatePolicy(maxAttempts: 3); + var attempt = 0; - var result = await policy.ExecuteAsync( - _ => throw new IOException("disk full"), - CancellationToken.None); + var result = await policy.ExecuteAsync(_ => + { + attempt++; + if (attempt < 2) + throw new Exception("fail"); + return Task.FromResult("data"); + }, CancellationToken.None); - Assert.That(result.IsSucceeded, Is.False); - Assert.That(result.Attempts, Is.EqualTo(2)); - Assert.That(result.LastException, Is.TypeOf()); - } + Assert.That(result.IsSucceeded, Is.True); - // ── Options Default Values ────────────────────────────────────────────── + var envelope = IntegrationEnvelope.Create( + result.Result!, "svc", "retry.success"); + await _output.PublishAsync(envelope, "processed-topic", CancellationToken.None); + _output.AssertReceivedOnTopic("processed-topic", 1); + } [Test] - public void Options_DefaultValues_AreCorrect() + public async Task Execute_MaxAttemptsOne_NoRetry() { - var opts = new RetryOptions(); + var policy = CreatePolicy(maxAttempts: 1); - Assert.That(opts.MaxAttempts, Is.EqualTo(3)); - Assert.That(opts.InitialDelayMs, Is.EqualTo(1000)); - Assert.That(opts.MaxDelayMs, Is.EqualTo(30000)); - Assert.That(opts.BackoffMultiplier, Is.EqualTo(2.0)); - Assert.That(opts.UseJitter, Is.True); - } + var result = await policy.ExecuteAsync( + _ => throw new Exception("fail"), + CancellationToken.None); - // ── Cancellation Is Propagated ────────────────────────────────────────── + Assert.That(result.IsSucceeded, Is.False); + Assert.That(result.Attempts, Is.EqualTo(1)); + } - [Test] - public void Execute_CancelledToken_ThrowsOperationCancelled() + private static ExponentialBackoffRetryPolicy CreatePolicy(int maxAttempts) { - var policy = CreatePolicy(maxAttempts: 5); - using var cts = new CancellationTokenSource(); - cts.Cancel(); - - Assert.ThrowsAsync( - () => policy.ExecuteAsync( - _ => Task.FromResult(1), cts.Token)); + var options = Options.Create(new RetryOptions + { + MaxAttempts = maxAttempts, + InitialDelayMs = 100, + BackoffMultiplier = 2.0, + MaxDelayMs = 5000, + UseJitter = false, + }); + + return new ExponentialBackoffRetryPolicy( + options, + NullLogger.Instance, + delayFunc: (_, _) => Task.CompletedTask); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial25/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial25/Exam.cs index e9f3afe..973f9e6 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial25/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial25/Exam.cs @@ -1,111 +1,97 @@ // ============================================================================ // Tutorial 25 – Dead Letter Queue (Exam) // ============================================================================ -// Coding challenges: publish with each distinct DeadLetterReason, verify -// the CausationId link from original to wrapper, and test the -// mock-based IDeadLetterPublisher contract. +// E2E challenges: multiple messages to DLQ with different reasons, +// original envelope integrity, and missing topic configuration. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.DeadLetter; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial25; [TestFixture] public sealed class Exam { - // ── Challenge 1: Publish With Multiple Reason Codes ────────────────────── - [Test] - public async Task Challenge1_PublishWithDifferentReasons_AllSucceed() + public async Task Challenge1_MultipleFailures_AllReachDlq() { - // Publish three messages with different DeadLetterReason values and - // verify the producer is called for each one. - var producer = Substitute.For(); - var options = Options.Create(new DeadLetterOptions - { - DeadLetterTopic = "dlq-multi", - }); + await using var output = new MockEndpoint("exam-dlq"); + var publisher = CreatePublisher(output); - var publisher = new DeadLetterPublisher(producer, options); + var e1 = IntegrationEnvelope.Create("order-1", "svc", "order.created"); + var e2 = IntegrationEnvelope.Create("order-2", "svc", "order.created"); + var e3 = IntegrationEnvelope.Create("order-3", "svc", "order.created"); - var envelope = IntegrationEnvelope.Create( - "data", "Svc", "msg.type"); + await publisher.PublishAsync(e1, DeadLetterReason.MaxRetriesExceeded, "Retries exhausted", 3, CancellationToken.None); + await publisher.PublishAsync(e2, DeadLetterReason.PoisonMessage, "Corrupt payload", 1, CancellationToken.None); + await publisher.PublishAsync(e3, DeadLetterReason.ValidationFailed, "Invalid schema", 1, CancellationToken.None); - await publisher.PublishAsync( - envelope, DeadLetterReason.MaxRetriesExceeded, "retries exhausted", 3, CancellationToken.None); - await publisher.PublishAsync( - envelope, DeadLetterReason.ProcessingTimeout, "timed out", 1, CancellationToken.None); - await publisher.PublishAsync( - envelope, DeadLetterReason.PoisonMessage, "corrupt payload", 1, CancellationToken.None); + output.AssertReceivedOnTopic("dlq-topic", 3); - await producer.Received(3).PublishAsync( - Arg.Any>>(), - "dlq-multi", - Arg.Any()); + var all = output.GetAllReceived>("dlq-topic"); + Assert.That(all[0].Payload.Reason, Is.EqualTo(DeadLetterReason.MaxRetriesExceeded)); + Assert.That(all[1].Payload.Reason, Is.EqualTo(DeadLetterReason.PoisonMessage)); + Assert.That(all[2].Payload.Reason, Is.EqualTo(DeadLetterReason.ValidationFailed)); } - // ── Challenge 2: CausationId Links Original To Wrapper ────────────────── - [Test] - public async Task Challenge2_CausationId_IsSetToOriginalMessageId() + public async Task Challenge2_OriginalEnvelope_MetadataPreserved() { - // The wrapper envelope's CausationId should equal the original - // envelope's MessageId — establishing a causal chain. - IntegrationEnvelope>? captured = null; - var producer = Substitute.For(); - producer - .PublishAsync( - Arg.Do>>(e => captured = e), - Arg.Any(), - Arg.Any()) - .Returns(Task.CompletedTask); + await using var output = new MockEndpoint("exam-meta"); + var publisher = CreatePublisher(output); + var original = IntegrationEnvelope.Create("sensitive-data", "AuthSvc", "auth.failed") with + { + Metadata = new Dictionary + { + ["userId"] = "user-42", + ["region"] = "eu-west", + }, + Priority = MessagePriority.High, + }; + + await publisher.PublishAsync(original, DeadLetterReason.MessageExpired, + "TTL exceeded", 1, CancellationToken.None); + + var received = output.GetReceived>(0); + var orig = received.Payload.OriginalEnvelope; + Assert.That(orig.Payload, Is.EqualTo("sensitive-data")); + Assert.That(orig.Source, Is.EqualTo("AuthSvc")); + Assert.That(orig.MessageType, Is.EqualTo("auth.failed")); + Assert.That(orig.Metadata["userId"], Is.EqualTo("user-42")); + Assert.That(orig.Metadata["region"], Is.EqualTo("eu-west")); + Assert.That(orig.Priority, Is.EqualTo(MessagePriority.High)); + } + + [Test] + public async Task Challenge3_MissingDeadLetterTopic_Throws() + { + await using var output = new MockEndpoint("exam-notopic"); var options = Options.Create(new DeadLetterOptions { - DeadLetterTopic = "dlq", + DeadLetterTopic = "", }); + var publisher = new DeadLetterPublisher(output, options); + var envelope = IntegrationEnvelope.Create("data", "svc", "type"); - var publisher = new DeadLetterPublisher(producer, options); - - var original = IntegrationEnvelope.Create( - "important-data", "CriticalSvc", "order.created"); - - await publisher.PublishAsync( - original, DeadLetterReason.ValidationFailed, "invalid schema", 1, CancellationToken.None); + Assert.ThrowsAsync(async () => + await publisher.PublishAsync(envelope, DeadLetterReason.PoisonMessage, + "Bad message", 1, CancellationToken.None)); - Assert.That(captured, Is.Not.Null); - Assert.That(captured!.CausationId, Is.EqualTo(original.MessageId)); + output.AssertNoneReceived(); } - // ── Challenge 3: Mock IDeadLetterPublisher Contract ───────────────────── - - [Test] - public async Task Challenge3_MockPublisher_VerifyCorrectParameters() + private static DeadLetterPublisher CreatePublisher(MockEndpoint output) { - // Use NSubstitute to mock IDeadLetterPublisher and verify it - // is called with the correct reason, error message, and attempt count. - var mockPublisher = Substitute.For>(); - - var envelope = IntegrationEnvelope.Create( - "payload", "SomeService", "event.type"); - - await mockPublisher.PublishAsync( - envelope, - DeadLetterReason.MessageExpired, - "TTL exceeded", - attemptCount: 0, - CancellationToken.None); - - await mockPublisher.Received(1).PublishAsync( - Arg.Is>(e => e.MessageId == envelope.MessageId), - DeadLetterReason.MessageExpired, - "TTL exceeded", - 0, - Arg.Any()); + var options = Options.Create(new DeadLetterOptions + { + DeadLetterTopic = "dlq-topic", + }); + + return new DeadLetterPublisher(output, options); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial25/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial25/Lab.cs index ecf1d57..77ab12e 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial25/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial25/Lab.cs @@ -1,185 +1,142 @@ // ============================================================================ // Tutorial 25 – Dead Letter Queue (Lab) // ============================================================================ -// This lab exercises the DeadLetterPublisher, DeadLetterReason enum, -// DeadLetterEnvelope construction, and the DeadLetterOptions defaults. -// You will verify correct DLQ publishing, reason codes, and error messages. +// EIP Pattern: Dead Letter Channel. +// E2E: Wire real DeadLetterPublisher with MockEndpoint as producer. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.DeadLetter; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial25; [TestFixture] public sealed class Lab { - // ── Publish Routes To Dead Letter Topic ────────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("dlq-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Publish_SendsToConfiguredDeadLetterTopic() + public async Task Publish_MaxRetriesExceeded_SendsToDeadLetterTopic() { - var producer = Substitute.For(); - var options = Options.Create(new DeadLetterOptions - { - DeadLetterTopic = "dlq-topic", - }); - - var publisher = new DeadLetterPublisher(producer, options); + var publisher = CreatePublisher(); + var envelope = IntegrationEnvelope.Create("order-data", "OrderSvc", "order.created"); - var envelope = IntegrationEnvelope.Create( - "bad-payload", "OrderSvc", "order.created"); + await publisher.PublishAsync(envelope, DeadLetterReason.MaxRetriesExceeded, + "Max retries exceeded", 3, CancellationToken.None); - await publisher.PublishAsync( - envelope, - DeadLetterReason.MaxRetriesExceeded, - "Failed after 3 retries", - attemptCount: 3, - CancellationToken.None); - - await producer.Received(1).PublishAsync( - Arg.Any>>(), - "dlq-topic", - Arg.Any()); + _output.AssertReceivedOnTopic("dlq-topic", 1); } - // ── Missing DeadLetterTopic Throws ─────────────────────────────────────── - [Test] - public void Publish_EmptyTopic_ThrowsInvalidOperationException() + public async Task Publish_PreservesOriginalEnvelope() { - var producer = Substitute.For(); - var options = Options.Create(new DeadLetterOptions - { - DeadLetterTopic = "", - }); + var publisher = CreatePublisher(); + var envelope = IntegrationEnvelope.Create("payload", "Svc", "type"); - var publisher = new DeadLetterPublisher(producer, options); - var envelope = IntegrationEnvelope.Create( - "data", "Svc", "type"); - - Assert.ThrowsAsync(() => - publisher.PublishAsync( - envelope, - DeadLetterReason.PoisonMessage, - "error", - 1, - CancellationToken.None)); - } + await publisher.PublishAsync(envelope, DeadLetterReason.PoisonMessage, + "Unprocessable", 1, CancellationToken.None); - // ── DeadLetterReason Enum Values ───────────────────────────────────────── - - [Test] - public void DeadLetterReason_ContainsExpectedValues() - { - Assert.That(Enum.IsDefined(typeof(DeadLetterReason), DeadLetterReason.MaxRetriesExceeded), Is.True); - Assert.That(Enum.IsDefined(typeof(DeadLetterReason), DeadLetterReason.PoisonMessage), Is.True); - Assert.That(Enum.IsDefined(typeof(DeadLetterReason), DeadLetterReason.ProcessingTimeout), Is.True); - Assert.That(Enum.IsDefined(typeof(DeadLetterReason), DeadLetterReason.ValidationFailed), Is.True); - Assert.That(Enum.IsDefined(typeof(DeadLetterReason), DeadLetterReason.UnroutableMessage), Is.True); - Assert.That(Enum.IsDefined(typeof(DeadLetterReason), DeadLetterReason.MessageExpired), Is.True); + var received = _output.GetReceived>(0); + Assert.That(received.Payload.OriginalEnvelope.Payload, Is.EqualTo("payload")); + Assert.That(received.Payload.OriginalEnvelope.MessageId, Is.EqualTo(envelope.MessageId)); } - // ── DeadLetterEnvelope Record Construction ─────────────────────────────── - [Test] - public void DeadLetterEnvelope_RecordProperties_AreCorrect() + public async Task Publish_SetsCorrectReason() { - var original = IntegrationEnvelope.Create( - "payload", "Svc", "type"); + var publisher = CreatePublisher(); + var envelope = IntegrationEnvelope.Create("data", "svc", "type"); - var dlEnvelope = new DeadLetterEnvelope - { - OriginalEnvelope = original, - Reason = DeadLetterReason.ValidationFailed, - ErrorMessage = "Schema mismatch", - FailedAt = DateTimeOffset.UtcNow, - AttemptCount = 2, - }; + await publisher.PublishAsync(envelope, DeadLetterReason.ValidationFailed, + "Schema invalid", 1, CancellationToken.None); - Assert.That(dlEnvelope.OriginalEnvelope.Payload, Is.EqualTo("payload")); - Assert.That(dlEnvelope.Reason, Is.EqualTo(DeadLetterReason.ValidationFailed)); - Assert.That(dlEnvelope.ErrorMessage, Is.EqualTo("Schema mismatch")); - Assert.That(dlEnvelope.AttemptCount, Is.EqualTo(2)); + var received = _output.GetReceived>(0); + Assert.That(received.Payload.Reason, Is.EqualTo(DeadLetterReason.ValidationFailed)); + Assert.That(received.Payload.ErrorMessage, Is.EqualTo("Schema invalid")); } - // ── Options Default Values ────────────────────────────────────────────── - [Test] - public void Options_DefaultValues_AreCorrect() + public async Task Publish_TracksAttemptCount() { - var opts = new DeadLetterOptions(); + var publisher = CreatePublisher(); + var envelope = IntegrationEnvelope.Create("data", "svc", "type"); - Assert.That(opts.DeadLetterTopic, Is.EqualTo(string.Empty)); - Assert.That(opts.MaxRetryAttempts, Is.EqualTo(3)); - Assert.That(opts.MessageType, Is.EqualTo("DeadLetter")); - } + await publisher.PublishAsync(envelope, DeadLetterReason.ProcessingTimeout, + "Timed out", 5, CancellationToken.None); - // ── Publisher Preserves CorrelationId On Wrapper ───────────────────────── + var received = _output.GetReceived>(0); + Assert.That(received.Payload.AttemptCount, Is.EqualTo(5)); + } [Test] - public async Task Publish_WrappedEnvelope_CarriesOriginalCorrelationId() + public async Task Publish_SetsFailedAtTimestamp() { - IntegrationEnvelope>? captured = null; - var producer = Substitute.For(); - producer - .PublishAsync( - Arg.Do>>(e => captured = e), - Arg.Any(), - Arg.Any()) - .Returns(Task.CompletedTask); + var publisher = CreatePublisher(); + var envelope = IntegrationEnvelope.Create("data", "svc", "type"); + var before = DateTimeOffset.UtcNow; - var options = Options.Create(new DeadLetterOptions - { - DeadLetterTopic = "dlq", - }); + await publisher.PublishAsync(envelope, DeadLetterReason.UnroutableMessage, + "No route", 1, CancellationToken.None); - var publisher = new DeadLetterPublisher(producer, options); + var received = _output.GetReceived>(0); + Assert.That(received.Payload.FailedAt, Is.GreaterThanOrEqualTo(before)); + Assert.That(received.Payload.FailedAt, Is.LessThanOrEqualTo(DateTimeOffset.UtcNow)); + } - var originalCorrelationId = Guid.NewGuid(); - var envelope = IntegrationEnvelope.Create( - "data", "Svc", "type", correlationId: originalCorrelationId); + [Test] + public async Task Publish_PreservesCorrelationId() + { + var publisher = CreatePublisher(); + var correlationId = Guid.NewGuid(); + var envelope = IntegrationEnvelope.Create("data", "svc", "type", correlationId); - await publisher.PublishAsync( - envelope, DeadLetterReason.MessageExpired, "expired", 0, CancellationToken.None); + await publisher.PublishAsync(envelope, DeadLetterReason.MaxRetriesExceeded, + "Exhausted", 3, CancellationToken.None); - Assert.That(captured, Is.Not.Null); - Assert.That(captured!.CorrelationId, Is.EqualTo(originalCorrelationId)); + var received = _output.GetReceived>(0); + Assert.That(received.CorrelationId, Is.EqualTo(correlationId)); } - // ── Publisher Uses Custom Source When Configured ───────────────────────── - [Test] - public async Task Publish_CustomSource_OverridesEnvelopeSource() + public async Task Publish_AllReasonValues_AreSupported() { - IntegrationEnvelope>? captured = null; - var producer = Substitute.For(); - producer - .PublishAsync( - Arg.Do>>(e => captured = e), - Arg.Any(), - Arg.Any()) - .Returns(Task.CompletedTask); + var publisher = CreatePublisher(); - var options = Options.Create(new DeadLetterOptions + var reasons = new[] { - DeadLetterTopic = "dlq", - Source = "DLQ-Publisher", - }); + DeadLetterReason.MaxRetriesExceeded, + DeadLetterReason.PoisonMessage, + DeadLetterReason.ProcessingTimeout, + DeadLetterReason.ValidationFailed, + DeadLetterReason.UnroutableMessage, + DeadLetterReason.MessageExpired, + }; - var publisher = new DeadLetterPublisher(producer, options); + foreach (var reason in reasons) + { + var envelope = IntegrationEnvelope.Create("data", "svc", "type"); + await publisher.PublishAsync(envelope, reason, $"Error: {reason}", 1, CancellationToken.None); + } - var envelope = IntegrationEnvelope.Create( - "data", "OriginalSvc", "type"); + _output.AssertReceivedOnTopic("dlq-topic", reasons.Length); + } - await publisher.PublishAsync( - envelope, DeadLetterReason.UnroutableMessage, "no route", 1, CancellationToken.None); + private DeadLetterPublisher CreatePublisher() + { + var options = Options.Create(new DeadLetterOptions + { + DeadLetterTopic = "dlq-topic", + }); - Assert.That(captured, Is.Not.Null); - Assert.That(captured!.Source, Is.EqualTo("DLQ-Publisher")); + return new DeadLetterPublisher(_output, options); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial26/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial26/Exam.cs index faa4458..31b21b0 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial26/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial26/Exam.cs @@ -1,101 +1,69 @@ // ============================================================================ // Tutorial 26 – Message Replay (Exam) // ============================================================================ -// Coding challenges: verify replay-id metadata injection, filter by -// CorrelationId, and confirm ReplayOptions default values. +// E2E challenges: filtered replay, max-messages cap, correlation-based replay. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Replay; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial26; [TestFixture] public sealed class Exam { - // ── Challenge 1: Replayed Envelope Carries replay-id Metadata ──────────── - [Test] - public async Task Challenge1_ReplayedEnvelope_ContainsReplayIdHeader() + public async Task Challenge1_FilterByCorrelationId_OnlyMatchingReplayed() { - IntegrationEnvelope? captured = null; + await using var output = new MockEndpoint("replay-corr"); var store = new InMemoryMessageReplayStore(); - var producer = Substitute.For(); - producer - .PublishAsync( - Arg.Do>(e => captured = e), - Arg.Any(), - Arg.Any()) - .Returns(Task.CompletedTask); - - var options = Options.Create(new ReplayOptions - { - SourceTopic = "src", - TargetTopic = "tgt", - MaxMessages = 10, - }); - - var replayer = new MessageReplayer( - store, producer, options, NullLogger.Instance); + var targetCorrelation = Guid.NewGuid(); - var env = IntegrationEnvelope.Create("data", "Svc", "type"); - await store.StoreForReplayAsync(env, "src", CancellationToken.None); + var env1 = IntegrationEnvelope.Create("a", "Svc", "evt") with { CorrelationId = targetCorrelation }; + var env2 = IntegrationEnvelope.Create("b", "Svc", "evt"); + var env3 = IntegrationEnvelope.Create("c", "Svc", "evt") with { CorrelationId = targetCorrelation }; + await store.StoreForReplayAsync(env1, "src", CancellationToken.None); + await store.StoreForReplayAsync(env2, "src", CancellationToken.None); + await store.StoreForReplayAsync(env3, "src", CancellationToken.None); - await replayer.ReplayAsync(new ReplayFilter(), CancellationToken.None); + var opts = Options.Create(new ReplayOptions { SourceTopic = "src", TargetTopic = "tgt", MaxMessages = 100 }); + var replayer = new MessageReplayer(store, output, opts, NullLogger.Instance); + var result = await replayer.ReplayAsync(new ReplayFilter { CorrelationId = targetCorrelation }, CancellationToken.None); - Assert.That(captured, Is.Not.Null); - Assert.That(captured!.Metadata.ContainsKey(MessageHeaders.ReplayId), Is.True); - Assert.That(Guid.TryParse(captured.Metadata[MessageHeaders.ReplayId], out _), Is.True); + Assert.That(result.ReplayedCount, Is.EqualTo(2)); + output.AssertReceivedOnTopic("tgt", 2); } - // ── Challenge 2: Filter By CorrelationId ──────────────────────────────── - [Test] - public async Task Challenge2_FilterByCorrelationId_ReturnsOnlyMatchingMessages() + public async Task Challenge2_MaxMessages_CapsReplayCount() { + await using var output = new MockEndpoint("replay-max"); var store = new InMemoryMessageReplayStore(); - var producer = Substitute.For(); - - var options = Options.Create(new ReplayOptions - { - SourceTopic = "src", - TargetTopic = "tgt", - MaxMessages = 100, - }); + for (var i = 0; i < 10; i++) + await store.StoreForReplayAsync( + IntegrationEnvelope.Create($"d{i}", "Svc", "evt"), "src", CancellationToken.None); - var replayer = new MessageReplayer( - store, producer, options, NullLogger.Instance); + var opts = Options.Create(new ReplayOptions { SourceTopic = "src", TargetTopic = "tgt", MaxMessages = 3 }); + var replayer = new MessageReplayer(store, output, opts, NullLogger.Instance); + var result = await replayer.ReplayAsync(new ReplayFilter(), CancellationToken.None); - var targetCorrelation = Guid.NewGuid(); - var match = IntegrationEnvelope.Create( - "match", "Svc", "type", correlationId: targetCorrelation); - var noMatch = IntegrationEnvelope.Create("no", "Svc", "type"); - - await store.StoreForReplayAsync(match, "src", CancellationToken.None); - await store.StoreForReplayAsync(noMatch, "src", CancellationToken.None); - - var filter = new ReplayFilter { CorrelationId = targetCorrelation }; - var result = await replayer.ReplayAsync(filter, CancellationToken.None); - - Assert.That(result.ReplayedCount, Is.EqualTo(1)); + Assert.That(result.ReplayedCount, Is.EqualTo(3)); + output.AssertReceivedCount(3); } - // ── Challenge 3: ReplayOptions Default Values ─────────────────────────── - [Test] - public void Challenge3_ReplayOptions_DefaultValues() + public async Task Challenge3_MissingSourceTopic_ThrowsInvalidOperation() { - var opts = new ReplayOptions(); + await using var output = new MockEndpoint("replay-err"); + var store = new InMemoryMessageReplayStore(); + var opts = Options.Create(new ReplayOptions { SourceTopic = "", TargetTopic = "tgt" }); + var replayer = new MessageReplayer(store, output, opts, NullLogger.Instance); - Assert.That(opts.SourceTopic, Is.EqualTo(string.Empty)); - Assert.That(opts.TargetTopic, Is.EqualTo(string.Empty)); - Assert.That(opts.MaxMessages, Is.EqualTo(1000)); - Assert.That(opts.BatchSize, Is.EqualTo(100)); - Assert.That(opts.SkipAlreadyReplayed, Is.False); + Assert.ThrowsAsync(async () => + await replayer.ReplayAsync(new ReplayFilter(), CancellationToken.None)); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial26/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial26/Lab.cs index b403aee..6554367 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial26/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial26/Lab.cs @@ -1,211 +1,138 @@ // ============================================================================ // Tutorial 26 – Message Replay (Lab) // ============================================================================ -// This lab exercises the MessageReplayer, ReplayFilter, ReplayResult, -// ReplayOptions, and the InMemoryMessageReplayStore. -// You will verify replay filtering, deduplication, and result reporting. +// EIP Pattern: Message Store / Replay. +// E2E: MessageReplayer with InMemoryMessageReplayStore + MockEndpoint. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; -using EnterpriseIntegrationPlatform.Ingestion; using EnterpriseIntegrationPlatform.Processing.Replay; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial26; [TestFixture] public sealed class Lab { - // ── Replay Returns Correct Counts ──────────────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("replay-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Replay_AllMessagesReplayed_CountsAreCorrect() + public async Task Replay_SingleMessage_PublishesToTargetTopic() { var store = new InMemoryMessageReplayStore(); - var producer = Substitute.For(); - - var options = Options.Create(new ReplayOptions - { - SourceTopic = "orders", - TargetTopic = "orders-replay", - MaxMessages = 100, - }); - - var replayer = new MessageReplayer( - store, producer, options, NullLogger.Instance); - - var env1 = IntegrationEnvelope.Create("p1", "Svc", "order.created"); - var env2 = IntegrationEnvelope.Create("p2", "Svc", "order.created"); - await store.StoreForReplayAsync(env1, "orders", CancellationToken.None); - await store.StoreForReplayAsync(env2, "orders", CancellationToken.None); + var envelope = IntegrationEnvelope.Create("order-1", "OrderService", "order.created"); + await store.StoreForReplayAsync(envelope, "source-topic", CancellationToken.None); + var replayer = CreateReplayer(store); var result = await replayer.ReplayAsync(new ReplayFilter(), CancellationToken.None); - Assert.That(result.ReplayedCount, Is.EqualTo(2)); - Assert.That(result.SkippedCount, Is.EqualTo(0)); + Assert.That(result.ReplayedCount, Is.EqualTo(1)); Assert.That(result.FailedCount, Is.EqualTo(0)); + _output.AssertReceivedOnTopic("replay-target", 1); } - // ── Replay Publishes To Target Topic ───────────────────────────────────── - [Test] - public async Task Replay_PublishesToConfiguredTargetTopic() + public async Task Replay_MultipleMessages_ReplaysAll() { var store = new InMemoryMessageReplayStore(); - var producer = Substitute.For(); - - var options = Options.Create(new ReplayOptions + for (var i = 0; i < 3; i++) { - SourceTopic = "events", - TargetTopic = "events-replay", - MaxMessages = 10, - }); - - var replayer = new MessageReplayer( - store, producer, options, NullLogger.Instance); - - var env = IntegrationEnvelope.Create("data", "Svc", "event.fired"); - await store.StoreForReplayAsync(env, "events", CancellationToken.None); + var env = IntegrationEnvelope.Create($"data-{i}", "Svc", "event.type"); + await store.StoreForReplayAsync(env, "source-topic", CancellationToken.None); + } - await replayer.ReplayAsync(new ReplayFilter(), CancellationToken.None); + var replayer = CreateReplayer(store); + var result = await replayer.ReplayAsync(new ReplayFilter(), CancellationToken.None); - await producer.Received(1).PublishAsync( - Arg.Any>(), - "events-replay", - Arg.Any()); + Assert.That(result.ReplayedCount, Is.EqualTo(3)); + _output.AssertReceivedOnTopic("replay-target", 3); } - // ── ReplayFilter By MessageType ────────────────────────────────────────── - [Test] - public async Task Replay_FilterByMessageType_OnlyMatchingMessagesReplayed() + public async Task Replay_FilterByMessageType_OnlyMatchingReplayed() { var store = new InMemoryMessageReplayStore(); - var producer = Substitute.For(); - - var options = Options.Create(new ReplayOptions - { - SourceTopic = "topic", - TargetTopic = "topic-replay", - MaxMessages = 100, - }); - - var replayer = new MessageReplayer( - store, producer, options, NullLogger.Instance); - - var match = IntegrationEnvelope.Create("m", "Svc", "order.created"); - var noMatch = IntegrationEnvelope.Create("n", "Svc", "invoice.created"); - await store.StoreForReplayAsync(match, "topic", CancellationToken.None); - await store.StoreForReplayAsync(noMatch, "topic", CancellationToken.None); + await store.StoreForReplayAsync( + IntegrationEnvelope.Create("a", "Svc", "order.created"), "source-topic", CancellationToken.None); + await store.StoreForReplayAsync( + IntegrationEnvelope.Create("b", "Svc", "payment.received"), "source-topic", CancellationToken.None); + var replayer = CreateReplayer(store); var filter = new ReplayFilter { MessageType = "order.created" }; var result = await replayer.ReplayAsync(filter, CancellationToken.None); Assert.That(result.ReplayedCount, Is.EqualTo(1)); + _output.AssertReceivedOnTopic("replay-target", 1); } - // ── SkipAlreadyReplayed Deduplication ──────────────────────────────────── - [Test] - public async Task Replay_SkipAlreadyReplayed_SkipsMessagesWithReplayIdHeader() + public async Task Replay_EmptyStore_ReturnsZeroReplayed() { var store = new InMemoryMessageReplayStore(); - var producer = Substitute.For(); - - var options = Options.Create(new ReplayOptions - { - SourceTopic = "src", - TargetTopic = "tgt", - MaxMessages = 100, - SkipAlreadyReplayed = true, - }); - - var replayer = new MessageReplayer( - store, producer, options, NullLogger.Instance); - - var alreadyReplayed = new IntegrationEnvelope - { - MessageId = Guid.NewGuid(), - CorrelationId = Guid.NewGuid(), - Timestamp = DateTimeOffset.UtcNow, - Source = "Svc", - MessageType = "type", - Payload = "data", - Metadata = new Dictionary - { - [MessageHeaders.ReplayId] = Guid.NewGuid().ToString(), - }, - }; - var fresh = IntegrationEnvelope.Create("fresh", "Svc", "type"); - - await store.StoreForReplayAsync(alreadyReplayed, "src", CancellationToken.None); - await store.StoreForReplayAsync(fresh, "src", CancellationToken.None); - + var replayer = CreateReplayer(store); var result = await replayer.ReplayAsync(new ReplayFilter(), CancellationToken.None); - Assert.That(result.ReplayedCount, Is.EqualTo(1)); - Assert.That(result.SkippedCount, Is.EqualTo(1)); + Assert.That(result.ReplayedCount, Is.EqualTo(0)); + Assert.That(result.SkippedCount, Is.EqualTo(0)); + _output.AssertNoneReceived(); } - // ── Empty SourceTopic Throws ───────────────────────────────────────────── - [Test] - public void Replay_EmptySourceTopic_ThrowsInvalidOperationException() + public async Task Replay_SkipAlreadyReplayed_SkipsTaggedMessages() { var store = new InMemoryMessageReplayStore(); - var producer = Substitute.For(); - - var options = Options.Create(new ReplayOptions + var env = IntegrationEnvelope.Create("data", "Svc", "event") with { - SourceTopic = "", - TargetTopic = "tgt", - }); + Metadata = new Dictionary { [MessageHeaders.ReplayId] = Guid.NewGuid().ToString() }, + }; + await store.StoreForReplayAsync(env, "source-topic", CancellationToken.None); - var replayer = new MessageReplayer( - store, producer, options, NullLogger.Instance); + var opts = new ReplayOptions + { + SourceTopic = "source-topic", + TargetTopic = "replay-target", + MaxMessages = 100, + SkipAlreadyReplayed = true, + }; + var replayer = new MessageReplayer(store, _output, Options.Create(opts), NullLogger.Instance); + var result = await replayer.ReplayAsync(new ReplayFilter(), CancellationToken.None); - Assert.ThrowsAsync( - () => replayer.ReplayAsync(new ReplayFilter(), CancellationToken.None)); + Assert.That(result.SkippedCount, Is.EqualTo(1)); + Assert.That(result.ReplayedCount, Is.EqualTo(0)); + _output.AssertNoneReceived(); } - // ── ReplayResult Timestamps Are Populated ──────────────────────────────── - [Test] - public async Task Replay_Result_HasValidTimestamps() + public async Task Replay_ResultTimestamps_ArePopulated() { var store = new InMemoryMessageReplayStore(); - var producer = Substitute.For(); - - var options = Options.Create(new ReplayOptions - { - SourceTopic = "src", - TargetTopic = "tgt", - MaxMessages = 10, - }); + await store.StoreForReplayAsync( + IntegrationEnvelope.Create("d", "Svc", "evt"), "source-topic", CancellationToken.None); - var replayer = new MessageReplayer( - store, producer, options, NullLogger.Instance); - - var before = DateTimeOffset.UtcNow; + var replayer = CreateReplayer(store); var result = await replayer.ReplayAsync(new ReplayFilter(), CancellationToken.None); - Assert.That(result.StartedAt, Is.GreaterThanOrEqualTo(before)); - Assert.That(result.CompletedAt, Is.GreaterThanOrEqualTo(result.StartedAt)); + Assert.That(result.StartedAt, Is.LessThanOrEqualTo(result.CompletedAt)); + Assert.That(result.CompletedAt, Is.LessThanOrEqualTo(DateTimeOffset.UtcNow)); } - // ── ReplayFilter Record Shape ──────────────────────────────────────────── - - [Test] - public void ReplayFilter_DefaultValues_AreNull() + private MessageReplayer CreateReplayer(InMemoryMessageReplayStore store) { - var filter = new ReplayFilter(); - - Assert.That(filter.CorrelationId, Is.Null); - Assert.That(filter.MessageType, Is.Null); - Assert.That(filter.FromTimestamp, Is.Null); - Assert.That(filter.ToTimestamp, Is.Null); + var opts = Options.Create(new ReplayOptions + { + SourceTopic = "source-topic", + TargetTopic = "replay-target", + MaxMessages = 100, + }); + return new MessageReplayer(store, _output, opts, NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial27/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial27/Exam.cs index df445a2..76b5efe 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial27/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial27/Exam.cs @@ -1,8 +1,8 @@ // ============================================================================ // Tutorial 27 – Resequencer (Exam) // ============================================================================ -// Coding challenges: multiple independent sequences, ResequencerOptions -// defaults, and ReleaseOnTimeout for unknown correlation ID. +// E2E challenges: large out-of-order batch, interleaved sequences, timeout +// partial release. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; @@ -10,75 +10,106 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial27; [TestFixture] public sealed class Exam { - private static MessageResequencer CreateResequencer() => - new(Options.Create(new ResequencerOptions()), NullLogger.Instance); - - private static IntegrationEnvelope MakeSequenced( - Guid correlationId, int seqNum, int totalCount) => - new() + [Test] + public async Task Challenge1_LargeOutOfOrderBatch_ReleasedInSequence() + { + await using var output = new MockEndpoint("reseq-batch"); + var resequencer = new MessageResequencer( + Options.Create(new ResequencerOptions()), NullLogger.Instance); + var correlationId = Guid.NewGuid(); + const int total = 10; + + var indices = Enumerable.Range(0, total).Reverse().ToList(); + IReadOnlyList> released = []; + foreach (var i in indices) { - MessageId = Guid.NewGuid(), - CorrelationId = correlationId, - Timestamp = DateTimeOffset.UtcNow, - Source = "Svc", - MessageType = "type", - Payload = $"msg-{seqNum}", - SequenceNumber = seqNum, - TotalCount = totalCount, - }; + var env = IntegrationEnvelope.Create($"msg-{i}", "Svc", "evt") with + { + CorrelationId = correlationId, SequenceNumber = i, TotalCount = total, + }; + released = resequencer.Accept(env); + } - // ── Challenge 1: Multiple Independent Sequences ───────────────────────── + Assert.That(released, Has.Count.EqualTo(total)); + for (var i = 0; i < total; i++) + Assert.That(released[i].SequenceNumber, Is.EqualTo(i)); + + foreach (var env in released) + await output.PublishAsync(env, "ordered"); + + output.AssertReceivedOnTopic("ordered", total); + } [Test] - public void Challenge1_TwoIndependentSequences_EachReleasedSeparately() + public async Task Challenge2_InterleavedSequences_EachReleasedIndependently() { - var resequencer = CreateResequencer(); - var seqA = Guid.NewGuid(); - var seqB = Guid.NewGuid(); + await using var output = new MockEndpoint("reseq-interleave"); + var resequencer = new MessageResequencer( + Options.Create(new ResequencerOptions()), NullLogger.Instance); - // Interleave messages from two sequences - resequencer.Accept(MakeSequenced(seqA, 1, 2)); - resequencer.Accept(MakeSequenced(seqB, 0, 2)); - var releaseA = resequencer.Accept(MakeSequenced(seqA, 0, 2)); - var releaseB = resequencer.Accept(MakeSequenced(seqB, 1, 2)); + var corrA = Guid.NewGuid(); + var corrB = Guid.NewGuid(); - Assert.That(releaseA, Has.Count.EqualTo(2)); - Assert.That(releaseA[0].Payload, Is.EqualTo("msg-0")); - Assert.That(releaseA[1].Payload, Is.EqualTo("msg-1")); + resequencer.Accept(CreateEnvelope("A1", corrA, 1, 2)); + resequencer.Accept(CreateEnvelope("B0", corrB, 0, 2)); - Assert.That(releaseB, Has.Count.EqualTo(2)); - Assert.That(releaseB[0].Payload, Is.EqualTo("msg-0")); - Assert.That(releaseB[1].Payload, Is.EqualTo("msg-1")); + var releasedA = resequencer.Accept(CreateEnvelope("A0", corrA, 0, 2)); + Assert.That(releasedA, Has.Count.EqualTo(2)); + Assert.That(releasedA[0].Payload, Is.EqualTo("A0")); - Assert.That(resequencer.ActiveSequenceCount, Is.EqualTo(0)); - } + var releasedB = resequencer.Accept(CreateEnvelope("B1", corrB, 1, 2)); + Assert.That(releasedB, Has.Count.EqualTo(2)); + Assert.That(releasedB[0].Payload, Is.EqualTo("B0")); - // ── Challenge 2: ResequencerOptions Default Values ────────────────────── + foreach (var env in releasedA.Concat(releasedB)) + await output.PublishAsync(env, "interleaved"); + + output.AssertReceivedOnTopic("interleaved", 4); + } [Test] - public void Challenge2_ResequencerOptions_DefaultValues() + public async Task Challenge3_TimeoutPartialRelease_ThenCompleteNewSequence() { - var opts = new ResequencerOptions(); + await using var output = new MockEndpoint("reseq-timeout"); + var resequencer = new MessageResequencer( + Options.Create(new ResequencerOptions()), NullLogger.Instance); - Assert.That(opts.ReleaseTimeout, Is.EqualTo(TimeSpan.FromSeconds(30))); - Assert.That(opts.MaxConcurrentSequences, Is.EqualTo(10_000)); - } + var corrOld = Guid.NewGuid(); + resequencer.Accept(CreateEnvelope("old-0", corrOld, 0, 5)); + resequencer.Accept(CreateEnvelope("old-3", corrOld, 3, 5)); - // ── Challenge 3: ReleaseOnTimeout For Unknown CorrelationId ───────────── + var partial = resequencer.ReleaseOnTimeout(corrOld); + Assert.That(partial, Has.Count.EqualTo(2)); - [Test] - public void Challenge3_ReleaseOnTimeout_UnknownCorrelationId_ReturnsEmpty() - { - var resequencer = CreateResequencer(); + foreach (var env in partial) + await output.PublishAsync(env, "partial"); + + var corrNew = Guid.NewGuid(); + resequencer.Accept(CreateEnvelope("new-1", corrNew, 1, 2)); + var complete = resequencer.Accept(CreateEnvelope("new-0", corrNew, 0, 2)); + Assert.That(complete, Has.Count.EqualTo(2)); - var result = resequencer.ReleaseOnTimeout(Guid.NewGuid()); + foreach (var env in complete) + await output.PublishAsync(env, "complete"); - Assert.That(result, Is.Empty); + output.AssertReceivedOnTopic("partial", 2); + output.AssertReceivedOnTopic("complete", 2); + Assert.That(resequencer.ActiveSequenceCount, Is.EqualTo(0)); } + + private static IntegrationEnvelope CreateEnvelope( + string payload, Guid correlationId, int sequenceNumber, int totalCount) => + IntegrationEnvelope.Create(payload, "Svc", "evt") with + { + CorrelationId = correlationId, + SequenceNumber = sequenceNumber, + TotalCount = totalCount, + }; } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial27/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial27/Lab.cs index 76a8d4c..382ffbb 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial27/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial27/Lab.cs @@ -1,9 +1,9 @@ // ============================================================================ // Tutorial 27 – Resequencer (Lab) // ============================================================================ -// This lab exercises the MessageResequencer, which buffers out-of-order -// messages and releases them in sequence-number order once complete. -// You will verify ordering, buffering, timeout release, and duplicate handling. +// EIP Pattern: Resequencer. +// E2E: MessageResequencer buffers out-of-order messages, releases in sequence, +// then publishes results to MockEndpoint. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; @@ -11,148 +11,142 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial27; [TestFixture] public sealed class Lab { - private MessageResequencer CreateResequencer(int maxConcurrent = 10_000) - { - var options = Options.Create(new ResequencerOptions - { - MaxConcurrentSequences = maxConcurrent, - }); - return new MessageResequencer(options, NullLogger.Instance); - } + private MockEndpoint _output = null!; - private static IntegrationEnvelope MakeSequenced( - Guid correlationId, int seqNum, int totalCount) => - new() - { - MessageId = Guid.NewGuid(), - CorrelationId = correlationId, - Timestamp = DateTimeOffset.UtcNow, - Source = "Svc", - MessageType = "type", - Payload = $"msg-{seqNum}", - SequenceNumber = seqNum, - TotalCount = totalCount, - }; + [SetUp] + public void SetUp() => _output = new MockEndpoint("reseq-out"); - // ── In-Order Delivery Releases Immediately ─────────────────────────────── + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public void Accept_CompleteSequenceInOrder_ReleasesAllMessages() + public async Task Accept_InOrder_ReleasesAllWhenComplete() { var resequencer = CreateResequencer(); var correlationId = Guid.NewGuid(); - var r1 = resequencer.Accept(MakeSequenced(correlationId, 0, 3)); - var r2 = resequencer.Accept(MakeSequenced(correlationId, 1, 3)); - var r3 = resequencer.Accept(MakeSequenced(correlationId, 2, 3)); - - // Only the last accept should release all 3 - Assert.That(r1, Is.Empty); - Assert.That(r2, Is.Empty); - Assert.That(r3, Has.Count.EqualTo(3)); - Assert.That(r3[0].Payload, Is.EqualTo("msg-0")); - Assert.That(r3[1].Payload, Is.EqualTo("msg-1")); - Assert.That(r3[2].Payload, Is.EqualTo("msg-2")); - } + var env1 = CreateEnvelope("p1", correlationId, sequenceNumber: 0, totalCount: 2); + var result1 = resequencer.Accept(env1); + Assert.That(result1, Is.Empty); + + var env2 = CreateEnvelope("p2", correlationId, sequenceNumber: 1, totalCount: 2); + var result2 = resequencer.Accept(env2); + Assert.That(result2, Has.Count.EqualTo(2)); - // ── Out-Of-Order Delivery Reorders Correctly ───────────────────────────── + foreach (var env in result2) + await _output.PublishAsync(env, "ordered-topic"); + + _output.AssertReceivedOnTopic("ordered-topic", 2); + } [Test] - public void Accept_OutOfOrder_ReleasesInCorrectOrder() + public async Task Accept_OutOfOrder_ReleasesInCorrectSequence() { var resequencer = CreateResequencer(); var correlationId = Guid.NewGuid(); - var r1 = resequencer.Accept(MakeSequenced(correlationId, 2, 3)); - var r2 = resequencer.Accept(MakeSequenced(correlationId, 0, 3)); - var r3 = resequencer.Accept(MakeSequenced(correlationId, 1, 3)); + var env3 = CreateEnvelope("third", correlationId, sequenceNumber: 2, totalCount: 3); + var env1 = CreateEnvelope("first", correlationId, sequenceNumber: 0, totalCount: 3); + var env2 = CreateEnvelope("second", correlationId, sequenceNumber: 1, totalCount: 3); - Assert.That(r1, Is.Empty); - Assert.That(r2, Is.Empty); - Assert.That(r3, Has.Count.EqualTo(3)); - Assert.That(r3[0].Payload, Is.EqualTo("msg-0")); - Assert.That(r3[1].Payload, Is.EqualTo("msg-1")); - Assert.That(r3[2].Payload, Is.EqualTo("msg-2")); - } + Assert.That(resequencer.Accept(env3), Is.Empty); + Assert.That(resequencer.Accept(env1), Is.Empty); + var released = resequencer.Accept(env2); + + Assert.That(released, Has.Count.EqualTo(3)); + Assert.That(released[0].Payload, Is.EqualTo("first")); + Assert.That(released[1].Payload, Is.EqualTo("second")); + Assert.That(released[2].Payload, Is.EqualTo("third")); - // ── Incomplete Sequence Stays Buffered ─────────────────────────────────── + foreach (var env in released) + await _output.PublishAsync(env, "ordered-topic"); + + _output.AssertReceivedOnTopic("ordered-topic", 3); + } [Test] - public void Accept_IncompleteSequence_BuffersAndReturnsEmpty() + public void Accept_DuplicateSequenceNumber_IsIgnored() { var resequencer = CreateResequencer(); var correlationId = Guid.NewGuid(); - var result = resequencer.Accept(MakeSequenced(correlationId, 1, 3)); + var env1 = CreateEnvelope("first", correlationId, sequenceNumber: 0, totalCount: 3); + resequencer.Accept(env1); + + var dup = CreateEnvelope("dup-first", correlationId, sequenceNumber: 0, totalCount: 3); + var result = resequencer.Accept(dup); Assert.That(result, Is.Empty); Assert.That(resequencer.ActiveSequenceCount, Is.EqualTo(1)); } - // ── Duplicate Sequence Number Is Ignored ───────────────────────────────── - [Test] - public void Accept_DuplicateSequenceNumber_IsIgnored() + public void Accept_MissingSequenceInfo_ThrowsArgumentException() { var resequencer = CreateResequencer(); - var correlationId = Guid.NewGuid(); - - resequencer.Accept(MakeSequenced(correlationId, 0, 2)); - var dup = resequencer.Accept(MakeSequenced(correlationId, 0, 2)); + var envelope = IntegrationEnvelope.Create("data", "Svc", "evt"); - Assert.That(dup, Is.Empty); - // Still waiting for seq 1 - Assert.That(resequencer.ActiveSequenceCount, Is.EqualTo(1)); + Assert.Throws(() => resequencer.Accept(envelope)); } - // ── ReleaseOnTimeout Returns Buffered Messages In Order ────────────────── - [Test] - public void ReleaseOnTimeout_IncompleteSequence_ReturnsBufferedInOrder() + public async Task ReleaseOnTimeout_IncompleteSequence_ReleasesBuffered() { var resequencer = CreateResequencer(); var correlationId = Guid.NewGuid(); - resequencer.Accept(MakeSequenced(correlationId, 2, 5)); - resequencer.Accept(MakeSequenced(correlationId, 0, 5)); + resequencer.Accept(CreateEnvelope("a", correlationId, 0, 5)); + resequencer.Accept(CreateEnvelope("c", correlationId, 2, 5)); var released = resequencer.ReleaseOnTimeout(correlationId); - Assert.That(released, Has.Count.EqualTo(2)); - Assert.That(released[0].Payload, Is.EqualTo("msg-0")); - Assert.That(released[1].Payload, Is.EqualTo("msg-2")); + Assert.That(released[0].SequenceNumber, Is.EqualTo(0)); + Assert.That(released[1].SequenceNumber, Is.EqualTo(2)); + + foreach (var env in released) + await _output.PublishAsync(env, "timeout-topic"); + + _output.AssertReceivedOnTopic("timeout-topic", 2); Assert.That(resequencer.ActiveSequenceCount, Is.EqualTo(0)); } - // ── Missing Sequence Info Throws ───────────────────────────────────────── - [Test] - public void Accept_NoSequenceInfo_ThrowsArgumentException() + public void ReleaseOnTimeout_UnknownCorrelation_ReturnsEmpty() { var resequencer = CreateResequencer(); - var envelope = IntegrationEnvelope.Create("data", "Svc", "type"); - - Assert.Throws(() => resequencer.Accept(envelope)); + var released = resequencer.ReleaseOnTimeout(Guid.NewGuid()); + Assert.That(released, Is.Empty); } - // ── Single Message Sequence Releases Immediately ───────────────────────── - [Test] - public void Accept_SingleMessageSequence_ReleasesImmediately() + public void ActiveSequenceCount_TracksBufferedSequences() { var resequencer = CreateResequencer(); - var correlationId = Guid.NewGuid(); + Assert.That(resequencer.ActiveSequenceCount, Is.EqualTo(0)); - var result = resequencer.Accept(MakeSequenced(correlationId, 0, 1)); + resequencer.Accept(CreateEnvelope("a", Guid.NewGuid(), 0, 3)); + Assert.That(resequencer.ActiveSequenceCount, Is.EqualTo(1)); - Assert.That(result, Has.Count.EqualTo(1)); - Assert.That(result[0].Payload, Is.EqualTo("msg-0")); - Assert.That(resequencer.ActiveSequenceCount, Is.EqualTo(0)); + resequencer.Accept(CreateEnvelope("b", Guid.NewGuid(), 0, 2)); + Assert.That(resequencer.ActiveSequenceCount, Is.EqualTo(2)); } + + private static MessageResequencer CreateResequencer() => + new(Options.Create(new ResequencerOptions()), NullLogger.Instance); + + private static IntegrationEnvelope CreateEnvelope( + string payload, Guid correlationId, int sequenceNumber, int totalCount) => + IntegrationEnvelope.Create(payload, "Svc", "evt") with + { + CorrelationId = correlationId, + SequenceNumber = sequenceNumber, + TotalCount = totalCount, + }; } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial28/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial28/Exam.cs index 9af3048..6612915 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial28/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial28/Exam.cs @@ -1,101 +1,106 @@ // ============================================================================ // Tutorial 28 – Competing Consumers (Exam) // ============================================================================ -// Coding challenges: scale-down behaviour, lag monitor default for unknown -// topic, and cooldown prevents rapid scaling. +// E2E challenges: progressive scale-up, cooldown enforcement, backpressure +// prevents scale-down. // ============================================================================ +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.CompetingConsumers; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Time.Testing; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial28; [TestFixture] public sealed class Exam { - // ── Challenge 1: Scale Down On Low Lag ────────────────────────────────── - [Test] - public async Task Challenge1_LowLag_ScalesDown() + public async Task Challenge1_ProgressiveScaleUp_ReachesMax() { - var lagMonitor = Substitute.For(); - var scaler = Substitute.For(); + await using var output = new MockEndpoint("cc-progressive"); + var lagMonitor = new InMemoryConsumerLagMonitor(); + var scaler = new InMemoryConsumerScaler(NullLogger.Instance, initialCount: 1); var backpressure = new BackpressureSignal(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - - scaler.CurrentCount.Returns(5); - lagMonitor.GetLagAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ConsumerLagInfo("grp", "topic", 10, DateTimeOffset.UtcNow)); - - var options = Options.Create(new CompetingConsumerOptions + var opts = Options.Create(new CompetingConsumerOptions { - MinConsumers = 1, - MaxConsumers = 10, - ScaleUpThreshold = 1000, - ScaleDownThreshold = 100, - CooldownMs = 1000, - TargetTopic = "topic", - ConsumerGroup = "grp", + TargetTopic = "topic", ConsumerGroup = "group", + ScaleUpThreshold = 100, ScaleDownThreshold = 10, + MaxConsumers = 4, MinConsumers = 1, CooldownMs = 0, }); - var orchestrator = new CompetingConsumerOrchestrator( - lagMonitor, scaler, backpressure, options, - NullLogger.Instance, timeProvider); + lagMonitor, scaler, backpressure, opts, + NullLogger.Instance, TimeProvider.System); - await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); + await lagMonitor.ReportLagAsync(new ConsumerLagInfo("group", "topic", 500, DateTimeOffset.UtcNow)); - await scaler.Received(1).ScaleAsync(4, Arg.Any()); - } + for (var i = 0; i < 3; i++) + await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); + + Assert.That(scaler.CurrentCount, Is.EqualTo(4)); - // ── Challenge 2: Unknown Topic Returns Zero Lag ───────────────────────── + var envelope = IntegrationEnvelope.Create($"count={scaler.CurrentCount}", "Svc", "scaled"); + await output.PublishAsync(envelope, "scale-events"); + output.AssertReceivedOnTopic("scale-events", 1); + } [Test] - public async Task Challenge2_UnknownTopic_ReturnsZeroLag() + public async Task Challenge2_ZeroLag_DefaultsReturned() { - var monitor = new InMemoryConsumerLagMonitor(); + await using var output = new MockEndpoint("cc-zero"); + var lagMonitor = new InMemoryConsumerLagMonitor(); + var scaler = new InMemoryConsumerScaler(NullLogger.Instance, initialCount: 1); + var backpressure = new BackpressureSignal(); + var opts = Options.Create(new CompetingConsumerOptions + { + TargetTopic = "topic", ConsumerGroup = "group", + ScaleUpThreshold = 100, ScaleDownThreshold = 10, + MaxConsumers = 5, MinConsumers = 1, CooldownMs = 0, + }); + var orchestrator = new CompetingConsumerOrchestrator( + lagMonitor, scaler, backpressure, opts, + NullLogger.Instance, TimeProvider.System); - var lag = await monitor.GetLagAsync("nonexistent", "grp", CancellationToken.None); + await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); - Assert.That(lag.CurrentLag, Is.EqualTo(0)); - Assert.That(lag.Topic, Is.EqualTo("nonexistent")); - Assert.That(lag.ConsumerGroup, Is.EqualTo("grp")); - } + Assert.That(scaler.CurrentCount, Is.EqualTo(1)); + Assert.That(backpressure.IsBackpressured, Is.False); - // ── Challenge 3: At Min Consumers Does Not Scale Down ─────────────────── + var envelope = IntegrationEnvelope.Create("stable", "Svc", "status"); + await output.PublishAsync(envelope, "status"); + output.AssertReceivedOnTopic("status", 1); + } [Test] - public async Task Challenge3_AtMinConsumers_DoesNotScaleDown() + public async Task Challenge3_BackpressureAtMax_ThenRelease() { - var lagMonitor = Substitute.For(); - var scaler = Substitute.For(); + await using var output = new MockEndpoint("cc-bp"); + var lagMonitor = new InMemoryConsumerLagMonitor(); + var scaler = new InMemoryConsumerScaler(NullLogger.Instance, initialCount: 3); var backpressure = new BackpressureSignal(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - - scaler.CurrentCount.Returns(1); - lagMonitor.GetLagAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ConsumerLagInfo("grp", "topic", 10, DateTimeOffset.UtcNow)); - - var options = Options.Create(new CompetingConsumerOptions + var opts = Options.Create(new CompetingConsumerOptions { - MinConsumers = 1, - MaxConsumers = 10, - ScaleUpThreshold = 1000, - ScaleDownThreshold = 100, - CooldownMs = 1000, - TargetTopic = "topic", - ConsumerGroup = "grp", + TargetTopic = "topic", ConsumerGroup = "group", + ScaleUpThreshold = 100, ScaleDownThreshold = 10, + MaxConsumers = 3, MinConsumers = 1, CooldownMs = 0, }); - var orchestrator = new CompetingConsumerOrchestrator( - lagMonitor, scaler, backpressure, options, - NullLogger.Instance, timeProvider); + lagMonitor, scaler, backpressure, opts, + NullLogger.Instance, TimeProvider.System); + + await lagMonitor.ReportLagAsync(new ConsumerLagInfo("group", "topic", 5000, DateTimeOffset.UtcNow)); + await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); + Assert.That(backpressure.IsBackpressured, Is.True); + await lagMonitor.ReportLagAsync(new ConsumerLagInfo("group", "topic", 50, DateTimeOffset.UtcNow)); await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); + Assert.That(backpressure.IsBackpressured, Is.False); - await scaler.DidNotReceive().ScaleAsync(Arg.Any(), Arg.Any()); + var envelope = IntegrationEnvelope.Create("bp-cycle", "Svc", "bp.cycle"); + await output.PublishAsync(envelope, "bp-events"); + output.AssertReceivedOnTopic("bp-events", 1); + output.AssertReceivedCount(1); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial28/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial28/Lab.cs index bec36ee..8b1522b 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial28/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial28/Lab.cs @@ -1,162 +1,168 @@ // ============================================================================ // Tutorial 28 – Competing Consumers (Lab) // ============================================================================ -// This lab exercises the CompetingConsumerOrchestrator, BackpressureSignal, -// InMemoryConsumerScaler, InMemoryConsumerLagMonitor, and ConsumerLagInfo. -// You will verify scaling decisions, backpressure, and cooldown behaviour. +// EIP Pattern: Competing Consumers. +// E2E: CompetingConsumerOrchestrator with InMemory scaler/lag monitor + +// MockEndpoint to verify scale decisions are published. // ============================================================================ +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Processing.CompetingConsumers; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Time.Testing; -using NSubstitute; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial28; [TestFixture] public sealed class Lab { - // ── BackpressureSignal Toggle ──────────────────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("consumers-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public void BackpressureSignal_SignalAndRelease_TogglesCorrectly() + public async Task HighLag_ScalesUp() { - var bp = new BackpressureSignal(); + var lagMonitor = new InMemoryConsumerLagMonitor(); + var scaler = new InMemoryConsumerScaler(NullLogger.Instance, initialCount: 1); + var backpressure = new BackpressureSignal(); + var orchestrator = CreateOrchestrator(lagMonitor, scaler, backpressure, + scaleUp: 100, scaleDown: 10, max: 5, cooldownMs: 0); - Assert.That(bp.IsBackpressured, Is.False); + await lagMonitor.ReportLagAsync(new ConsumerLagInfo("group", "topic", 500, DateTimeOffset.UtcNow)); + await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); - bp.Signal(); - Assert.That(bp.IsBackpressured, Is.True); + Assert.That(scaler.CurrentCount, Is.EqualTo(2)); - bp.Release(); - Assert.That(bp.IsBackpressured, Is.False); + var envelope = IntegrationEnvelope.Create($"consumers={scaler.CurrentCount}", "Svc", "scale.up"); + await _output.PublishAsync(envelope, "scale-events"); + _output.AssertReceivedOnTopic("scale-events", 1); } - // ── InMemoryConsumerScaler Scales Up ───────────────────────────────────── - [Test] - public async Task InMemoryConsumerScaler_ScaleUp_IncreasesCount() + public async Task LowLag_ScalesDown() { - var scaler = new InMemoryConsumerScaler( - NullLogger.Instance, initialCount: 1); + var lagMonitor = new InMemoryConsumerLagMonitor(); + var scaler = new InMemoryConsumerScaler(NullLogger.Instance, initialCount: 3); + var backpressure = new BackpressureSignal(); + var orchestrator = CreateOrchestrator(lagMonitor, scaler, backpressure, + scaleUp: 100, scaleDown: 10, max: 5, cooldownMs: 0, min: 1); - Assert.That(scaler.CurrentCount, Is.EqualTo(1)); + await lagMonitor.ReportLagAsync(new ConsumerLagInfo("group", "topic", 5, DateTimeOffset.UtcNow)); + await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); - await scaler.ScaleAsync(3, CancellationToken.None); + Assert.That(scaler.CurrentCount, Is.EqualTo(2)); - Assert.That(scaler.CurrentCount, Is.EqualTo(3)); + var envelope = IntegrationEnvelope.Create($"consumers={scaler.CurrentCount}", "Svc", "scale.down"); + await _output.PublishAsync(envelope, "scale-events"); + _output.AssertReceivedOnTopic("scale-events", 1); } - // ── ConsumerLagInfo Record Shape ───────────────────────────────────────── - [Test] - public void ConsumerLagInfo_RecordProperties_AreCorrect() + public async Task MaxConsumers_SignalsBackpressure() { - var now = DateTimeOffset.UtcNow; - var info = new ConsumerLagInfo("group-1", "orders", 500, now); + var lagMonitor = new InMemoryConsumerLagMonitor(); + var scaler = new InMemoryConsumerScaler(NullLogger.Instance, initialCount: 5); + var backpressure = new BackpressureSignal(); + var orchestrator = CreateOrchestrator(lagMonitor, scaler, backpressure, + scaleUp: 100, scaleDown: 10, max: 5, cooldownMs: 0); - Assert.That(info.ConsumerGroup, Is.EqualTo("group-1")); - Assert.That(info.Topic, Is.EqualTo("orders")); - Assert.That(info.CurrentLag, Is.EqualTo(500)); - Assert.That(info.Timestamp, Is.EqualTo(now)); - } + await lagMonitor.ReportLagAsync(new ConsumerLagInfo("group", "topic", 2000, DateTimeOffset.UtcNow)); + await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); - // ── InMemoryConsumerLagMonitor Reports And Retrieves ───────────────────── + Assert.That(backpressure.IsBackpressured, Is.True); + Assert.That(scaler.CurrentCount, Is.EqualTo(5)); + + var envelope = IntegrationEnvelope.Create("backpressure", "Svc", "backpressure.active"); + await _output.PublishAsync(envelope, "bp-events"); + _output.AssertReceivedOnTopic("bp-events", 1); + } [Test] - public async Task InMemoryLagMonitor_ReportAndGet_ReturnsReportedLag() + public async Task MinConsumers_DoesNotScaleBelow() { - var monitor = new InMemoryConsumerLagMonitor(); - var lag = new ConsumerLagInfo("grp", "topic", 1234, DateTimeOffset.UtcNow); + var lagMonitor = new InMemoryConsumerLagMonitor(); + var scaler = new InMemoryConsumerScaler(NullLogger.Instance, initialCount: 2); + var backpressure = new BackpressureSignal(); + var orchestrator = CreateOrchestrator(lagMonitor, scaler, backpressure, + scaleUp: 100, scaleDown: 10, max: 5, cooldownMs: 0, min: 2); - await monitor.ReportLagAsync(lag); - var retrieved = await monitor.GetLagAsync("topic", "grp", CancellationToken.None); + await lagMonitor.ReportLagAsync(new ConsumerLagInfo("group", "topic", 0, DateTimeOffset.UtcNow)); + await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); - Assert.That(retrieved.CurrentLag, Is.EqualTo(1234)); - } + Assert.That(scaler.CurrentCount, Is.EqualTo(2)); - // ── Orchestrator Scales Up On High Lag ─────────────────────────────────── + var envelope = IntegrationEnvelope.Create("no-change", "Svc", "scale.none"); + await _output.PublishAsync(envelope, "scale-events"); + _output.AssertReceivedOnTopic("scale-events", 1); + } [Test] - public async Task EvaluateAndScale_HighLag_ScalesUp() + public async Task ModerateLag_NoScaleChange() { - var lagMonitor = Substitute.For(); - var scaler = Substitute.For(); + var lagMonitor = new InMemoryConsumerLagMonitor(); + var scaler = new InMemoryConsumerScaler(NullLogger.Instance, initialCount: 3); var backpressure = new BackpressureSignal(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - - scaler.CurrentCount.Returns(1); - lagMonitor.GetLagAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ConsumerLagInfo("grp", "topic", 5000, DateTimeOffset.UtcNow)); - - var options = Options.Create(new CompetingConsumerOptions - { - MinConsumers = 1, - MaxConsumers = 10, - ScaleUpThreshold = 1000, - ScaleDownThreshold = 100, - CooldownMs = 1000, - TargetTopic = "topic", - ConsumerGroup = "grp", - }); - - var orchestrator = new CompetingConsumerOrchestrator( - lagMonitor, scaler, backpressure, options, - NullLogger.Instance, timeProvider); + var orchestrator = CreateOrchestrator(lagMonitor, scaler, backpressure, + scaleUp: 1000, scaleDown: 10, max: 5, cooldownMs: 0); + await lagMonitor.ReportLagAsync(new ConsumerLagInfo("group", "topic", 500, DateTimeOffset.UtcNow)); await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); - await scaler.Received(1).ScaleAsync(2, Arg.Any()); - } + Assert.That(scaler.CurrentCount, Is.EqualTo(3)); + Assert.That(backpressure.IsBackpressured, Is.False); - // ── Orchestrator Signals Backpressure At Max ───────────────────────────── + var envelope = IntegrationEnvelope.Create("stable", "Svc", "scale.stable"); + await _output.PublishAsync(envelope, "scale-events"); + _output.AssertReceivedOnTopic("scale-events", 1); + } [Test] - public async Task EvaluateAndScale_AtMaxConsumersWithHighLag_SignalsBackpressure() + public async Task BackpressureReleased_AfterLagDrops() { - var lagMonitor = Substitute.For(); - var scaler = Substitute.For(); - var backpressure = Substitute.For(); - var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); - - scaler.CurrentCount.Returns(5); - lagMonitor.GetLagAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ConsumerLagInfo("grp", "topic", 5000, DateTimeOffset.UtcNow)); - - var options = Options.Create(new CompetingConsumerOptions - { - MinConsumers = 1, - MaxConsumers = 5, - ScaleUpThreshold = 1000, - TargetTopic = "topic", - ConsumerGroup = "grp", - }); + var lagMonitor = new InMemoryConsumerLagMonitor(); + var scaler = new InMemoryConsumerScaler(NullLogger.Instance, initialCount: 5); + var backpressure = new BackpressureSignal(); + var orchestrator = CreateOrchestrator(lagMonitor, scaler, backpressure, + scaleUp: 100, scaleDown: 10, max: 5, cooldownMs: 0); - var orchestrator = new CompetingConsumerOrchestrator( - lagMonitor, scaler, backpressure, options, - NullLogger.Instance, timeProvider); + await lagMonitor.ReportLagAsync(new ConsumerLagInfo("group", "topic", 2000, DateTimeOffset.UtcNow)); + await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); + Assert.That(backpressure.IsBackpressured, Is.True); + await lagMonitor.ReportLagAsync(new ConsumerLagInfo("group", "topic", 50, DateTimeOffset.UtcNow)); await orchestrator.EvaluateAndScaleAsync(CancellationToken.None); + Assert.That(backpressure.IsBackpressured, Is.False); - backpressure.Received(1).Signal(); - await scaler.DidNotReceive().ScaleAsync(Arg.Any(), Arg.Any()); + var envelope = IntegrationEnvelope.Create("released", "Svc", "bp.released"); + await _output.PublishAsync(envelope, "bp-events"); + _output.AssertReceivedOnTopic("bp-events", 1); } - // ── CompetingConsumerOptions Default Values ────────────────────────────── - - [Test] - public void CompetingConsumerOptions_DefaultValues() + private static CompetingConsumerOrchestrator CreateOrchestrator( + InMemoryConsumerLagMonitor lagMonitor, + InMemoryConsumerScaler scaler, + BackpressureSignal backpressure, + long scaleUp, long scaleDown, int max, int cooldownMs, int min = 1) { - var opts = new CompetingConsumerOptions(); - - Assert.That(opts.MinConsumers, Is.EqualTo(1)); - Assert.That(opts.MaxConsumers, Is.EqualTo(10)); - Assert.That(opts.ScaleUpThreshold, Is.EqualTo(1000)); - Assert.That(opts.ScaleDownThreshold, Is.EqualTo(100)); - Assert.That(opts.CooldownMs, Is.EqualTo(30_000)); - Assert.That(opts.TargetTopic, Is.EqualTo(string.Empty)); - Assert.That(opts.ConsumerGroup, Is.EqualTo(string.Empty)); + var opts = Options.Create(new CompetingConsumerOptions + { + TargetTopic = "topic", + ConsumerGroup = "group", + ScaleUpThreshold = scaleUp, + ScaleDownThreshold = scaleDown, + MaxConsumers = max, + MinConsumers = min, + CooldownMs = cooldownMs, + }); + return new CompetingConsumerOrchestrator( + lagMonitor, scaler, backpressure, opts, + NullLogger.Instance, TimeProvider.System); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial29/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial29/Exam.cs index 2e06bae..bfc4684 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial29/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial29/Exam.cs @@ -1,8 +1,8 @@ // ============================================================================ // Tutorial 29 – Throttle and Rate Limiting (Exam) // ============================================================================ -// Coding challenges: burst capacity exhaustion, partition key isolation, -// and metrics tracking under load. +// E2E challenges: burst exhaustion, metric accumulation, backpressure reject +// across multiple messages. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; @@ -10,85 +10,93 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial29; [TestFixture] public sealed class Exam { - // ── Challenge 1: Exhaust Burst Capacity ───────────────────────────────── - [Test] - public async Task Challenge1_ExhaustBurstCapacity_SubsequentAcquiresBlocked() + public async Task Challenge1_BurstExhaustion_PermittedThenRejected() { - // Configure a small burst capacity and consume all tokens. - // Verify that the next acquire is rejected when RejectOnBackpressure = true. - var options = Options.Create(new ThrottleOptions - { - MaxMessagesPerSecond = 1, - BurstCapacity = 3, - RejectOnBackpressure = true, - }); - - using var throttle = new TokenBucketThrottle(options, NullLogger.Instance); - - var envelope = IntegrationEnvelope.Create("data", "TestService", "test.event"); + await using var output = new MockEndpoint("throttle-burst"); + using var throttle = CreateThrottle(burstCapacity: 3, rejectOnBackpressure: true); - // Consume all 3 burst tokens. - for (var i = 0; i < 3; i++) + var permitted = 0; + var rejected = 0; + for (var i = 0; i < 5; i++) { - var ok = await throttle.AcquireAsync(envelope); - Assert.That(ok.Permitted, Is.True, $"Token {i} should be permitted"); + var env = IntegrationEnvelope.Create($"msg-{i}", "Svc", "evt"); + var result = await throttle.AcquireAsync(env); + if (result.Permitted) + { + permitted++; + await output.PublishAsync(env, "ok"); + } + else + { + rejected++; + } } - // 4th acquire should be rejected. - var rejected = await throttle.AcquireAsync(envelope); - Assert.That(rejected.Permitted, Is.False); - Assert.That(rejected.RejectionReason, Is.Not.Null); + Assert.That(permitted, Is.EqualTo(3)); + Assert.That(rejected, Is.EqualTo(2)); + output.AssertReceivedOnTopic("ok", 3); } - // ── Challenge 2: Global Partition Key ─────────────────────────────────── - [Test] - public void Challenge2_GlobalPartitionKey_HasWildcards() + public async Task Challenge2_MetricAccumulation_TracksAllOperations() { - // The Global partition key should use wildcards for all dimensions. - var global = ThrottlePartitionKey.Global; + await using var output = new MockEndpoint("throttle-metrics"); + using var throttle = CreateThrottle(burstCapacity: 2, rejectOnBackpressure: true); - Assert.That(global.TenantId, Is.Null); - Assert.That(global.Queue, Is.Null); - Assert.That(global.Endpoint, Is.Null); + for (var i = 0; i < 4; i++) + { + var env = IntegrationEnvelope.Create($"m{i}", "Svc", "evt"); + var result = await throttle.AcquireAsync(env); + if (result.Permitted) + await output.PublishAsync(env, "processed"); + } - var key = global.ToKey(); - Assert.That(key, Does.Contain("tenant:*")); - Assert.That(key, Does.Contain("queue:*")); - Assert.That(key, Does.Contain("endpoint:*")); - } + var metrics = throttle.GetMetrics(); + Assert.That(metrics.TotalAcquired, Is.EqualTo(2)); + Assert.That(metrics.TotalRejected, Is.EqualTo(2)); + Assert.That(metrics.TotalWaitTime, Is.GreaterThanOrEqualTo(TimeSpan.Zero)); - // ── Challenge 3: Metrics Track Rejections ─────────────────────────────── + output.AssertReceivedOnTopic("processed", 2); + } [Test] - public async Task Challenge3_MetricsTrackRejections_AfterExhaustion() + public async Task Challenge3_SingleToken_AlternatePermitReject() { - var options = Options.Create(new ThrottleOptions - { - MaxMessagesPerSecond = 1, - BurstCapacity = 1, - RejectOnBackpressure = true, - }); + await using var output = new MockEndpoint("throttle-single"); + using var throttle = CreateThrottle(burstCapacity: 1, rejectOnBackpressure: true); - using var throttle = new TokenBucketThrottle(options, NullLogger.Instance); + var env1 = IntegrationEnvelope.Create("first", "Svc", "evt"); + var r1 = await throttle.AcquireAsync(env1); + Assert.That(r1.Permitted, Is.True); + await output.PublishAsync(env1, "ok"); - var envelope = IntegrationEnvelope.Create("data", "TestService", "test.event"); + var env2 = IntegrationEnvelope.Create("second", "Svc", "evt"); + var r2 = await throttle.AcquireAsync(env2); + Assert.That(r2.Permitted, Is.False); + Assert.That(r2.RemainingTokens, Is.EqualTo(0)); - // Consume the single token. - await throttle.AcquireAsync(envelope); - // This one should be rejected. - await throttle.AcquireAsync(envelope); - - var metrics = throttle.GetMetrics(); + output.AssertReceivedCount(1); + output.AssertReceivedOnTopic("ok", 1); + } - Assert.That(metrics.TotalAcquired, Is.EqualTo(1)); - Assert.That(metrics.TotalRejected, Is.EqualTo(1)); + private static TokenBucketThrottle CreateThrottle( + int burstCapacity = 10, bool rejectOnBackpressure = false) + { + var opts = Options.Create(new ThrottleOptions + { + BurstCapacity = burstCapacity, + MaxMessagesPerSecond = 0, + RejectOnBackpressure = rejectOnBackpressure, + MaxWaitTime = TimeSpan.FromMilliseconds(50), + }); + return new TokenBucketThrottle(opts, NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial29/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial29/Lab.cs index 4f3bafe..942d314 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial29/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial29/Lab.cs @@ -1,8 +1,8 @@ // ============================================================================ // Tutorial 29 – Throttle and Rate Limiting (Lab) // ============================================================================ -// This lab exercises the TokenBucketThrottle, demonstrating token acquisition, -// backpressure rejection, and ThrottleOptions configuration. +// EIP Pattern: Throttle. +// E2E: TokenBucketThrottle + MockEndpoint. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; @@ -10,152 +10,136 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial29; [TestFixture] public sealed class Lab { - // ── Acquire Token Successfully ────────────────────────────────────────── + private MockEndpoint _output = null!; - [Test] - public async Task AcquireAsync_WithAvailableTokens_ReturnsPermitted() - { - var options = Options.Create(new ThrottleOptions - { - MaxMessagesPerSecond = 100, - BurstCapacity = 10, - MaxWaitTime = TimeSpan.FromSeconds(5), - }); + [SetUp] + public void SetUp() => _output = new MockEndpoint("throttle-out"); - using var throttle = new TokenBucketThrottle(options, NullLogger.Instance); + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); - var envelope = IntegrationEnvelope.Create("data", "TestService", "test.event"); + [Test] + public async Task Acquire_WithTokens_IsPermitted() + { + using var throttle = CreateThrottle(burstCapacity: 5); + var envelope = IntegrationEnvelope.Create("data", "Svc", "evt"); var result = await throttle.AcquireAsync(envelope); Assert.That(result.Permitted, Is.True); Assert.That(result.RejectionReason, Is.Null); - } - // ── Available Tokens Decreases After Acquire ──────────────────────────── + await _output.PublishAsync(envelope, "permitted"); + _output.AssertReceivedOnTopic("permitted", 1); + } [Test] - public async Task AcquireAsync_ConsumesToken_DecreasesAvailableCount() + public async Task Acquire_ExhaustsTokens_StillPermittedUntilEmpty() { - var options = Options.Create(new ThrottleOptions - { - MaxMessagesPerSecond = 100, - BurstCapacity = 5, - }); + using var throttle = CreateThrottle(burstCapacity: 3, refillRate: 0); + var permitted = 0; - using var throttle = new TokenBucketThrottle(options, NullLogger.Instance); - - var before = throttle.AvailableTokens; - - var envelope = IntegrationEnvelope.Create("data", "TestService", "test.event"); - await throttle.AcquireAsync(envelope); - - Assert.That(throttle.AvailableTokens, Is.LessThan(before)); + for (var i = 0; i < 3; i++) + { + var env = IntegrationEnvelope.Create($"d{i}", "Svc", "evt"); + var result = await throttle.AcquireAsync(env); + if (result.Permitted) + { + permitted++; + await _output.PublishAsync(env, "processed"); + } + } + + Assert.That(permitted, Is.EqualTo(3)); + _output.AssertReceivedOnTopic("processed", 3); } - // ── Reject On Backpressure When No Tokens ─────────────────────────────── - [Test] - public async Task AcquireAsync_NoTokensWithRejectOnBackpressure_RejectsImmediately() + public async Task Acquire_RejectOnBackpressure_RejectsWhenEmpty() { - var options = Options.Create(new ThrottleOptions - { - MaxMessagesPerSecond = 1, - BurstCapacity = 1, - RejectOnBackpressure = true, - }); - - using var throttle = new TokenBucketThrottle(options, NullLogger.Instance); + using var throttle = CreateThrottle(burstCapacity: 1, refillRate: 0, rejectOnBackpressure: true); - var envelope = IntegrationEnvelope.Create("data", "TestService", "test.event"); + var env1 = IntegrationEnvelope.Create("first", "Svc", "evt"); + var r1 = await throttle.AcquireAsync(env1); + Assert.That(r1.Permitted, Is.True); + await _output.PublishAsync(env1, "allowed"); - // Consume the only token. - await throttle.AcquireAsync(envelope); - - // Next acquire should be rejected (no tokens, reject mode). - var result = await throttle.AcquireAsync(envelope); + var env2 = IntegrationEnvelope.Create("second", "Svc", "evt"); + var r2 = await throttle.AcquireAsync(env2); + Assert.That(r2.Permitted, Is.False); + Assert.That(r2.RejectionReason, Is.Not.Null); - Assert.That(result.Permitted, Is.False); - Assert.That(result.RejectionReason, Is.Not.Null.And.Not.Empty); + _output.AssertReceivedOnTopic("allowed", 1); + _output.AssertReceivedCount(1); } - // ── ThrottleOptions Default Values ────────────────────────────────────── - [Test] - public void ThrottleOptions_Defaults_AreReasonable() + public async Task AvailableTokens_DecrementsOnAcquire() { - var opts = new ThrottleOptions(); + using var throttle = CreateThrottle(burstCapacity: 5, refillRate: 0); + var initial = throttle.AvailableTokens; + Assert.That(initial, Is.EqualTo(5)); - Assert.That(opts.MaxMessagesPerSecond, Is.EqualTo(100)); - Assert.That(opts.BurstCapacity, Is.EqualTo(200)); - Assert.That(opts.MaxWaitTime, Is.EqualTo(TimeSpan.FromSeconds(30))); - Assert.That(opts.RejectOnBackpressure, Is.False); - } + var env = IntegrationEnvelope.Create("data", "Svc", "evt"); + await throttle.AcquireAsync(env); + await _output.PublishAsync(env, "topic"); - // ── ThrottleResult Shape ──────────────────────────────────────────────── + Assert.That(throttle.AvailableTokens, Is.EqualTo(4)); + _output.AssertReceivedOnTopic("topic", 1); + } [Test] - public async Task ThrottleResult_ContainsExpectedFields() + public async Task GetMetrics_ReflectsAcquireAndReject() { - var options = Options.Create(new ThrottleOptions - { - MaxMessagesPerSecond = 100, - BurstCapacity = 10, - }); + using var throttle = CreateThrottle(burstCapacity: 1, refillRate: 0, rejectOnBackpressure: true); - using var throttle = new TokenBucketThrottle(options, NullLogger.Instance); + var env1 = IntegrationEnvelope.Create("a", "Svc", "evt"); + await throttle.AcquireAsync(env1); + await _output.PublishAsync(env1, "ok"); - var envelope = IntegrationEnvelope.Create("data", "TestService", "test.event"); - var result = await throttle.AcquireAsync(envelope); + var env2 = IntegrationEnvelope.Create("b", "Svc", "evt"); + await throttle.AcquireAsync(env2); - Assert.That(result.Permitted, Is.True); - Assert.That(result.WaitTime, Is.GreaterThanOrEqualTo(TimeSpan.Zero)); - Assert.That(result.RemainingTokens, Is.GreaterThanOrEqualTo(0)); - } + var metrics = throttle.GetMetrics(); + Assert.That(metrics.TotalAcquired, Is.EqualTo(1)); + Assert.That(metrics.TotalRejected, Is.EqualTo(1)); + Assert.That(metrics.BurstCapacity, Is.EqualTo(1)); - // ── GetMetrics Returns Throttle Statistics ─────────────────────────────── + _output.AssertReceivedOnTopic("ok", 1); + } [Test] - public async Task GetMetrics_AfterAcquire_TracksStatistics() + public async Task GetMetrics_RefillRate_MatchesConfig() { - var options = Options.Create(new ThrottleOptions - { - MaxMessagesPerSecond = 100, - BurstCapacity = 10, - }); - - using var throttle = new TokenBucketThrottle(options, NullLogger.Instance); + using var throttle = CreateThrottle(burstCapacity: 10, refillRate: 50); - var envelope = IntegrationEnvelope.Create("data", "TestService", "test.event"); - await throttle.AcquireAsync(envelope); + var env = IntegrationEnvelope.Create("data", "Svc", "evt"); + await throttle.AcquireAsync(env); + await _output.PublishAsync(env, "topic"); var metrics = throttle.GetMetrics(); - - Assert.That(metrics.TotalAcquired, Is.GreaterThan(0)); + Assert.That(metrics.RefillRate, Is.EqualTo(50)); + Assert.That(metrics.BurstCapacity, Is.EqualTo(10)); + _output.AssertReceivedOnTopic("topic", 1); } - // ── ThrottlePartitionKey ──────────────────────────────────────────────── - - [Test] - public void ThrottlePartitionKey_ToKey_FormatsCorrectly() + private static TokenBucketThrottle CreateThrottle( + int burstCapacity = 10, int refillRate = 100, bool rejectOnBackpressure = false) { - var key = new ThrottlePartitionKey + var opts = Options.Create(new ThrottleOptions { - TenantId = "tenant-a", - Queue = "orders", - Endpoint = "api/v1", - }; - - var formatted = key.ToKey(); - - Assert.That(formatted, Does.Contain("tenant:tenant-a")); - Assert.That(formatted, Does.Contain("queue:orders")); - Assert.That(formatted, Does.Contain("endpoint:api/v1")); + BurstCapacity = burstCapacity, + MaxMessagesPerSecond = refillRate, + RejectOnBackpressure = rejectOnBackpressure, + MaxWaitTime = TimeSpan.FromMilliseconds(50), + }); + return new TokenBucketThrottle(opts, NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial30/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial30/Exam.cs index 899863c..51795cf 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial30/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial30/Exam.cs @@ -1,8 +1,8 @@ // ============================================================================ // Tutorial 30 – Business Rule Engine (Exam) // ============================================================================ -// Coding challenges: priority-based rule evaluation, StopOnMatch behavior, -// and metadata-based routing rules. +// E2E challenges: multi-rule evaluation, reject action, In operator with +// comma-separated values. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; @@ -10,115 +10,107 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial30; [TestFixture] public sealed class Exam { - // ── Challenge 1: Priority-Based Evaluation ────────────────────────────── - [Test] - public async Task Challenge1_PriorityRouting_LowerPriorityWins() + public async Task Challenge1_MultiRuleEvaluation_CollectsAllMatches() { - // Two rules match the same message. The lower-priority-number rule - // should fire first. With StopOnMatch = true (default), only one fires. + await using var output = new MockEndpoint("rules-multi"); var store = new InMemoryRuleStore(); - var engine = new BusinessRuleEngine( - store, - Options.Create(new RuleEngineOptions { Enabled = true }), - NullLogger.Instance); - await store.AddOrUpdateAsync(new BusinessRule { - Name = "BroadMatch", - Priority = 10, - Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Contains, Value = "order" }], - Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "general-orders" }, + Name = "Rule1", Priority = 1, StopOnMatch = false, + Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "order.created" }], + Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "audit" }, }); - await store.AddOrUpdateAsync(new BusinessRule { - Name = "SpecificMatch", - Priority = 1, - Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "order.created" }], - Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "new-orders" }, + Name = "Rule2", Priority = 2, StopOnMatch = false, + Conditions = [new RuleCondition { FieldName = "Source", Operator = RuleConditionOperator.Contains, Value = "Order" }], + Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "analytics" }, }); + var engine = CreateEngine(store); var envelope = IntegrationEnvelope.Create("data", "OrderService", "order.created"); var result = await engine.EvaluateAsync(envelope); - Assert.That(result.HasMatch, Is.True); - Assert.That(result.MatchedRules, Has.Count.EqualTo(1)); - Assert.That(result.MatchedRules[0].Name, Is.EqualTo("SpecificMatch")); - Assert.That(result.Actions[0].TargetTopic, Is.EqualTo("new-orders")); - } + Assert.That(result.MatchedRules, Has.Count.EqualTo(2)); + Assert.That(result.Actions, Has.Count.EqualTo(2)); - // ── Challenge 2: StopOnMatch = false Collects Multiple ────────────────── + foreach (var action in result.Actions.Where(a => a.TargetTopic is not null)) + await output.PublishAsync(envelope, action.TargetTopic!); + + output.AssertReceivedOnTopic("audit", 1); + output.AssertReceivedOnTopic("analytics", 1); + output.AssertReceivedCount(2); + } [Test] - public async Task Challenge2_StopOnMatchFalse_CollectsMultipleRules() + public async Task Challenge2_RejectAction_NoRouting() { + await using var output = new MockEndpoint("rules-reject"); var store = new InMemoryRuleStore(); - var engine = new BusinessRuleEngine( - store, - Options.Create(new RuleEngineOptions { Enabled = true }), - NullLogger.Instance); - await store.AddOrUpdateAsync(new BusinessRule { - Name = "Rule1", - Priority = 1, - StopOnMatch = false, - Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Contains, Value = "order" }], - Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "audit-topic" }, + Name = "RejectBadSource", Priority = 1, + Conditions = [new RuleCondition { FieldName = "Source", Operator = RuleConditionOperator.Equals, Value = "MaliciousService" }], + Action = new RuleAction { ActionType = RuleActionType.Reject, Reason = "Blocked source" }, }); - await store.AddOrUpdateAsync(new BusinessRule - { - Name = "Rule2", - Priority = 2, - StopOnMatch = true, - Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "order.created" }], - Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "orders-topic" }, - }); - - var envelope = IntegrationEnvelope.Create("data", "Service", "order.created"); + var engine = CreateEngine(store); + var envelope = IntegrationEnvelope.Create("data", "MaliciousService", "evt"); var result = await engine.EvaluateAsync(envelope); - // Both rules match. Rule1 doesn't stop, Rule2 does. Assert.That(result.HasMatch, Is.True); - Assert.That(result.MatchedRules.Count, Is.EqualTo(2)); - Assert.That(result.Actions.Count, Is.EqualTo(2)); - } + Assert.That(result.Actions[0].ActionType, Is.EqualTo(RuleActionType.Reject)); + Assert.That(result.Actions[0].Reason, Is.EqualTo("Blocked source")); - // ── Challenge 3: Metadata-Based Rule ──────────────────────────────────── + // No publish for rejected messages — output stays empty. + output.AssertNoneReceived(); + } [Test] - public async Task Challenge3_MetadataBasedRule_RoutesOnTenantId() + public async Task Challenge3_InOperator_MatchesCommaList() { + await using var output = new MockEndpoint("rules-in"); var store = new InMemoryRuleStore(); - var engine = new BusinessRuleEngine( - store, - Options.Create(new RuleEngineOptions { Enabled = true }), - NullLogger.Instance); - await store.AddOrUpdateAsync(new BusinessRule { - Name = "PremiumTenant", - Priority = 1, - Conditions = [new RuleCondition { FieldName = "Metadata.tenant", Operator = RuleConditionOperator.Equals, Value = "premium-corp" }], - Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "premium-processing" }, + Name = "RegionFilter", Priority = 1, + Conditions = [new RuleCondition { FieldName = "Metadata.region", Operator = RuleConditionOperator.In, Value = "us-east,eu-west,ap-south" }], + Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "regional" }, }); - var envelope = IntegrationEnvelope.Create("data", "Service", "event") with + var engine = CreateEngine(store); + + var envMatch = IntegrationEnvelope.Create("d1", "Svc", "evt") with + { + Metadata = new Dictionary { ["region"] = "eu-west" }, + }; + var envNoMatch = IntegrationEnvelope.Create("d2", "Svc", "evt") with { - Metadata = new Dictionary { ["tenant"] = "premium-corp" }, + Metadata = new Dictionary { ["region"] = "af-south" }, }; - var result = await engine.EvaluateAsync(envelope); + var r1 = await engine.EvaluateAsync(envMatch); + var r2 = await engine.EvaluateAsync(envNoMatch); - Assert.That(result.HasMatch, Is.True); - Assert.That(result.Actions[0].TargetTopic, Is.EqualTo("premium-processing")); + Assert.That(r1.HasMatch, Is.True); + Assert.That(r2.HasMatch, Is.False); + + await output.PublishAsync(envMatch, r1.Actions[0].TargetTopic!); + output.AssertReceivedOnTopic("regional", 1); + output.AssertReceivedCount(1); + } + + private static BusinessRuleEngine CreateEngine(InMemoryRuleStore store) + { + var opts = Options.Create(new RuleEngineOptions { Enabled = true, CacheEnabled = false }); + return new BusinessRuleEngine(store, opts, NullLogger.Instance); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial30/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial30/Lab.cs index 624e140..ec74c49 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial30/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial30/Lab.cs @@ -1,8 +1,8 @@ // ============================================================================ // Tutorial 30 – Business Rule Engine (Lab) // ============================================================================ -// This lab exercises the BusinessRuleEngine with InMemoryRuleStore, testing -// rule evaluation with different conditions, operators, actions, and logic. +// EIP Pattern: Rule Engine (Message Routing variant). +// E2E: BusinessRuleEngine with InMemoryRuleStore + MockEndpoint. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; @@ -10,179 +10,181 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial30; [TestFixture] public sealed class Lab { - private InMemoryRuleStore _store = null!; - private BusinessRuleEngine _engine = null!; + private MockEndpoint _output = null!; [SetUp] - public void SetUp() - { - _store = new InMemoryRuleStore(); - var options = Options.Create(new RuleEngineOptions { Enabled = true }); - _engine = new BusinessRuleEngine(_store, options, NullLogger.Instance); - } + public void SetUp() => _output = new MockEndpoint("rules-out"); - // ── Single Rule Matches by MessageType ────────────────────────────────── + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public async Task Evaluate_SingleEqualsRule_MatchesByMessageType() + public async Task Evaluate_MatchingRule_ReturnsMatch() { - await _store.AddOrUpdateAsync(new BusinessRule - { - Name = "RouteOrders", - Priority = 1, - Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "order.created" }], - Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "orders-topic" }, - }); + var store = new InMemoryRuleStore(); + await store.AddOrUpdateAsync(CreateRouteRule("OrderRule", "MessageType", + RuleConditionOperator.Equals, "order.created", "orders-topic")); - var envelope = IntegrationEnvelope.Create("data", "OrderService", "order.created"); - var result = await _engine.EvaluateAsync(envelope); + var engine = CreateEngine(store); + var envelope = IntegrationEnvelope.Create("data", "Svc", "order.created"); + var result = await engine.EvaluateAsync(envelope); Assert.That(result.HasMatch, Is.True); Assert.That(result.MatchedRules, Has.Count.EqualTo(1)); + Assert.That(result.MatchedRules[0].Name, Is.EqualTo("OrderRule")); Assert.That(result.Actions[0].TargetTopic, Is.EqualTo("orders-topic")); - } - // ── No Match Returns Empty Result ─────────────────────────────────────── + await _output.PublishAsync(envelope, result.Actions[0].TargetTopic!); + _output.AssertReceivedOnTopic("orders-topic", 1); + } [Test] - public async Task Evaluate_NoMatchingRule_ReturnsNoMatch() + public async Task Evaluate_NoMatch_ReturnsEmpty() { - await _store.AddOrUpdateAsync(new BusinessRule - { - Name = "RouteOrders", - Priority = 1, - Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "order.created" }], - Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "orders-topic" }, - }); + var store = new InMemoryRuleStore(); + await store.AddOrUpdateAsync(CreateRouteRule("OrderRule", "MessageType", + RuleConditionOperator.Equals, "order.created", "orders-topic")); - var envelope = IntegrationEnvelope.Create("data", "PaymentService", "payment.received"); - var result = await _engine.EvaluateAsync(envelope); + var engine = CreateEngine(store); + var envelope = IntegrationEnvelope.Create("data", "Svc", "unknown.event"); + var result = await engine.EvaluateAsync(envelope); Assert.That(result.HasMatch, Is.False); Assert.That(result.MatchedRules, Is.Empty); - Assert.That(result.Actions, Is.Empty); - } - // ── Contains Operator ─────────────────────────────────────────────────── + await _output.PublishAsync(envelope, "default-topic"); + _output.AssertReceivedOnTopic("default-topic", 1); + } [Test] public async Task Evaluate_ContainsOperator_MatchesSubstring() { - await _store.AddOrUpdateAsync(new BusinessRule - { - Name = "AllOrders", - Priority = 1, - Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Contains, Value = "order" }], - Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "all-orders" }, - }); + var store = new InMemoryRuleStore(); + await store.AddOrUpdateAsync(CreateRouteRule("PartialMatch", "Source", + RuleConditionOperator.Contains, "Order", "order-events")); - var envelope = IntegrationEnvelope.Create("data", "Service", "order.shipped"); - var result = await _engine.EvaluateAsync(envelope); + var engine = CreateEngine(store); + var envelope = IntegrationEnvelope.Create("data", "MyOrderService", "evt"); + var result = await engine.EvaluateAsync(envelope); Assert.That(result.HasMatch, Is.True); - Assert.That(result.Actions[0].TargetTopic, Is.EqualTo("all-orders")); + await _output.PublishAsync(envelope, result.Actions[0].TargetTopic!); + _output.AssertReceivedOnTopic("order-events", 1); } - // ── AND Logic: All Conditions Must Match ──────────────────────────────── - [Test] - public async Task Evaluate_AndLogic_AllConditionsMustMatch() + public async Task Evaluate_MetadataCondition_MatchesMetadataField() { - await _store.AddOrUpdateAsync(new BusinessRule - { - Name = "HighPriorityOrders", - Priority = 1, - LogicOperator = RuleLogicOperator.And, - Conditions = - [ - new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "order.created" }, - new RuleCondition { FieldName = "Source", Operator = RuleConditionOperator.Equals, Value = "PremiumService" }, - ], - Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "premium-orders" }, - }); + var store = new InMemoryRuleStore(); + await store.AddOrUpdateAsync(CreateRouteRule("RegionRule", "Metadata.region", + RuleConditionOperator.Equals, "us-east", "us-east-topic")); - // Only MessageType matches, Source doesn't → no match. - var envelope1 = IntegrationEnvelope.Create("data", "BasicService", "order.created"); - var result1 = await _engine.EvaluateAsync(envelope1); - Assert.That(result1.HasMatch, Is.False); + var engine = CreateEngine(store); + var envelope = IntegrationEnvelope.Create("data", "Svc", "evt") with + { + Metadata = new Dictionary { ["region"] = "us-east" }, + }; + var result = await engine.EvaluateAsync(envelope); - // Both match → match. - var envelope2 = IntegrationEnvelope.Create("data", "PremiumService", "order.created"); - var result2 = await _engine.EvaluateAsync(envelope2); - Assert.That(result2.HasMatch, Is.True); + Assert.That(result.HasMatch, Is.True); + await _output.PublishAsync(envelope, result.Actions[0].TargetTopic!); + _output.AssertReceivedOnTopic("us-east-topic", 1); } - // ── OR Logic: Any Condition Matches ───────────────────────────────────── - [Test] - public async Task Evaluate_OrLogic_AnyConditionMatches() + public async Task Evaluate_DisabledRule_IsSkipped() { - await _store.AddOrUpdateAsync(new BusinessRule + var store = new InMemoryRuleStore(); + var rule = new BusinessRule { - Name = "OrderOrPayment", - Priority = 1, - LogicOperator = RuleLogicOperator.Or, - Conditions = - [ - new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "order.created" }, - new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "payment.received" }, - ], - Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "finance" }, - }); + Name = "Disabled", Priority = 1, Enabled = false, + Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "evt" }], + Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "topic" }, + }; + await store.AddOrUpdateAsync(rule); - var orderEnvelope = IntegrationEnvelope.Create("data", "Service", "order.created"); - var orderResult = await _engine.EvaluateAsync(orderEnvelope); - Assert.That(orderResult.HasMatch, Is.True); + var engine = CreateEngine(store); + var envelope = IntegrationEnvelope.Create("data", "Svc", "evt"); + var result = await engine.EvaluateAsync(envelope); - var paymentEnvelope = IntegrationEnvelope.Create("data", "Service", "payment.received"); - var paymentResult = await _engine.EvaluateAsync(paymentEnvelope); - Assert.That(paymentResult.HasMatch, Is.True); - } + Assert.That(result.HasMatch, Is.False); + Assert.That(result.RulesEvaluated, Is.EqualTo(0)); - // ── Disabled Rule Is Skipped ──────────────────────────────────────────── + await _output.PublishAsync(envelope, "fallback"); + _output.AssertReceivedOnTopic("fallback", 1); + } [Test] - public async Task Evaluate_DisabledRule_IsSkipped() + public async Task Evaluate_PriorityOrder_HigherPriorityWins() { - await _store.AddOrUpdateAsync(new BusinessRule + var store = new InMemoryRuleStore(); + await store.AddOrUpdateAsync(new BusinessRule + { + Name = "LowPriority", Priority = 10, + Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "order.created" }], + Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "general" }, + }); + await store.AddOrUpdateAsync(new BusinessRule { - Name = "DisabledRule", - Priority = 1, - Enabled = false, + Name = "HighPriority", Priority = 1, StopOnMatch = true, Conditions = [new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "order.created" }], - Action = new RuleAction { ActionType = RuleActionType.Reject, Reason = "disabled" }, + Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "fast-lane" }, }); - var envelope = IntegrationEnvelope.Create("data", "Service", "order.created"); - var result = await _engine.EvaluateAsync(envelope); + var engine = CreateEngine(store); + var envelope = IntegrationEnvelope.Create("data", "Svc", "order.created"); + var result = await engine.EvaluateAsync(envelope); - Assert.That(result.HasMatch, Is.False); - } + Assert.That(result.MatchedRules, Has.Count.EqualTo(1)); + Assert.That(result.Actions[0].TargetTopic, Is.EqualTo("fast-lane")); - // ── Reject Action Type ────────────────────────────────────────────────── + await _output.PublishAsync(envelope, result.Actions[0].TargetTopic!); + _output.AssertReceivedOnTopic("fast-lane", 1); + } [Test] - public async Task Evaluate_RejectAction_ReturnsRejectWithReason() + public async Task Evaluate_OrLogic_MatchesAnyCondition() { - await _store.AddOrUpdateAsync(new BusinessRule + var store = new InMemoryRuleStore(); + await store.AddOrUpdateAsync(new BusinessRule { - Name = "RejectSpam", - Priority = 1, - Conditions = [new RuleCondition { FieldName = "Source", Operator = RuleConditionOperator.Equals, Value = "SpamService" }], - Action = new RuleAction { ActionType = RuleActionType.Reject, Reason = "Spam detected" }, + Name = "OrRule", Priority = 1, LogicOperator = RuleLogicOperator.Or, + Conditions = + [ + new RuleCondition { FieldName = "MessageType", Operator = RuleConditionOperator.Equals, Value = "order.created" }, + new RuleCondition { FieldName = "Source", Operator = RuleConditionOperator.Equals, Value = "PaymentService" }, + ], + Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = "combined" }, }); - var envelope = IntegrationEnvelope.Create("data", "SpamService", "spam.event"); - var result = await _engine.EvaluateAsync(envelope); + var engine = CreateEngine(store); + var envelope = IntegrationEnvelope.Create("data", "PaymentService", "payment.received"); + var result = await engine.EvaluateAsync(envelope); Assert.That(result.HasMatch, Is.True); - Assert.That(result.Actions[0].ActionType, Is.EqualTo(RuleActionType.Reject)); - Assert.That(result.Actions[0].Reason, Is.EqualTo("Spam detected")); + await _output.PublishAsync(envelope, result.Actions[0].TargetTopic!); + _output.AssertReceivedOnTopic("combined", 1); + } + + private static BusinessRuleEngine CreateEngine(InMemoryRuleStore store) + { + var opts = Options.Create(new RuleEngineOptions { Enabled = true, CacheEnabled = false }); + return new BusinessRuleEngine(store, opts, NullLogger.Instance); } + + private static BusinessRule CreateRouteRule( + string name, string field, RuleConditionOperator op, string value, string targetTopic) => + new() + { + Name = name, Priority = 1, + Conditions = [new RuleCondition { FieldName = field, Operator = op, Value = value }], + Action = new RuleAction { ActionType = RuleActionType.Route, TargetTopic = targetTopic }, + }; } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial31/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial31/Exam.cs index 3b801a6..06df1d5 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial31/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial31/Exam.cs @@ -1,133 +1,113 @@ // ============================================================================ // Tutorial 31 – Event Sourcing (Exam) // ============================================================================ -// Coding challenges: projection that sums order totals, snapshot + rebuild -// state restore, and concurrent append detection via optimistic concurrency. +// EIP Pattern: Event Sourcing +// E2E: Projection rebuild, snapshot integration, and concurrent append +// detection with MockEndpoint for event notification publishing. // ============================================================================ - +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.EventSourcing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; +using EnterpriseIntegrationPlatform.Testing; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial31; [TestFixture] public sealed class Exam { - // ── Challenge 1: Projection That Sums Order Totals ────────────────────── - [Test] - public async Task Challenge1_Projection_SumsOrderTotals() + public async Task Challenge1_ProjectionEngine_RebuildsSumFromEvents() { - var options = Options.Create(new EventSourcingOptions()); - var store = new InMemoryEventStore(options, NullLogger.Instance); - var snapshots = new InMemorySnapshotStore(); - - var projection = Substitute.For>(); - projection - .ProjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(callInfo => - { - var currentState = callInfo.ArgAt(0); - var envelope = callInfo.ArgAt(1); - // Parse the total from the event Data field - if (decimal.TryParse(envelope.Data, out var amount)) - return Task.FromResult(currentState + amount); - return Task.FromResult(currentState); - }); - - var engine = new EventProjectionEngine( - store, snapshots, projection, options, - NullLogger>.Instance); - - var e1 = new EventEnvelope(Guid.NewGuid(), "orders", "OrderPlaced", "100.50", 0, DateTimeOffset.UtcNow, []); - var e2 = new EventEnvelope(Guid.NewGuid(), "orders", "OrderPlaced", "200.25", 0, DateTimeOffset.UtcNow, []); - var e3 = new EventEnvelope(Guid.NewGuid(), "orders", "OrderPlaced", "50.00", 0, DateTimeOffset.UtcNow, []); - - await store.AppendAsync("orders", [e1, e2, e3], expectedVersion: 0); - - var (state, version) = await engine.RebuildAsync("orders", 0m); - - Assert.That(state, Is.EqualTo(350.75m)); - Assert.That(version, Is.EqualTo(3)); - } + await using var output = new MockEndpoint("exam-es"); + var store = new InMemoryEventStore( + Options.Create(new EventSourcingOptions()), + NullLogger.Instance); - // ── Challenge 2: Snapshot + Rebuild Restores State ─────────────────────── + var e1 = new EventEnvelope(Guid.NewGuid(), "account-1", "Deposit", "50", 1, + DateTimeOffset.UtcNow, new Dictionary()); + var e2 = new EventEnvelope(Guid.NewGuid(), "account-1", "Deposit", "30", 2, + DateTimeOffset.UtcNow, new Dictionary()); + await store.AppendAsync("account-1", [e1, e2], 0); + + var projection = new MockEventProjection((state, envelope) => state + int.Parse(envelope.Data)); + + var snapStore = new InMemorySnapshotStore(); + var engine = new EventProjectionEngine( + store, snapStore, projection, + Options.Create(new EventSourcingOptions { SnapshotInterval = 100 }), + NullLogger>.Instance); + + var (state, version) = await engine.RebuildAsync("account-1", 0); + Assert.That(state, Is.EqualTo(80)); + Assert.That(version, Is.EqualTo(2)); + + var envelope = IntegrationEnvelope.Create( + state.ToString(), "projection", "BalanceRebuilt"); + await output.PublishAsync(envelope, "projections", default); + output.AssertReceivedOnTopic("projections", 1); + } [Test] - public async Task Challenge2_SnapshotAndRebuild_RestoresState() + public async Task Challenge2_SnapshotAcceleratesRebuild() { - var options = Options.Create(new EventSourcingOptions { SnapshotInterval = 2 }); - var store = new InMemoryEventStore(options, NullLogger.Instance); - var snapshots = new InMemorySnapshotStore(); + await using var output = new MockEndpoint("exam-snap"); + var store = new InMemoryEventStore( + Options.Create(new EventSourcingOptions()), + NullLogger.Instance); + + var events = Enumerable.Range(1, 5) + .Select(i => new EventEnvelope(Guid.NewGuid(), "s1", "Inc", "10", i, + DateTimeOffset.UtcNow, new Dictionary())) + .ToList(); + await store.AppendAsync("s1", events, 0); - var projection = Substitute.For>(); - projection - .ProjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(callInfo => Task.FromResult(callInfo.ArgAt(0) + 1)); + var snapStore = new InMemorySnapshotStore(); + await snapStore.SaveAsync("s1", 30, 3); + + var projection = new MockEventProjection((state, envelope) => state + int.Parse(envelope.Data)); var engine = new EventProjectionEngine( - store, snapshots, projection, options, + store, snapStore, projection, + Options.Create(new EventSourcingOptions { SnapshotInterval = 100 }), NullLogger>.Instance); - // Append 3 events (>= SnapshotInterval of 2, so snapshot should be saved) - var events = Enumerable.Range(0, 3) - .Select(_ => new EventEnvelope(Guid.NewGuid(), "s", "Evt", "d", 0, DateTimeOffset.UtcNow, [])) - .ToList(); - await store.AppendAsync("s", events, expectedVersion: 0); - - // First rebuild: processes all events, saves snapshot at version 3 - var (state1, ver1) = await engine.RebuildAsync("s", 0); - Assert.That(state1, Is.EqualTo(3)); - Assert.That(ver1, Is.EqualTo(3)); - - // Verify snapshot was saved - var (snapState, snapVer) = await snapshots.LoadAsync("s"); - Assert.That(snapState, Is.EqualTo(3)); - Assert.That(snapVer, Is.EqualTo(3)); - - // Add one more event - var e4 = new EventEnvelope(Guid.NewGuid(), "s", "Evt", "d", 0, DateTimeOffset.UtcNow, []); - await store.AppendAsync("s", [e4], expectedVersion: 3); - - // Second rebuild: starts from snapshot, only processes 1 new event - var (state2, ver2) = await engine.RebuildAsync("s", 0); - Assert.That(state2, Is.EqualTo(4)); - Assert.That(ver2, Is.EqualTo(4)); - } + var (state, version) = await engine.RebuildAsync("s1", 0); + Assert.That(state, Is.EqualTo(50)); + Assert.That(version, Is.EqualTo(5)); - // ── Challenge 3: Concurrent Append Detection ──────────────────────────── + var envelope = IntegrationEnvelope.Create( + state.ToString(), "projection", "Rebuilt"); + await output.PublishAsync(envelope, "snapshot-results", default); + output.AssertReceivedOnTopic("snapshot-results", 1); + } [Test] - public async Task Challenge3_ConcurrentAppendDetection_OptimisticConcurrency() + public async Task Challenge3_ConcurrentAppend_DetectsConflict() { - var options = Options.Create(new EventSourcingOptions()); - var store = new InMemoryEventStore(options, NullLogger.Instance); + await using var output = new MockEndpoint("exam-conflict"); + var store = new InMemoryEventStore( + Options.Create(new EventSourcingOptions()), + NullLogger.Instance); - // Two writers both read version 0 - var writerA = new EventEnvelope(Guid.NewGuid(), "stream", "A", "a", 0, DateTimeOffset.UtcNow, []); - var writerB = new EventEnvelope(Guid.NewGuid(), "stream", "B", "b", 0, DateTimeOffset.UtcNow, []); + var e1 = new EventEnvelope(Guid.NewGuid(), "conflict-s", "Init", "{}", 1, + DateTimeOffset.UtcNow, new Dictionary()); + await store.AppendAsync("conflict-s", [e1], 0); - // Writer A succeeds - var newVersion = await store.AppendAsync("stream", [writerA], expectedVersion: 0); - Assert.That(newVersion, Is.EqualTo(1)); - - // Writer B fails because stream is now at version 1, not 0 + var conflict = new EventEnvelope(Guid.NewGuid(), "conflict-s", "Bad", "{}", 2, + DateTimeOffset.UtcNow, new Dictionary()); var ex = Assert.ThrowsAsync( - () => store.AppendAsync("stream", [writerB], expectedVersion: 0)); + () => store.AppendAsync("conflict-s", [conflict], 0)); - Assert.That(ex!.StreamId, Is.EqualTo("stream")); + Assert.That(ex!.StreamId, Is.EqualTo("conflict-s")); Assert.That(ex.ExpectedVersion, Is.EqualTo(0)); Assert.That(ex.ActualVersion, Is.EqualTo(1)); - // Writer B retries with correct version and succeeds - var retryVersion = await store.AppendAsync("stream", [writerB], expectedVersion: 1); - Assert.That(retryVersion, Is.EqualTo(2)); - - // Verify both events are in the stream - var allEvents = await store.ReadStreamAsync("stream", fromVersion: 1, count: 100); - Assert.That(allEvents, Has.Count.EqualTo(2)); + var envelope = IntegrationEnvelope.Create( + $"Conflict on {ex.StreamId}", "guard", "ConflictDetected"); + await output.PublishAsync(envelope, "conflicts", default); + output.AssertReceivedOnTopic("conflicts", 1); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial31/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial31/Lab.cs index 9069f5f..0243db9 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial31/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial31/Lab.cs @@ -1,151 +1,135 @@ // ============================================================================ // Tutorial 31 – Event Sourcing (Lab) // ============================================================================ -// This lab exercises the InMemoryEventStore, InMemorySnapshotStore, -// EventProjectionEngine, EventEnvelope, OptimisticConcurrencyException, -// and EventSourcingOptions to learn the event sourcing subsystem. +// EIP Pattern: Event Sourcing +// E2E: InMemoryEventStore — append events, read stream forward/backward, +// then publish event notifications to MockEndpoint. // ============================================================================ - +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.EventSourcing; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial31; [TestFixture] public sealed class Lab { - private InMemoryEventStore _store = null!; + private MockEndpoint _output = null!; [SetUp] - public void SetUp() - { - var options = Options.Create(new EventSourcingOptions()); - _store = new InMemoryEventStore(options, NullLogger.Instance); - } + public void SetUp() => _output = new MockEndpoint("event-out"); - // ── Append and Read Roundtrip ─────────────────────────────────────────── + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); - [Test] - public async Task AppendAsync_AndReadStreamAsync_Roundtrip() - { - var envelope = new EventEnvelope( - Guid.NewGuid(), "stream-1", "OrderCreated", - """{"total":42}""", 0, DateTimeOffset.UtcNow, []); + private static InMemoryEventStore CreateStore(int maxPerRead = 1000) => + new(Options.Create(new EventSourcingOptions { MaxEventsPerRead = maxPerRead }), + NullLogger.Instance); - await _store.AppendAsync("stream-1", [envelope], expectedVersion: 0); + private static EventEnvelope MakeEvent(string streamId, string type, string data, long version) => + new(Guid.NewGuid(), streamId, type, data, version, + DateTimeOffset.UtcNow, new Dictionary()); - var events = await _store.ReadStreamAsync("stream-1", fromVersion: 1, count: 100); + [Test] + public async Task AppendAndReadForward_RoundTrip() + { + var store = CreateStore(); + var evt = MakeEvent("order-1", "OrderCreated", "{\"total\":100}", 1); + var newVersion = await store.AppendAsync("order-1", [evt], 0); + Assert.That(newVersion, Is.EqualTo(1)); + var events = await store.ReadStreamAsync("order-1", 1, 10); Assert.That(events, Has.Count.EqualTo(1)); - Assert.That(events[0].StreamId, Is.EqualTo("stream-1")); Assert.That(events[0].EventType, Is.EqualTo("OrderCreated")); - Assert.That(events[0].Version, Is.EqualTo(1)); - } - // ── Append Multiple and Read All Back in Order ────────────────────────── + var envelope = IntegrationEnvelope.Create(events[0].Data, "event-store", "OrderCreated"); + await _output.PublishAsync(envelope, "event-notifications", default); + _output.AssertReceivedOnTopic("event-notifications", 1); + } [Test] - public async Task AppendMultiple_ReadAllBack_InOrder() + public async Task AppendMultipleEvents_VersionsIncrement() { - var e1 = new EventEnvelope(Guid.NewGuid(), "s", "A", "d1", 0, DateTimeOffset.UtcNow, []); - var e2 = new EventEnvelope(Guid.NewGuid(), "s", "B", "d2", 0, DateTimeOffset.UtcNow, []); - var e3 = new EventEnvelope(Guid.NewGuid(), "s", "C", "d3", 0, DateTimeOffset.UtcNow, []); - - await _store.AppendAsync("s", [e1], expectedVersion: 0); - await _store.AppendAsync("s", [e2], expectedVersion: 1); - await _store.AppendAsync("s", [e3], expectedVersion: 2); - - var events = await _store.ReadStreamAsync("s", fromVersion: 1, count: 100); - - Assert.That(events, Has.Count.EqualTo(3)); + var store = CreateStore(); + var e1 = MakeEvent("stream-a", "Created", "{}", 1); + var e2 = MakeEvent("stream-a", "Updated", "{}", 2); + var newVersion = await store.AppendAsync("stream-a", [e1, e2], 0); + Assert.That(newVersion, Is.EqualTo(2)); + + var events = await store.ReadStreamAsync("stream-a", 1, 10); + Assert.That(events, Has.Count.EqualTo(2)); Assert.That(events[0].Version, Is.EqualTo(1)); Assert.That(events[1].Version, Is.EqualTo(2)); - Assert.That(events[2].Version, Is.EqualTo(3)); - Assert.That(events[0].EventType, Is.EqualTo("A")); - Assert.That(events[2].EventType, Is.EqualTo("C")); + await Task.CompletedTask; } - // ── OptimisticConcurrencyException on Version Conflict ────────────────── - [Test] - public async Task AppendAsync_VersionConflict_ThrowsOptimisticConcurrencyException() + public async Task ReadStreamBackward_ReturnsDescendingOrder() { - var e = new EventEnvelope(Guid.NewGuid(), "s", "E", "d", 0, DateTimeOffset.UtcNow, []); - await _store.AppendAsync("s", [e], expectedVersion: 0); + var store = CreateStore(); + var e1 = MakeEvent("s1", "A", "{}", 1); + var e2 = MakeEvent("s1", "B", "{}", 2); + var e3 = MakeEvent("s1", "C", "{}", 3); + await store.AppendAsync("s1", [e1, e2, e3], 0); - var e2 = new EventEnvelope(Guid.NewGuid(), "s", "E2", "d2", 0, DateTimeOffset.UtcNow, []); - - var ex = Assert.ThrowsAsync( - () => _store.AppendAsync("s", [e2], expectedVersion: 0)); - - Assert.That(ex!.StreamId, Is.EqualTo("s")); - Assert.That(ex.ExpectedVersion, Is.EqualTo(0)); - Assert.That(ex.ActualVersion, Is.EqualTo(1)); + var events = await store.ReadStreamBackwardAsync("s1", 3, 10); + Assert.That(events, Has.Count.EqualTo(3)); + Assert.That(events[0].EventType, Is.EqualTo("C")); + Assert.That(events[2].EventType, Is.EqualTo("A")); } - // ── ReadStreamBackwardAsync Returns Reversed Order ────────────────────── - [Test] - public async Task ReadStreamBackwardAsync_ReturnsReversedOrder() + public async Task OptimisticConcurrency_ThrowsOnVersionMismatch() { - var e1 = new EventEnvelope(Guid.NewGuid(), "s", "A", "d1", 0, DateTimeOffset.UtcNow, []); - var e2 = new EventEnvelope(Guid.NewGuid(), "s", "B", "d2", 0, DateTimeOffset.UtcNow, []); - var e3 = new EventEnvelope(Guid.NewGuid(), "s", "C", "d3", 0, DateTimeOffset.UtcNow, []); + var store = CreateStore(); + var e1 = MakeEvent("s2", "Init", "{}", 1); + await store.AppendAsync("s2", [e1], 0); - await _store.AppendAsync("s", [e1, e2, e3], expectedVersion: 0); - - var events = await _store.ReadStreamBackwardAsync("s", fromVersion: 3, count: 100); - - Assert.That(events, Has.Count.EqualTo(3)); - Assert.That(events[0].Version, Is.EqualTo(3)); - Assert.That(events[1].Version, Is.EqualTo(2)); - Assert.That(events[2].Version, Is.EqualTo(1)); + var e2 = MakeEvent("s2", "Conflict", "{}", 2); + Assert.ThrowsAsync( + () => store.AppendAsync("s2", [e2], 0)); } - // ── InMemorySnapshotStore Save and Load Roundtrip ─────────────────────── - [Test] - public async Task SnapshotStore_SaveAndLoad_Roundtrip() + public async Task ReadFromMiddleOfStream_ReturnsSubset() { - var snapshots = new InMemorySnapshotStore(); - - await snapshots.SaveAsync("stream-1", 42, 5); - var (state, version) = await snapshots.LoadAsync("stream-1"); - - Assert.That(state, Is.EqualTo(42)); - Assert.That(version, Is.EqualTo(5)); + var store = CreateStore(); + var events = Enumerable.Range(1, 5) + .Select(i => MakeEvent("s3", $"E{i}", "{}", i)) + .ToList(); + await store.AppendAsync("s3", events, 0); + + var subset = await store.ReadStreamAsync("s3", 3, 10); + Assert.That(subset, Has.Count.EqualTo(3)); + Assert.That(subset[0].Version, Is.EqualTo(3)); } - // ── EventSourcingOptions Defaults ──────────────────────────────────────── - [Test] - public void EventSourcingOptions_Defaults() + public async Task EmptyStream_ReturnsEmptyList() { - var opts = new EventSourcingOptions(); - - Assert.That(opts.SnapshotInterval, Is.EqualTo(50)); - Assert.That(opts.MaxEventsPerRead, Is.EqualTo(1000)); + var store = CreateStore(); + var events = await store.ReadStreamAsync("nonexistent", 1, 10); + Assert.That(events, Is.Empty); } - // ── EventEnvelope Record Shape ────────────────────────────────────────── - [Test] - public void EventEnvelope_RecordShape_AllPropertiesAccessible() + public async Task PublishAllEventsToMockEndpoint() { - var id = Guid.NewGuid(); - var ts = DateTimeOffset.UtcNow; - var meta = new Dictionary { ["key"] = "value" }; - - var envelope = new EventEnvelope(id, "stream-1", "OrderCreated", """{"x":1}""", 7, ts, meta); - - Assert.That(envelope.EventId, Is.EqualTo(id)); - Assert.That(envelope.StreamId, Is.EqualTo("stream-1")); - Assert.That(envelope.EventType, Is.EqualTo("OrderCreated")); - Assert.That(envelope.Data, Is.EqualTo("""{"x":1}""")); - Assert.That(envelope.Version, Is.EqualTo(7)); - Assert.That(envelope.Timestamp, Is.EqualTo(ts)); - Assert.That(envelope.Metadata["key"], Is.EqualTo("value")); + var store = CreateStore(); + var e1 = MakeEvent("pub-1", "A", "{\"v\":1}", 1); + var e2 = MakeEvent("pub-1", "B", "{\"v\":2}", 2); + await store.AppendAsync("pub-1", [e1, e2], 0); + + var stream = await store.ReadStreamAsync("pub-1", 1, 10); + foreach (var evt in stream) + { + var envelope = IntegrationEnvelope.Create(evt.Data, "event-store", evt.EventType); + await _output.PublishAsync(envelope, "event-stream", default); + } + + _output.AssertReceivedOnTopic("event-stream", 2); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial32/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial32/Exam.cs index 181c04b..f26aaff 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial32/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial32/Exam.cs @@ -1,99 +1,81 @@ // ============================================================================ // Tutorial 32 – Multi-Tenancy (Exam) // ============================================================================ -// Coding challenges: multi-tenant routing from metadata, cross-tenant -// rejection scenario, and anonymous tenant handling in the guard. +// EIP Pattern: Multi-Tenant Messaging +// E2E: Multi-tenant routing, cross-tenant rejection, and anonymous tenant +// guard behavior with MockEndpoint verification. // ============================================================================ - using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.MultiTenancy; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial32; [TestFixture] public sealed class Exam { - // ── Challenge 1: Resolve Tenant From Metadata, Verify Isolation ────────── - [Test] - public void Challenge1_MultiTenantRouting_ResolveAndVerifyIsolation() + public async Task Challenge1_MultiTenantRouting_IsolatesPerTenant() { + await using var output = new MockEndpoint("exam-tenant"); var resolver = new TenantResolver(); - var guard = new TenantIsolationGuard(resolver); - // Simulate two tenants sending messages - var envTenantA = IntegrationEnvelope.Create("orderA", "OrderService", "order.created") with + var tenants = new[] { "alpha", "beta", "gamma" }; + foreach (var tid in tenants) { - Metadata = new Dictionary - { - [TenantResolver.TenantMetadataKey] = "acme-corp", - }, - }; - - var envTenantB = IntegrationEnvelope.Create("orderB", "OrderService", "order.created") with - { - Metadata = new Dictionary - { - [TenantResolver.TenantMetadataKey] = "globex-inc", - }, - }; - - // Resolve each tenant - var ctxA = resolver.Resolve(envTenantA.Metadata); - var ctxB = resolver.Resolve(envTenantB.Metadata); - Assert.That(ctxA.TenantId, Is.EqualTo("acme-corp")); - Assert.That(ctxB.TenantId, Is.EqualTo("globex-inc")); - - // Guard passes for correct tenant - Assert.DoesNotThrow(() => guard.Enforce(envTenantA, "acme-corp")); - Assert.DoesNotThrow(() => guard.Enforce(envTenantB, "globex-inc")); + var envelope = IntegrationEnvelope.Create($"data-{tid}", "src", "Order"); + envelope.Metadata["tenantId"] = tid; + var ctx = resolver.Resolve(envelope.Metadata); + Assert.That(ctx.IsResolved, Is.True); + await output.PublishAsync(envelope, $"orders.{ctx.TenantId}", default); + } + + output.AssertReceivedCount(3); + output.AssertReceivedOnTopic("orders.alpha", 1); + output.AssertReceivedOnTopic("orders.beta", 1); + output.AssertReceivedOnTopic("orders.gamma", 1); } - // ── Challenge 2: Cross-Tenant Rejection ───────────────────────────────── - [Test] - public void Challenge2_CrossTenantRejection() + public async Task Challenge2_CrossTenantAccess_Rejected() { + await using var output = new MockEndpoint("exam-reject"); var resolver = new TenantResolver(); var guard = new TenantIsolationGuard(resolver); - var envelope = IntegrationEnvelope.Create("data", "Svc", "event") with - { - Metadata = new Dictionary - { - [TenantResolver.TenantMetadataKey] = "tenant-alpha", - }, - }; + var envelope = IntegrationEnvelope.Create("secret", "src", "Data"); + envelope.Metadata["tenantId"] = "tenant-a"; - // Attempt to process in wrong tenant context var ex = Assert.Throws( - () => guard.Enforce(envelope, "tenant-beta")); + () => guard.Enforce(envelope, "tenant-b")); - Assert.That(ex!.MessageId, Is.EqualTo(envelope.MessageId)); - Assert.That(ex.ActualTenantId, Is.EqualTo("tenant-alpha")); - Assert.That(ex.ExpectedTenantId, Is.EqualTo("tenant-beta")); - Assert.That(ex.Message, Does.Contain("tenant-alpha")); - Assert.That(ex.Message, Does.Contain("tenant-beta")); - } + Assert.That(ex!.ActualTenantId, Is.EqualTo("tenant-a")); + Assert.That(ex.ExpectedTenantId, Is.EqualTo("tenant-b")); - // ── Challenge 3: Anonymous Tenant Handling in Guard ────────────────────── + var alert = IntegrationEnvelope.Create(ex.Message, "guard", "Violation"); + await output.PublishAsync(alert, "violations", default); + output.AssertReceivedOnTopic("violations", 1); + } [Test] - public void Challenge3_AnonymousTenant_GuardThrows() + public async Task Challenge3_AnonymousTenant_GuardRejects() { + await using var output = new MockEndpoint("exam-anon"); var resolver = new TenantResolver(); var guard = new TenantIsolationGuard(resolver); - // Envelope with no tenantId metadata → resolves to Anonymous - var envelope = IntegrationEnvelope.Create("data", "Svc", "event"); + var envelope = IntegrationEnvelope.Create("payload", "src", "Msg"); var ex = Assert.Throws( - () => guard.Enforce(envelope, "required-tenant")); + () => guard.Enforce(envelope, "expected-tenant")); - // Anonymous is not resolved, so ActualTenantId should be null Assert.That(ex!.ActualTenantId, Is.Null); - Assert.That(ex.ExpectedTenantId, Is.EqualTo("required-tenant")); - Assert.That(ex.Message, Does.Contain("tenant identifier")); + Assert.That(ex.ExpectedTenantId, Is.EqualTo("expected-tenant")); + + var notification = IntegrationEnvelope.Create( + "Anonymous access attempt", "guard", "AnonymousRejected"); + await output.PublishAsync(notification, "security", default); + output.AssertReceivedOnTopic("security", 1); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial32/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial32/Lab.cs index faac81f..e812fb9 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial32/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial32/Lab.cs @@ -1,128 +1,108 @@ // ============================================================================ // Tutorial 32 – Multi-Tenancy (Lab) // ============================================================================ -// This lab exercises TenantResolver, TenantIsolationGuard, TenantContext, -// and TenantIsolationException to learn multi-tenant message handling. +// EIP Pattern: Multi-Tenant Messaging +// E2E: TenantResolver + TenantIsolationGuard resolve and enforce tenant +// boundaries, with MockEndpoint for tenant-scoped publishing. // ============================================================================ - using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.MultiTenancy; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial32; [TestFixture] public sealed class Lab { - private TenantResolver _resolver = null!; + private MockEndpoint _output = null!; [SetUp] - public void SetUp() - { - _resolver = new TenantResolver(); - } + public void SetUp() => _output = new MockEndpoint("tenant-out"); - // ── Resolve From Metadata With tenantId Key ───────────────────────────── + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public void Resolve_FromMetadata_WithTenantIdKey() + public async Task ResolveFromMetadata_ReturnsTenantContext() { + var resolver = new TenantResolver(); var metadata = new Dictionary { - [TenantResolver.TenantMetadataKey] = "tenant-abc", + { TenantResolver.TenantMetadataKey, "acme" }, }; + var ctx = resolver.Resolve(metadata); - var context = _resolver.Resolve(metadata); + Assert.That(ctx.IsResolved, Is.True); + Assert.That(ctx.TenantId, Is.EqualTo("acme")); - Assert.That(context.TenantId, Is.EqualTo("tenant-abc")); - Assert.That(context.IsResolved, Is.True); + var envelope = IntegrationEnvelope.Create("ok", "resolver", "TenantResolved"); + await _output.PublishAsync(envelope, $"tenant.{ctx.TenantId}", default); + _output.AssertReceivedOnTopic("tenant.acme", 1); } - // ── Resolve Returns Anonymous For Missing tenantId ────────────────────── - [Test] - public void Resolve_MissingTenantId_ReturnsAnonymous() + public async Task ResolveFromMetadata_MissingKey_ReturnsAnonymous() { - var metadata = new Dictionary(); - - var context = _resolver.Resolve(metadata); + var resolver = new TenantResolver(); + var ctx = resolver.Resolve(new Dictionary()); - Assert.That(context.IsResolved, Is.False); - Assert.That(context, Is.SameAs(TenantContext.Anonymous)); + Assert.That(ctx.IsResolved, Is.False); + Assert.That(ctx.TenantId, Is.EqualTo("anonymous")); + await Task.CompletedTask; } - // ── Resolve(string) With Explicit TenantId ────────────────────────────── - [Test] - public void Resolve_String_WithExplicitTenantId() + public async Task ResolveFromString_ReturnsTenantContext() { - var context = _resolver.Resolve("my-tenant"); + var resolver = new TenantResolver(); + var ctx = resolver.Resolve("tenant-42"); - Assert.That(context.TenantId, Is.EqualTo("my-tenant")); - Assert.That(context.IsResolved, Is.True); + Assert.That(ctx.IsResolved, Is.True); + Assert.That(ctx.TenantId, Is.EqualTo("tenant-42")); + await Task.CompletedTask; } - // ── TenantIsolationGuard Passes When Tenant Matches ───────────────────── - [Test] - public void IsolationGuard_Enforce_PassesWhenTenantMatches() + public async Task ResolveFromString_NullOrWhitespace_ReturnsAnonymous() { - var guard = new TenantIsolationGuard(_resolver); - var envelope = IntegrationEnvelope.Create("data", "Svc", "event") with - { - Metadata = new Dictionary - { - [TenantResolver.TenantMetadataKey] = "tenant-x", - }, - }; + var resolver = new TenantResolver(); - Assert.DoesNotThrow(() => guard.Enforce(envelope, "tenant-x")); + Assert.That(resolver.Resolve((string?)null).IsResolved, Is.False); + Assert.That(resolver.Resolve(" ").IsResolved, Is.False); + await Task.CompletedTask; } - // ── TenantIsolationGuard Throws On Mismatch ───────────────────────────── - [Test] - public void IsolationGuard_Enforce_ThrowsOnMismatch() + public async Task IsolationGuard_MatchingTenant_DoesNotThrow() { - var guard = new TenantIsolationGuard(_resolver); - var envelope = IntegrationEnvelope.Create("data", "Svc", "event") with - { - Metadata = new Dictionary - { - [TenantResolver.TenantMetadataKey] = "tenant-a", - }, - }; + var resolver = new TenantResolver(); + var guard = new TenantIsolationGuard(resolver); + var envelope = IntegrationEnvelope.Create("data", "src", "type"); + envelope.Metadata["tenantId"] = "acme"; - var ex = Assert.Throws( - () => guard.Enforce(envelope, "tenant-b")); - - Assert.That(ex!.ActualTenantId, Is.EqualTo("tenant-a")); - Assert.That(ex.ExpectedTenantId, Is.EqualTo("tenant-b")); + Assert.DoesNotThrow(() => guard.Enforce(envelope, "acme")); + await Task.CompletedTask; } - // ── TenantContext.Anonymous Has Expected Defaults ──────────────────────── - [Test] - public void TenantContext_Anonymous_HasExpectedDefaults() + public async Task IsolationGuard_MismatchedTenant_ThrowsAndPublishesAlert() { - var anon = TenantContext.Anonymous; + var resolver = new TenantResolver(); + var guard = new TenantIsolationGuard(resolver); + var envelope = IntegrationEnvelope.Create("data", "src", "type"); + envelope.Metadata["tenantId"] = "acme"; - Assert.That(anon.TenantId, Is.EqualTo("anonymous")); - Assert.That(anon.IsResolved, Is.False); - Assert.That(anon.TenantName, Is.Null); - } - - // ── TenantIsolationException Captures Fields ──────────────────────────── + var ex = Assert.Throws( + () => guard.Enforce(envelope, "globex")); - [Test] - public void TenantIsolationException_CapturesFields() - { - var msgId = Guid.NewGuid(); - var ex = new TenantIsolationException(msgId, "actual-t", "expected-t", "details"); + Assert.That(ex!.ExpectedTenantId, Is.EqualTo("globex")); + Assert.That(ex.ActualTenantId, Is.EqualTo("acme")); - Assert.That(ex.MessageId, Is.EqualTo(msgId)); - Assert.That(ex.ActualTenantId, Is.EqualTo("actual-t")); - Assert.That(ex.ExpectedTenantId, Is.EqualTo("expected-t")); - Assert.That(ex.Message, Is.EqualTo("details")); + var alert = IntegrationEnvelope.Create( + $"Cross-tenant violation: {ex.ActualTenantId} vs {ex.ExpectedTenantId}", + "guard", "TenantViolation"); + await _output.PublishAsync(alert, "security-alerts", default); + _output.AssertReceivedOnTopic("security-alerts", 1); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial33/Exam.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial33/Exam.cs index ebf689c..10ab3d7 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial33/Exam.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial33/Exam.cs @@ -1,88 +1,91 @@ // ============================================================================ // Tutorial 33 – Security (Exam) // ============================================================================ -// Coding challenges: SQL injection sanitization, secret rotation with -// SecretRotationService, and PayloadSizeOptions defaults with custom limits. +// EIP Pattern: Security Patterns +// E2E: Full sanitize pipeline, byte[] payload guard, and combined +// sanitizer + size guard E2E flow with MockEndpoint. // ============================================================================ - +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Security; -using EnterpriseIntegrationPlatform.Security.Secrets; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial33; [TestFixture] public sealed class Exam { - // ── Challenge 1: SQL Injection Sanitization ───────────────────────────── - [Test] - public void Challenge1_SqlInjection_Sanitized() + public async Task Challenge1_FullSanitizePipeline_PublishesCleanMessages() { + await using var output = new MockEndpoint("exam-sanitize"); var sanitizer = new InputSanitizer(); - // SQL injection patterns should be detected as unclean - Assert.That(sanitizer.IsClean("'; DROP TABLE users"), Is.False); - Assert.That(sanitizer.IsClean("1 OR 1=1"), Is.False); - Assert.That(sanitizer.IsClean("UNION SELECT * FROM passwords"), Is.False); - - // Sanitize removes the SQL injection pattern - var sanitized = sanitizer.Sanitize("Hello '; DROP TABLE users --"); - Assert.That(sanitized, Does.Not.Contain("DROP TABLE")); + var inputs = new[] + { + "normal text", + "injected", + "hello\r\nworld", + }; + + foreach (var raw in inputs) + { + var clean = sanitizer.Sanitize(raw); + var envelope = IntegrationEnvelope.Create(clean, "pipeline", "Sanitized"); + await output.PublishAsync(envelope, "clean-output", default); + } + + output.AssertReceivedOnTopic("clean-output", 3); + var all = output.GetAllReceived("clean-output"); + foreach (var env in all) + { + Assert.That(sanitizer.IsClean(env.Payload), Is.True); + } } - // ── Challenge 2: Secret Rotation ──────────────────────────────────────── - [Test] - public async Task Challenge2_SecretRotation_WithRotationService() + public async Task Challenge2_ByteArrayPayloadGuard() { - var auditLogger = new SecretAuditLogger(NullLogger.Instance); - var provider = new InMemorySecretProvider(auditLogger); - var secretsOptions = Options.Create(new SecretsOptions()); - var rotationService = new SecretRotationService( - provider, auditLogger, secretsOptions, - NullLogger.Instance); + await using var output = new MockEndpoint("exam-bytes"); + var guard = new PayloadSizeGuard(Options.Create( + new PayloadSizeOptions { MaxPayloadBytes = 16 })); - // Store an initial secret - var initial = await provider.SetSecretAsync("api-key", "original-value"); - Assert.That(initial.Value, Is.EqualTo("original-value")); + var smallBytes = new byte[10]; + Assert.DoesNotThrow(() => guard.Enforce(smallBytes)); - // Rotate now - var rotated = await rotationService.RotateNowAsync("api-key"); + var largeBytes = new byte[20]; + var ex = Assert.Throws( + () => guard.Enforce(largeBytes)); - // Verify the secret was rotated to a new value - Assert.That(rotated.Key, Is.EqualTo("api-key")); - Assert.That(rotated.Value, Is.Not.EqualTo("original-value")); - Assert.That(rotated.Version, Is.Not.EqualTo(initial.Version)); + Assert.That(ex!.ActualBytes, Is.EqualTo(20)); + Assert.That(ex.MaxBytes, Is.EqualTo(16)); - // Verify the rotated value is persisted - var current = await provider.GetSecretAsync("api-key"); - Assert.That(current!.Value, Is.EqualTo(rotated.Value)); + var envelope = IntegrationEnvelope.Create( + "byte-guard-verified", "guard", "SizeCheck"); + await output.PublishAsync(envelope, "guard-results", default); + output.AssertReceivedOnTopic("guard-results", 1); } - // ── Challenge 3: PayloadSizeOptions Defaults and Custom Enforcement ───── - [Test] - public void Challenge3_PayloadSizeOptions_DefaultsAndCustom() + public async Task Challenge3_CombinedSanitizerAndGuard_E2E() { - // Verify defaults - var defaults = new PayloadSizeOptions(); - Assert.That(defaults.MaxPayloadBytes, Is.EqualTo(1_048_576)); // 1 MB - - // Custom limit of 10 bytes - var guard = new PayloadSizeGuard( - Options.Create(new PayloadSizeOptions { MaxPayloadBytes = 10 })); + await using var output = new MockEndpoint("exam-combined"); + var sanitizer = new InputSanitizer(); + var guard = new PayloadSizeGuard(Options.Create( + new PayloadSizeOptions { MaxPayloadBytes = 4096 })); - // Small payload passes - Assert.DoesNotThrow(() => guard.Enforce("tiny")); + var raw = "Order: amount=100 OR 1=1"; + var clean = sanitizer.Sanitize(raw); + guard.Enforce(clean); + Assert.That(sanitizer.IsClean(clean), Is.True); - // Oversized payload throws with correct sizes - var ex = Assert.Throws( - () => guard.Enforce("this is way too long for a 10-byte limit")); + var envelope = IntegrationEnvelope.Create(clean, "security", "Verified"); + await output.PublishAsync(envelope, "verified-output", default); + output.AssertReceivedOnTopic("verified-output", 1); - Assert.That(ex!.MaxBytes, Is.EqualTo(10)); - Assert.That(ex.ActualBytes, Is.GreaterThan(10)); + var oversized = new string('X', 5000); + Assert.Throws(() => guard.Enforce(oversized)); + output.AssertReceivedCount(1); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial33/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial33/Lab.cs index 4de5af1..3ecdb97 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial33/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial33/Lab.cs @@ -1,123 +1,106 @@ // ============================================================================ // Tutorial 33 – Security (Lab) // ============================================================================ -// This lab exercises InputSanitizer, PayloadSizeGuard, InMemorySecretProvider, -// and SecretEntry to learn the security subsystem. +// EIP Pattern: Security Patterns (Input Sanitization + Payload Size Guard) +// E2E: InputSanitizer sanitize/detect, PayloadSizeGuard enforce, +// MockEndpoint for publishing sanitized messages. // ============================================================================ - +using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.Security; -using EnterpriseIntegrationPlatform.Security.Secrets; using Microsoft.Extensions.Options; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial33; [TestFixture] public sealed class Lab { - private InputSanitizer _sanitizer = null!; + private MockEndpoint _output = null!; [SetUp] - public void SetUp() - { - _sanitizer = new InputSanitizer(); - } + public void SetUp() => _output = new MockEndpoint("security-out"); - // ── Sanitize Removes XSS Script Tags ──────────────────────────────────── + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public void InputSanitizer_Sanitize_RemovesScriptTags() + public async Task Sanitizer_RemovesScriptTags() { + var sanitizer = new InputSanitizer(); var input = "Hello World"; + var clean = sanitizer.Sanitize(input); - var result = _sanitizer.Sanitize(input); + Assert.That(clean, Does.Not.Contain(""; + var sanitizer = new InputSanitizer(); + var input = "'; DROP TABLE users"; + var clean = sanitizer.Sanitize(input); - Assert.That(_sanitizer.IsClean(dirty), Is.False); + Assert.That(clean, Does.Not.Contain("DROP TABLE")); + await Task.CompletedTask; } - // ── IsClean Returns True For Clean Input ──────────────────────────────── - [Test] - public void InputSanitizer_IsClean_ReturnsTrueForClean() + public async Task IsClean_DetectsDangerousInput() { - var clean = "Hello, this is perfectly safe text."; + var sanitizer = new InputSanitizer(); - Assert.That(_sanitizer.IsClean(clean), Is.True); + Assert.That(sanitizer.IsClean("safe text"), Is.True); + Assert.That(sanitizer.IsClean("has\nnewline"), Is.False); + Assert.That(sanitizer.IsClean(""), Is.False); + await Task.CompletedTask; } - // ── PayloadSizeGuard Passes For Small Payload ─────────────────────────── - [Test] - public void PayloadSizeGuard_Enforce_PassesForSmallPayload() + public async Task PayloadSizeGuard_AllowsUnderLimit() { - var guard = new PayloadSizeGuard( - Options.Create(new PayloadSizeOptions { MaxPayloadBytes = 1024 })); - - var smallPayload = new string('x', 100); + var guard = new PayloadSizeGuard(Options.Create( + new PayloadSizeOptions { MaxPayloadBytes = 1024 })); - Assert.DoesNotThrow(() => guard.Enforce(smallPayload)); + Assert.DoesNotThrow(() => guard.Enforce("small payload")); + await Task.CompletedTask; } - // ── PayloadSizeGuard Throws For Oversized Payload ─────────────────────── - [Test] - public void PayloadSizeGuard_Enforce_ThrowsPayloadTooLargeException() + public async Task PayloadSizeGuard_RejectsOverLimit() { - var guard = new PayloadSizeGuard( - Options.Create(new PayloadSizeOptions { MaxPayloadBytes = 50 })); - - var oversized = new string('x', 200); + var guard = new PayloadSizeGuard(Options.Create( + new PayloadSizeOptions { MaxPayloadBytes = 10 })); var ex = Assert.Throws( - () => guard.Enforce(oversized)); + () => guard.Enforce("this is way too large for limit")); - Assert.That(ex!.MaxBytes, Is.EqualTo(50)); - Assert.That(ex.ActualBytes, Is.GreaterThan(50)); + Assert.That(ex!.MaxBytes, Is.EqualTo(10)); + Assert.That(ex.ActualBytes, Is.GreaterThan(10)); + await Task.CompletedTask; } - // ── InMemorySecretProvider Set/Get Roundtrip ──────────────────────────── - [Test] - public async Task SecretProvider_SetAndGet_Roundtrip() + public async Task SanitizedMessage_PublishedToMockEndpoint() { - var provider = new InMemorySecretProvider(); + var sanitizer = new InputSanitizer(); + var guard = new PayloadSizeGuard(Options.Create( + new PayloadSizeOptions { MaxPayloadBytes = 4096 })); - var stored = await provider.SetSecretAsync("db-password", "s3cret!"); - var retrieved = await provider.GetSecretAsync("db-password"); + var raw = "Hello World"; + var clean = sanitizer.Sanitize(raw); + guard.Enforce(clean); - Assert.That(retrieved, Is.Not.Null); - Assert.That(retrieved!.Key, Is.EqualTo("db-password")); - Assert.That(retrieved.Value, Is.EqualTo("s3cret!")); - Assert.That(retrieved.Version, Is.EqualTo(stored.Version)); - } + var envelope = IntegrationEnvelope.Create(clean, "pipeline", "SafeMessage"); + await _output.PublishAsync(envelope, "safe-messages", default); + _output.AssertReceivedOnTopic("safe-messages", 1); - // ── SecretEntry Record Has Expected Properties ────────────────────────── - - [Test] - public void SecretEntry_RecordProperties() - { - var now = DateTimeOffset.UtcNow; - var meta = new Dictionary { ["env"] = "prod" }; - var entry = new SecretEntry("api-key", "value123", "3", now, Metadata: meta); - - Assert.That(entry.Key, Is.EqualTo("api-key")); - Assert.That(entry.Value, Is.EqualTo("value123")); - Assert.That(entry.Version, Is.EqualTo("3")); - Assert.That(entry.CreatedAt, Is.EqualTo(now)); - Assert.That(entry.ExpiresAt, Is.Null); - Assert.That(entry.Metadata!["env"], Is.EqualTo("prod")); + var received = _output.GetReceived(); + Assert.That(received.Payload, Does.Not.Contain(" Order data", "OrderService", "order.created") with { - Metadata = new Dictionary - { - ["tenantId"] = "premium-corp", - ["region"] = "eu-west-1", - }, + Metadata = new Dictionary { ["tenantId"] = "premium-corp" }, }; - // Sanitize the payload var sanitizer = new InputSanitizer(); - var cleanPayload = sanitizer.Sanitize(envelope.Payload); - Assert.That(sanitizer.IsClean(cleanPayload), Is.True); + var clean = sanitizer.Sanitize(envelope.Payload); + Assert.That(sanitizer.IsClean(clean), Is.True); - // Resolve tenant var resolver = new TenantResolver(); var tenant = resolver.Resolve(envelope.Metadata); Assert.That(tenant.IsResolved, Is.True); Assert.That(tenant.TenantId, Is.EqualTo("premium-corp")); - // Verify envelope integrity - Assert.That(envelope.Source, Is.EqualTo("OrderService")); - Assert.That(envelope.MessageType, Is.EqualTo("order.created")); + var sanitized = IntegrationEnvelope.Create( + clean, envelope.Source, envelope.MessageType); + await output.PublishAsync(sanitized, $"tenant.{tenant.TenantId}"); + output.AssertReceivedOnTopic("tenant.premium-corp", 1); } - // ── Challenge 2: Expiration and Priority ──────────────────────────────── - [Test] - public void Challenge2_ExpirationAndPriority_CombinedScenario() + public async Task Challenge2_ExpirationPriority_ProcessesOnlyValid() { - var urgentEnvelope = IntegrationEnvelope.Create( - "urgent-data", "AlertService", "alert.fired") with + await using var output = new MockEndpoint("priority"); + var urgent = IntegrationEnvelope.Create("urgent", "Alert", "alert.fired") with { Priority = MessagePriority.Critical, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5), }; - - var expiredEnvelope = IntegrationEnvelope.Create( - "old-data", "BatchService", "batch.completed") with + var expired = IntegrationEnvelope.Create("old", "Batch", "batch.done") with { Priority = MessagePriority.Low, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-10), }; - Assert.That(urgentEnvelope.Priority, Is.EqualTo(MessagePriority.Critical)); - Assert.That(urgentEnvelope.IsExpired, Is.False); - - Assert.That(expiredEnvelope.Priority, Is.EqualTo(MessagePriority.Low)); - Assert.That(expiredEnvelope.IsExpired, Is.True); - - // Best practice: check expiration before processing - var toProcess = new[] { urgentEnvelope, expiredEnvelope } + var toProcess = new[] { urgent, expired } .Where(e => !e.IsExpired) .OrderByDescending(e => e.Priority) .ToList(); + foreach (var env in toProcess) + await output.PublishAsync(env, "processed"); + Assert.That(toProcess, Has.Count.EqualTo(1)); - Assert.That(toProcess[0].MessageType, Is.EqualTo("alert.fired")); + output.AssertReceivedOnTopic("processed", 1); } - // ── Challenge 3: Cross-Cutting Concerns Flow ──────────────────────────── - [Test] - public void Challenge3_CrossCuttingFlow_SanitizeTenantValidate() + public async Task Challenge3_CrossCuttingFlow_SanitizeTenantPublish() { - // Step 1: Create envelope with potentially unsafe data + await using var output = new MockEndpoint("cross"); var envelope = IntegrationEnvelope.Create( - "SELECT * FROM users; --", "ExternalService", "data.imported") with + "SELECT * FROM users; --", "External", "data.imported") with { - Metadata = new Dictionary - { - ["tenantId"] = "acme-inc", - }, - Priority = MessagePriority.High, + Metadata = new Dictionary { ["tenantId"] = "acme-inc" }, ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), }; - // Step 2: Check not expired Assert.That(envelope.IsExpired, Is.False); - // Step 3: Sanitize var sanitizer = new InputSanitizer(); var clean = sanitizer.Sanitize(envelope.Payload); - // Step 4: Resolve and verify tenant var resolver = new TenantResolver(); var tenant = resolver.Resolve(envelope.Metadata); Assert.That(tenant.IsResolved, Is.True); - Assert.That(tenant.TenantId, Is.EqualTo("acme-inc")); - // Step 5: Verify isolation var guard = new TenantIsolationGuard(resolver); Assert.DoesNotThrow(() => guard.Enforce(envelope, "acme-inc")); + Assert.Throws(() => guard.Enforce(envelope, "other-tenant")); - // Step 6: Cross-tenant access should throw - Assert.Throws( - () => guard.Enforce(envelope, "other-tenant")); + var result = IntegrationEnvelope.Create(clean, "pipeline", "data.processed"); + await output.PublishAsync(result, $"tenant.{tenant.TenantId}"); + output.AssertReceivedOnTopic("tenant.acme-inc", 1); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial50/Lab.cs b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial50/Lab.cs index 8a34d17..808408b 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial50/Lab.cs +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/Tutorial50/Lab.cs @@ -1,65 +1,71 @@ // ============================================================================ // Tutorial 50 – Best Practices (Lab) // ============================================================================ -// This lab exercises cross-cutting EIP best practices: envelope expiration, -// sanitization idempotency, tenant resolution, metadata, schema versioning, -// and message headers. +// EIP Pattern: Cross-cutting best practices integration. +// E2E: Combine envelope expiration, sanitization, tenancy, and metadata +// with MockEndpoint to demonstrate production-ready message flows. // ============================================================================ using EnterpriseIntegrationPlatform.Contracts; using EnterpriseIntegrationPlatform.MultiTenancy; using EnterpriseIntegrationPlatform.Security; using NUnit.Framework; +using TutorialLabs.Infrastructure; namespace TutorialLabs.Tutorial50; [TestFixture] public sealed class Lab { - // ── Envelope IsExpired For Past ExpiresAt ──────────────────────────────── + private MockEndpoint _output = null!; + + [SetUp] + public void SetUp() => _output = new MockEndpoint("bp-out"); + + [TearDown] + public async Task TearDown() => await _output.DisposeAsync(); [Test] - public void IntegrationEnvelope_IsExpired_TrueForPastDate() + public async Task ExpiredMessage_NotPublished() { - var envelope = IntegrationEnvelope.Create( - "data", "Service", "event") with + var envelope = IntegrationEnvelope.Create("data", "Svc", "event") with { ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5), }; + if (!envelope.IsExpired) + await _output.PublishAsync(envelope, "active-messages"); + Assert.That(envelope.IsExpired, Is.True); + _output.AssertNoneReceived(); } - // ── Envelope IsExpired For Future ExpiresAt ───────────────────────────── - [Test] - public void IntegrationEnvelope_IsExpired_FalseForFutureDate() + public async Task ValidMessage_Published() { - var envelope = IntegrationEnvelope.Create( - "data", "Service", "event") with + var envelope = IntegrationEnvelope.Create("data", "Svc", "event") with { ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), }; + if (!envelope.IsExpired) + await _output.PublishAsync(envelope, "active-messages"); + Assert.That(envelope.IsExpired, Is.False); + _output.AssertReceivedOnTopic("active-messages", 1); } - // ── InputSanitizer Idempotent ─────────────────────────────────────────── - [Test] - public void InputSanitizer_Sanitize_IsIdempotent() + public void InputSanitizer_Idempotent() { var sanitizer = new InputSanitizer(); var input = "Hello World"; - var first = sanitizer.Sanitize(input); var second = sanitizer.Sanitize(first); Assert.That(second, Is.EqualTo(first)); } - // ── TenantResolver Handles Null TenantId ──────────────────────────────── - [Test] public void TenantResolver_NullTenantId_ReturnsAnonymous() { @@ -69,23 +75,16 @@ public void TenantResolver_NullTenantId_ReturnsAnonymous() Assert.That(context.TenantId, Is.EqualTo(TenantContext.Anonymous.TenantId)); } - // ── MessageHeaders.ReplayId Exists ────────────────────────────────────── - [Test] public void MessageHeaders_ReplayId_ConstantExists() { - var replayId = MessageHeaders.ReplayId; - - Assert.That(replayId, Is.Not.Null.And.Not.Empty); + Assert.That(MessageHeaders.ReplayId, Is.Not.Null.And.Not.Empty); } - // ── Metadata Round-Trip ───────────────────────────────────────────────── - [Test] - public void IntegrationEnvelope_Metadata_RoundTrip() + public async Task Metadata_RoundTrip_PublishedWithEnvelope() { - var envelope = IntegrationEnvelope.Create( - "data", "Service", "event") with + var envelope = IntegrationEnvelope.Create("data", "Svc", "event") with { Metadata = new Dictionary { @@ -95,19 +94,18 @@ public void IntegrationEnvelope_Metadata_RoundTrip() }, }; - Assert.That(envelope.Metadata["tenantId"], Is.EqualTo("tenant-a")); - Assert.That(envelope.Metadata["region"], Is.EqualTo("us-east-1")); - Assert.That(envelope.Metadata, Has.Count.EqualTo(3)); - } + await _output.PublishAsync(envelope, "metadata-test"); - // ── SchemaVersion Defaults To 1.0 ─────────────────────────────────────── + _output.AssertReceivedOnTopic("metadata-test", 1); + var received = _output.GetReceived(); + Assert.That(received.Metadata["tenantId"], Is.EqualTo("tenant-a")); + Assert.That(received.Metadata, Has.Count.EqualTo(3)); + } [Test] - public void IntegrationEnvelope_SchemaVersion_DefaultsTo1() + public void SchemaVersion_DefaultsTo1() { - var envelope = IntegrationEnvelope.Create( - "data", "Service", "event"); - + var envelope = IntegrationEnvelope.Create("data", "Svc", "event"); Assert.That(envelope.SchemaVersion, Is.EqualTo("1.0")); } } diff --git a/EnterpriseIntegrationPlatform/tests/TutorialLabs/TutorialLabs.csproj b/EnterpriseIntegrationPlatform/tests/TutorialLabs/TutorialLabs.csproj index 901441f..f6dc6f7 100644 --- a/EnterpriseIntegrationPlatform/tests/TutorialLabs/TutorialLabs.csproj +++ b/EnterpriseIntegrationPlatform/tests/TutorialLabs/TutorialLabs.csproj @@ -8,8 +8,8 @@ - + @@ -55,6 +55,7 @@ +