diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2713add..505e49e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,4 +27,10 @@ jobs: run: dotnet build --no-restore --configuration Release - name: Test - run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName!~GcpSecretManager" + run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName!~GcpSecretManager" --collect:"XPlat Code Coverage" --results-directory ./coverage + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ./coverage diff --git a/CSharpEssentials.Tests/AspNetCore/ApiVersioningExtensionsTests.cs b/CSharpEssentials.Tests/AspNetCore/ApiVersioningExtensionsTests.cs new file mode 100644 index 0000000..f6d92b4 --- /dev/null +++ b/CSharpEssentials.Tests/AspNetCore/ApiVersioningExtensionsTests.cs @@ -0,0 +1,78 @@ +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using CSharpEssentials.AspNetCore; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CSharpEssentials.Tests.AspNetCore; + +public class ApiVersioningExtensionsTests +{ + [Fact] + public void AddAndConfigureApiVersioning_Should_RegisterRequiredServices_When_CalledWithDefaults() + { + var services = new ServiceCollection(); + + services.AddAndConfigureApiVersioning(); + + var provider = services.BuildServiceProvider(); + var apiVersioningOptions = provider.GetService(); + services.Should().NotBeEmpty(); + } + + [Fact] + public void AddAndConfigureApiVersioning_Should_ReturnSameServiceCollection_When_Called() + { + var services = new ServiceCollection(); + + IServiceCollection result = services.AddAndConfigureApiVersioning(); + + result.Should().BeSameAs(services); + } + + [Fact] + public void AddAndConfigureApiVersioning_Should_ApplyCustomOptions_When_ConfigureOptionsProvided() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddAndConfigureApiVersioning(opt => + { + opt.DefaultApiVersion = new ApiVersion(2, 0); + }); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + + options.Value.DefaultApiVersion.Should().Be(new ApiVersion(2, 0)); + } + + [Fact] + public void AddAndConfigureApiVersioning_Should_ApplyCustomExplorerOptions_When_ConfigureExplorerProvided() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddAndConfigureApiVersioning( + configureExplorer: explorer => + { + explorer.GroupNameFormat = "'ver'VVV"; + }); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + + options.Value.GroupNameFormat.Should().Be("'ver'VVV"); + } + + [Fact] + public void AddAndConfigureApiVersioning_Should_WorkWithNullOptions_When_NoCallbacksProvided() + { + var services = new ServiceCollection(); + + Action act = () => services.AddAndConfigureApiVersioning(null, null); + + act.Should().NotThrow(); + } +} diff --git a/CSharpEssentials.Tests/AspNetCore/ConfigureSwaggerExtensionTests.cs b/CSharpEssentials.Tests/AspNetCore/ConfigureSwaggerExtensionTests.cs new file mode 100644 index 0000000..5a4d2e1 --- /dev/null +++ b/CSharpEssentials.Tests/AspNetCore/ConfigureSwaggerExtensionTests.cs @@ -0,0 +1,98 @@ +using CSharpEssentials.AspNetCore; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +namespace CSharpEssentials.Tests.AspNetCore; + +public class ConfigureSwaggerExtensionTests +{ + private static OpenApiSecurityScheme CreateBearerScheme() => new() + { + Reference = new OpenApiReference { Id = "Bearer", Type = ReferenceType.SecurityScheme }, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http + }; + + [Fact] + public void AddSwagger_Should_ReturnSameServiceCollection_When_Called() + { + var services = new ServiceCollection(); + OpenApiSecurityScheme scheme = CreateBearerScheme(); + + IServiceCollection result = services.AddSwagger(scheme); + + result.Should().BeSameAs(services); + } + + [Fact] + public void AddSwagger_Should_RegisterSwaggerServices_When_Called() + { + var services = new ServiceCollection(); + OpenApiSecurityScheme scheme = CreateBearerScheme(); + + services.AddSwagger(scheme); + + services.Should().NotBeEmpty(); + } + + [Fact] + public void AddSwagger_Should_RegisterConfigureSwaggerOptions_When_Called() + { + var services = new ServiceCollection(); + OpenApiSecurityScheme scheme = CreateBearerScheme(); + + services.AddSwagger(scheme); + + bool hasConfigureOptions = services.Any(sd => + sd.ServiceType == typeof(Microsoft.Extensions.Options.IConfigureOptions)); + hasConfigureOptions.Should().BeTrue(); + } + + [Fact] + public void UseVersionableSwagger_Should_ConfigureMiddleware_When_NoApiVersionProvider() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddSwagger(CreateBearerScheme()); + WebApplication app = builder.Build(); + + Action act = () => app.UseVersionableSwagger(); + + act.Should().NotThrow(); + } + + [Fact] + public void UseVersionableSwagger_Should_InvokeOptionsCallback_When_CallbackProvided() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddSwagger(CreateBearerScheme()); + WebApplication app = builder.Build(); + bool callbackInvoked = false; + + app.UseVersionableSwagger(options => + { + callbackInvoked = true; + }); + + callbackInvoked.Should().BeTrue(); + } + + [Fact] + public void UseVersionableSwagger_Should_InvokeUiOptionsCallback_When_CallbackProvided() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddSwagger(CreateBearerScheme()); + WebApplication app = builder.Build(); + bool uiCallbackInvoked = false; + + app.UseVersionableSwagger(uiOptions: uiOptions => + { + uiCallbackInvoked = true; + }); + + uiCallbackInvoked.Should().BeTrue(); + } +} diff --git a/CSharpEssentials.Tests/AspNetCore/ConfigureSwaggerOptionsTests.cs b/CSharpEssentials.Tests/AspNetCore/ConfigureSwaggerOptionsTests.cs new file mode 100644 index 0000000..9491b0c --- /dev/null +++ b/CSharpEssentials.Tests/AspNetCore/ConfigureSwaggerOptionsTests.cs @@ -0,0 +1,131 @@ +using CSharpEssentials.AspNetCore; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace CSharpEssentials.Tests.AspNetCore; + +public class ConfigureSwaggerOptionsTests +{ + private static (IServiceProvider serviceProvider, IHostEnvironment environment, IConfiguration configuration) BuildDependencies( + Dictionary? configValues = null) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(configValues ?? []) + .Build(); + + var envMock = new Mock(); + envMock.Setup(e => e.EnvironmentName).Returns("Development"); + + var services = new ServiceCollection(); + services.AddSingleton(config); + var provider = services.BuildServiceProvider(); + + return (provider, envMock.Object, config); + } + + [Fact] + public void Configure_Should_AddV1SwaggerDoc_When_NoApiVersionProviderRegistered() + { + var (provider, env, config) = BuildDependencies(new Dictionary + { + ["Swagger:Title"] = "Test API", + ["Swagger:Description"] = "Test Description" + }); + + var sut = new DefaultConfigureSwaggerOptions(provider, env, config); + var swaggerGenOptions = new SwaggerGenOptions(); + + sut.Configure(swaggerGenOptions); + + swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs.Should().ContainKey("v1"); + swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs["v1"].Title.Should().Be("Test API"); + } + + [Fact] + public void Configure_Should_UseDefaultTitle_When_ConfigurationKeyMissing() + { + var (provider, env, config) = BuildDependencies(); + + var sut = new DefaultConfigureSwaggerOptions(provider, env, config); + var swaggerGenOptions = new SwaggerGenOptions(); + + sut.Configure(swaggerGenOptions); + + swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs["v1"].Title.Should().Be("API"); + } + + [Fact] + public void Configure_Should_UseDefaultDescription_When_ConfigurationKeyMissing() + { + var (provider, env, config) = BuildDependencies(); + + var sut = new DefaultConfigureSwaggerOptions(provider, env, config); + var swaggerGenOptions = new SwaggerGenOptions(); + + sut.Configure(swaggerGenOptions); + + swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs["v1"].Description.Should().Contain("API Description"); + } + + [Fact] + public void Configure_Should_SetLicenseUrl_When_LicenseUrlConfigured() + { + var (provider, env, config) = BuildDependencies(new Dictionary + { + ["Swagger:LicenseUrl"] = "https://example.com/license" + }); + + var sut = new DefaultConfigureSwaggerOptions(provider, env, config); + var swaggerGenOptions = new SwaggerGenOptions(); + + sut.Configure(swaggerGenOptions); + + swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs["v1"].License!.Url + .Should().Be(new Uri("https://example.com/license")); + } + + [Fact] + public void Configure_Should_SetDefaultLicenseUrl_When_LicenseUrlNotConfigured() + { + var (provider, env, config) = BuildDependencies(); + + var sut = new DefaultConfigureSwaggerOptions(provider, env, config); + var swaggerGenOptions = new SwaggerGenOptions(); + + sut.Configure(swaggerGenOptions); + + swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs["v1"].License!.Url + .Should().Be(new Uri("https://opensource.org/license/mit")); + } + + [Fact] + public void Configure_Should_RegisterTimeSpanMapping_When_Called() + { + var (provider, env, config) = BuildDependencies(); + + var sut = new DefaultConfigureSwaggerOptions(provider, env, config); + var swaggerGenOptions = new SwaggerGenOptions(); + + sut.Configure(swaggerGenOptions); + + swaggerGenOptions.SchemaGeneratorOptions.CustomTypeMappings.Should().ContainKey(typeof(TimeSpan)); + swaggerGenOptions.SchemaGeneratorOptions.CustomTypeMappings.Should().ContainKey(typeof(TimeOnly)); + } + + [Fact] + public void Configure_Named_Should_DelegateToConfigureMethod() + { + var (provider, env, config) = BuildDependencies(); + + var sut = new DefaultConfigureSwaggerOptions(provider, env, config); + var swaggerGenOptions = new SwaggerGenOptions(); + + sut.Configure("anyName", swaggerGenOptions); + + swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs.Should().ContainKey("v1"); + } +} diff --git a/CSharpEssentials.Tests/AspNetCore/JsonExtensionsTests.cs b/CSharpEssentials.Tests/AspNetCore/JsonExtensionsTests.cs new file mode 100644 index 0000000..a2caf12 --- /dev/null +++ b/CSharpEssentials.Tests/AspNetCore/JsonExtensionsTests.cs @@ -0,0 +1,63 @@ +using CSharpEssentials.AspNetCore; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpEssentials.Tests.AspNetCore; + +public class JsonExtensionsTests +{ + [Fact] + public void ConfigureSystemTextJson_Should_ReturnSameServiceCollection_When_Called() + { + var services = new ServiceCollection(); + + IServiceCollection result = services.ConfigureSystemTextJson(); + + result.Should().BeSameAs(services); + } + + [Fact] + public void ConfigureSystemTextJson_Should_RegisterServices_When_CalledWithDefaults() + { + var services = new ServiceCollection(); + + services.ConfigureSystemTextJson(); + + services.Should().NotBeEmpty(); + } + + [Fact] + public void ConfigureSystemTextJson_Should_NotThrow_When_CalledWithNullOptions() + { + var services = new ServiceCollection(); + + Action act = () => services.ConfigureSystemTextJson(null, null); + + act.Should().NotThrow(); + } + + [Fact] + public void ConfigureSystemTextJson_Should_InvokeConfigureCallback_When_CallbackProvided() + { + var services = new ServiceCollection(); + bool callbackInvoked = false; + + services.ConfigureSystemTextJson(configureOptions: _ => { callbackInvoked = true; }); + + var provider = services.BuildServiceProvider(); + var mvcOptions = provider.GetService>(); + mvcOptions.Should().NotBeNull(); + } + + [Fact] + public void ConfigureSystemTextJson_Should_RegisterJsonOptions_When_Called() + { + var services = new ServiceCollection(); + + services.ConfigureSystemTextJson(); + + var provider = services.BuildServiceProvider(); + var jsonOptions = provider.GetService>(); + jsonOptions.Should().NotBeNull(); + } +} diff --git a/CSharpEssentials.Tests/AspNetCore/ReApplyOptionalRouteParameterOperationFilterTests.cs b/CSharpEssentials.Tests/AspNetCore/ReApplyOptionalRouteParameterOperationFilterTests.cs new file mode 100644 index 0000000..b1cd2f7 --- /dev/null +++ b/CSharpEssentials.Tests/AspNetCore/ReApplyOptionalRouteParameterOperationFilterTests.cs @@ -0,0 +1,129 @@ +using CSharpEssentials.AspNetCore.Swagger.Filters; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Reflection; + +namespace CSharpEssentials.Tests.AspNetCore; + +public class ReApplyOptionalRouteParameterOperationFilterTests +{ + private static OperationFilterContext CreateContext(MethodInfo methodInfo) + { + var apiDescription = new Microsoft.AspNetCore.Mvc.ApiExplorer.ApiDescription(); + var schemaRepository = new SchemaRepository(); + var schemaGenerator = new SchemaGenerator( + new SchemaGeneratorOptions(), + new JsonSerializerDataContractResolver(new System.Text.Json.JsonSerializerOptions())); + return new OperationFilterContext(apiDescription, schemaGenerator, schemaRepository, methodInfo); + } + + [HttpGet("{id?}")] + private static void MethodWithOptionalRoute(int id) { } + + [HttpGet("{id}")] + private static void MethodWithRequiredRoute(int id) { } + + private static void MethodWithNoRoute() { } + + [Fact] + public void Apply_Should_MarkParameterAsOptional_When_RouteHasOptionalSegment() + { + var filter = new ReApplyOptionalRouteParameterOperationFilter(); + var operation = new OpenApiOperation + { + Parameters = + [ + new OpenApiParameter + { + Name = "id", + In = ParameterLocation.Path, + Required = true, + Schema = new OpenApiSchema() + } + ] + }; + MethodInfo methodInfo = typeof(ReApplyOptionalRouteParameterOperationFilterTests) + .GetMethod(nameof(MethodWithOptionalRoute), BindingFlags.NonPublic | BindingFlags.Static)!; + OperationFilterContext context = CreateContext(methodInfo); + + filter.Apply(operation, context); + + OpenApiParameter param = operation.Parameters[0]; + param.Required.Should().BeFalse(); + param.AllowEmptyValue.Should().BeTrue(); + param.Schema.Nullable.Should().BeTrue(); + } + + [Fact] + public void Apply_Should_NotModifyParameters_When_RouteHasNoOptionalSegment() + { + var filter = new ReApplyOptionalRouteParameterOperationFilter(); + var operation = new OpenApiOperation + { + Parameters = + [ + new OpenApiParameter + { + Name = "id", + In = ParameterLocation.Path, + Required = true, + Schema = new OpenApiSchema() + } + ] + }; + MethodInfo methodInfo = typeof(ReApplyOptionalRouteParameterOperationFilterTests) + .GetMethod(nameof(MethodWithRequiredRoute), BindingFlags.NonPublic | BindingFlags.Static)!; + OperationFilterContext context = CreateContext(methodInfo); + + filter.Apply(operation, context); + + operation.Parameters[0].Required.Should().BeTrue(); + } + + [Fact] + public void Apply_Should_DoNothing_When_MethodHasNoHttpMethodAttribute() + { + var filter = new ReApplyOptionalRouteParameterOperationFilter(); + var operation = new OpenApiOperation + { + Parameters = + [ + new OpenApiParameter + { + Name = "id", + In = ParameterLocation.Path, + Required = true, + Schema = new OpenApiSchema() + } + ] + }; + MethodInfo methodInfo = typeof(ReApplyOptionalRouteParameterOperationFilterTests) + .GetMethod(nameof(MethodWithNoRoute), BindingFlags.NonPublic | BindingFlags.Static)!; + OperationFilterContext context = CreateContext(methodInfo); + + filter.Apply(operation, context); + + operation.Parameters[0].Required.Should().BeTrue(); + } + + [Fact] + public void Apply_Should_DoNothing_When_OptionalParamNotInOperationParameters() + { + var filter = new ReApplyOptionalRouteParameterOperationFilter(); + var operation = new OpenApiOperation + { + Parameters = [] + }; + MethodInfo methodInfo = typeof(ReApplyOptionalRouteParameterOperationFilterTests) + .GetMethod(nameof(MethodWithOptionalRoute), BindingFlags.NonPublic | BindingFlags.Static)!; + OperationFilterContext context = CreateContext(methodInfo); + + Action act = () => filter.Apply(operation, context); + + act.Should().NotThrow(); + operation.Parameters.Should().BeEmpty(); + } +} diff --git a/CSharpEssentials.Tests/EntityFrameworkCore/DbContextOptionsBuilderExtensionsTests.cs b/CSharpEssentials.Tests/EntityFrameworkCore/DbContextOptionsBuilderExtensionsTests.cs new file mode 100644 index 0000000..d1081d5 --- /dev/null +++ b/CSharpEssentials.Tests/EntityFrameworkCore/DbContextOptionsBuilderExtensionsTests.cs @@ -0,0 +1,104 @@ +using CSharpEssentials.EntityFrameworkCore.Extensions; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace CSharpEssentials.Tests.EntityFrameworkCore; + +public class DbContextOptionsBuilderExtensionsTests +{ + [Fact] + public void UseAsWriteContext_Should_ReturnSameBuilder_When_Called() + { + var builder = new DbContextOptionsBuilder(); + + var result = builder.UseAsWriteContext(); + + result.Should().BeSameAs(builder); + } + + [Fact] + public void UseAsWriteContext_Should_SetTrackAllBehavior_When_Called() + { + var builder = new DbContextOptionsBuilder(); + + builder.UseAsWriteContext(); + + builder.Options.FindExtension()! + .QueryTrackingBehavior + .Should().Be(QueryTrackingBehavior.TrackAll); + } + + [Fact] + public void UseAsWriteContext_Should_ThrowArgumentNullException_When_OptionsIsNull() + { + DbContextOptionsBuilder options = null!; + + Action act = () => options.UseAsWriteContext(); + + act.Should().Throw(); + } + + [Fact] + public void UseAsReadContext_Should_ReturnSameBuilder_When_Called() + { + var builder = new DbContextOptionsBuilder(); + + var result = builder.UseAsReadContext(); + + result.Should().BeSameAs(builder); + } + + [Fact] + public void UseAsReadContext_Should_SetNoTrackingBehavior_When_Called() + { + var builder = new DbContextOptionsBuilder(); + + builder.UseAsReadContext(); + + builder.Options.FindExtension()! + .QueryTrackingBehavior + .Should().Be(QueryTrackingBehavior.NoTracking); + } + + [Fact] + public void UseAsReadContext_Should_ThrowArgumentNullException_When_OptionsIsNull() + { + DbContextOptionsBuilder options = null!; + + Action act = () => options.UseAsReadContext(); + + act.Should().Throw(); + } + + [Fact] + public void UseAsReadContextWithIdentityResolution_Should_ReturnSameBuilder_When_Called() + { + var builder = new DbContextOptionsBuilder(); + + var result = builder.UseAsReadContextWithIdentityResolution(); + + result.Should().BeSameAs(builder); + } + + [Fact] + public void UseAsReadContextWithIdentityResolution_Should_SetNoTrackingWithIdentityResolutionBehavior_When_Called() + { + var builder = new DbContextOptionsBuilder(); + + builder.UseAsReadContextWithIdentityResolution(); + + builder.Options.FindExtension()! + .QueryTrackingBehavior + .Should().Be(QueryTrackingBehavior.NoTrackingWithIdentityResolution); + } + + [Fact] + public void UseAsReadContextWithIdentityResolution_Should_ThrowArgumentNullException_When_OptionsIsNull() + { + DbContextOptionsBuilder options = null!; + + Action act = () => options.UseAsReadContextWithIdentityResolution(); + + act.Should().Throw(); + } +} diff --git a/CSharpEssentials.Tests/EntityFrameworkCore/DbContextRegistrationExtensionsTests.cs b/CSharpEssentials.Tests/EntityFrameworkCore/DbContextRegistrationExtensionsTests.cs new file mode 100644 index 0000000..17ebe04 --- /dev/null +++ b/CSharpEssentials.Tests/EntityFrameworkCore/DbContextRegistrationExtensionsTests.cs @@ -0,0 +1,295 @@ +using CSharpEssentials.EntityFrameworkCore.Extensions; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpEssentials.Tests.EntityFrameworkCore; + +public class DbContextRegistrationExtensionsTests +{ + private sealed class TestDbContext(DbContextOptions options) : DbContext(options); + private sealed class WriteDbContext(DbContextOptions options) : DbContext(options); + private sealed class ReadDbContext(DbContextOptions options) : DbContext(options); + + private static Action InMemoryProvider(string? dbName = null) => + (_, b) => b.UseInMemoryDatabase(dbName ?? Guid.NewGuid().ToString()); + + [Fact] + public void AddPooledDbContext_Should_RegisterDbContext_When_ConfigureCallbackProvided() + { + var services = new ServiceCollection(); + + services.AddPooledDbContext(InMemoryProvider()); + + var provider = services.BuildServiceProvider(); + var ctx = provider.GetService(); + + ctx.Should().NotBeNull(); + } + + [Fact] + public void AddPooledDbContext_Should_ThrowArgumentNullException_When_ServicesIsNull() + { + IServiceCollection services = null!; + + Action act = () => services.AddPooledDbContext(InMemoryProvider()); + + act.Should().Throw(); + } + + [Fact] + public void AddPooledDbContext_Should_ThrowArgumentNullException_When_ConfigureIsNull() + { + var services = new ServiceCollection(); + + Action act = () => services.AddPooledDbContext( + (Action)null!); + + act.Should().Throw(); + } + + [Fact] + public void AddPooledDbContext_WithOptions_Should_RegisterDbContext_When_OptionsConfigured() + { + var services = new ServiceCollection(); + + services.AddPooledDbContext( + opts => { }, + InMemoryProvider()); + + var provider = services.BuildServiceProvider(); + var ctx = provider.GetService(); + + ctx.Should().NotBeNull(); + } + + [Fact] + public void AddPooledDbContext_WithOptions_Should_ThrowArgumentNullException_When_ServicesIsNull() + { + IServiceCollection services = null!; + + Action act = () => services.AddPooledDbContext( + (Action)(_ => { }), + InMemoryProvider()); + + act.Should().Throw(); + } + + [Fact] + public void AddPooledDbContext_WithOptions_Should_ThrowArgumentNullException_When_ConfigureOptionsIsNull() + { + var services = new ServiceCollection(); + + Action act = () => services.AddPooledDbContext( + (Action)null!, + InMemoryProvider()); + + act.Should().Throw(); + } + + [Fact] + public void RegisterDbContextFactory_Should_RegisterFactory_When_ConfigureCallbackProvided() + { + var services = new ServiceCollection(); + + services.RegisterDbContextFactory(InMemoryProvider()); + + var provider = services.BuildServiceProvider(); + var factory = provider.GetService>(); + + factory.Should().NotBeNull(); + } + + [Fact] + public void RegisterDbContextFactory_Should_ThrowArgumentNullException_When_ServicesIsNull() + { + IServiceCollection services = null!; + + Action act = () => services.RegisterDbContextFactory(InMemoryProvider()); + + act.Should().Throw(); + } + + [Fact] + public void RegisterDbContextFactory_Should_ThrowArgumentNullException_When_ConfigureIsNull() + { + var services = new ServiceCollection(); + + Action act = () => services.RegisterDbContextFactory( + (Action)null!); + + act.Should().Throw(); + } + + [Fact] + public void RegisterDbContextFactory_WithOptions_Should_RegisterFactory_When_OptionsConfigured() + { + var services = new ServiceCollection(); + + services.RegisterDbContextFactory( + opts => { }, + InMemoryProvider()); + + var provider = services.BuildServiceProvider(); + var factory = provider.GetService>(); + + factory.Should().NotBeNull(); + } + + [Fact] + public void AddWriteDbContext_Should_RegisterWriteContext_When_ConfigureCallbackProvided() + { + var services = new ServiceCollection(); + + services.AddWriteDbContext(InMemoryProvider()); + + var provider = services.BuildServiceProvider(); + var ctx = provider.GetService(); + + ctx.Should().NotBeNull(); + } + + [Fact] + public void AddWriteDbContext_Should_UseTrackAllBehavior_When_Resolved() + { + var services = new ServiceCollection(); + + services.AddWriteDbContext(InMemoryProvider("write-track")); + + var provider = services.BuildServiceProvider(); + var ctx = provider.GetRequiredService(); + + ctx.ChangeTracker.QueryTrackingBehavior.Should().Be(QueryTrackingBehavior.TrackAll); + } + + [Fact] + public void AddWriteDbContext_Should_ThrowArgumentNullException_When_ServicesIsNull() + { + IServiceCollection services = null!; + + Action act = () => services.AddWriteDbContext(InMemoryProvider()); + + act.Should().Throw(); + } + + [Fact] + public void AddWriteDbContext_WithOptions_Should_RegisterWriteContext_When_OptionsConfigured() + { + var services = new ServiceCollection(); + + services.AddWriteDbContext( + opts => { }, + InMemoryProvider()); + + var provider = services.BuildServiceProvider(); + var ctx = provider.GetService(); + + ctx.Should().NotBeNull(); + } + + [Fact] + public void AddReadDbContext_Should_RegisterReadContext_When_ConfigureCallbackProvided() + { + var services = new ServiceCollection(); + + services.AddReadDbContext(InMemoryProvider()); + + var provider = services.BuildServiceProvider(); + var ctx = provider.GetService(); + + ctx.Should().NotBeNull(); + } + + [Fact] + public void AddReadDbContext_Should_UseNoTrackingBehavior_When_Resolved() + { + var services = new ServiceCollection(); + + services.AddReadDbContext(InMemoryProvider("read-notrack")); + + var provider = services.BuildServiceProvider(); + var ctx = provider.GetRequiredService(); + + ctx.ChangeTracker.QueryTrackingBehavior.Should().Be(QueryTrackingBehavior.NoTracking); + } + + [Fact] + public void AddReadDbContext_Should_ThrowArgumentNullException_When_ServicesIsNull() + { + IServiceCollection services = null!; + + Action act = () => services.AddReadDbContext(InMemoryProvider()); + + act.Should().Throw(); + } + + [Fact] + public void AddReadDbContext_WithOptions_Should_RegisterReadContext_When_OptionsConfigured() + { + var services = new ServiceCollection(); + + services.AddReadDbContext( + opts => { }, + InMemoryProvider()); + + var provider = services.BuildServiceProvider(); + var ctx = provider.GetService(); + + ctx.Should().NotBeNull(); + } + + [Fact] + public void AddCqrsDbContexts_Should_RegisterBothContexts_When_ConfigureCallbackProvided() + { + var services = new ServiceCollection(); + + services.AddCqrsDbContexts(InMemoryProvider()); + + var provider = services.BuildServiceProvider(); + + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + } + + [Fact] + public void AddCqrsDbContexts_Should_WriteContextUseTrackAll_And_ReadContextUseNoTracking_When_Resolved() + { + var services = new ServiceCollection(); + + services.AddCqrsDbContexts( + (_, b) => b.UseInMemoryDatabase("cqrs-shared")); + + var provider = services.BuildServiceProvider(); + + var writeCtx = provider.GetRequiredService(); + var readCtx = provider.GetRequiredService(); + + writeCtx.ChangeTracker.QueryTrackingBehavior.Should().Be(QueryTrackingBehavior.TrackAll); + readCtx.ChangeTracker.QueryTrackingBehavior.Should().Be(QueryTrackingBehavior.NoTracking); + } + + [Fact] + public void AddCqrsDbContexts_Should_ThrowArgumentNullException_When_ServicesIsNull() + { + IServiceCollection services = null!; + + Action act = () => services.AddCqrsDbContexts(InMemoryProvider()); + + act.Should().Throw(); + } + + [Fact] + public void AddCqrsDbContexts_WithOptions_Should_RegisterBothContexts_When_OptionsConfigured() + { + var services = new ServiceCollection(); + + services.AddCqrsDbContexts( + opts => { }, + InMemoryProvider()); + + var provider = services.BuildServiceProvider(); + + provider.GetService().Should().NotBeNull(); + provider.GetService().Should().NotBeNull(); + } +} diff --git a/CSharpEssentials.Tests/EntityFrameworkCore/SlowQueryInterceptorBehaviorTests.cs b/CSharpEssentials.Tests/EntityFrameworkCore/SlowQueryInterceptorBehaviorTests.cs new file mode 100644 index 0000000..653fad1 --- /dev/null +++ b/CSharpEssentials.Tests/EntityFrameworkCore/SlowQueryInterceptorBehaviorTests.cs @@ -0,0 +1,343 @@ +using System.Data; +using System.Data.Common; +using System.Reflection; +using CSharpEssentials.EntityFrameworkCore.Interceptors; +using FluentAssertions; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using Moq; + +namespace CSharpEssentials.Tests.EntityFrameworkCore; + +public class SlowQueryInterceptorBehaviorTests +{ + private sealed class FakeDbCommand(string commandText = "SELECT 1") : DbCommand + { + public override string CommandText { get; set; } = commandText; + public override int CommandTimeout { get; set; } + public override CommandType CommandType { get; set; } + public override bool DesignTimeVisible { get; set; } + public override UpdateRowSource UpdatedRowSource { get; set; } + protected override DbConnection? DbConnection { get; set; } + protected override DbParameterCollection DbParameterCollection { get; } = new FakeDbParameterCollection(); + protected override DbTransaction? DbTransaction { get; set; } + public override void Cancel() { } + public override int ExecuteNonQuery() => 0; + public override object? ExecuteScalar() => null; + public override void Prepare() { } + protected override DbParameter CreateDbParameter() => throw new NotSupportedException(); + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw new NotSupportedException(); + } + + private sealed class FakeDbParameterCollection : DbParameterCollection + { + private readonly List _inner = []; + public override int Count => _inner.Count; + public override object SyncRoot => _inner; + public override int Add(object? value) { _inner.Add((DbParameter)value!); return _inner.Count - 1; } + public override void AddRange(Array values) { foreach (DbParameter p in values) _inner.Add(p); } + public override void Clear() => _inner.Clear(); + public override bool Contains(object? value) => _inner.Contains((DbParameter)value!); + public override bool Contains(string value) => _inner.Any(p => p.ParameterName == value); + public override void CopyTo(Array array, int index) => ((System.Collections.ICollection)_inner).CopyTo(array, index); + public override System.Collections.IEnumerator GetEnumerator() => _inner.GetEnumerator(); + public override int IndexOf(object? value) => _inner.IndexOf((DbParameter)value!); + public override int IndexOf(string parameterName) => _inner.FindIndex(p => p.ParameterName == parameterName); + public override void Insert(int index, object? value) => _inner.Insert(index, (DbParameter)value!); + public override void Remove(object? value) => _inner.Remove((DbParameter)value!); + public override void RemoveAt(int index) => _inner.RemoveAt(index); + public override void RemoveAt(string parameterName) => _inner.RemoveAt(IndexOf(parameterName)); + protected override DbParameter GetParameter(int index) => _inner[index]; + protected override DbParameter GetParameter(string parameterName) => _inner.First(p => p.ParameterName == parameterName); + protected override void SetParameter(int index, DbParameter value) => _inner[index] = value; + protected override void SetParameter(string parameterName, DbParameter value) => _inner[IndexOf(parameterName)] = value; + } + + private static readonly ConstructorInfo CommandExecutedEventDataCtor = + typeof(CommandExecutedEventData) + .GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)[0]; + + private static CommandExecutedEventData CreateEventData(TimeSpan duration) + { + var ps = CommandExecutedEventDataCtor.GetParameters(); + var ctorArgs = new object?[ps.Length]; + for (int i = 0; i < ps.Length; i++) + { + ctorArgs[i] = (ps[i].Name) switch + { + "duration" => duration, + "startTime" => DateTimeOffset.UtcNow, + "commandId" => Guid.NewGuid(), + "connectionId" => Guid.NewGuid(), + "async" => false, + "logParameterValues" => false, + _ => null + }; + } + return (CommandExecutedEventData)CommandExecutedEventDataCtor.Invoke(ctorArgs)!; + } + + private static DbCommand CreateFakeCommand(string commandText = "SELECT 1") => + new FakeDbCommand(commandText); + + private static Mock CreateFakeReader() + { + var mock = new Mock(); + mock.Setup(r => r.IsClosed).Returns(false); + return mock; + } + + [Fact] + public void ReaderExecuted_Should_InvokeHandler_When_DurationExceedsThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromMilliseconds(100) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(500)); + var reader = CreateFakeReader().Object; + + interceptor.ReaderExecuted(command, eventData, reader); + + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Once); + } + + [Fact] + public void ReaderExecuted_Should_NotInvokeHandler_When_DurationBelowThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromSeconds(5) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(50)); + var reader = CreateFakeReader().Object; + + interceptor.ReaderExecuted(command, eventData, reader); + + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Never); + } + + [Fact] + public void ReaderExecuted_Should_PassCorrectContextToHandler_When_DurationExceedsThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + SlowQueryContext? capturedContext = null; + var handlerMock = new Mock(); + handlerMock.Setup(h => h.OnSlowQuery(It.IsAny())) + .Callback(ctx => capturedContext = ctx); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromMilliseconds(10) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand("SELECT * FROM Users"); + var elapsed = TimeSpan.FromMilliseconds(999); + var eventData = CreateEventData(elapsed); + var reader = CreateFakeReader().Object; + + interceptor.ReaderExecuted(command, eventData, reader); + + capturedContext.Should().NotBeNull(); + capturedContext!.CommandText.Should().Be("SELECT * FROM Users"); + capturedContext.ElapsedTime.Should().Be(elapsed); + capturedContext.MethodName.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ReaderExecutedAsync_Should_InvokeHandler_When_DurationExceedsThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromMilliseconds(100) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(500)); + var reader = CreateFakeReader().Object; + + await interceptor.ReaderExecutedAsync(command, eventData, reader); + + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Once); + } + + [Fact] + public async Task ReaderExecutedAsync_Should_NotInvokeHandler_When_DurationBelowThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromSeconds(5) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(10)); + var reader = CreateFakeReader().Object; + + await interceptor.ReaderExecutedAsync(command, eventData, reader); + + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Never); + } + + [Fact] + public void ScalarExecuted_Should_InvokeHandler_When_DurationExceedsThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromMilliseconds(100) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(500)); + + interceptor.ScalarExecuted(command, eventData, result: null); + + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Once); + } + + [Fact] + public void ScalarExecuted_Should_NotInvokeHandler_When_DurationBelowThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromSeconds(5) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(10)); + + interceptor.ScalarExecuted(command, eventData, result: null); + + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ScalarExecutedAsync_Should_InvokeHandler_When_DurationExceedsThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromMilliseconds(100) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(500)); + + await interceptor.ScalarExecutedAsync(command, eventData, result: null); + + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Once); + } + + [Fact] + public void NonQueryExecuted_Should_InvokeHandler_When_DurationExceedsThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromMilliseconds(100) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(500)); + + interceptor.NonQueryExecuted(command, eventData, result: 1); + + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Once); + } + + [Fact] + public void NonQueryExecuted_Should_NotInvokeHandler_When_DurationBelowThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromSeconds(5) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(10)); + + interceptor.NonQueryExecuted(command, eventData, result: 1); + + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Never); + } + + [Fact] + public async Task NonQueryExecutedAsync_Should_InvokeHandler_When_DurationExceedsThreshold() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromMilliseconds(100) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options, handlerMock.Object); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(500)); + + await interceptor.NonQueryExecutedAsync(command, eventData, result: 1); + + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Once); + } + + [Fact] + public void HandleIfSlow_Should_UseDefaultThresholdOfOneSecond_When_NoOptionsProvided() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var handlerMock = new Mock(); + var interceptor = new SlowQueryInterceptor(loggerMock.Object, slowQueryHandler: handlerMock.Object); + + var command = CreateFakeCommand(); + + interceptor.NonQueryExecuted(command, CreateEventData(TimeSpan.FromMilliseconds(999)), result: 0); + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Never); + + interceptor.NonQueryExecuted(command, CreateEventData(TimeSpan.FromSeconds(2)), result: 0); + handlerMock.Verify(h => h.OnSlowQuery(It.IsAny()), Times.Once); + } + + [Fact] + public void HandleIfSlow_Should_NotThrow_When_HandlerIsNull() + { + var loggerMock = new Mock>(); + loggerMock.Setup(x => x.IsEnabled(It.IsAny())).Returns(true); + var options = new SlowQueryOptions { Threshold = TimeSpan.FromMilliseconds(10) }; + var interceptor = new SlowQueryInterceptor(loggerMock.Object, options); + + var command = CreateFakeCommand(); + var eventData = CreateEventData(TimeSpan.FromMilliseconds(500)); + + Action act = () => interceptor.NonQueryExecuted(command, eventData, result: 0); + + act.Should().NotThrow(); + } + + [Fact] + public void SlowQueryContext_Should_HaveCorrectProperties_When_Populated() + { + var elapsed = TimeSpan.FromMilliseconds(250); + var ctx = new SlowQueryContext + { + CommandText = "SELECT 1", + Parameters = "p1=1", + ElapsedTime = elapsed, + Database = "mydb", + HasTransaction = true, + MethodName = "ReaderExecuted" + }; + + ctx.CommandText.Should().Be("SELECT 1"); + ctx.Parameters.Should().Be("p1=1"); + ctx.ElapsedTime.Should().Be(elapsed); + ctx.Database.Should().Be("mydb"); + ctx.HasTransaction.Should().BeTrue(); + ctx.MethodName.Should().Be("ReaderExecuted"); + } +} diff --git a/CSharpEssentials.Tests/Maybe/MaybeAsMaybeAsyncTests.cs b/CSharpEssentials.Tests/Maybe/MaybeAsMaybeAsyncTests.cs new file mode 100644 index 0000000..1fce278 --- /dev/null +++ b/CSharpEssentials.Tests/Maybe/MaybeAsMaybeAsyncTests.cs @@ -0,0 +1,57 @@ +using CSharpEssentials.Maybe; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Maybe; + +public class MaybeAsMaybeAsyncTests +{ + #region AsMaybeAsync — ValueTask + + [Fact] + public async Task AsMaybeAsync_ValueTask_WithNonNullValue_ShouldHaveValue() + { + ValueTask task = ValueTask.FromResult("hello"); + + Maybe result = await task.AsMaybeAsync(); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be("hello"); + } + + [Fact] + public async Task AsMaybeAsync_ValueTask_WithNullValue_ShouldHaveNoValue() + { + ValueTask task = ValueTask.FromResult(null); + + Maybe result = await task.AsMaybeAsync(); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region AsMaybeAsync — Task where T : class + + [Fact] + public async Task AsMaybeAsync_Task_WithNonNullValue_ShouldHaveValue() + { + Task task = Task.FromResult("world"); + + Maybe result = await task.AsMaybeAsync(); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be("world"); + } + + [Fact] + public async Task AsMaybeAsync_Task_WithNullValue_ShouldHaveNoValue() + { + Task task = Task.FromResult(null); + + Maybe result = await task.AsMaybeAsync(); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Maybe/MaybeBindAsyncTests.cs b/CSharpEssentials.Tests/Maybe/MaybeBindAsyncTests.cs new file mode 100644 index 0000000..98797f4 --- /dev/null +++ b/CSharpEssentials.Tests/Maybe/MaybeBindAsyncTests.cs @@ -0,0 +1,112 @@ +using CSharpEssentials.Maybe; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Maybe; + +public class MaybeBindAsyncTests +{ + #region BindAsync on Maybe — Task> + + [Fact] + public async Task BindAsync_Task_WithValue_ShouldChainOperations() + { + var maybe = Maybe.From(5); + + Maybe result = await maybe.BindAsync(v => Task.FromResult(Maybe.From(v.ToString()))); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be("5"); + } + + [Fact] + public async Task BindAsync_Task_WithNoValue_ShouldReturnNone() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.BindAsync(v => Task.FromResult(Maybe.From(v.ToString()))); + + result.HasNoValue.Should().BeTrue(); + } + + [Fact] + public async Task BindAsync_Task_ReturningNone_ShouldReturnNone() + { + var maybe = Maybe.From(5); + + Maybe result = await maybe.BindAsync(_ => Task.FromResult(Maybe.None)); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region BindAsync extension on Task> — sync selector + + [Fact] + public async Task BindAsync_TaskMaybe_SyncSelector_WithValue_ShouldChain() + { + Task> maybeTask = Task.FromResult(Maybe.From(10)); + + Maybe result = await maybeTask.BindAsync(v => Maybe.From(v * 2)); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(20); + } + + [Fact] + public async Task BindAsync_TaskMaybe_SyncSelector_WithNoValue_ShouldReturnNone() + { + Task> maybeTask = Task.FromResult(Maybe.None); + + Maybe result = await maybeTask.BindAsync(v => Maybe.From(v * 2)); + + result.HasNoValue.Should().BeTrue(); + } + + [Fact] + public async Task BindAsync_TaskMaybe_SyncSelector_ReturningNone_ShouldReturnNone() + { + Task> maybeTask = Task.FromResult(Maybe.From(10)); + + Maybe result = await maybeTask.BindAsync(_ => Maybe.None); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region BindAsync extension on ValueTask> — ValueTask selector + + [Fact] + public async Task BindAsync_ValueTaskMaybe_WithValue_ShouldChain() + { + ValueTask> maybeTask = ValueTask.FromResult(Maybe.From(3)); + + Maybe result = await maybeTask.BindAsync(v => ValueTask.FromResult(Maybe.From(v.ToString()))); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be("3"); + } + + [Fact] + public async Task BindAsync_ValueTaskMaybe_WithNoValue_ShouldReturnNone() + { + ValueTask> maybeTask = ValueTask.FromResult(Maybe.None); + + Maybe result = await maybeTask.BindAsync(v => ValueTask.FromResult(Maybe.From(v.ToString()))); + + result.HasNoValue.Should().BeTrue(); + } + + [Fact] + public async Task BindAsync_ValueTaskMaybe_ReturningNone_ShouldReturnNone() + { + ValueTask> maybeTask = ValueTask.FromResult(Maybe.From(3)); + + Maybe result = await maybeTask.BindAsync(_ => ValueTask.FromResult(Maybe.None)); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Maybe/MaybeExecuteAsyncTests.cs b/CSharpEssentials.Tests/Maybe/MaybeExecuteAsyncTests.cs new file mode 100644 index 0000000..bfcbe7d --- /dev/null +++ b/CSharpEssentials.Tests/Maybe/MaybeExecuteAsyncTests.cs @@ -0,0 +1,215 @@ +using CSharpEssentials.Maybe; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Maybe; + +public class MaybeExecuteAsyncTests +{ + #region Execute async — Func + + [Fact] + public async Task Execute_FuncTask_WithValue_ShouldInvokeAction() + { + var maybe = Maybe.From(5); + int captured = 0; + + await maybe.Execute(v => { captured = v; return Task.CompletedTask; }); + + captured.Should().Be(5); + } + + [Fact] + public async Task Execute_FuncTask_WithNoValue_ShouldNotInvokeAction() + { + Maybe maybe = Maybe.None; + int captured = 0; + + await maybe.Execute(v => { captured = v; return Task.CompletedTask; }); + + captured.Should().Be(0); + } + + #endregion + + #region Execute async — Func + + [Fact] + public async Task Execute_FuncValueTask_WithValue_ShouldInvokeAction() + { + var maybe = Maybe.From(10); + int captured = 0; + + await maybe.Execute(v => { captured = v; return ValueTask.CompletedTask; }); + + captured.Should().Be(10); + } + + [Fact] + public async Task Execute_FuncValueTask_WithNoValue_ShouldNotInvokeAction() + { + Maybe maybe = Maybe.None; + int captured = 0; + + await maybe.Execute(v => { captured = v; return ValueTask.CompletedTask; }); + + captured.Should().Be(0); + } + + #endregion + + #region ExecuteNoValue async — Func + + [Fact] + public async Task ExecuteNoValue_FuncTask_WithNoValue_ShouldInvokeAction() + { + Maybe maybe = Maybe.None; + bool invoked = false; + + await maybe.ExecuteNoValue(() => { invoked = true; return Task.CompletedTask; }); + + invoked.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteNoValue_FuncTask_WithValue_ShouldNotInvokeAction() + { + var maybe = Maybe.From(3); + bool invoked = false; + + await maybe.ExecuteNoValue(() => { invoked = true; return Task.CompletedTask; }); + + invoked.Should().BeFalse(); + } + + #endregion + + #region ExecuteNoValue async — Func + + [Fact] + public async Task ExecuteNoValue_FuncValueTask_WithNoValue_ShouldInvokeAction() + { + Maybe maybe = Maybe.None; + bool invoked = false; + + await maybe.ExecuteNoValue(() => { invoked = true; return ValueTask.CompletedTask; }); + + invoked.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteNoValue_FuncValueTask_WithValue_ShouldNotInvokeAction() + { + var maybe = Maybe.From(7); + bool invoked = false; + + await maybe.ExecuteNoValue(() => { invoked = true; return ValueTask.CompletedTask; }); + + invoked.Should().BeFalse(); + } + + #endregion + + #region Execute extension on Task> — Action + + [Fact] + public async Task Execute_TaskMaybe_Action_WithValue_ShouldInvokeAction() + { + Task> maybeTask = Task.FromResult(Maybe.From(20)); + int captured = 0; + + await maybeTask.Execute(v => { captured = v; }); + + captured.Should().Be(20); + } + + [Fact] + public async Task Execute_TaskMaybe_Action_WithNoValue_ShouldNotInvokeAction() + { + Task> maybeTask = Task.FromResult(Maybe.None); + int captured = 0; + + await maybeTask.Execute(v => { captured = v; }); + + captured.Should().Be(0); + } + + #endregion + + #region Execute extension on Task> — Func + + [Fact] + public async Task Execute_TaskMaybe_FuncTask_WithValue_ShouldInvokeAction() + { + Task> maybeTask = Task.FromResult(Maybe.From(15)); + int captured = 0; + + await maybeTask.Execute(v => { captured = v; return Task.CompletedTask; }); + + captured.Should().Be(15); + } + + [Fact] + public async Task Execute_TaskMaybe_FuncTask_WithNoValue_ShouldNotInvokeAction() + { + Task> maybeTask = Task.FromResult(Maybe.None); + int captured = 0; + + await maybeTask.Execute(v => { captured = v; return Task.CompletedTask; }); + + captured.Should().Be(0); + } + + #endregion + + #region Execute extension on ValueTask> — Func + + [Fact] + public async Task Execute_ValueTaskMaybe_FuncValueTask_WithValue_ShouldInvokeAction() + { + ValueTask> maybeTask = ValueTask.FromResult(Maybe.From(8)); + int captured = 0; + + await maybeTask.Execute(v => { captured = v; return ValueTask.CompletedTask; }); + + captured.Should().Be(8); + } + + [Fact] + public async Task Execute_ValueTaskMaybe_FuncValueTask_WithNoValue_ShouldNotInvokeAction() + { + ValueTask> maybeTask = ValueTask.FromResult(Maybe.None); + int captured = 0; + + await maybeTask.Execute(v => { captured = v; return ValueTask.CompletedTask; }); + + captured.Should().Be(0); + } + + #endregion + + #region ExecuteNoValue extension on Task> + + [Fact] + public async Task ExecuteNoValue_TaskMaybe_Action_WithNoValue_ShouldInvokeAction() + { + Task> maybeTask = Task.FromResult(Maybe.None); + bool invoked = false; + + await maybeTask.ExecuteNoValue(() => { invoked = true; }); + + invoked.Should().BeTrue(); + } + + [Fact] + public async Task ExecuteNoValue_TaskMaybe_Action_WithValue_ShouldNotInvokeAction() + { + Task> maybeTask = Task.FromResult(Maybe.From(1)); + bool invoked = false; + + await maybeTask.ExecuteNoValue(() => { invoked = true; }); + + invoked.Should().BeFalse(); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Maybe/MaybeFromAsyncTests.cs b/CSharpEssentials.Tests/Maybe/MaybeFromAsyncTests.cs new file mode 100644 index 0000000..ae736a2 --- /dev/null +++ b/CSharpEssentials.Tests/Maybe/MaybeFromAsyncTests.cs @@ -0,0 +1,82 @@ +using CSharpEssentials.Maybe; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Maybe; + +public class MaybeFromAsyncTests +{ + #region Maybe.FromAsync — Task + + [Fact] + public async Task FromAsync_Task_WithNonNullValue_ShouldHaveValue() + { + Task task = Task.FromResult("hello"); + + Maybe result = await Maybe.FromAsync(task); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be("hello"); + } + + [Fact] + public async Task FromAsync_Task_WithNullValue_ShouldHaveNoValue() + { + Task task = Task.FromResult(null); + + Maybe result = await Maybe.FromAsync(task); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region Maybe.FromAsync — Func> + + [Fact] + public async Task FromAsync_FuncTask_WithNonNullValue_ShouldHaveValue() + { + Task task = Task.FromResult("async-value"); + + Maybe result = await Maybe.FromAsync(() => task); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be("async-value"); + } + + [Fact] + public async Task FromAsync_FuncTask_WithNullValue_ShouldHaveNoValue() + { + Task task = Task.FromResult(null); + + Maybe result = await Maybe.FromAsync(() => task); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region Maybe.From static — Task via extension + + [Fact] + public async Task From_TaskExtension_WithNonNullValue_ShouldHaveValue() + { + Task task = Task.FromResult("world"); + + Maybe result = await CSharpEssentials.Maybe.Maybe.From(task); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be("world"); + } + + [Fact] + public async Task From_TaskExtension_WithNullValue_ShouldHaveNoValue() + { + Task task = Task.FromResult(null); + + Maybe result = await CSharpEssentials.Maybe.Maybe.From(task); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Maybe/MaybeGetValueAsyncTests.cs b/CSharpEssentials.Tests/Maybe/MaybeGetValueAsyncTests.cs new file mode 100644 index 0000000..5b47b65 --- /dev/null +++ b/CSharpEssentials.Tests/Maybe/MaybeGetValueAsyncTests.cs @@ -0,0 +1,159 @@ +using CSharpEssentials.Maybe; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Maybe; + +public class MaybeGetValueAsyncTests +{ + #region GetValueOrDefaultAsync — Func> + + [Fact] + public async Task GetValueOrDefaultAsync_FuncTaskT_WithValue_ShouldReturnValue() + { + var maybe = Maybe.From(42); + + int result = await maybe.GetValueOrDefaultAsync(() => Task.FromResult(0)); + + result.Should().Be(42); + } + + [Fact] + public async Task GetValueOrDefaultAsync_FuncTaskT_WithNoValue_ShouldReturnDefault() + { + Maybe maybe = Maybe.None; + + int result = await maybe.GetValueOrDefaultAsync(() => Task.FromResult(99)); + + result.Should().Be(99); + } + + #endregion + + #region GetValueOrDefaultAsync — sync selector + Func> + + [Fact] + public async Task GetValueOrDefaultAsync_SyncSelectorFuncTask_WithValue_ShouldApplySelector() + { + var maybe = Maybe.From(5); + + string result = await maybe.GetValueOrDefaultAsync( + v => v.ToString(), + () => Task.FromResult("none")); + + result.Should().Be("5"); + } + + [Fact] + public async Task GetValueOrDefaultAsync_SyncSelectorFuncTask_WithNoValue_ShouldReturnDefault() + { + Maybe maybe = Maybe.None; + + string result = await maybe.GetValueOrDefaultAsync( + v => v.ToString(), + () => Task.FromResult("none")); + + result.Should().Be("none"); + } + + #endregion + + #region GetValueOrDefaultAsync — async selector + sync default + + [Fact] + public async Task GetValueOrDefaultAsync_AsyncSelectorSyncDefault_WithValue_ShouldApplySelector() + { + var maybe = Maybe.From(7); + + string result = await maybe.GetValueOrDefaultAsync( + v => Task.FromResult(v.ToString()), + "none"); + + result.Should().Be("7"); + } + + [Fact] + public async Task GetValueOrDefaultAsync_AsyncSelectorSyncDefault_WithNoValue_ShouldReturnDefault() + { + Maybe maybe = Maybe.None; + + string result = await maybe.GetValueOrDefaultAsync( + v => Task.FromResult(v.ToString()), + "none"); + + result.Should().Be("none"); + } + + #endregion + + #region GetValueOrDefaultAsync extension on Task> — sync default + + [Fact] + public async Task GetValueOrDefaultAsync_TaskMaybe_SyncDefault_WithValue_ShouldReturnValue() + { + Task> maybeTask = Task.FromResult(Maybe.From(10)); + + int result = await maybeTask.GetValueOrDefaultAsync(() => 0); + + result.Should().Be(10); + } + + [Fact] + public async Task GetValueOrDefaultAsync_TaskMaybe_SyncDefault_WithNoValue_ShouldReturnDefault() + { + Task> maybeTask = Task.FromResult(Maybe.None); + + int result = await maybeTask.GetValueOrDefaultAsync(() => 99); + + result.Should().Be(99); + } + + #endregion + + #region GetValueOrDefaultAsync extension on Task> — selector + sync default value + + [Fact] + public async Task GetValueOrDefaultAsync_TaskMaybe_SelectorSyncDefault_WithValue_ShouldApplySelector() + { + Task> maybeTask = Task.FromResult(Maybe.From(3)); + + string result = await maybeTask.GetValueOrDefaultAsync(v => v.ToString(), "none"); + + result.Should().Be("3"); + } + + [Fact] + public async Task GetValueOrDefaultAsync_TaskMaybe_SelectorSyncDefault_WithNoValue_ShouldReturnDefault() + { + Task> maybeTask = Task.FromResult(Maybe.None); + + string result = await maybeTask.GetValueOrDefaultAsync(v => v.ToString(), "none"); + + result.Should().Be("none"); + } + + #endregion + + #region GetValueOrDefaultAsync extension on Task> — selector + Func default + + [Fact] + public async Task GetValueOrDefaultAsync_TaskMaybe_SelectorFuncDefault_WithValue_ShouldApplySelector() + { + Task> maybeTask = Task.FromResult(Maybe.From(8)); + + string result = await maybeTask.GetValueOrDefaultAsync(v => v.ToString(), () => "none"); + + result.Should().Be("8"); + } + + [Fact] + public async Task GetValueOrDefaultAsync_TaskMaybe_SelectorFuncDefault_WithNoValue_ShouldReturnDefault() + { + Task> maybeTask = Task.FromResult(Maybe.None); + + string result = await maybeTask.GetValueOrDefaultAsync(v => v.ToString(), () => "none"); + + result.Should().Be("none"); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Maybe/MaybeMapAsyncTests.cs b/CSharpEssentials.Tests/Maybe/MaybeMapAsyncTests.cs new file mode 100644 index 0000000..b1d18c9 --- /dev/null +++ b/CSharpEssentials.Tests/Maybe/MaybeMapAsyncTests.cs @@ -0,0 +1,132 @@ +using CSharpEssentials.Maybe; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Maybe; + +public class MaybeMapAsyncTests +{ + #region MapAsync on Maybe — Task + + [Fact] + public async Task MapAsync_Task_WithValue_ShouldTransformValue() + { + var maybe = Maybe.From(5); + + Maybe result = await maybe.MapAsync(v => Task.FromResult(v.ToString())); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be("5"); + } + + [Fact] + public async Task MapAsync_Task_WithNoValue_ShouldReturnNone() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.MapAsync(v => Task.FromResult(v.ToString())); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region MapAsync on Maybe — ValueTask + + [Fact] + public async Task MapAsync_ValueTask_WithValue_ShouldTransformValue() + { + var maybe = Maybe.From(10); + + Maybe result = await maybe.MapAsync(v => ValueTask.FromResult(v * 2)); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(20); + } + + [Fact] + public async Task MapAsync_ValueTask_WithNoValue_ShouldReturnNone() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.MapAsync(v => ValueTask.FromResult(v * 2)); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region MapAsync extension on Task> — sync selector + + [Fact] + public async Task MapAsync_TaskMaybe_SyncSelector_WithValue_ShouldTransformValue() + { + Task> maybeTask = Task.FromResult(Maybe.From(3)); + + Maybe result = await maybeTask.MapAsync(v => v * 4); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(12); + } + + [Fact] + public async Task MapAsync_TaskMaybe_SyncSelector_WithNoValue_ShouldReturnNone() + { + Task> maybeTask = Task.FromResult(Maybe.None); + + Maybe result = await maybeTask.MapAsync(v => v * 4); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region MapAsync extension on Task> — async selector + + [Fact] + public async Task MapAsync_TaskMaybe_AsyncSelector_WithValue_ShouldTransformValue() + { + Task> maybeTask = Task.FromResult(Maybe.From(7)); + + Maybe result = await maybeTask.MapAsync(v => Task.FromResult(v.ToString())); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be("7"); + } + + [Fact] + public async Task MapAsync_TaskMaybe_AsyncSelector_WithNoValue_ShouldReturnNone() + { + Task> maybeTask = Task.FromResult(Maybe.None); + + Maybe result = await maybeTask.MapAsync(v => Task.FromResult(v.ToString())); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region MapAsync extension on ValueTask> + + [Fact] + public async Task MapAsync_ValueTaskMaybe_SyncSelector_WithValue_ShouldTransformValue() + { + ValueTask> maybeTask = ValueTask.FromResult(Maybe.From(6)); + + Maybe result = await maybeTask.MapAsync(v => v + 1); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(7); + } + + [Fact] + public async Task MapAsync_ValueTaskMaybe_SyncSelector_WithNoValue_ShouldReturnNone() + { + ValueTask> maybeTask = ValueTask.FromResult(Maybe.None); + + Maybe result = await maybeTask.MapAsync(v => v + 1); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Maybe/MaybeMatchAsyncTests.cs b/CSharpEssentials.Tests/Maybe/MaybeMatchAsyncTests.cs new file mode 100644 index 0000000..4b01d61 --- /dev/null +++ b/CSharpEssentials.Tests/Maybe/MaybeMatchAsyncTests.cs @@ -0,0 +1,291 @@ +using CSharpEssentials.Maybe; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Maybe; + +public class MaybeMatchAsyncTests +{ + #region Match Task returning + + [Fact] + public async Task MatchAsync_TaskTE_WithValue_ShouldInvokeSome() + { + var maybe = Maybe.From(5); + + string result = await maybe.Match( + (v, ct) => Task.FromResult(v.ToString()), + ct => Task.FromResult("none")); + + result.Should().Be("5"); + } + + [Fact] + public async Task MatchAsync_TaskTE_WithNoValue_ShouldInvokeNone() + { + Maybe maybe = Maybe.None; + + string result = await maybe.Match( + (v, ct) => Task.FromResult(v.ToString()), + ct => Task.FromResult("none")); + + result.Should().Be("none"); + } + + [Fact] + public async Task MatchAsync_TaskTE_WithContext_WithValue_ShouldInvokeSome() + { + var maybe = Maybe.From(10); + string context = "ctx"; + + string result = await maybe.Match( + (v, c, ct) => Task.FromResult(v + c), + (c, ct) => Task.FromResult("no-" + c), + context); + + result.Should().Be("10ctx"); + } + + [Fact] + public async Task MatchAsync_TaskTE_WithContext_WithNoValue_ShouldInvokeNone() + { + Maybe maybe = Maybe.None; + string context = "ctx"; + + string result = await maybe.Match( + (v, c, ct) => Task.FromResult(v + c), + (c, ct) => Task.FromResult("no-" + c), + context); + + result.Should().Be("no-ctx"); + } + + #endregion + + #region Match Task (void) returning + + [Fact] + public async Task MatchAsync_Task_WithValue_ShouldInvokeSome() + { + var maybe = Maybe.From(7); + int captured = 0; + + await maybe.Match( + (v, ct) => { captured = v; return Task.CompletedTask; }, + ct => { captured = -1; return Task.CompletedTask; }); + + captured.Should().Be(7); + } + + [Fact] + public async Task MatchAsync_Task_WithNoValue_ShouldInvokeNone() + { + Maybe maybe = Maybe.None; + int captured = 0; + + await maybe.Match( + (v, ct) => { captured = v; return Task.CompletedTask; }, + ct => { captured = -1; return Task.CompletedTask; }); + + captured.Should().Be(-1); + } + + [Fact] + public async Task MatchAsync_Task_WithContext_WithValue_ShouldInvokeSome() + { + var maybe = Maybe.From(3); + int captured = 0; + + await maybe.Match( + (v, c, ct) => { captured = v + c; return Task.CompletedTask; }, + (c, ct) => { captured = c; return Task.CompletedTask; }, + 10); + + captured.Should().Be(13); + } + + [Fact] + public async Task MatchAsync_Task_WithContext_WithNoValue_ShouldInvokeNone() + { + Maybe maybe = Maybe.None; + int captured = 0; + + await maybe.Match( + (v, c, ct) => { captured = v + c; return Task.CompletedTask; }, + (c, ct) => { captured = c; return Task.CompletedTask; }, + 10); + + captured.Should().Be(10); + } + + #endregion + + #region Match ValueTask returning + + [Fact] + public async Task MatchAsync_ValueTaskTE_WithValue_ShouldInvokeSome() + { + var maybe = Maybe.From(42); + + string result = await maybe.Match( + (v, ct) => ValueTask.FromResult(v.ToString()), + ct => ValueTask.FromResult("none")); + + result.Should().Be("42"); + } + + [Fact] + public async Task MatchAsync_ValueTaskTE_WithNoValue_ShouldInvokeNone() + { + Maybe maybe = Maybe.None; + + string result = await maybe.Match( + (v, ct) => ValueTask.FromResult(v.ToString()), + ct => ValueTask.FromResult("none")); + + result.Should().Be("none"); + } + + [Fact] + public async Task MatchAsync_ValueTaskTE_WithContext_WithValue_ShouldInvokeSome() + { + var maybe = Maybe.From(5); + + string result = await maybe.Match( + (v, c, ct) => ValueTask.FromResult(v + c), + (c, ct) => ValueTask.FromResult("no-" + c), + "_ctx"); + + result.Should().Be("5_ctx"); + } + + [Fact] + public async Task MatchAsync_ValueTaskTE_WithContext_WithNoValue_ShouldInvokeNone() + { + Maybe maybe = Maybe.None; + + string result = await maybe.Match( + (v, c, ct) => ValueTask.FromResult(v + c), + (c, ct) => ValueTask.FromResult("no-" + c), + "_ctx"); + + result.Should().Be("no-_ctx"); + } + + #endregion + + #region Match ValueTask (void) returning + + [Fact] + public async Task MatchAsync_ValueTask_WithValue_ShouldInvokeSome() + { + var maybe = Maybe.From(9); + int captured = 0; + + await maybe.Match( + (v, ct) => { captured = v; return ValueTask.CompletedTask; }, + ct => { captured = -1; return ValueTask.CompletedTask; }); + + captured.Should().Be(9); + } + + [Fact] + public async Task MatchAsync_ValueTask_WithNoValue_ShouldInvokeNone() + { + Maybe maybe = Maybe.None; + int captured = 0; + + await maybe.Match( + (v, ct) => { captured = v; return ValueTask.CompletedTask; }, + ct => { captured = -1; return ValueTask.CompletedTask; }); + + captured.Should().Be(-1); + } + + [Fact] + public async Task MatchAsync_ValueTask_WithContext_WithValue_ShouldInvokeSome() + { + var maybe = Maybe.From(4); + int captured = 0; + + await maybe.Match( + (v, c, ct) => { captured = v * c; return ValueTask.CompletedTask; }, + (c, ct) => { captured = c; return ValueTask.CompletedTask; }, + 3); + + captured.Should().Be(12); + } + + [Fact] + public async Task MatchAsync_ValueTask_WithContext_WithNoValue_ShouldInvokeNone() + { + Maybe maybe = Maybe.None; + int captured = 0; + + await maybe.Match( + (v, c, ct) => { captured = v * c; return ValueTask.CompletedTask; }, + (c, ct) => { captured = c; return ValueTask.CompletedTask; }, + 3); + + captured.Should().Be(3); + } + + #endregion + + #region KeyValuePair Match async extensions + + [Fact] + public async Task MatchAsync_KeyValuePair_ValueTask_WithValue_ShouldInvokeSome() + { + var kvp = new KeyValuePair("key", 42); + var maybe = Maybe>.From(kvp); + string? capturedKey = null; + int capturedValue = 0; + + await maybe.Match( + (k, v, ct) => { capturedKey = k; capturedValue = v; return ValueTask.CompletedTask; }, + ct => ValueTask.CompletedTask); + + capturedKey.Should().Be("key"); + capturedValue.Should().Be(42); + } + + [Fact] + public async Task MatchAsync_KeyValuePair_ValueTask_WithNoValue_ShouldInvokeNone() + { + Maybe> maybe = Maybe>.None; + bool noneCalled = false; + + await maybe.Match( + (k, v, ct) => ValueTask.CompletedTask, + ct => { noneCalled = true; return ValueTask.CompletedTask; }); + + noneCalled.Should().BeTrue(); + } + + [Fact] + public async Task MatchAsync_KeyValuePair_ValueTaskTE_WithValue_ShouldReturnSomeResult() + { + var kvp = new KeyValuePair("x", 10); + var maybe = Maybe>.From(kvp); + + string result = await maybe.Match( + (k, v, ct) => ValueTask.FromResult($"{k}={v}"), + ct => ValueTask.FromResult("none")); + + result.Should().Be("x=10"); + } + + [Fact] + public async Task MatchAsync_KeyValuePair_ValueTaskTE_WithNoValue_ShouldReturnNoneResult() + { + Maybe> maybe = Maybe>.None; + + string result = await maybe.Match( + (k, v, ct) => ValueTask.FromResult($"{k}={v}"), + ct => ValueTask.FromResult("none")); + + result.Should().Be("none"); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Maybe/MaybeOrAsyncTests.cs b/CSharpEssentials.Tests/Maybe/MaybeOrAsyncTests.cs new file mode 100644 index 0000000..4649f35 --- /dev/null +++ b/CSharpEssentials.Tests/Maybe/MaybeOrAsyncTests.cs @@ -0,0 +1,189 @@ +using CSharpEssentials.Maybe; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Maybe; + +public class MaybeOrAsyncTests +{ + #region Or async — Task fallback operation + + [Fact] + public async Task Or_TaskT_WithValue_ShouldReturnOriginal() + { + var maybe = Maybe.From(5); + + Maybe result = await maybe.Or(() => Task.FromResult(99)); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(5); + } + + [Fact] + public async Task Or_TaskT_WithNoValue_ShouldReturnFallback() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.Or(() => Task.FromResult(99)); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(99); + } + + #endregion + + #region Or async — Task> fallback + + [Fact] + public async Task Or_TaskMaybe_WithValue_ShouldReturnOriginal() + { + var maybe = Maybe.From(1); + + Maybe result = await maybe.Or(Task.FromResult(Maybe.From(99))); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(1); + } + + [Fact] + public async Task Or_TaskMaybe_WithNoValue_ShouldReturnFallback() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.Or(Task.FromResult(Maybe.From(99))); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(99); + } + + #endregion + + #region Or async — Func>> fallback operation + + [Fact] + public async Task Or_FuncTaskMaybe_WithValue_ShouldReturnOriginal() + { + var maybe = Maybe.From(2); + + Maybe result = await maybe.Or(() => Task.FromResult(Maybe.From(99))); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(2); + } + + [Fact] + public async Task Or_FuncTaskMaybe_WithNoValue_ShouldReturnFallback() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.Or(() => Task.FromResult(Maybe.From(99))); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(99); + } + + #endregion + + #region Or async — ValueTask fallback operation + + [Fact] + public async Task Or_ValueTaskT_WithValue_ShouldReturnOriginal() + { + var maybe = Maybe.From(3); + + Maybe result = await maybe.Or(() => ValueTask.FromResult(99)); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(3); + } + + [Fact] + public async Task Or_ValueTaskT_WithNoValue_ShouldReturnFallback() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.Or(() => ValueTask.FromResult(99)); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(99); + } + + #endregion + + #region Or async — ValueTask> fallback + + [Fact] + public async Task Or_ValueTaskMaybe_WithValue_ShouldReturnOriginal() + { + var maybe = Maybe.From(4); + + Maybe result = await maybe.Or(ValueTask.FromResult(Maybe.From(99))); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(4); + } + + [Fact] + public async Task Or_ValueTaskMaybe_WithNoValue_ShouldReturnFallback() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.Or(ValueTask.FromResult(Maybe.From(99))); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(99); + } + + #endregion + + #region Or async — Func>> fallback operation + + [Fact] + public async Task Or_FuncValueTaskMaybe_WithValue_ShouldReturnOriginal() + { + var maybe = Maybe.From(6); + + Maybe result = await maybe.Or(() => ValueTask.FromResult(Maybe.From(99))); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(6); + } + + [Fact] + public async Task Or_FuncValueTaskMaybe_WithNoValue_ShouldReturnFallback() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.Or(() => ValueTask.FromResult(Maybe.From(99))); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(99); + } + + #endregion + + #region Or extension on Task> + + [Fact] + public async Task Or_TaskMaybeExtension_WithValue_ShouldReturnOriginal() + { + Task> maybeTask = Task.FromResult(Maybe.From(7)); + + Maybe result = await maybeTask.Or(99); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(7); + } + + [Fact] + public async Task Or_TaskMaybeExtension_WithNoValue_ShouldReturnFallback() + { + Task> maybeTask = Task.FromResult(Maybe.None); + + Maybe result = await maybeTask.Or(99); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(99); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Maybe/MaybeWhereChooseAsyncTests.cs b/CSharpEssentials.Tests/Maybe/MaybeWhereChooseAsyncTests.cs new file mode 100644 index 0000000..cedb9e1 --- /dev/null +++ b/CSharpEssentials.Tests/Maybe/MaybeWhereChooseAsyncTests.cs @@ -0,0 +1,168 @@ +using CSharpEssentials.Maybe; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Maybe; + +public class MaybeWhereChooseAsyncTests +{ + #region Where — Func> + + [Fact] + public async Task Where_TaskPredicate_WithValue_PredicateTrue_ShouldReturnValue() + { + var maybe = Maybe.From(10); + + Maybe result = await maybe.Where(v => Task.FromResult(v > 5)); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(10); + } + + [Fact] + public async Task Where_TaskPredicate_WithValue_PredicateFalse_ShouldReturnNone() + { + var maybe = Maybe.From(3); + + Maybe result = await maybe.Where(v => Task.FromResult(v > 5)); + + result.HasNoValue.Should().BeTrue(); + } + + [Fact] + public async Task Where_TaskPredicate_WithNoValue_ShouldReturnNone() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.Where(v => Task.FromResult(v > 5)); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region Where — Func> + + [Fact] + public async Task Where_ValueTaskPredicate_WithValue_PredicateTrue_ShouldReturnValue() + { + var maybe = Maybe.From("hello"); + + Maybe result = await maybe.Where(v => ValueTask.FromResult(v.Length > 3)); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be("hello"); + } + + [Fact] + public async Task Where_ValueTaskPredicate_WithValue_PredicateFalse_ShouldReturnNone() + { + var maybe = Maybe.From("hi"); + + Maybe result = await maybe.Where(v => ValueTask.FromResult(v.Length > 3)); + + result.HasNoValue.Should().BeTrue(); + } + + [Fact] + public async Task Where_ValueTaskPredicate_WithNoValue_ShouldReturnNone() + { + Maybe maybe = Maybe.None; + + Maybe result = await maybe.Where(v => ValueTask.FromResult(v.Length > 3)); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region Where extension on Task> + + [Fact] + public async Task Where_TaskMaybe_SyncPredicate_WithValue_PredicateTrue_ShouldReturnValue() + { + Task> maybeTask = Task.FromResult(Maybe.From(20)); + + Maybe result = await maybeTask.Where(v => v > 10); + + result.HasValue.Should().BeTrue(); + result.Value.Should().Be(20); + } + + [Fact] + public async Task Where_TaskMaybe_SyncPredicate_WithValue_PredicateFalse_ShouldReturnNone() + { + Task> maybeTask = Task.FromResult(Maybe.From(5)); + + Maybe result = await maybeTask.Where(v => v > 10); + + result.HasNoValue.Should().BeTrue(); + } + + [Fact] + public async Task Where_TaskMaybe_SyncPredicate_WithNoValue_ShouldReturnNone() + { + Task> maybeTask = Task.FromResult(Maybe.None); + + Maybe result = await maybeTask.Where(v => v > 10); + + result.HasNoValue.Should().BeTrue(); + } + + #endregion + + #region ChooseAsync — IEnumerable>> + + [Fact] + public async Task ChooseAsync_WithMixedTasks_ShouldReturnOnlyValues() + { + var tasks = new List>> + { + Task.FromResult(Maybe.From(1)), + Task.FromResult(Maybe.None), + Task.FromResult(Maybe.From(3)), + Task.FromResult(Maybe.None), + Task.FromResult(Maybe.From(5)) + }; + + var results = new List(); + await foreach (int value in tasks.ChooseAsync()) + results.Add(value); + + results.Should().BeEquivalentTo(new[] { 1, 3, 5 }); + } + + [Fact] + public async Task ChooseAsync_AllNone_ShouldReturnEmpty() + { + var tasks = new List>> + { + Task.FromResult(Maybe.None), + Task.FromResult(Maybe.None) + }; + + var results = new List(); + await foreach (int value in tasks.ChooseAsync()) + results.Add(value); + + results.Should().BeEmpty(); + } + + [Fact] + public async Task ChooseAsync_WithSelector_ShouldTransformValues() + { + var tasks = new List>> + { + Task.FromResult(Maybe.From(2)), + Task.FromResult(Maybe.None), + Task.FromResult(Maybe.From(4)) + }; + + var results = new List(); + await foreach (string value in tasks.ChooseAsync(v => v.ToString())) + results.Add(value); + + results.Should().BeEquivalentTo(new[] { "2", "4" }); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/RequestResponseLogging/LogMessageCreatorTests.cs b/CSharpEssentials.Tests/RequestResponseLogging/LogMessageCreatorTests.cs new file mode 100644 index 0000000..4d9df00 --- /dev/null +++ b/CSharpEssentials.Tests/RequestResponseLogging/LogMessageCreatorTests.cs @@ -0,0 +1,246 @@ +using CSharpEssentials.RequestResponseLogging; +using CSharpEssentials.RequestResponseLogging.Infrastructure.MessageCreators; +using FluentAssertions; +using Microsoft.AspNetCore.Http; + +namespace CSharpEssentials.Tests.RequestResponseLogging; + +public sealed class LogMessageCreatorTests +{ + private static RequestResponseContext CreateContext( + string? requestBody = null, + string? responseBody = null, + string path = "/test", + string method = "GET", + string queryString = "", + string host = "localhost") + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = method; + httpContext.Request.Path = path; + if (!string.IsNullOrEmpty(queryString)) + httpContext.Request.QueryString = new QueryString(queryString); + httpContext.Request.Host = new HostString(host); + + return new RequestResponseContext(httpContext) + { + RequestBody = requestBody, + ResponseBody = responseBody, + ResponseCreationTime = TimeSpan.FromMilliseconds(123) + }; + } + + [Fact] + public void Create_Should_ReturnNonEmptyLogString_When_FieldsConfigured() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Path] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(); + + (string logString, List? values) = creator.Create(context); + + logString.Should().NotBeNullOrEmpty(); + values.Should().BeNull(); + } + + [Fact] + public void Create_Should_ContainRequestBody_When_RequestFieldIncluded() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Request] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(requestBody: "my request payload"); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("my request payload"); + } + + [Fact] + public void Create_Should_ContainResponseBody_When_ResponseFieldIncluded() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Response] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(responseBody: "my response payload"); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("my response payload"); + } + + [Fact] + public void Create_Should_ContainPath_When_PathFieldIncluded() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Path] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(path: "/api/users"); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("/api/users"); + } + + [Fact] + public void Create_Should_ContainMethod_When_MethodFieldIncluded() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Method] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(method: "POST"); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("POST"); + } + + [Fact] + public void Create_Should_ContainQueryString_When_QueryStringFieldIncluded() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.QueryString] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(queryString: "?id=42"); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("id=42"); + } + + [Fact] + public void Create_Should_ContainHostName_When_HostNameFieldIncluded() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.HostName] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(host: "example.com"); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("example.com"); + } + + [Fact] + public void Create_Should_ContainResponseTiming_When_ResponseTimingFieldIncluded() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.ResponseTiming] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("ResponseTiming"); + } + + [Fact] + public void Create_Should_ContainRequestLength_When_RequestLengthFieldIncluded() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.RequestLength] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(requestBody: "hello"); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("5"); + } + + [Fact] + public void Create_Should_ContainResponseLength_When_ResponseLengthFieldIncluded() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.ResponseLength] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(responseBody: "hello world"); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("11"); + } + + [Fact] + public void Create_Should_ContainHeaders_When_HeadersFieldIncluded() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Headers], + HeaderKeys = ["X-Correlation-Id"] + }; + var creator = new LogMessageCreator(options); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Correlation-Id"] = "corr-999"; + var context = new RequestResponseContext(httpContext); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("corr-999"); + } + + [Fact] + public void Create_Should_ReturnEmptyString_When_NoFieldsConfigured() + { + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [] + }; + var creator = new LogMessageCreator(options); + var context = CreateContext(); + + (string logString, List? values) = creator.Create(context); + + logString.Should().BeEmpty(); + values.Should().BeNull(); + } + + [Fact] + public void Create_Should_ContainAllFields_When_AllFieldsConfigured() + { + var options = LoggingOptions.CreateAllFields(); + options.UseSeparateContext = false; + var creator = new LogMessageCreator(options); + var context = CreateContext(requestBody: "req", responseBody: "resp", path: "/all", method: "PUT"); + + (string logString, _) = creator.Create(context); + + logString.Should().Contain("req"); + logString.Should().Contain("resp"); + logString.Should().Contain("/all"); + logString.Should().Contain("PUT"); + } +} diff --git a/CSharpEssentials.Tests/RequestResponseLogging/LoggerFactoryLogWriterTests.cs b/CSharpEssentials.Tests/RequestResponseLogging/LoggerFactoryLogWriterTests.cs new file mode 100644 index 0000000..68b1c57 --- /dev/null +++ b/CSharpEssentials.Tests/RequestResponseLogging/LoggerFactoryLogWriterTests.cs @@ -0,0 +1,194 @@ +using CSharpEssentials.RequestResponseLogging; +using CSharpEssentials.RequestResponseLogging.LogWriters; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; + +namespace CSharpEssentials.Tests.RequestResponseLogging; + +public sealed class LoggerFactoryLogWriterTests +{ + private static RequestResponseContext CreateContext(string? requestBody = null, string? responseBody = null) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = "GET"; + httpContext.Request.Path = "/test"; + return new RequestResponseContext(httpContext) + { + RequestBody = requestBody, + ResponseBody = responseBody + }; + } + + [Fact] + public void MessageCreator_Should_BeLogMessageCreator_When_UseSeparateContextIsFalse() + { + var loggerFactory = LoggerFactory.Create(_ => { }); + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Request, LogFields.Response] + }; + + var writer = new LoggerFactoryLogWriter(loggerFactory, options); + + writer.MessageCreator.Should().NotBeNull(); + } + + [Fact] + public void MessageCreator_Should_BeLogMessageWithContextCreator_When_UseSeparateContextIsTrue() + { + var loggerFactory = LoggerFactory.Create(_ => { }); + var options = new LoggingOptions + { + UseSeparateContext = true, + LoggingFields = [LogFields.Request, LogFields.Response] + }; + + var writer = new LoggerFactoryLogWriter(loggerFactory, options); + + writer.MessageCreator.Should().NotBeNull(); + } + + [Fact] + public async Task Write_Should_CompleteSuccessfully_When_UseSeparateContextIsFalse() + { + var loggerFactory = LoggerFactory.Create(_ => { }); + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Path, LogFields.Method] + }; + var writer = new LoggerFactoryLogWriter(loggerFactory, options); + var context = CreateContext("request body", "response body"); + + Func act = () => writer.Write(context); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Write_Should_CompleteSuccessfully_When_UseSeparateContextIsTrue() + { + var loggerFactory = LoggerFactory.Create(_ => { }); + var options = new LoggingOptions + { + UseSeparateContext = true, + LoggingFields = [LogFields.Path, LogFields.Method] + }; + var writer = new LoggerFactoryLogWriter(loggerFactory, options); + var context = CreateContext("request body", "response body"); + + Func act = () => writer.Write(context); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Write_Should_CompleteSuccessfully_When_LoggingFieldsIsEmpty() + { + var loggerFactory = LoggerFactory.Create(_ => { }); + var options = new LoggingOptions + { + UseSeparateContext = true, + LoggingFields = [] + }; + var writer = new LoggerFactoryLogWriter(loggerFactory, options); + var context = CreateContext(); + + Func act = () => writer.Write(context); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Write_Should_NotThrow_When_AllFieldsLogged_WithSeparateContext() + { + var loggerFactory = LoggerFactory.Create(_ => { }); + var options = LoggingOptions.CreateAllFields(); + var writer = new LoggerFactoryLogWriter(loggerFactory, options); + var context = CreateContext("req", "resp"); + + Func act = () => writer.Write(context); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Write_Should_NotThrow_When_AllFieldsLogged_WithoutSeparateContext() + { + var loggerFactory = LoggerFactory.Create(_ => { }); + var options = LoggingOptions.CreateAllFields(); + options.UseSeparateContext = false; + var writer = new LoggerFactoryLogWriter(loggerFactory, options); + var context = CreateContext("req", "resp"); + + Func act = () => writer.Write(context); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Write_Should_UseCustomLoggerCategoryName_When_Configured() + { + var mockLoggerFactory = new Mock(); + var mockLogger = new Mock(); + mockLoggerFactory + .Setup(f => f.CreateLogger("MyCategory")) + .Returns(mockLogger.Object); + mockLogger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Path], + LoggerCategoryName = "MyCategory" + }; + var writer = new LoggerFactoryLogWriter(mockLoggerFactory.Object, options); + var context = CreateContext(); + + await writer.Write(context); + + mockLoggerFactory.Verify(f => f.CreateLogger("MyCategory"), Times.Once); + } + + [Fact] + public async Task Write_Should_RespectLogLevel_When_LevelIsWarning() + { + var loggerFactory = LoggerFactory.Create(b => b.SetMinimumLevel(LogLevel.Warning)); + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Path], + LoggingLevel = LogLevel.Information + }; + var writer = new LoggerFactoryLogWriter(loggerFactory, options); + var context = CreateContext(); + + Func act = () => writer.Write(context); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Write_Should_IncludeHeaderValues_When_HeaderKeysConfigured() + { + var loggerFactory = LoggerFactory.Create(_ => { }); + var options = new LoggingOptions + { + UseSeparateContext = false, + LoggingFields = [LogFields.Headers], + HeaderKeys = ["X-Request-Id"] + }; + var writer = new LoggerFactoryLogWriter(loggerFactory, options); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Request-Id"] = "abc-123"; + var context = new RequestResponseContext(httpContext); + + Func act = () => writer.Write(context); + + await act.Should().NotThrowAsync(); + } +} diff --git a/CSharpEssentials.Tests/RequestResponseLogging/MiddlewarePipelineTests.cs b/CSharpEssentials.Tests/RequestResponseLogging/MiddlewarePipelineTests.cs new file mode 100644 index 0000000..51a0c71 --- /dev/null +++ b/CSharpEssentials.Tests/RequestResponseLogging/MiddlewarePipelineTests.cs @@ -0,0 +1,261 @@ +using CSharpEssentials.RequestResponseLogging; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; + +namespace CSharpEssentials.Tests.RequestResponseLogging; + +public sealed class MiddlewarePipelineTests : IAsyncLifetime +{ + private IHost? _host; + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + if (_host is not null) + { + await _host.StopAsync(); + _host.Dispose(); + } + } + + private static IHostBuilder CreateHostBuilder(Action configureOptions, RequestDelegate? appHandler = null) => + new HostBuilder() + .ConfigureWebHost(webHost => + { + webHost.UseTestServer(); + webHost.ConfigureServices(services => services.AddRouting()); + webHost.Configure(app => + { + app.UseRouting(); + app.AddRequestResponseLogging(configureOptions); + app.Run(appHandler ?? (ctx => ctx.Response.WriteAsync("OK"))); + }); + }); + + [Fact] + public async Task InvokeAsync_Should_PassThrough_When_RequestIsNormal() + { + _host = await CreateHostBuilder(_ => { }).StartAsync(); + var client = _host.GetTestClient(); + + var response = await client.GetAsync("/test"); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Be("OK"); + } + + [Fact] + public async Task InvokeAsync_Should_SkipMiddleware_When_PathIsIgnored() + { + var handlerInvoked = false; + + _host = await CreateHostBuilder( + opts => opts.IgnorePaths("/health"), + ctx => + { + handlerInvoked = true; + return ctx.Response.WriteAsync("OK"); + }).StartAsync(); + + var client = _host.GetTestClient(); + + var response = await client.GetAsync("/health"); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + handlerInvoked.Should().BeTrue(); + } + + [Fact] + public async Task InvokeAsync_Should_LogRequest_When_LoggerFactoryConfigured() + { + var loggerFactory = LoggerFactory.Create(b => b.AddConsole()); + + _host = await CreateHostBuilder(opts => + opts.UseLogger(loggerFactory, loggingOpts => + { + loggingOpts.LoggingFields = [LogFields.Request, LogFields.Response, LogFields.Path]; + })).StartAsync(); + + var client = _host.GetTestClient(); + + var response = await client.GetAsync("/api/values"); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + } + + [Fact] + public async Task InvokeAsync_Should_InvokeHandler_When_HandlerConfigured() + { + RequestResponseContext? capturedContext = null; + + _host = await CreateHostBuilder(opts => + opts.UseHandler(ctx => + { + capturedContext = ctx; + return Task.CompletedTask; + })).StartAsync(); + + var client = _host.GetTestClient(); + + var response = await client.GetAsync("/api/data"); + + response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK); + capturedContext.Should().NotBeNull(); + } + + [Fact] + public async Task InvokeAsync_Should_CaptureResponseBody_When_HandlerConfigured() + { + RequestResponseContext? capturedContext = null; + + _host = await CreateHostBuilder( + opts => opts.UseHandler(ctx => + { + capturedContext = ctx; + return Task.CompletedTask; + }), + ctx => ctx.Response.WriteAsync("hello world")).StartAsync(); + + var client = _host.GetTestClient(); + await client.GetAsync("/test"); + + capturedContext.Should().NotBeNull(); + capturedContext!.ResponseBody.Should().Be("hello world"); + } + + [Fact] + public async Task InvokeAsync_Should_IgnorePath_When_PathStartsWithIgnoredPrefix() + { + RequestResponseContext? capturedContext = null; + + _host = await CreateHostBuilder(opts => + { + opts.IgnorePaths("/swagger"); + opts.UseHandler(ctx => + { + capturedContext = ctx; + return Task.CompletedTask; + }); + }).StartAsync(); + + var client = _host.GetTestClient(); + await client.GetAsync("/swagger/index.html"); + + capturedContext.Should().BeNull(); + } + + [Fact] + public async Task InvokeAsync_Should_ProcessPath_When_PathDoesNotMatchIgnored() + { + RequestResponseContext? capturedContext = null; + + _host = await CreateHostBuilder(opts => + { + opts.IgnorePaths("/swagger"); + opts.UseHandler(ctx => + { + capturedContext = ctx; + return Task.CompletedTask; + }); + }).StartAsync(); + + var client = _host.GetTestClient(); + await client.GetAsync("/api/test"); + + capturedContext.Should().NotBeNull(); + } + + [Fact] + public async Task InvokeAsync_Should_SkipRequestLogging_When_SkipRequestResponseLoggingAttributePresent() + { + RequestResponseContext? capturedContext = null; + + _host = new HostBuilder() + .ConfigureWebHost(webHost => + { + webHost.UseTestServer(); + webHost.ConfigureServices(services => services.AddRouting()); + webHost.Configure(app => + { + app.UseRouting(); + app.AddRequestResponseLogging(opts => + opts.UseHandler(ctx => + { + capturedContext = ctx; + return Task.CompletedTask; + })); + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/skip-all", [SkipRequestResponseLogging] () => "skipped") + .WithMetadata(new SkipRequestResponseLoggingAttribute()); + }); + }); + }) + .Build(); + + await _host.StartAsync(); + var client = _host.GetTestClient(); + await client.GetAsync("/skip-all"); + + capturedContext.Should().NotBeNull(); + capturedContext!.RequestBody.Should().Contain("Skipped"); + capturedContext.ResponseBody.Should().Contain("Skipped"); + } + + [Fact] + public async Task InvokeAsync_Should_SkipResponseLogging_When_SkipResponseLoggingAttributePresent() + { + RequestResponseContext? capturedContext = null; + + _host = new HostBuilder() + .ConfigureWebHost(webHost => + { + webHost.UseTestServer(); + webHost.ConfigureServices(services => services.AddRouting()); + webHost.Configure(app => + { + app.UseRouting(); + app.AddRequestResponseLogging(opts => + opts.UseHandler(ctx => + { + capturedContext = ctx; + return Task.CompletedTask; + })); + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/skip-response", () => "response body") + .WithMetadata(new SkipResponseLoggingAttribute()); + }); + }); + }) + .Build(); + + await _host.StartAsync(); + var client = _host.GetTestClient(); + await client.GetAsync("/skip-response"); + + capturedContext.Should().NotBeNull(); + capturedContext!.ResponseBody.Should().Contain("Skipped"); + } + + [Fact] + public async Task InvokeAsync_Should_UseNullLogWriter_When_NoLoggerFactoryConfigured() + { + _host = await CreateHostBuilder(_ => { }).StartAsync(); + var client = _host.GetTestClient(); + + Func act = () => client.GetAsync("/test"); + + await act.Should().NotThrowAsync(); + } +} diff --git a/CSharpEssentials.Tests/RequestResponseLogging/SkipAttributesTests.cs b/CSharpEssentials.Tests/RequestResponseLogging/SkipAttributesTests.cs new file mode 100644 index 0000000..7967ced --- /dev/null +++ b/CSharpEssentials.Tests/RequestResponseLogging/SkipAttributesTests.cs @@ -0,0 +1,104 @@ +using CSharpEssentials.RequestResponseLogging; +using FluentAssertions; + +namespace CSharpEssentials.Tests.RequestResponseLogging; + +public sealed class SkipAttributesTests +{ + [Fact] + public void SkipResponseLoggingAttribute_Should_BeInstantiable() + { + var attribute = new SkipResponseLoggingAttribute(); + + attribute.Should().NotBeNull(); + attribute.Should().BeAssignableTo(); + } + + [Fact] + public void SkipResponseLoggingAttribute_Should_HaveCorrectAttributeUsage() + { + var usage = typeof(SkipResponseLoggingAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .Single(); + + usage.ValidOn.Should().HaveFlag(AttributeTargets.Class); + usage.ValidOn.Should().HaveFlag(AttributeTargets.Method); + usage.AllowMultiple.Should().BeFalse(); + usage.Inherited.Should().BeFalse(); + } + + [Fact] + public void SkipRequestResponseLoggingAttribute_Should_BeInstantiable() + { + var attribute = new SkipRequestResponseLoggingAttribute(); + + attribute.Should().NotBeNull(); + attribute.Should().BeAssignableTo(); + } + + [Fact] + public void SkipRequestResponseLoggingAttribute_Should_HaveCorrectAttributeUsage() + { + var usage = typeof(SkipRequestResponseLoggingAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .Single(); + + usage.ValidOn.Should().HaveFlag(AttributeTargets.Class); + usage.ValidOn.Should().HaveFlag(AttributeTargets.Method); + usage.AllowMultiple.Should().BeFalse(); + usage.Inherited.Should().BeFalse(); + } + + [Fact] + public void SkipResponseLoggingAttribute_Should_BeApplicableToMethod() + { + var methodInfo = typeof(FakeController).GetMethod(nameof(FakeController.SkippedResponseAction))!; + var attribute = methodInfo.GetCustomAttributes(typeof(SkipResponseLoggingAttribute), false); + + attribute.Should().HaveCount(1); + } + + [Fact] + public void SkipRequestResponseLoggingAttribute_Should_BeApplicableToClass() + { + var attribute = typeof(FullySkippedController) + .GetCustomAttributes(typeof(SkipRequestResponseLoggingAttribute), false); + + attribute.Should().HaveCount(1); + } + + [Fact] + public void SkipResponseLoggingAttribute_Should_BeApplicableToClass() + { + var attribute = typeof(ResponseSkippedController) + .GetCustomAttributes(typeof(SkipResponseLoggingAttribute), false); + + attribute.Should().HaveCount(1); + } + + [Fact] + public void SkipRequestResponseLoggingAttribute_Should_BeApplicableToMethod() + { + var methodInfo = typeof(FakeController).GetMethod(nameof(FakeController.FullySkippedAction))!; + var attribute = methodInfo.GetCustomAttributes(typeof(SkipRequestResponseLoggingAttribute), false); + + attribute.Should().HaveCount(1); + } + + private sealed class FakeController + { + [SkipResponseLogging] + public void SkippedResponseAction() { } + + [SkipRequestResponseLogging] + public void FullySkippedAction() { } + } + + [SkipRequestResponseLogging] + private sealed class FullySkippedController { } + + [SkipResponseLogging] + private sealed class ResponseSkippedController { } +} diff --git a/CSharpEssentials.Tests/Results/ResultCombineAsyncTests.cs b/CSharpEssentials.Tests/Results/ResultCombineAsyncTests.cs new file mode 100644 index 0000000..6609f67 --- /dev/null +++ b/CSharpEssentials.Tests/Results/ResultCombineAsyncTests.cs @@ -0,0 +1,184 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.ResultPattern; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Results; + +public class ResultCombineAsyncTests +{ + private static readonly Error ErrorA = Error.Failure("ERR_A", "Error A"); + private static readonly Error ErrorB = Error.Validation("ERR_B", "Error B"); + private static readonly Error ErrorC = Error.NotFound("ERR_C", "Error C"); + + #region Combine two async results + + [Fact] + public async Task CombineAsync_Should_ReturnTuple_When_BothTasksSucceed() + { + Result first = await Task.FromResult(42.ToResult()); + Result second = await Task.FromResult("hello".ToResult()); + + Result<(int, string)> combined = Result.Combine(first, second); + + combined.IsSuccess.Should().BeTrue(); + combined.Value.Item1.Should().Be(42); + combined.Value.Item2.Should().Be("hello"); + } + + [Fact] + public async Task CombineAsync_Should_ReturnFailure_When_FirstTaskFails() + { + Result first = await Task.FromResult(Result.Failure(ErrorA)); + Result second = await Task.FromResult("hello".ToResult()); + + Result<(int, string)> combined = Result.Combine(first, second); + + combined.IsFailure.Should().BeTrue(); + combined.Errors.Should().ContainSingle(e => e.Code == "ERR_A"); + } + + [Fact] + public async Task CombineAsync_Should_ReturnFailure_When_SecondTaskFails() + { + Result first = await Task.FromResult(42.ToResult()); + Result second = await Task.FromResult(Result.Failure(ErrorB)); + + Result<(int, string)> combined = Result.Combine(first, second); + + combined.IsFailure.Should().BeTrue(); + combined.Errors.Should().ContainSingle(e => e.Code == "ERR_B"); + } + + [Fact] + public async Task CombineAsync_Should_AggregateAllErrors_When_BothTasksFail() + { + Result first = await Task.FromResult(Result.Failure(ErrorA)); + Result second = await Task.FromResult(Result.Failure(ErrorB)); + + Result<(int, string)> combined = Result.Combine(first, second); + + combined.IsFailure.Should().BeTrue(); + combined.Errors.Should().HaveCount(2); + combined.Errors.Should().Contain(e => e.Code == "ERR_A"); + combined.Errors.Should().Contain(e => e.Code == "ERR_B"); + } + + #endregion + + #region Combine three async results + + [Fact] + public async Task CombineAsync_Should_ReturnTuple_When_AllThreeTasksSucceed() + { + Result first = await Task.FromResult(1.ToResult()); + Result second = await Task.FromResult("two".ToResult()); + Result third = await Task.FromResult(true.ToResult()); + + Result<(int, string, bool)> combined = Result.Combine(first, second, third); + + combined.IsSuccess.Should().BeTrue(); + combined.Value.Item1.Should().Be(1); + combined.Value.Item2.Should().Be("two"); + combined.Value.Item3.Should().BeTrue(); + } + + [Fact] + public async Task CombineAsync_Should_ReturnFailure_When_OneOfThreeTasksFails() + { + Result first = await Task.FromResult(1.ToResult()); + Result second = await Task.FromResult(Result.Failure(ErrorB)); + Result third = await Task.FromResult(true.ToResult()); + + Result<(int, string, bool)> combined = Result.Combine(first, second, third); + + combined.IsFailure.Should().BeTrue(); + combined.Errors.Should().ContainSingle(e => e.Code == "ERR_B"); + } + + [Fact] + public async Task CombineAsync_Should_AggregateAllErrors_When_AllThreeTasksFail() + { + Result first = await Task.FromResult(Result.Failure(ErrorA)); + Result second = await Task.FromResult(Result.Failure(ErrorB)); + Result third = await Task.FromResult(Result.Failure(ErrorC)); + + Result<(int, string, bool)> combined = Result.Combine(first, second, third); + + combined.IsFailure.Should().BeTrue(); + combined.Errors.Should().HaveCount(3); + combined.Errors.Should().Contain(e => e.Code == "ERR_A"); + combined.Errors.Should().Contain(e => e.Code == "ERR_B"); + combined.Errors.Should().Contain(e => e.Code == "ERR_C"); + } + + #endregion + + #region Combine via Task.WhenAll pattern + + [Fact] + public async Task CombineAsync_Should_ReturnTuple_When_ParallelTasksSucceed() + { + Task> taskA = Task.Run(() => 10.ToResult()); + Task> taskB = Task.Run(() => "world".ToResult()); + + Result first = await taskA; + Result second = await taskB; + + Result<(int, string)> combined = Result.Combine(first, second); + + combined.IsSuccess.Should().BeTrue(); + combined.Value.Should().Be((10, "world")); + } + + [Fact] + public async Task CombineAsync_Should_ReturnFailure_When_OneParallelTaskFails() + { + Task> taskA = Task.Run(() => Result.Failure(ErrorA)); + Task> taskB = Task.Run(() => "world".ToResult()); + + Result first = await taskA; + Result second = await taskB; + + Result<(int, string)> combined = Result.Combine(first, second); + + combined.IsFailure.Should().BeTrue(); + combined.FirstError.Code.Should().Be("ERR_A"); + } + + #endregion + + #region Combine four async results + + [Fact] + public async Task CombineAsync_Should_ReturnTuple_When_AllFourTasksSucceed() + { + Result first = await Task.FromResult(1.ToResult()); + Result second = await Task.FromResult("two".ToResult()); + Result third = await Task.FromResult(true.ToResult()); + Result fourth = await Task.FromResult(3.14.ToResult()); + + Result<(int, string, bool, double)> combined = Result.Combine(first, second, third, fourth); + + combined.IsSuccess.Should().BeTrue(); + combined.Value.Item1.Should().Be(1); + combined.Value.Item2.Should().Be("two"); + combined.Value.Item3.Should().BeTrue(); + combined.Value.Item4.Should().Be(3.14); + } + + [Fact] + public async Task CombineAsync_Should_ReturnFailure_When_OneOfFourTasksFails() + { + Result first = await Task.FromResult(1.ToResult()); + Result second = await Task.FromResult("two".ToResult()); + Result third = await Task.FromResult(Result.Failure(ErrorC)); + Result fourth = await Task.FromResult(3.14.ToResult()); + + Result<(int, string, bool, double)> combined = Result.Combine(first, second, third, fourth); + + combined.IsFailure.Should().BeTrue(); + combined.Errors.Should().ContainSingle(e => e.Code == "ERR_C"); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Results/ResultComparerTests.cs b/CSharpEssentials.Tests/Results/ResultComparerTests.cs new file mode 100644 index 0000000..9a71608 --- /dev/null +++ b/CSharpEssentials.Tests/Results/ResultComparerTests.cs @@ -0,0 +1,351 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.ResultPattern; +using CSharpEssentials.ResultPattern.Comparers; +using CSharpEssentials.ResultPattern.Interfaces; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Results; + +public class ResultComparerTests +{ + private static readonly Error ErrorA = Error.Failure("ERR_A", "Error A"); + private static readonly Error ErrorB = Error.Validation("ERR_B", "Error B"); + + #region ResultBaseComparer + + [Fact] + public void ResultBaseComparer_Equals_Should_ReturnTrue_When_BothNull() + { + var comparer = new ResultBaseComparer(); + + bool result = comparer.Equals(null, null); + + result.Should().BeTrue(); + } + + [Fact] + public void ResultBaseComparer_Equals_Should_ReturnFalse_When_FirstIsNull() + { + var comparer = new ResultBaseComparer(); + IResultBase other = Result.Success(); + + bool result = comparer.Equals(null, other); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultBaseComparer_Equals_Should_ReturnFalse_When_SecondIsNull() + { + var comparer = new ResultBaseComparer(); + IResultBase other = Result.Success(); + + bool result = comparer.Equals(other, null); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultBaseComparer_Equals_Should_ReturnTrue_When_BothSucceed() + { + var comparer = new ResultBaseComparer(); + IResultBase x = Result.Success(); + IResultBase y = Result.Success(); + + bool result = comparer.Equals(x, y); + + result.Should().BeTrue(); + } + + [Fact] + public void ResultBaseComparer_Equals_Should_ReturnFalse_When_OneSucceedsAndOneFails() + { + var comparer = new ResultBaseComparer(); + IResultBase x = Result.Success(); + IResultBase y = Result.Failure(ErrorA); + + bool result = comparer.Equals(x, y); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultBaseComparer_Equals_Should_ReturnFalse_When_OneFailsAndOneSucceeds() + { + var comparer = new ResultBaseComparer(); + IResultBase x = Result.Failure(ErrorA); + IResultBase y = Result.Success(); + + bool result = comparer.Equals(x, y); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultBaseComparer_Equals_Should_ReturnTrue_When_BothFailWithSameErrors() + { + var comparer = new ResultBaseComparer(); + IResultBase x = Result.Failure(ErrorA); + IResultBase y = Result.Failure(ErrorA); + + bool result = comparer.Equals(x, y); + + result.Should().BeTrue(); + } + + [Fact] + public void ResultBaseComparer_Equals_Should_ReturnFalse_When_BothFailWithDifferentErrors() + { + var comparer = new ResultBaseComparer(); + IResultBase x = Result.Failure(ErrorA); + IResultBase y = Result.Failure(ErrorB); + + bool result = comparer.Equals(x, y); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultBaseComparer_GetHashCode_Should_BeSame_When_BothSucceed() + { + var comparer = new ResultBaseComparer(); + IResultBase x = Result.Success(); + IResultBase y = Result.Success(); + + int hashX = comparer.GetHashCode(x); + int hashY = comparer.GetHashCode(y); + + hashX.Should().Be(hashY); + } + + [Fact] + public void ResultBaseComparer_GetHashCode_Should_BeSame_When_BothFailWithSameErrors() + { + var comparer = new ResultBaseComparer(); + IResultBase x = Result.Failure(ErrorA); + IResultBase y = Result.Failure(ErrorA); + + int hashX = comparer.GetHashCode(x); + int hashY = comparer.GetHashCode(y); + + hashX.Should().Be(hashY); + } + + [Fact] + public void ResultBaseComparer_GetHashCode_Should_BeDifferent_When_SuccessVsFailure() + { + var comparer = new ResultBaseComparer(); + IResultBase success = Result.Success(); + IResultBase failure = Result.Failure(ErrorA); + + int hashSuccess = comparer.GetHashCode(success); + int hashFailure = comparer.GetHashCode(failure); + + hashSuccess.Should().NotBe(hashFailure); + } + + #endregion + + #region ResultComparer + + [Fact] + public void ResultComparer_Equals_Should_ReturnTrue_When_BothSucceed() + { + var comparer = new ResultComparer(); + IResultBase x = Result.Success(); + IResultBase y = Result.Success(); + + bool result = comparer.Equals(x, y); + + result.Should().BeTrue(); + } + + [Fact] + public void ResultComparer_Equals_Should_ReturnFalse_When_OneSucceedsAndOneFails() + { + var comparer = new ResultComparer(); + IResultBase x = Result.Success(); + IResultBase y = Result.Failure(ErrorA); + + bool result = comparer.Equals(x, y); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultComparer_Equals_Should_ReturnTrue_When_BothFailWithSameErrors() + { + var comparer = new ResultComparer(); + IResultBase x = Result.Failure(ErrorA); + IResultBase y = Result.Failure(ErrorA); + + bool result = comparer.Equals(x, y); + + result.Should().BeTrue(); + } + + [Fact] + public void ResultComparer_GetHashCode_Should_BeSame_When_BothSucceed() + { + var comparer = new ResultComparer(); + IResultBase x = Result.Success(); + IResultBase y = Result.Success(); + + int hashX = comparer.GetHashCode(x); + int hashY = comparer.GetHashCode(y); + + hashX.Should().Be(hashY); + } + + #endregion + + #region ResultComparer + + [Fact] + public void ResultComparerT_Equals_Should_ReturnTrue_When_BothNull() + { + var comparer = new ResultComparer(); + + bool result = comparer.Equals(null, null); + + result.Should().BeTrue(); + } + + [Fact] + public void ResultComparerT_Equals_Should_ReturnFalse_When_FirstIsNull() + { + var comparer = new ResultComparer(); + IResult other = 42.ToResult(); + + bool result = comparer.Equals(null, other); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultComparerT_Equals_Should_ReturnFalse_When_SecondIsNull() + { + var comparer = new ResultComparer(); + IResult other = 42.ToResult(); + + bool result = comparer.Equals(other, null); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultComparerT_Equals_Should_ReturnTrue_When_BothSucceedWithSameValue() + { + var comparer = new ResultComparer(); + IResult x = 42.ToResult(); + IResult y = 42.ToResult(); + + bool result = comparer.Equals(x, y); + + result.Should().BeTrue(); + } + + [Fact] + public void ResultComparerT_Equals_Should_ReturnFalse_When_BothSucceedWithDifferentValues() + { + var comparer = new ResultComparer(); + IResult x = 42.ToResult(); + IResult y = 99.ToResult(); + + bool result = comparer.Equals(x, y); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultComparerT_Equals_Should_ReturnFalse_When_OneSucceedsAndOneFails() + { + var comparer = new ResultComparer(); + IResult x = 42.ToResult(); + IResult y = Result.Failure(ErrorA); + + bool result = comparer.Equals(x, y); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultComparerT_Equals_Should_ReturnTrue_When_BothFailWithSameErrors() + { + var comparer = new ResultComparer(); + IResult x = Result.Failure(ErrorA); + IResult y = Result.Failure(ErrorA); + + bool result = comparer.Equals(x, y); + + result.Should().BeTrue(); + } + + [Fact] + public void ResultComparerT_Equals_Should_ReturnFalse_When_BothFailWithDifferentErrors() + { + var comparer = new ResultComparer(); + IResult x = Result.Failure(ErrorA); + IResult y = Result.Failure(ErrorB); + + bool result = comparer.Equals(x, y); + + result.Should().BeFalse(); + } + + [Fact] + public void ResultComparerT_Equals_Should_UseCustomComparer_When_Provided() + { + var customComparer = EqualityComparer.Create( + (a, b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase), + s => s?.ToUpperInvariant().GetHashCode() ?? 0); + var comparer = new ResultComparer(customComparer); + IResult x = "hello".ToResult(); + IResult y = "HELLO".ToResult(); + + bool result = comparer.Equals(x, y); + + result.Should().BeTrue(); + } + + [Fact] + public void ResultComparerT_GetHashCode_Should_BeSame_When_BothSucceedWithSameValue() + { + var comparer = new ResultComparer(); + IResult x = 42.ToResult(); + IResult y = 42.ToResult(); + + int hashX = comparer.GetHashCode(x); + int hashY = comparer.GetHashCode(y); + + hashX.Should().Be(hashY); + } + + [Fact] + public void ResultComparerT_GetHashCode_Should_BeSame_When_BothFailWithSameErrors() + { + var comparer = new ResultComparer(); + IResult x = Result.Failure(ErrorA); + IResult y = Result.Failure(ErrorA); + + int hashX = comparer.GetHashCode(x); + int hashY = comparer.GetHashCode(y); + + hashX.Should().Be(hashY); + } + + [Fact] + public void ResultComparerT_GetHashCode_Should_BeDifferent_When_SuccessVsFailure() + { + var comparer = new ResultComparer(); + IResult success = 42.ToResult(); + IResult failure = Result.Failure(ErrorA); + + int hashSuccess = comparer.GetHashCode(success); + int hashFailure = comparer.GetHashCode(failure); + + hashSuccess.Should().NotBe(hashFailure); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Results/ResultMapAsyncTests.cs b/CSharpEssentials.Tests/Results/ResultMapAsyncTests.cs new file mode 100644 index 0000000..e54b606 --- /dev/null +++ b/CSharpEssentials.Tests/Results/ResultMapAsyncTests.cs @@ -0,0 +1,195 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.ResultPattern; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Results; + +public class ResultMapAsyncTests +{ + private static readonly Error TestError = Error.Failure("TEST", "Test error"); + + #region Task.MapAsync(Func) + + [Fact] + public async Task MapAsync_Should_TransformResult_When_TaskResultIsSuccess() + { + Task task = Task.FromResult(Result.Success()); + + Result mapped = await task.MapAsync(() => 42); + + mapped.IsSuccess.Should().BeTrue(); + mapped.Value.Should().Be(42); + } + + [Fact] + public async Task MapAsync_Should_ReturnFailure_When_TaskResultIsFailure() + { + Task task = Task.FromResult(Result.Failure(TestError)); + bool called = false; + + Result mapped = await task.MapAsync(() => + { + called = true; + return 42; + }); + + mapped.IsFailure.Should().BeTrue(); + called.Should().BeFalse(); + } + + #endregion + + #region Task.MapAsync(Func>) + + [Fact] + public async Task MapAsync_Should_TransformResult_When_TaskResultIsSuccess_AndMapIsAsync() + { + Task task = Task.FromResult(Result.Success()); + + Result mapped = await task.MapAsync(() => Task.FromResult(42)); + + mapped.IsSuccess.Should().BeTrue(); + mapped.Value.Should().Be(42); + } + + [Fact] + public async Task MapAsync_Should_ReturnFailure_When_TaskResultIsFailure_AndMapIsAsync() + { + Task task = Task.FromResult(Result.Failure(TestError)); + bool called = false; + + Result mapped = await task.MapAsync(() => + { + called = true; + return Task.FromResult(42); + }); + + mapped.IsFailure.Should().BeTrue(); + called.Should().BeFalse(); + } + + [Fact] + public async Task MapAsync_Should_PreserveErrors_When_TaskResultIsFailure_AndMapIsAsync() + { + Task task = Task.FromResult(Result.Failure(TestError)); + + Result mapped = await task.MapAsync(() => Task.FromResult(99)); + + mapped.IsFailure.Should().BeTrue(); + mapped.FirstError.Code.Should().Be("TEST"); + } + + #endregion + + #region Task>.MapAsync(Func) + + [Fact] + public async Task MapAsync_Should_TransformValue_When_TaskResultTIsSuccess() + { + Task> task = Task.FromResult(10.ToResult()); + + Result mapped = await task.MapAsync(v => v.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + mapped.IsSuccess.Should().BeTrue(); + mapped.Value.Should().Be("10"); + } + + [Fact] + public async Task MapAsync_Should_ReturnFailure_When_TaskResultTIsFailure() + { + Task> task = Task.FromResult(Result.Failure(TestError)); + bool called = false; + + Result mapped = await task.MapAsync(v => + { + called = true; + return v.ToString(System.Globalization.CultureInfo.InvariantCulture); + }); + + mapped.IsFailure.Should().BeTrue(); + called.Should().BeFalse(); + } + + [Fact] + public async Task MapAsync_Should_PreserveErrors_When_TaskResultTIsFailure() + { + Task> task = Task.FromResult(Result.Failure(TestError)); + + Result mapped = await task.MapAsync(v => v.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + mapped.IsFailure.Should().BeTrue(); + mapped.FirstError.Code.Should().Be("TEST"); + } + + #endregion + + #region Task>.MapAsync(Func>) + + [Fact] + public async Task MapAsync_Should_TransformValue_When_TaskResultTIsSuccess_AndMapIsAsync() + { + Task> task = Task.FromResult(10.ToResult()); + + Result mapped = await task.MapAsync(v => Task.FromResult(v.ToString(System.Globalization.CultureInfo.InvariantCulture))); + + mapped.IsSuccess.Should().BeTrue(); + mapped.Value.Should().Be("10"); + } + + [Fact] + public async Task MapAsync_Should_ReturnFailure_When_TaskResultTIsFailure_AndMapIsAsync() + { + Task> task = Task.FromResult(Result.Failure(TestError)); + bool called = false; + + Result mapped = await task.MapAsync(v => + { + called = true; + return Task.FromResult(v.ToString(System.Globalization.CultureInfo.InvariantCulture)); + }); + + mapped.IsFailure.Should().BeTrue(); + called.Should().BeFalse(); + } + + [Fact] + public async Task MapAsync_Should_PreserveErrors_When_TaskResultTIsFailure_AndMapIsAsync() + { + Task> task = Task.FromResult(Result.Failure(TestError)); + + Result mapped = await task.MapAsync(v => Task.FromResult(v.ToString(System.Globalization.CultureInfo.InvariantCulture))); + + mapped.IsFailure.Should().BeTrue(); + mapped.FirstError.Code.Should().Be("TEST"); + } + + #endregion + + #region CancellationToken + + [Fact] + public async Task MapAsync_Should_RespectCancellationToken_When_TaskResultIsSuccess() + { + using var cts = new CancellationTokenSource(); + Task task = Task.FromResult(Result.Success()); + + Result mapped = await task.MapAsync(() => 42, cts.Token); + + mapped.IsSuccess.Should().BeTrue(); + mapped.Value.Should().Be(42); + } + + [Fact] + public async Task MapAsync_Should_RespectCancellationToken_When_TaskResultTIsSuccess() + { + using var cts = new CancellationTokenSource(); + Task> task = Task.FromResult(10.ToResult()); + + Result mapped = await task.MapAsync(v => v.ToString(System.Globalization.CultureInfo.InvariantCulture), cts.Token); + + mapped.IsSuccess.Should().BeTrue(); + mapped.Value.Should().Be("10"); + } + + #endregion +} diff --git a/CSharpEssentials.Tests/Results/StringResultExtensionsTests.cs b/CSharpEssentials.Tests/Results/StringResultExtensionsTests.cs new file mode 100644 index 0000000..b543028 --- /dev/null +++ b/CSharpEssentials.Tests/Results/StringResultExtensionsTests.cs @@ -0,0 +1,192 @@ +using CSharpEssentials.Errors; +using CSharpEssentials.ResultPattern; +using FluentAssertions; + +namespace CSharpEssentials.Tests.Results; + +public class StringResultExtensionsTests +{ + #region TrimStart + + [Fact] + public void TrimStart_Should_ReturnTrimmedString_When_InputStartsWithPrefix() + { + string input = "Hello, World!"; + + Result result = input.TrimStart("Hello, "); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("World!"); + } + + [Fact] + public void TrimStart_Should_ReturnOriginalString_When_InputDoesNotStartWithPrefix() + { + string input = "Hello, World!"; + + Result result = input.TrimStart("Goodbye, "); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("Hello, World!"); + } + + [Fact] + public void TrimStart_Should_ReturnFailure_When_InputIsNull() + { + string? input = null; + + Result result = input.TrimStart("prefix"); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("InputIsEmpty"); + } + + [Fact] + public void TrimStart_Should_ReturnFailure_When_InputIsEmpty() + { + string input = string.Empty; + + Result result = input.TrimStart("prefix"); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("InputIsEmpty"); + } + + [Fact] + public void TrimStart_Should_ReturnFailure_When_PrefixIsEmpty() + { + string input = "Hello, World!"; + + Result result = input.TrimStart(string.Empty); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("PrefixIsEmpty"); + } + + [Fact] + public void TrimStart_Should_RespectComparisonType_When_CaseSensitive() + { + string input = "Hello, World!"; + + Result result = input.TrimStart("hello, ", StringComparison.Ordinal); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("Hello, World!"); + } + + [Fact] + public void TrimStart_Should_RespectComparisonType_When_CaseInsensitive() + { + string input = "Hello, World!"; + + Result result = input.TrimStart("hello, ", StringComparison.OrdinalIgnoreCase); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("World!"); + } + + [Fact] + public void TrimStart_Should_ReturnEmptyString_When_InputEqualsPrefix() + { + string input = "prefix"; + + Result result = input.TrimStart("prefix"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(string.Empty); + } + + #endregion + + #region TrimEnd + + [Fact] + public void TrimEnd_Should_ReturnTrimmedString_When_InputEndsWithSuffix() + { + string input = "Hello, World!"; + + Result result = input.TrimEnd(", World!"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("Hello"); + } + + [Fact] + public void TrimEnd_Should_ReturnOriginalString_When_InputDoesNotEndWithSuffix() + { + string input = "Hello, World!"; + + Result result = input.TrimEnd(", Universe!"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("Hello, World!"); + } + + [Fact] + public void TrimEnd_Should_ReturnFailure_When_InputIsNull() + { + string? input = null; + + Result result = input.TrimEnd("suffix"); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("InputIsEmpty"); + } + + [Fact] + public void TrimEnd_Should_ReturnFailure_When_InputIsEmpty() + { + string input = string.Empty; + + Result result = input.TrimEnd("suffix"); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("InputIsEmpty"); + } + + [Fact] + public void TrimEnd_Should_ReturnFailure_When_SuffixIsEmpty() + { + string input = "Hello, World!"; + + Result result = input.TrimEnd(string.Empty); + + result.IsFailure.Should().BeTrue(); + result.FirstError.Code.Should().Be("SuffixIsEmpty"); + } + + [Fact] + public void TrimEnd_Should_RespectComparisonType_When_CaseSensitive() + { + string input = "Hello, World!"; + + Result result = input.TrimEnd(", WORLD!", StringComparison.Ordinal); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("Hello, World!"); + } + + [Fact] + public void TrimEnd_Should_RespectComparisonType_When_CaseInsensitive() + { + string input = "Hello, World!"; + + Result result = input.TrimEnd(", WORLD!", StringComparison.OrdinalIgnoreCase); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("Hello"); + } + + [Fact] + public void TrimEnd_Should_ReturnEmptyString_When_InputEqualsSuffix() + { + string input = "suffix"; + + Result result = input.TrimEnd("suffix"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(string.Empty); + } + + #endregion +} diff --git a/README.MD b/README.MD index c14651b..08cd369 100644 --- a/README.MD +++ b/README.MD @@ -5,7 +5,7 @@ # CSharpEssentials [![Build](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml/badge.svg)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) -[![Tests](https://img.shields.io/badge/tests-2425%20passing-brightgreen)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) +[![Tests](https://img.shields.io/badge/tests-2708%20passing-brightgreen)](https://github.com/senrecep/CSharpEssentials/actions/workflows/build.yml) [![NuGet](https://img.shields.io/nuget/v/CSharpEssentials.svg)](https://www.nuget.org/packages/CSharpEssentials) [![Downloads](https://img.shields.io/nuget/dt/CSharpEssentials.svg)](https://www.nuget.org/packages/CSharpEssentials) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/senrecep/CSharpEssentials/blob/main/LICENCE) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..60b5460 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,16 @@ +coverage: + status: + project: + default: + target: auto + threshold: 1% + patch: + default: + target: auto + threshold: 5% + +comment: + layout: "reach,diff,flags,tree" + behavior: default + require_changes: false + hide_project_coverage: false