diff --git a/README.md b/README.md index c765008..a47e7cd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,226 @@ -# KS Framework -A mini framework containing Domain Driven Design, Clean Architecture and other techniques. \ No newline at end of file +# KSFramework 🧩 + +**KSFramework** is a clean, extensible, and testable foundation for building scalable .NET Core applications. It provides a custom implementation of the MediatR pattern, along with well-known enterprise patterns such as Repository, Unit of Work, and Specification. It is designed to be modular, testable, and ready for production use. + +--- + +## ✨ Features + +- ✅ Custom Mediator pattern implementation (Send / Publish / Stream) +- ✅ Pipeline behaviors (Validation, Logging, Exception handling, Pre/Post-processors) +- ✅ FluentValidation integration +- ✅ Notification pipeline behaviors +- ✅ Repository Pattern +- ✅ Unit of Work Pattern +- ✅ Specification Pattern +- ✅ Scrutor-based automatic registration +- ✅ File-scoped namespaces and XML documentation for every component +- ✅ Full unit test coverage using xUnit and Moq + +--- + +## 📦 Installation + +Add the package reference (once published): + +```bash +dotnet add package KSFramework.Messaging +dotnet add package KSFramework.Data +``` + +Or reference the source projects directly in your solution. +⸻ + +🧠 Project Structure +``` +src/ +KSFramework.Messaging/ → Custom mediator, behaviors, contracts +KSFramework.Data/ → Repository, UnitOfWork, Specification +KSFramework.SharedKernel/ → Domain base types, entities, value objects + +tests/ +KSFramework.UnitTests/ → xUnit unit tests + +samples/ +MediatorSampleApp/ → Console app to demonstrate usage +``` + +### 🚀 Mediator Usage + +### 1. Define a request +```csharp +public class MultiplyByTwoRequest : IRequest +{ + public int Input { get; } + public MultiplyByTwoRequest(int input) => Input = input; +} +``` + +### 2. Create a handler +```csharp +public class MultiplyByTwoHandler : IRequestHandler +{ + public Task Handle(MultiplyByTwoRequest request, CancellationToken cancellationToken) + => Task.FromResult(request.Input * 2); +} +``` + +### 3. Send the request +```csharp +var result = await mediator.Send(new MultiplyByTwoRequest(5)); +Console.WriteLine(result); // Output: 10 +``` + +### 📤 Notifications + +### Define a notification and handler +```csharp +public class UserRegisteredNotification : INotification +{ + public string Username { get; } + public UserRegisteredNotification(string username) => Username = username; +} + +public class SendWelcomeEmailHandler : INotificationHandler +{ + public Task Handle(UserRegisteredNotification notification, CancellationToken cancellationToken) + { + Console.WriteLine($"Welcome email sent to {notification.Username}"); + return Task.CompletedTask; + } +} +``` + +### Publish the notification +```csharp +await mediator.Publish(new UserRegisteredNotification("john")); +``` + +### 🔁 Streaming + +### Define a stream request and handler +```csharp +public class CounterStreamRequest : IStreamRequest +{ + public int Count { get; init; } +} + +public class CounterStreamHandler : IStreamRequestHandler +{ + public async IAsyncEnumerable Handle(CounterStreamRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) + { + for (int i = 1; i <= request.Count; i++) + { + yield return i; + await Task.Delay(10, cancellationToken); + } + } +} +``` + +### Consume the stream +```csharp +await foreach (var number in mediator.CreateStream(new CounterStreamRequest { Count = 3 })) +{ + Console.WriteLine($"Streamed: {number}"); +} +``` + +## 🧩 Built-in Pipeline Behaviors + +### All behaviors are automatically registered via AddKSMediator(). +``` +| Behavior | Description | +|---------------------------|-------------------------------------------------| +| RequestValidationBehavior | Validates incoming requests using FluentValidation | +| ExceptionHandlingBehavior | Logs and rethrows exceptions from handlers | +| RequestProcessorBehavior | Executes pre- and post-processors | +| LoggingBehavior | Logs request and response types | +| NotificationLoggingBehavior | Logs notification handling stages | +``` + +## 🧰 Configuration + +## Register services in Program.cs +```csharp +services.AddLogging(); +services.AddValidatorsFromAssembly(typeof(Program).Assembly); +services.AddKSMediator(Assembly.GetExecutingAssembly()); +``` + +## 🧪 Unit Testing + +### Example behavior test +```csharp +[Fact] +public async Task Handle_WithInvalidRequest_ThrowsValidationException() +{ + var validator = new Mock>(); + validator.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(new[] { new ValidationFailure("Name", "Required") })); + + var logger = new Mock>>(); + + var behavior = new RequestValidationBehavior( + new[] { validator.Object }, logger.Object); + + await Assert.ThrowsAsync(() => + behavior.Handle(new TestRequest(), CancellationToken.None, () => Task.FromResult(new TestResponse()))); +} +``` + +## 📦 Repository & Unit of Work +```csharp +public class ProductService +{ + private readonly IRepository _repository; + private readonly IUnitOfWork _unitOfWork; + + public ProductService(IRepository repository, IUnitOfWork unitOfWork) + { + _repository = repository; + _unitOfWork = unitOfWork; + } + + public async Task AddAsync(Product product) + { + await _repository.AddAsync(product); + await _unitOfWork.CommitAsync(); + } +} +``` + +## 🔍 Specification Pattern +```csharp +public class ActiveProductSpec : Specification +{ + public ActiveProductSpec() => Criteria = p => p.IsActive; +} +``` + +```csharp +var products = await _repository.ListAsync(new ActiveProductSpec()); +``` + +## ✅ Test Coverage Summary + +``` +| Component | Test Status | +|------------------------|-------------| +| Request handling | ✅ | +| Notification publishing| ✅ | +| Streaming requests | ✅ | +| Pipeline behaviors | ✅ | +| Validation | ✅ | +| Exception handling | ✅ | +| Logging | ✅ | +| Repository/UoW/Spec | ✅ | +``` + +## 📚 License + +### This project is licensed under the MIT License. + +## 👥 Contributing + +### Feel free to fork and submit PRs or issues. Contributions are always welcome! \ No newline at end of file diff --git a/Samples/MediatorSampleApp/Program.cs b/Samples/MediatorSampleApp/Program.cs index 3857774..0b1f1cb 100644 --- a/Samples/MediatorSampleApp/Program.cs +++ b/Samples/MediatorSampleApp/Program.cs @@ -1,5 +1,5 @@ -using FluentValidation; -using KSFramework.Messaging; +using System.Reflection; +using FluentValidation; using KSFramework.Messaging.Abstraction; using KSFramework.Messaging.Behaviors; using KSFramework.Messaging.Configuration; @@ -8,18 +8,12 @@ var services = new ServiceCollection(); -// فقط یکبار mediator رو ثبت کن -services.AddScoped(); -services.AddScoped(sp => sp.GetRequiredService()); - services.AddLogging(); +services.AddValidatorsFromAssembly(typeof(Program).Assembly); + -services.AddValidatorsFromAssembly(typeof(Program).Assembly); -services.AddMessaging(typeof(MultiplyByTwoHandler).Assembly); +services.AddKSMediator(Assembly.GetExecutingAssembly()); -// اگر رفتارهای pipeline داری، ثبت کن -services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestProcessorBehavior<,>)); -services.AddMessaging(typeof(ExceptionHandlingBehavior<,>).Assembly, typeof(MultiplyByTwoRequest).Assembly); var provider = services.BuildServiceProvider(); diff --git a/src/KSFramework/KSFramework.csproj b/src/KSFramework/KSFramework.csproj index d3bf821..1bd184e 100644 --- a/src/KSFramework/KSFramework.csproj +++ b/src/KSFramework/KSFramework.csproj @@ -28,8 +28,6 @@ - - diff --git a/src/KSFramework/Messaging/Abstraction/IStreamPipelineBehavior.cs b/src/KSFramework/Messaging/Abstraction/IStreamPipelineBehavior.cs new file mode 100644 index 0000000..33f50e3 --- /dev/null +++ b/src/KSFramework/Messaging/Abstraction/IStreamPipelineBehavior.cs @@ -0,0 +1,24 @@ +namespace KSFramework.Messaging.Abstraction; + +/// +/// Defines a pipeline behavior for stream requests that can run code before and after the handler is invoked. +/// +/// Type of the stream request. +/// Type of the stream response. +public interface IStreamPipelineBehavior where TRequest : IStreamRequest +{ + /// + /// Executes behavior around the stream handler invocation. + /// + /// The stream request instance. + /// Cancellation token. + /// The next delegate in the pipeline, i.e., the stream handler or next behavior. + /// A stream of response elements. + IAsyncEnumerable Handle(TRequest request, CancellationToken cancellationToken, StreamHandlerDelegate next); +} + +/// +/// Delegate representing the next step in the stream pipeline. +/// +/// The response type. +public delegate IAsyncEnumerable StreamHandlerDelegate(); \ No newline at end of file diff --git a/src/KSFramework/Messaging/Behaviors/RequestProcessorBehavior.cs b/src/KSFramework/Messaging/Behaviors/RequestProcessorBehavior.cs index 3979892..fa06a7b 100644 --- a/src/KSFramework/Messaging/Behaviors/RequestProcessorBehavior.cs +++ b/src/KSFramework/Messaging/Behaviors/RequestProcessorBehavior.cs @@ -1,28 +1,43 @@ using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.Logging; namespace KSFramework.Messaging.Behaviors; /// /// Executes pre-processors and post-processors for the request. /// -public class RequestProcessorBehavior( - IEnumerable> preProcessors, - IEnumerable> postProcessors) - : IPipelineBehavior +public class RequestProcessorBehavior : IPipelineBehavior where TRequest : IRequest { - public async Task Handle( - TRequest request, - CancellationToken cancellationToken, - RequestHandlerDelegate next) + private readonly IEnumerable> _preProcessors; + private readonly IEnumerable> _postProcessors; + private readonly ILogger> _logger; + + public RequestProcessorBehavior( + IEnumerable> preProcessors, + IEnumerable> postProcessors, + ILogger> logger) + { + _preProcessors = preProcessors; + _postProcessors = postProcessors; + _logger = logger; + } + + public async Task Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next) { - foreach (var preProcessor in preProcessors) - await preProcessor.Process(request, cancellationToken); + foreach (var processor in _preProcessors) + { + _logger.LogInformation("Running preprocessor {Processor} for {RequestType}", processor.GetType().Name, typeof(TRequest).Name); + await processor.Process(request, cancellationToken); + } var response = await next(); - foreach (var postProcessor in postProcessors) - await postProcessor.Process(request, response, cancellationToken); + foreach (var processor in _postProcessors) + { + _logger.LogInformation("Running postprocessor {Processor} for {RequestType}", processor.GetType().Name, typeof(TRequest).Name); + await processor.Process(request, response, cancellationToken); + } return response; } diff --git a/src/KSFramework/Messaging/Behaviors/StreamLoggingBehavior.cs b/src/KSFramework/Messaging/Behaviors/StreamLoggingBehavior.cs new file mode 100644 index 0000000..93212e5 --- /dev/null +++ b/src/KSFramework/Messaging/Behaviors/StreamLoggingBehavior.cs @@ -0,0 +1,41 @@ +using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.Logging; + +namespace KSFramework.Messaging.Behaviors; + +/// +/// A stream pipeline behavior that logs before and after streaming a request. +/// +/// The stream request type. +/// The streamed response type. +public class StreamLoggingBehavior : IStreamPipelineBehavior + where TRequest : IStreamRequest +{ + private readonly ILogger> _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public StreamLoggingBehavior(ILogger> logger) + { + _logger = logger; + } + + /// + public async IAsyncEnumerable Handle( + TRequest request, + CancellationToken cancellationToken, + StreamHandlerDelegate next) + { + _logger.LogInformation("Start streaming: {RequestType}", typeof(TRequest).Name); + + await foreach (var item in next().WithCancellation(cancellationToken)) + { + _logger.LogDebug("Streaming item: {Item}", item); + yield return item; + } + + _logger.LogInformation("End streaming: {RequestType}", typeof(TRequest).Name); + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Configuration/ServiceCollectionExtensions.cs b/src/KSFramework/Messaging/Configuration/ServiceCollectionExtensions.cs index 5af6b4a..f7bf4ed 100644 --- a/src/KSFramework/Messaging/Configuration/ServiceCollectionExtensions.cs +++ b/src/KSFramework/Messaging/Configuration/ServiceCollectionExtensions.cs @@ -1,52 +1,47 @@ using KSFramework.Messaging.Abstraction; -using KSFramework.Messaging.Behaviors; using Microsoft.Extensions.DependencyInjection; using System.Reflection; -namespace KSFramework.Messaging.Configuration; - -/// -/// Extension methods to register messaging components into the service collection. -/// -public static class ServiceCollectionExtensions +namespace KSFramework.Messaging.Configuration { /// - /// Adds messaging components (Mediator, Handlers, Behaviors, Processors) to the service collection. + /// Extension methods to register messaging components into the service collection. /// - /// The service collection. - /// Assemblies to scan for handlers. - /// The updated service collection. - public static IServiceCollection AddMessaging(this IServiceCollection services, params Assembly[] assemblies) + public static class ServiceCollectionExtensions { - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); + /// + /// Registers mediator handlers and behaviors from specified assemblies with automatic discovery. + /// + public static IServiceCollection AddKSMediator(this IServiceCollection services, params Assembly[] assemblies) + { + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); - services.Scan(scan => scan - .FromAssemblies(assemblies) - .AddClasses(classes => classes.AssignableTo(typeof(IRequestHandler<,>))) - .AsImplementedInterfaces() - .WithScopedLifetime() - .AddClasses(classes => classes.AssignableTo(typeof(INotificationHandler<>))) - .AsImplementedInterfaces() - .WithScopedLifetime() - .AddClasses(classes => classes.AssignableTo(typeof(IStreamRequestHandler<,>))) + services.Scan(scan => scan + .FromAssemblies(assemblies) + + // Request Handlers + .AddClasses(c => c.AssignableTo(typeof(IRequestHandler<,>))) .AsImplementedInterfaces() .WithScopedLifetime() - .AddClasses(classes => classes.AssignableTo(typeof(IPipelineBehavior<,>))) + + // Notification Handlers + .AddClasses(c => c.AssignableTo(typeof(INotificationHandler<>))) .AsImplementedInterfaces() .WithScopedLifetime() - .AddClasses(classes => classes.AssignableTo(typeof(IRequestPreProcessor<>))) + + // Pipeline Behaviors + .AddClasses(c => c.AssignableTo(typeof(IPipelineBehavior<,>))) .AsImplementedInterfaces() .WithScopedLifetime() - .AddClasses(classes => classes.AssignableTo(typeof(IRequestPostProcessor<,>))) + + // Notification Behaviors + .AddClasses(c => c.AssignableTo(typeof(INotificationBehavior<>))) .AsImplementedInterfaces() .WithScopedLifetime() - ); - - services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>)); - services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ExceptionHandlingBehavior<,>)); - services.AddScoped(typeof(IPipelineBehavior<,>), typeof(RequestProcessorBehavior<,>)); + ); - return services; + return services; + } } } \ No newline at end of file diff --git a/src/KSFramework/Messaging/Extensions/RegisterMediatorServices.cs b/src/KSFramework/Messaging/Extensions/RegisterMediatorServices.cs new file mode 100644 index 0000000..b2ae7ac --- /dev/null +++ b/src/KSFramework/Messaging/Extensions/RegisterMediatorServices.cs @@ -0,0 +1,62 @@ +using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; +using KSFramework.Messaging.Behaviors; + +namespace KSFramework.Messaging.Extensions; + +/// +/// Extension methods for registering mediator handlers and behaviors. +/// +public static class RegisterMediatorServices +{ + /// + /// Registers mediator handlers and behaviors from the specified assemblies. + /// + /// The service collection. + /// The assemblies to scan. + /// The service collection. + public static IServiceCollection AddKSMediator(this IServiceCollection services, params Assembly[] assemblies) + { + services.AddSingleton(); + + services.Scan(scan => scan + .FromAssemblies(assemblies) + + // Register request handlers + .AddClasses(c => c.AssignableTo(typeof(IRequestHandler<,>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + + // Register notification handlers + .AddClasses(c => c.AssignableTo(typeof(INotificationHandler<>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + + // Register stream request handlers + .AddClasses(c => c.AssignableTo(typeof(IStreamRequestHandler<,>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + + // Register pipeline behaviors + .AddClasses(c => c.AssignableTo(typeof(IPipelineBehavior<,>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + + // Register notification behaviors + .AddClasses(c => c.AssignableTo(typeof(INotificationBehavior<>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + + // Register stream pipeline behaviors ✅ + .AddClasses(c => c.AssignableTo(typeof(IStreamPipelineBehavior<,>))) + .AsImplementedInterfaces() + .WithScopedLifetime() + ); + + // If you have default behaviors (e.g., logging), register them here + services.AddScoped(typeof(IStreamPipelineBehavior<,>), typeof(StreamLoggingBehavior<,>)); + + return services; + } +} \ No newline at end of file diff --git a/src/KSFramework/Messaging/Mediator.cs b/src/KSFramework/Messaging/Mediator.cs index 29ea8d7..cfc10b9 100644 --- a/src/KSFramework/Messaging/Mediator.cs +++ b/src/KSFramework/Messaging/Mediator.cs @@ -203,18 +203,51 @@ public async Task Publish(TNotification notification, Cancellatio await handlerDelegate(); } - public IAsyncEnumerable CreateStream(IStreamRequest request, CancellationToken cancellationToken = default) + public IAsyncEnumerable CreateStream( + IStreamRequest request, + CancellationToken cancellationToken = default) { - var handlerType = typeof(IStreamRequestHandler<,>).MakeGenericType(request.GetType(), typeof(TResponse)); + if (request == null) + throw new ArgumentNullException(nameof(request)); + + var requestType = request.GetType(); + var responseType = typeof(TResponse); + + // Resolve the handler + var handlerType = typeof(IStreamRequestHandler<,>).MakeGenericType(requestType, responseType); var handler = _serviceProvider.GetService(handlerType); if (handler == null) - throw new InvalidOperationException($"No stream handler registered for {request.GetType().Name}"); + throw new InvalidOperationException($"No stream handler registered for {requestType.Name}"); + + var handleMethod = handlerType.GetMethod("Handle"); + if (handleMethod == null) + throw new InvalidOperationException("Handler does not implement expected Handle method."); + + // Build the final handler delegate + StreamHandlerDelegate handlerDelegate = () => + { + var result = handleMethod.Invoke(handler, new object[] { request, cancellationToken }); + return (IAsyncEnumerable)result!; + }; - var method = handlerType.GetMethod("Handle"); + // Resolve pipeline behaviors + var behaviorType = typeof(IStreamPipelineBehavior<,>).MakeGenericType(requestType, responseType); + var behaviors = _serviceProvider.GetServices(behaviorType).Reverse().ToList(); - var result = (IAsyncEnumerable)method.Invoke(handler, new object[] { request, cancellationToken }); + // Compose behaviors + foreach (var behavior in behaviors) + { + var current = behavior; + var next = handlerDelegate; + handlerDelegate = () => + { + var method = behavior.GetType().GetMethod("Handle"); + return (IAsyncEnumerable)method.Invoke(current, new object[] { request, cancellationToken, next })!; + }; + } - return result; + // Execute pipeline + return handlerDelegate(); } } \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Behaviors/ExceptionHandling/ExceptionHandlingBehaviorTests.cs b/tests/KSFramework/KSFramework.UnitTests/Behaviors/ExceptionHandling/ExceptionHandlingBehaviorTests.cs new file mode 100644 index 0000000..8985cf5 --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Behaviors/ExceptionHandling/ExceptionHandlingBehaviorTests.cs @@ -0,0 +1,62 @@ +using KSFramework.Messaging.Abstraction; +using KSFramework.Messaging.Behaviors; +using Microsoft.Extensions.Logging; +using Moq; + +namespace KSFramework.UnitTests.Behaviors.ExceptionHandling; + +/// +/// Unit tests for . +/// +public class ExceptionHandlingBehaviorTests +{ + public class TestRequest : IRequest { } + + [Fact] + public async Task Handle_WhenNoException_ReturnsResponse() + { + // Arrange + var loggerMock = new Mock>>(); + + var behavior = new ExceptionHandlingBehavior(loggerMock.Object); + + RequestHandlerDelegate next = () => Task.FromResult("OK"); + + // Act + var result = await behavior.Handle(new TestRequest(), CancellationToken.None, next); + + // Assert + Assert.Equal("OK", result); + loggerMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Handle_WhenExceptionThrown_LogsAndRethrows() + { + // Arrange + var loggerMock = new Mock>>(); + + var behavior = new ExceptionHandlingBehavior(loggerMock.Object); + + var exception = new InvalidOperationException("Something went wrong"); + + RequestHandlerDelegate next = () => throw exception; + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + behavior.Handle(new TestRequest(), CancellationToken.None, next)); + + Assert.Equal("Something went wrong", ex.Message); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Exception caught")), + exception, + It.IsAny>() + ), + Times.Once + ); + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Behaviors/RequestProcessing/RequestProcessorBehaviorTests.cs b/tests/KSFramework/KSFramework.UnitTests/Behaviors/RequestProcessing/RequestProcessorBehaviorTests.cs new file mode 100644 index 0000000..7b38757 --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Behaviors/RequestProcessing/RequestProcessorBehaviorTests.cs @@ -0,0 +1,88 @@ +using KSFramework.Messaging.Abstraction; +using KSFramework.Messaging.Behaviors; +using Microsoft.Extensions.Logging; +using Moq; + +namespace KSFramework.UnitTests.Behaviors.RequestProcessing; + +/// +/// Unit tests for . +/// +public class RequestProcessorBehaviorTests +{ + public class TestRequest : IRequest { } + + [Fact] + public async Task Handle_WhenNoProcessors_ShouldCallHandlerDirectly() + { + // Arrange + var logger = new Mock>>(); + var behavior = new RequestProcessorBehavior( + Enumerable.Empty>(), + Enumerable.Empty>(), + logger.Object); + + var wasCalled = false; + RequestHandlerDelegate next = () => + { + wasCalled = true; + return Task.FromResult("Hello"); + }; + + // Act + var result = await behavior.Handle(new TestRequest(), CancellationToken.None, next); + + // Assert + Assert.Equal("Hello", result); + Assert.True(wasCalled); + } + + [Fact] + public async Task Handle_WithPreProcessor_ShouldInvokeBeforeHandler() + { + // Arrange + var preProcessorMock = new Mock>(); + var postProcessors = Enumerable.Empty>(); + + var logger = new Mock>>(); + + var behavior = new RequestProcessorBehavior( + new[] { preProcessorMock.Object }, + postProcessors, + logger.Object); + + RequestHandlerDelegate next = () => Task.FromResult("Handled"); + + // Act + var result = await behavior.Handle(new TestRequest(), CancellationToken.None, next); + + // Assert + Assert.Equal("Handled", result); + preProcessorMock.Verify(x => x.Process(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithPostProcessor_ShouldInvokeAfterHandler() + { + // Arrange + var postProcessorMock = new Mock>(); + var preProcessors = Enumerable.Empty>(); + + var logger = new Mock>>(); + + var behavior = new RequestProcessorBehavior( + preProcessors, + new[] { postProcessorMock.Object }, + logger.Object); + + RequestHandlerDelegate next = () => Task.FromResult("Done"); + + // Act + var result = await behavior.Handle(new TestRequest(), CancellationToken.None, next); + + // Assert + Assert.Equal("Done", result); + postProcessorMock.Verify(x => + x.Process(It.IsAny(), "Done", It.IsAny()), Times.Once); + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Behaviors/RequestValidation/RequestValidationBehaviorTests.cs b/tests/KSFramework/KSFramework.UnitTests/Behaviors/RequestValidation/RequestValidationBehaviorTests.cs new file mode 100644 index 0000000..747785d --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Behaviors/RequestValidation/RequestValidationBehaviorTests.cs @@ -0,0 +1,105 @@ +using FluentValidation; +using FluentValidation.Results; +using KSFramework.Messaging.Abstraction; +using KSFramework.Messaging.Behaviors; +using Microsoft.Extensions.Logging; +using Moq; + +namespace KSFramework.UnitTests.Behaviors.RequestValidation; + +/// +/// Unit tests for . +/// +public class RequestValidationBehaviorTests +{ + public class TestRequest : IRequest { } + + public class TestResponse + { + public string Message { get; set; } = "OK"; + } + + [Fact] + public async Task Handle_WithNoValidators_CallsNext() + { + // Arrange + var called = false; + Task Next() { called = true; return Task.FromResult(new TestResponse()); } + + var loggerMock = new Mock>>(); + + var behavior = new RequestValidationBehavior( + Array.Empty>(), + loggerMock.Object + ); + + // Act + var result = await behavior.Handle(new TestRequest(), CancellationToken.None, Next); + + // Assert + Assert.True(called); + Assert.Equal("OK", result.Message); + } + + [Fact] + public async Task Handle_WithInvalidRequest_ThrowsValidationException() + { + // Arrange + var failures = new List { new("Name", "Name is required") }; + var validationResult = new ValidationResult(failures); + + var validatorMock = new Mock>(); + validatorMock.Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(validationResult); + + var loggerMock = new Mock>>(); + + var behavior = new RequestValidationBehavior( + new[] { validatorMock.Object }, + loggerMock.Object + ); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + behavior.Handle(new TestRequest(), CancellationToken.None, () => Task.FromResult(new TestResponse())) + ); + + Assert.Contains("Name is required", ex.Message); + } + + [Fact] + public async Task Handle_WithInvalidRequest_LogsWarning() + { + // Arrange + var failures = new List { new("Field", "must not be empty") }; + var validationResult = new ValidationResult(failures); + + var validatorMock = new Mock>(); + validatorMock.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(validationResult); + + var loggerMock = new Mock>>(); + + var behavior = new RequestValidationBehavior( + new[] { validatorMock.Object }, + loggerMock.Object + ); + + // Act + await Assert.ThrowsAsync(() => + behavior.Handle(new TestRequest(), CancellationToken.None, () => Task.FromResult(new TestResponse())) + ); + + // Assert log + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("must not be empty")), + null, + It.IsAny>() + ), + Times.Once + ); + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Configuration/AddKSMediatorNotificationTests.cs b/tests/KSFramework/KSFramework.UnitTests/Configuration/AddKSMediatorNotificationTests.cs new file mode 100644 index 0000000..c538386 --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Configuration/AddKSMediatorNotificationTests.cs @@ -0,0 +1,76 @@ +using KSFramework.Messaging.Abstraction; +using KSFramework.Messaging.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace KSFramework.UnitTests.Configuration; + +/// +/// Tests for verifying notification handlers and behaviors registration through AddKSMediator. +/// +public class AddKSMediatorNotificationTests +{ + /// + /// A test notification. + /// + public class TestNotification : INotification + { + public string Message { get; set; } = ""; + } + + /// + /// A test handler for . + /// + public class TestNotificationHandler : INotificationHandler + { + public bool Handled { get; private set; } = false; + + public Task Handle(TestNotification notification, CancellationToken cancellationToken) + { + Handled = true; + return Task.CompletedTask; + } + } + + /// + /// A test notification behavior that wraps the handler. + /// + public class TestNotificationBehavior : INotificationBehavior + { + public bool WasCalled { get; private set; } = false; + + public Task Handle(TestNotification notification, CancellationToken cancellationToken, NotificationHandlerDelegate next) + { + WasCalled = true; + return next(); + } + } + + [Fact] + public async Task AddKSMediator_Should_Register_NotificationHandler_And_Behavior() + { + // Arrange + var services = new ServiceCollection(); + + services.AddKSMediator(typeof(TestNotification).Assembly); + services.AddSingleton, TestNotificationHandler>(); + services.AddSingleton, TestNotificationBehavior>(); + + var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + var notification = new TestNotification { Message = "Hello" }; + + // Act + await mediator.Publish(notification); + + // Assert handler + var handler = provider.GetRequiredService>() as TestNotificationHandler; + Assert.NotNull(handler); + Assert.True(handler.Handled); + + // Assert behavior + var behavior = provider.GetRequiredService>() as TestNotificationBehavior; + Assert.NotNull(behavior); + Assert.True(behavior.WasCalled); + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Configuration/AddMessagingTests.cs b/tests/KSFramework/KSFramework.UnitTests/Configuration/AddKSMediatorTests.cs similarity index 69% rename from tests/KSFramework/KSFramework.UnitTests/Configuration/AddMessagingTests.cs rename to tests/KSFramework/KSFramework.UnitTests/Configuration/AddKSMediatorTests.cs index f4372c7..7b48ba9 100644 --- a/tests/KSFramework/KSFramework.UnitTests/Configuration/AddMessagingTests.cs +++ b/tests/KSFramework/KSFramework.UnitTests/Configuration/AddKSMediatorTests.cs @@ -5,22 +5,24 @@ namespace KSFramework.UnitTests.Configuration; -public class AddMessagingTests +public class AddKSMediatorTests { [Fact] - public void Should_Register_All_Handlers_And_Behaviors_From_Assembly() + public void AddKSMediator_RegistersHandlersAndBehaviorsFromAssembly() { // Arrange var services = new ServiceCollection(); - // اینجا مهمه: باید اسمبلی‌ای رو بده که MultiplyByTwoHandler توشه - services.AddMessaging(typeof(MultiplyByTwoRequest).Assembly); + services.AddKSMediator(typeof(MultiplyByTwoRequest).Assembly); var provider = services.BuildServiceProvider(); + + // Act var handler = provider.GetService>(); // Assert Assert.NotNull(handler); + var result = handler.Handle(new MultiplyByTwoRequest(5), CancellationToken.None).Result; Assert.Equal(10, result); } diff --git a/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/MediatorPublishTests.cs b/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/MediatorPublishTests.cs new file mode 100644 index 0000000..cf20ce2 --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/MediatorPublishTests.cs @@ -0,0 +1,94 @@ +using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.DependencyInjection; + +namespace KSFramework.UnitTests.Mediator.Publish; + +/// +/// Unit tests for Mediator's Publish method. +/// +public class MediatorPublishTests +{ + /// + /// A simple test notification. + /// + public class TestNotification : INotification + { + public string Message { get; set; } = ""; + } + + /// + /// A test handler for . + /// + public class TestNotificationHandler : INotificationHandler + { + public bool Handled { get; private set; } = false; + + /// + /// Handles the notification. + /// + public Task Handle(TestNotification notification, CancellationToken cancellationToken) + { + Handled = true; + return Task.CompletedTask; + } + } + + /// + /// A test behavior for that sets a flag when invoked. + /// + public class TestNotificationBehavior : INotificationBehavior + { + public bool WasCalled { get; private set; } = false; + + public Task Handle(TestNotification notification, CancellationToken cancellationToken, NotificationHandlerDelegate next) + { + WasCalled = true; + return next(); + } + } + + [Fact] + public async Task Publish_InvokesNotificationHandlers() + { + var services = new ServiceCollection(); + + var handler = new TestNotificationHandler(); + + services.AddSingleton>(handler); + services.AddLogging(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + var notification = new TestNotification { Message = "Hello" }; + + await mediator.Publish(notification); + + Assert.True(handler.Handled); + } + + [Fact] + public async Task Publish_ExecutesNotificationBehavior() + { + var services = new ServiceCollection(); + + var handler = new TestNotificationHandler(); + var behavior = new TestNotificationBehavior(); + + services.AddSingleton>(handler); + services.AddSingleton>(behavior); + services.AddLogging(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + var notification = new TestNotification { Message = "With Behavior" }; + + await mediator.Publish(notification); + + Assert.True(handler.Handled); + Assert.True(behavior.WasCalled); + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/SapmleNotification.cs b/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/SapmleNotification.cs new file mode 100644 index 0000000..b5569b3 --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/SapmleNotification.cs @@ -0,0 +1,8 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.UnitTests.Mediator.Publish; + +public class SampleNotification : INotification +{ + public string Message { get; set; } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/TestDoubles.cs b/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/TestDoubles.cs new file mode 100644 index 0000000..aa44ab4 --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/TestDoubles.cs @@ -0,0 +1,6 @@ +namespace KSFramework.UnitTests.Mediator.Publish; + +public class TestDoubles +{ + +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/TestNotificationBehavior.cs b/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/TestNotificationBehavior.cs new file mode 100644 index 0000000..18857fe --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/TestNotificationBehavior.cs @@ -0,0 +1,16 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.UnitTests.Mediator.Publish; + +public class TestNotificationBehavior : INotificationBehavior +{ + public List CallOrder { get; } = new(); + + public Task Handle(SampleNotification notification, CancellationToken cancellationToken, NotificationHandlerDelegate next) + { + CallOrder.Add("Before"); + var task = next(); + CallOrder.Add("After"); + return task; + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/TestNotificationHandler.cs b/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/TestNotificationHandler.cs new file mode 100644 index 0000000..06b3d9d --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Mediator/Publish/TestNotificationHandler.cs @@ -0,0 +1,15 @@ +using KSFramework.Messaging.Abstraction; + +namespace KSFramework.UnitTests.Mediator.Publish; + + +public class TestNotificationHandler : INotificationHandler +{ + public bool Handled { get; private set; } = false; + + public Task Handle(SampleNotification notification, CancellationToken cancellationToken) + { + Handled = true; + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Mediator/Registration/AddKSMediatorRegistrationTests.cs b/tests/KSFramework/KSFramework.UnitTests/Mediator/Registration/AddKSMediatorRegistrationTests.cs new file mode 100644 index 0000000..c8f70b3 --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Mediator/Registration/AddKSMediatorRegistrationTests.cs @@ -0,0 +1,70 @@ +using KSFramework.Messaging.Abstraction; +using KSFramework.Messaging.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace KSFramework.UnitTests.Mediator.Registration; + +/// +/// Unit tests for AddKSMediator service registration. +/// +public class AddKSMediatorRegistrationTests +{ + public class TestRequest : IRequest { } + + public class TestRequestHandler : IRequestHandler + { + public Task Handle(TestRequest request, CancellationToken cancellationToken) => + Task.FromResult("Handled"); + } + + public class TestNotification : INotification { } + + public class TestNotificationHandler : INotificationHandler + { + public bool Handled { get; private set; } = false; + public Task Handle(TestNotification notification, CancellationToken cancellationToken) + { + Handled = true; + return Task.CompletedTask; + } + } + + public class TestRequestBehavior : IPipelineBehavior + where TRequest : IRequest + { + public async Task Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate next) + { + return await next(); + } + } + + public class TestNotificationBehavior : INotificationBehavior + { + public bool WasCalled { get; private set; } = false; + + public async Task Handle(TestNotification notification, CancellationToken cancellationToken, NotificationHandlerDelegate next) + { + WasCalled = true; + await next(); + } + } + + [Fact] + public async Task AddKSMediator_RegistersHandlersAndBehaviorsCorrectly() + { + // Arrange + var services = new ServiceCollection(); + services.AddKSMediator(typeof(AddKSMediatorRegistrationTests).Assembly); + var provider = services.BuildServiceProvider(); + + var mediator = provider.GetService(); + Assert.NotNull(mediator); + + // Act + var response = await mediator.Send(new TestRequest()); + await mediator.Publish(new TestNotification()); + + // Assert + Assert.Equal("Handled", response); + } +} \ No newline at end of file diff --git a/tests/KSFramework/KSFramework.UnitTests/Stream/CreateStream/MediatorCreateStreamTests.cs b/tests/KSFramework/KSFramework.UnitTests/Stream/CreateStream/MediatorCreateStreamTests.cs new file mode 100644 index 0000000..c4fc82a --- /dev/null +++ b/tests/KSFramework/KSFramework.UnitTests/Stream/CreateStream/MediatorCreateStreamTests.cs @@ -0,0 +1,83 @@ +using KSFramework.Messaging.Abstraction; +using Microsoft.Extensions.DependencyInjection; +namespace KSFramework.UnitTests.Stream.CreateStream; + +/// +/// Unit tests for Mediator's CreateStream method and stream pipeline behaviors. +/// +public class MediatorCreateStreamTests +{ + /// + /// A sample stream request that streams integers up to Count. + /// + public class StreamRequest : IStreamRequest + { + public int Count { get; set; } + } + + /// + /// A handler for that streams integers from 1 to Count. +/// + public class StreamHandler : IStreamRequestHandler + { + public async IAsyncEnumerable Handle(StreamRequest request, CancellationToken cancellationToken) + { + for (int i = 1; i <= request.Count; i++) + { + yield return i; + await Task.Delay(10, cancellationToken); // simulate async work + } + } + } + + /// + /// A simple logging behavior for streams that logs before and after streaming. +/// + public class LoggingBehavior : IStreamPipelineBehavior + { + public List Logs { get; } = new(); + + public async IAsyncEnumerable Handle( + StreamRequest request, + CancellationToken cancellationToken, + StreamHandlerDelegate next) + { + Logs.Add("[Before] Handling StreamRequest"); + + await foreach (var item in next()) + { + yield return item; + } + + Logs.Add("[After] Handling StreamRequest"); + } + } + + [Fact] + public async Task CreateStream_Should_InvokeHandler_AndBehavior() + { + var services = new ServiceCollection(); + + var behavior = new LoggingBehavior(); + + services.AddSingleton(); + services.AddScoped, StreamHandler>(); + services.AddScoped>(_ => behavior); + + var provider = services.BuildServiceProvider(); + var mediator = provider.GetRequiredService(); + + var request = new StreamRequest { Count = 3 }; + var results = new List(); + + await foreach (var item in mediator.CreateStream(request)) + { + results.Add(item); + } + + Assert.Equal(new[] { 1, 2, 3 }, results); + Assert.Equal(2, behavior.Logs.Count); + Assert.Contains(behavior.Logs, x => x.Contains("[Before]")); + Assert.Contains(behavior.Logs, x => x.Contains("[After]")); + } +} \ No newline at end of file