diff --git a/Directory.Packages.props b/Directory.Packages.props index d5081413..257642c2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,16 +33,19 @@ + + + diff --git a/Pulse.slnx b/Pulse.slnx index 69612508..0f59473d 100644 --- a/Pulse.slnx +++ b/Pulse.slnx @@ -33,8 +33,10 @@ + + diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkEventOutboxTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkEventOutboxTests.cs new file mode 100644 index 00000000..11d9b7bb --- /dev/null +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkEventOutboxTests.cs @@ -0,0 +1,117 @@ +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using NetEvolve.Pulse; +using NetEvolve.Pulse.Extensibility; +using NetEvolve.Pulse.Outbox; +using TUnit.Core; + +public sealed class EntityFrameworkEventOutboxTests +{ + [Test] + public async Task Constructor_WithNullContext_ThrowsArgumentNullException() => + _ = await Assert + .That(() => + new EntityFrameworkEventOutbox( + null!, + Options.Create(new OutboxOptions()), + TimeProvider.System + ) + ) + .Throws(); + + [Test] + public async Task Constructor_WithNullOptions_ThrowsArgumentNullException() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(Constructor_WithNullOptions_ThrowsArgumentNullException)) + .Options; + await using var context = new TestDbContext(options); + + _ = await Assert + .That(() => new EntityFrameworkEventOutbox(context, null!, TimeProvider.System)) + .Throws(); + } + + [Test] + public async Task Constructor_WithNullTimeProvider_ThrowsArgumentNullException() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(Constructor_WithNullTimeProvider_ThrowsArgumentNullException)) + .Options; + await using var context = new TestDbContext(options); + + _ = await Assert + .That(() => + new EntityFrameworkEventOutbox(context, Options.Create(new OutboxOptions()), null!) + ) + .Throws(); + } + + [Test] + public async Task Constructor_WithValidArguments_CreatesInstance() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(Constructor_WithValidArguments_CreatesInstance)) + .Options; + await using var context = new TestDbContext(options); + + var outbox = new EntityFrameworkEventOutbox( + context, + Options.Create(new OutboxOptions()), + TimeProvider.System + ); + + _ = await Assert.That(outbox).IsNotNull(); + } + + [Test] + public async Task StoreAsync_WithNullMessage_ThrowsArgumentNullException() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(StoreAsync_WithNullMessage_ThrowsArgumentNullException)) + .Options; + await using var context = new TestDbContext(options); + var outbox = new EntityFrameworkEventOutbox( + context, + Options.Create(new OutboxOptions()), + TimeProvider.System + ); + + _ = await Assert + .That(async () => await outbox.StoreAsync(null!).ConfigureAwait(false)) + .Throws(); + } + + [Test] + public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException)) + .Options; + await using var context = new TestDbContext(options); + var outbox = new EntityFrameworkEventOutbox( + context, + Options.Create(new OutboxOptions()), + TimeProvider.System + ); + var message = new TestEvent + { + CorrelationId = new string('x', OutboxMessageSchema.MaxLengths.CorrelationId + 1), + }; + + _ = await Assert + .That(async () => await outbox.StoreAsync(message).ConfigureAwait(false)) + .Throws(); + } + + private sealed record TestEvent : IEvent + { + public string? CorrelationId { get; set; } + public string Id { get; init; } = Guid.NewGuid().ToString(); + public DateTimeOffset? PublishedAt { get; set; } + } +} diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkMediatorConfiguratorExtensionsTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkMediatorConfiguratorExtensionsTests.cs new file mode 100644 index 00000000..b7a32a05 --- /dev/null +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkMediatorConfiguratorExtensionsTests.cs @@ -0,0 +1,165 @@ +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NetEvolve.Pulse; +using NetEvolve.Pulse.Extensibility; +using NetEvolve.Pulse.Outbox; +using TUnit.Core; + +public sealed class EntityFrameworkMediatorConfiguratorExtensionsTests +{ + [Test] + public async Task AddEntityFrameworkOutbox_WithNullConfigurator_ThrowsArgumentNullException() => + _ = await Assert + .That(() => EntityFrameworkMediatorConfiguratorExtensions.AddEntityFrameworkOutbox(null!)) + .Throws(); + + [Test] + public async Task AddEntityFrameworkOutbox_WithValidConfigurator_ReturnsConfiguratorForChaining() + { + var stub = new MediatorConfiguratorStub(); + + var result = stub.AddEntityFrameworkOutbox(); + + _ = await Assert.That(result).IsSameReferenceAs(stub); + } + + [Test] + public async Task AddEntityFrameworkOutbox_RegistersOutboxRepositoryAsScoped() + { + var services = new ServiceCollection(); + _ = services.AddDbContext(o => + o.UseInMemoryDatabase(nameof(AddEntityFrameworkOutbox_RegistersOutboxRepositoryAsScoped)) + ); + _ = services.AddPulse(config => config.AddOutbox().AddEntityFrameworkOutbox()); + + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IOutboxRepository)); + + using (Assert.Multiple()) + { + _ = await Assert.That(descriptor).IsNotNull(); + _ = await Assert.That(descriptor!.Lifetime).IsEqualTo(ServiceLifetime.Scoped); + } + } + + [Test] + public async Task AddEntityFrameworkOutbox_RegistersEventOutboxAsScoped() + { + var services = new ServiceCollection(); + _ = services.AddDbContext(o => + o.UseInMemoryDatabase(nameof(AddEntityFrameworkOutbox_RegistersEventOutboxAsScoped)) + ); + _ = services.AddPulse(config => config.AddOutbox().AddEntityFrameworkOutbox()); + + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IEventOutbox)); + + using (Assert.Multiple()) + { + _ = await Assert.That(descriptor).IsNotNull(); + _ = await Assert.That(descriptor!.Lifetime).IsEqualTo(ServiceLifetime.Scoped); + } + } + + [Test] + public async Task AddEntityFrameworkOutbox_RegistersTransactionScopeAsScoped() + { + var services = new ServiceCollection(); + _ = services.AddDbContext(o => + o.UseInMemoryDatabase(nameof(AddEntityFrameworkOutbox_RegistersTransactionScopeAsScoped)) + ); + _ = services.AddPulse(config => config.AddOutbox().AddEntityFrameworkOutbox()); + + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IOutboxTransactionScope)); + + using (Assert.Multiple()) + { + _ = await Assert.That(descriptor).IsNotNull(); + _ = await Assert.That(descriptor!.Lifetime).IsEqualTo(ServiceLifetime.Scoped); + } + } + + [Test] + public async Task AddEntityFrameworkOutbox_RegistersTimeProviderAsSingleton() + { + var services = new ServiceCollection(); + _ = services.AddPulse(config => config.AddEntityFrameworkOutbox()); + + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(TimeProvider)); + + using (Assert.Multiple()) + { + _ = await Assert.That(descriptor).IsNotNull(); + _ = await Assert.That(descriptor!.Lifetime).IsEqualTo(ServiceLifetime.Singleton); + } + } + + [Test] + public async Task AddEntityFrameworkOutbox_WithConfigureOptions_AppliesOptions() + { + var services = new ServiceCollection(); + _ = services.AddDbContext(o => + o.UseInMemoryDatabase(nameof(AddEntityFrameworkOutbox_WithConfigureOptions_AppliesOptions)) + ); + _ = services.AddPulse(config => + config.AddOutbox().AddEntityFrameworkOutbox(options => options.Schema = "myschema") + ); + + await using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + + _ = await Assert.That(options.Value.Schema).IsEqualTo("myschema"); + } + + [Test] + public async Task AddEntityFrameworkOutbox_WithTableNameOption_AppliesOptions() + { + var services = new ServiceCollection(); + _ = services.AddDbContext(o => + o.UseInMemoryDatabase(nameof(AddEntityFrameworkOutbox_WithTableNameOption_AppliesOptions)) + ); + _ = services.AddPulse(config => + config.AddOutbox().AddEntityFrameworkOutbox(options => options.TableName = "CustomOutbox") + ); + + await using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + + _ = await Assert.That(options.Value.TableName).IsEqualTo("CustomOutbox"); + } + + private sealed class MediatorConfiguratorStub : IMediatorConfigurator + { + public IServiceCollection Services { get; } = new ServiceCollection(); + + public IMediatorConfigurator AddActivityAndMetrics() => throw new NotImplementedException(); + + public IMediatorConfigurator UseDefaultEventDispatcher( + ServiceLifetime lifetime = ServiceLifetime.Singleton + ) + where TDispatcher : class, IEventDispatcher => throw new NotImplementedException(); + + public IMediatorConfigurator UseDefaultEventDispatcher( + Func factory, + ServiceLifetime lifetime = ServiceLifetime.Singleton + ) + where TDispatcher : class, IEventDispatcher => throw new NotImplementedException(); + + public IMediatorConfigurator UseEventDispatcherFor( + ServiceLifetime lifetime = ServiceLifetime.Singleton + ) + where TEvent : IEvent + where TDispatcher : class, IEventDispatcher => throw new NotImplementedException(); + + public IMediatorConfigurator UseEventDispatcherFor( + Func factory, + ServiceLifetime lifetime = ServiceLifetime.Singleton + ) + where TEvent : IEvent + where TDispatcher : class, IEventDispatcher => throw new NotImplementedException(); + } +} diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxRepositoryTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxRepositoryTests.cs new file mode 100644 index 00000000..85b83ac3 --- /dev/null +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxRepositoryTests.cs @@ -0,0 +1,56 @@ +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using NetEvolve.Pulse; +using TUnit.Core; + +public sealed class EntityFrameworkOutboxRepositoryTests +{ + [Test] + public async Task Constructor_WithNullContext_ThrowsArgumentNullException() => + _ = await Assert + .That(() => new EntityFrameworkOutboxRepository(null!, TimeProvider.System)) + .Throws(); + + [Test] + public async Task Constructor_WithNullTimeProvider_ThrowsArgumentNullException() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(Constructor_WithNullTimeProvider_ThrowsArgumentNullException)) + .Options; + await using var context = new TestDbContext(options); + + _ = await Assert + .That(() => new EntityFrameworkOutboxRepository(context, null!)) + .Throws(); + } + + [Test] + public async Task Constructor_WithValidArguments_CreatesInstance() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(Constructor_WithValidArguments_CreatesInstance)) + .Options; + await using var context = new TestDbContext(options); + + var repository = new EntityFrameworkOutboxRepository(context, TimeProvider.System); + + _ = await Assert.That(repository).IsNotNull(); + } + + [Test] + public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(AddAsync_WithNullMessage_ThrowsArgumentNullException)) + .Options; + await using var context = new TestDbContext(options); + var repository = new EntityFrameworkOutboxRepository(context, TimeProvider.System); + + _ = await Assert + .That(async () => await repository.AddAsync(null!).ConfigureAwait(false)) + .Throws(); + } +} diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxTransactionScopeTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxTransactionScopeTests.cs new file mode 100644 index 00000000..1d2e0601 --- /dev/null +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/EntityFrameworkOutboxTransactionScopeTests.cs @@ -0,0 +1,43 @@ +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using NetEvolve.Pulse; +using TUnit.Core; + +public sealed class EntityFrameworkOutboxTransactionScopeTests +{ + [Test] + public async Task Constructor_WithNullContext_ThrowsArgumentNullException() => + _ = await Assert + .That(() => new EntityFrameworkOutboxTransactionScope(null!)) + .Throws(); + + [Test] + public async Task Constructor_WithValidContext_CreatesInstance() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(Constructor_WithValidContext_CreatesInstance)) + .Options; + await using var context = new TestDbContext(options); + + var scope = new EntityFrameworkOutboxTransactionScope(context); + + _ = await Assert.That(scope).IsNotNull(); + } + + [Test] + public async Task GetCurrentTransaction_WithNoActiveTransaction_ReturnsNull() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(GetCurrentTransaction_WithNoActiveTransaction_ReturnsNull)) + .Options; + await using var context = new TestDbContext(options); + var scope = new EntityFrameworkOutboxTransactionScope(context); + + var result = scope.GetCurrentTransaction(); + + _ = await Assert.That(result).IsNull(); + } +} diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/NetEvolve.Pulse.EntityFramework.Tests.Unit.csproj b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/NetEvolve.Pulse.EntityFramework.Tests.Unit.csproj new file mode 100644 index 00000000..29619600 --- /dev/null +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/NetEvolve.Pulse.EntityFramework.Tests.Unit.csproj @@ -0,0 +1,22 @@ + + + Exe + $(_TestTargetFrameworks) + $(NoWarn);CA1812; + + + + + + + + + + + + + + + + + diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/OutboxMessageConfigurationFactoryTests.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/OutboxMessageConfigurationFactoryTests.cs new file mode 100644 index 00000000..a59ed34b --- /dev/null +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/OutboxMessageConfigurationFactoryTests.cs @@ -0,0 +1,113 @@ +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using NetEvolve.Pulse; +using NetEvolve.Pulse.Extensibility; +using NetEvolve.Pulse.Outbox; +using TUnit.Core; + +public sealed class OutboxMessageConfigurationFactoryTests +{ + [Test] + public async Task Create_WithNullDbContext_ThrowsArgumentNullException() => + _ = await Assert + .That(() => OutboxMessageConfigurationFactory.Create(context: null!)) + .Throws(); + + [Test] + public async Task Create_WithNullProviderName_ThrowsNotSupportedException() => + _ = await Assert + .That(() => OutboxMessageConfigurationFactory.Create(providerName: null)) + .Throws(); + + [Test] + public async Task Create_WithUnknownProviderName_ThrowsNotSupportedException() => + _ = await Assert + .That(() => OutboxMessageConfigurationFactory.Create("Unknown.Provider")) + .Throws(); + + [Test] + public async Task Create_WithNpgsqlProviderName_ReturnsConfiguration() + { + var config = OutboxMessageConfigurationFactory.Create(OutboxMessageConfigurationFactory.NpgsqlProviderName); + + _ = await Assert.That(config).IsNotNull(); + } + + [Test] + public async Task Create_WithSqliteProviderName_ReturnsConfiguration() + { + var config = OutboxMessageConfigurationFactory.Create(OutboxMessageConfigurationFactory.SqliteProviderName); + + _ = await Assert.That(config).IsNotNull(); + } + + [Test] + public async Task Create_WithSqlServerProviderName_ReturnsConfiguration() + { + var config = OutboxMessageConfigurationFactory.Create(OutboxMessageConfigurationFactory.SqlServerProviderName); + + _ = await Assert.That(config).IsNotNull(); + } + + [Test] + public async Task Create_WithPomeloMySqlProviderName_ReturnsConfiguration() + { + var config = OutboxMessageConfigurationFactory.Create( + OutboxMessageConfigurationFactory.PomeloMySqlProviderName + ); + + _ = await Assert.That(config).IsNotNull(); + } + + [Test] + public async Task Create_WithOracleMySqlProviderName_ReturnsConfiguration() + { + var config = OutboxMessageConfigurationFactory.Create( + OutboxMessageConfigurationFactory.OracleMySqlProviderName + ); + + _ = await Assert.That(config).IsNotNull(); + } + + [Test] + public async Task Create_WithProviderName_WithOptions_ReturnsConfiguration() + { + var options = Options.Create(new OutboxOptions { Schema = "custom" }); + + var config = OutboxMessageConfigurationFactory.Create( + OutboxMessageConfigurationFactory.SqlServerProviderName, + options + ); + + _ = await Assert.That(config).IsNotNull(); + } + + [Test] + public async Task Create_WithInMemoryDbContext_ThrowsNotSupportedException() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(Create_WithInMemoryDbContext_ThrowsNotSupportedException)) + .Options; + await using var context = new TestDbContext(options); + + _ = await Assert.That(() => OutboxMessageConfigurationFactory.Create(context)).Throws(); + } + + [Test] + public async Task Create_WithDbContext_WithOptions_ThrowsNotSupportedExceptionForInMemory() + { + var dbOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(Create_WithDbContext_WithOptions_ThrowsNotSupportedExceptionForInMemory)) + .Options; + await using var context = new TestDbContext(dbOptions); + var outboxOptions = Options.Create(new OutboxOptions { Schema = "custom" }); + + _ = await Assert + .That(() => OutboxMessageConfigurationFactory.Create(context, outboxOptions)) + .Throws(); + } +} diff --git a/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/TestDbContext.cs b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/TestDbContext.cs new file mode 100644 index 00000000..3ab8b5d5 --- /dev/null +++ b/tests/NetEvolve.Pulse.EntityFramework.Tests.Unit/TestDbContext.cs @@ -0,0 +1,13 @@ +namespace NetEvolve.Pulse.EntityFramework.Tests.Unit; + +using Microsoft.EntityFrameworkCore; +using NetEvolve.Pulse; +using NetEvolve.Pulse.Extensibility; + +internal sealed class TestDbContext : DbContext, IOutboxDbContext +{ + public TestDbContext(DbContextOptions options) + : base(options) { } + + public DbSet OutboxMessages => Set(); +} diff --git a/tests/NetEvolve.Pulse.Polly.Tests.Integration/PollyEventInterceptorInMemoryTests.cs b/tests/NetEvolve.Pulse.Polly.Tests.Integration/PollyEventInterceptorInMemoryTests.cs index 5dd7466e..3eac4917 100644 --- a/tests/NetEvolve.Pulse.Polly.Tests.Integration/PollyEventInterceptorInMemoryTests.cs +++ b/tests/NetEvolve.Pulse.Polly.Tests.Integration/PollyEventInterceptorInMemoryTests.cs @@ -61,7 +61,7 @@ public async Task TimeoutPolicy_WithSlowHandler_HandlerIsCalled() .AddScoped>(_ => handler) .AddPulse(configurator => configurator.AddPollyEventPolicies(pipeline => - pipeline.AddTimeout(TimeSpan.FromMilliseconds(100)) + pipeline.AddTimeout(TimeSpan.FromMilliseconds(300)) ) ); diff --git a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/NetEvolve.Pulse.SqlServer.Tests.Unit.csproj b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/NetEvolve.Pulse.SqlServer.Tests.Unit.csproj new file mode 100644 index 00000000..bedec098 --- /dev/null +++ b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/NetEvolve.Pulse.SqlServer.Tests.Unit.csproj @@ -0,0 +1,21 @@ + + + Exe + $(_TestTargetFrameworks) + $(NoWarn);CA1812; + + + + + + + + + + + + + + + + diff --git a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerEventOutboxTests.cs b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerEventOutboxTests.cs new file mode 100644 index 00000000..341cf666 --- /dev/null +++ b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerEventOutboxTests.cs @@ -0,0 +1,97 @@ +namespace NetEvolve.Pulse.SqlServer.Tests.Unit; + +using System; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Options; +using NetEvolve.Pulse; +using NetEvolve.Pulse.Extensibility; +using NetEvolve.Pulse.Outbox; +using TUnit.Core; + +public sealed class SqlServerEventOutboxTests +{ + [Test] + public async Task Constructor_WithNullConnection_ThrowsArgumentNullException() => + _ = await Assert + .That(() => new SqlServerEventOutbox(null!, Options.Create(new OutboxOptions()), TimeProvider.System)) + .Throws(); + + [Test] + public async Task Constructor_WithNullOptions_ThrowsArgumentNullException() + { + using var connection = new SqlConnection("Server=.;Encrypt=true;"); + + _ = await Assert + .That(() => new SqlServerEventOutbox(connection, null!, TimeProvider.System)) + .Throws(); + } + + [Test] + public async Task Constructor_WithNullTimeProvider_ThrowsArgumentNullException() + { + using var connection = new SqlConnection("Server=.;Encrypt=true;"); + + _ = await Assert + .That(() => new SqlServerEventOutbox(connection, Options.Create(new OutboxOptions()), null!)) + .Throws(); + } + + [Test] + public async Task Constructor_WithValidArguments_CreatesInstance() + { + using var connection = new SqlConnection("Server=.;Encrypt=true;"); + + var outbox = new SqlServerEventOutbox(connection, Options.Create(new OutboxOptions()), TimeProvider.System); + + _ = await Assert.That(outbox).IsNotNull(); + } + + [Test] + public async Task Constructor_WithTransaction_CreatesInstance() + { + using var connection = new SqlConnection("Server=.;Encrypt=true;"); + + var outbox = new SqlServerEventOutbox( + connection, + Options.Create(new OutboxOptions()), + TimeProvider.System, + transaction: null + ); + + _ = await Assert.That(outbox).IsNotNull(); + } + + [Test] + public async Task StoreAsync_WithNullMessage_ThrowsArgumentNullException() + { + using var connection = new SqlConnection("Server=.;Encrypt=true;"); + var outbox = new SqlServerEventOutbox(connection, Options.Create(new OutboxOptions()), TimeProvider.System); + + _ = await Assert + .That(async () => await outbox.StoreAsync(null!).ConfigureAwait(false)) + .Throws(); + } + + [Test] + public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException() + { + using var connection = new SqlConnection("Server=.;Encrypt=true;"); + var outbox = new SqlServerEventOutbox(connection, Options.Create(new OutboxOptions()), TimeProvider.System); + var message = new TestEvent + { + CorrelationId = new string('x', OutboxMessageSchema.MaxLengths.CorrelationId + 1), + }; + + _ = await Assert + .That(async () => await outbox.StoreAsync(message).ConfigureAwait(false)) + .Throws(); + } + + private sealed record TestEvent : IEvent + { + public string? CorrelationId { get; set; } + public string Id { get; init; } = Guid.NewGuid().ToString(); + public DateTimeOffset? PublishedAt { get; set; } + } +} diff --git a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerMediatorConfiguratorExtensionsTests.cs b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerMediatorConfiguratorExtensionsTests.cs new file mode 100644 index 00000000..d5dcd295 --- /dev/null +++ b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerMediatorConfiguratorExtensionsTests.cs @@ -0,0 +1,195 @@ +namespace NetEvolve.Pulse.SqlServer.Tests.Unit; + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NetEvolve.Pulse; +using NetEvolve.Pulse.Extensibility; +using NetEvolve.Pulse.Outbox; +using TUnit.Core; + +public sealed class SqlServerMediatorConfiguratorExtensionsTests +{ + [Test] + public async Task AddSqlServerOutbox_WithNullConfigurator_ThrowsArgumentNullException() => + _ = await Assert + .That(() => SqlServerMediatorConfiguratorExtensions.AddSqlServerOutbox(null!, "Server=.;Encrypt=true;")) + .Throws(); + + [Test] + public async Task AddSqlServerOutbox_WithNullConnectionString_ThrowsArgumentNullException() => + _ = await Assert + .That(() => + SqlServerMediatorConfiguratorExtensions.AddSqlServerOutbox( + new MediatorConfiguratorStub(), + (string)null! + ) + ) + .Throws(); + + [Test] + public async Task AddSqlServerOutbox_WithEmptyConnectionString_ThrowsArgumentException() => + _ = await Assert + .That(() => + SqlServerMediatorConfiguratorExtensions.AddSqlServerOutbox(new MediatorConfiguratorStub(), string.Empty) + ) + .Throws(); + + [Test] + public async Task AddSqlServerOutbox_WithWhitespaceConnectionString_ThrowsArgumentException() => + _ = await Assert + .That(() => + SqlServerMediatorConfiguratorExtensions.AddSqlServerOutbox(new MediatorConfiguratorStub(), " ") + ) + .Throws(); + + [Test] + public async Task AddSqlServerOutbox_WithValidConnectionString_ReturnsConfiguratorForChaining() + { + var stub = new MediatorConfiguratorStub(); + + var result = stub.AddSqlServerOutbox("Server=.;Encrypt=true;"); + + _ = await Assert.That(result).IsSameReferenceAs(stub); + } + + [Test] + public async Task AddSqlServerOutbox_WithValidConnectionString_RegistersOutboxRepositoryAsScoped() + { + var services = new ServiceCollection(); + _ = services.AddPulse(config => config.AddOutbox().AddSqlServerOutbox("Server=.;Encrypt=true;")); + + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IOutboxRepository)); + + using (Assert.Multiple()) + { + _ = await Assert.That(descriptor).IsNotNull(); + _ = await Assert.That(descriptor!.Lifetime).IsEqualTo(ServiceLifetime.Scoped); + } + } + + [Test] + public async Task AddSqlServerOutbox_WithValidConnectionString_RegistersTimeProviderAsSingleton() + { + var services = new ServiceCollection(); + _ = services.AddPulse(config => config.AddSqlServerOutbox("Server=.;Encrypt=true;")); + + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(TimeProvider)); + + using (Assert.Multiple()) + { + _ = await Assert.That(descriptor).IsNotNull(); + _ = await Assert.That(descriptor!.Lifetime).IsEqualTo(ServiceLifetime.Singleton); + } + } + + [Test] + public async Task AddSqlServerOutbox_WithConfigureOptions_AppliesOptions() + { + var services = new ServiceCollection(); + _ = services.AddPulse(config => + config.AddOutbox().AddSqlServerOutbox("Server=.;Encrypt=true;", options => options.Schema = "myschema") + ); + + await using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + + _ = await Assert.That(options.Value.Schema).IsEqualTo("myschema"); + } + + [Test] + public async Task AddSqlServerOutbox_WithFactory_WithNullConfigurator_ThrowsArgumentNullException() => + _ = await Assert + .That(() => + SqlServerMediatorConfiguratorExtensions.AddSqlServerOutbox( + null!, + (Func)(_ => "Server=.;Encrypt=true;") + ) + ) + .Throws(); + + [Test] + public async Task AddSqlServerOutbox_WithFactory_WithNullFactory_ThrowsArgumentNullException() => + _ = await Assert + .That(() => + SqlServerMediatorConfiguratorExtensions.AddSqlServerOutbox( + new MediatorConfiguratorStub(), + (Func)null! + ) + ) + .Throws(); + + [Test] + public async Task AddSqlServerOutbox_WithFactory_ReturnsConfiguratorForChaining() + { + var stub = new MediatorConfiguratorStub(); + + var result = stub.AddSqlServerOutbox(_ => "Server=.;Encrypt=true;"); + + _ = await Assert.That(result).IsSameReferenceAs(stub); + } + + [Test] + public async Task AddSqlServerOutbox_WithFactory_RegistersOutboxRepositoryAsScoped() + { + var services = new ServiceCollection(); + _ = services.AddPulse(config => config.AddOutbox().AddSqlServerOutbox(_ => "Server=.;Encrypt=true;")); + + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IOutboxRepository)); + + using (Assert.Multiple()) + { + _ = await Assert.That(descriptor).IsNotNull(); + _ = await Assert.That(descriptor!.Lifetime).IsEqualTo(ServiceLifetime.Scoped); + } + } + + [Test] + public async Task AddSqlServerOutbox_WithFactory_WithConfigureOptions_AppliesOptions() + { + var services = new ServiceCollection(); + _ = services.AddPulse(config => + config + .AddOutbox() + .AddSqlServerOutbox(_ => "Server=.;Encrypt=true;", options => options.TableName = "CustomTable") + ); + + await using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + + _ = await Assert.That(options.Value.TableName).IsEqualTo("CustomTable"); + } + + private sealed class MediatorConfiguratorStub : IMediatorConfigurator + { + public IServiceCollection Services { get; } = new ServiceCollection(); + + public IMediatorConfigurator AddActivityAndMetrics() => throw new NotImplementedException(); + + public IMediatorConfigurator UseDefaultEventDispatcher( + ServiceLifetime lifetime = ServiceLifetime.Singleton + ) + where TDispatcher : class, IEventDispatcher => throw new NotImplementedException(); + + public IMediatorConfigurator UseDefaultEventDispatcher( + Func factory, + ServiceLifetime lifetime = ServiceLifetime.Singleton + ) + where TDispatcher : class, IEventDispatcher => throw new NotImplementedException(); + + public IMediatorConfigurator UseEventDispatcherFor( + ServiceLifetime lifetime = ServiceLifetime.Singleton + ) + where TEvent : IEvent + where TDispatcher : class, IEventDispatcher => throw new NotImplementedException(); + + public IMediatorConfigurator UseEventDispatcherFor( + Func factory, + ServiceLifetime lifetime = ServiceLifetime.Singleton + ) + where TEvent : IEvent + where TDispatcher : class, IEventDispatcher => throw new NotImplementedException(); + } +} diff --git a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxRepositoryTests.cs b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxRepositoryTests.cs new file mode 100644 index 00000000..080a2488 --- /dev/null +++ b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxRepositoryTests.cs @@ -0,0 +1,131 @@ +namespace NetEvolve.Pulse.SqlServer.Tests.Unit; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using NetEvolve.Pulse; +using NetEvolve.Pulse.Extensibility; +using NetEvolve.Pulse.Outbox; +using TUnit.Core; + +public sealed class SqlServerOutboxRepositoryTests +{ + private const string ValidConnectionString = "Server=.;Database=Test;Integrated Security=true;"; + + [Test] + public async Task Constructor_WithNullConnectionString_ThrowsArgumentNullException() => + _ = await Assert + .That(() => new SqlServerOutboxRepository(null!, Options.Create(new OutboxOptions()), TimeProvider.System)) + .Throws(); + + [Test] + public async Task Constructor_WithEmptyConnectionString_ThrowsArgumentException() => + _ = await Assert + .That(() => + new SqlServerOutboxRepository(string.Empty, Options.Create(new OutboxOptions()), TimeProvider.System) + ) + .Throws(); + + [Test] + public async Task Constructor_WithWhitespaceConnectionString_ThrowsArgumentException() => + _ = await Assert + .That(() => new SqlServerOutboxRepository(" ", Options.Create(new OutboxOptions()), TimeProvider.System)) + .Throws(); + + [Test] + public async Task Constructor_WithNullOptions_ThrowsArgumentNullException() => + _ = await Assert + .That(() => new SqlServerOutboxRepository(ValidConnectionString, null!, TimeProvider.System)) + .Throws(); + + [Test] + public async Task Constructor_WithNullTimeProvider_ThrowsArgumentNullException() => + _ = await Assert + .That(() => + new SqlServerOutboxRepository(ValidConnectionString, Options.Create(new OutboxOptions()), null!) + ) + .Throws(); + + [Test] + public async Task Constructor_WithValidArguments_CreatesInstance() + { + var repository = new SqlServerOutboxRepository( + ValidConnectionString, + Options.Create(new OutboxOptions()), + TimeProvider.System + ); + + _ = await Assert.That(repository).IsNotNull(); + } + + [Test] + public async Task Constructor_WithTransactionScope_CreatesInstance() + { + var transactionScope = new SqlServerOutboxTransactionScope(null); + + var repository = new SqlServerOutboxRepository( + ValidConnectionString, + Options.Create(new OutboxOptions()), + TimeProvider.System, + transactionScope + ); + + _ = await Assert.That(repository).IsNotNull(); + } + + [Test] + public async Task Constructor_WithCustomSchema_CreatesInstance() + { + var options = new OutboxOptions { Schema = "custom" }; + + var repository = new SqlServerOutboxRepository( + ValidConnectionString, + Options.Create(options), + TimeProvider.System + ); + + _ = await Assert.That(repository).IsNotNull(); + } + + [Test] + public async Task Constructor_WithNullSchema_CreatesInstance() + { + var options = new OutboxOptions { Schema = null }; + + var repository = new SqlServerOutboxRepository( + ValidConnectionString, + Options.Create(options), + TimeProvider.System + ); + + _ = await Assert.That(repository).IsNotNull(); + } + + [Test] + public async Task Constructor_WithEmptySchema_CreatesInstance() + { + var options = new OutboxOptions { Schema = string.Empty }; + + var repository = new SqlServerOutboxRepository( + ValidConnectionString, + Options.Create(options), + TimeProvider.System + ); + + _ = await Assert.That(repository).IsNotNull(); + } + + [Test] + public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException() + { + var repository = new SqlServerOutboxRepository( + ValidConnectionString, + Options.Create(new OutboxOptions()), + TimeProvider.System + ); + + _ = await Assert + .That(async () => await repository.AddAsync(null!).ConfigureAwait(false)) + .Throws(); + } +} diff --git a/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxTransactionScopeTests.cs b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxTransactionScopeTests.cs new file mode 100644 index 00000000..fc051e57 --- /dev/null +++ b/tests/NetEvolve.Pulse.SqlServer.Tests.Unit/SqlServerOutboxTransactionScopeTests.cs @@ -0,0 +1,43 @@ +namespace NetEvolve.Pulse.SqlServer.Tests.Unit; + +using System.Threading.Tasks; +using TUnit.Core; + +public sealed class SqlServerOutboxTransactionScopeTests +{ + [Test] + public async Task Constructor_WithDefaultParameter_CreatesInstance() + { + var scope = new SqlServerOutboxTransactionScope(); + + _ = await Assert.That(scope).IsNotNull(); + } + + [Test] + public async Task Constructor_WithNullTransaction_CreatesInstance() + { + var scope = new SqlServerOutboxTransactionScope(null); + + _ = await Assert.That(scope).IsNotNull(); + } + + [Test] + public async Task GetCurrentTransaction_WithDefaultParameter_ReturnsNull() + { + var scope = new SqlServerOutboxTransactionScope(); + + var result = scope.GetCurrentTransaction(); + + _ = await Assert.That(result).IsNull(); + } + + [Test] + public async Task GetCurrentTransaction_WithNullTransaction_ReturnsNull() + { + var scope = new SqlServerOutboxTransactionScope(null); + + var result = scope.GetCurrentTransaction(); + + _ = await Assert.That(result).IsNull(); + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorHostedServiceTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorHostedServiceTests.cs index 5a49bbd1..7b1f6787 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorHostedServiceTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorHostedServiceTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Tests.Unit.Outbox; +namespace NetEvolve.Pulse.Tests.Unit.Outbox; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -356,8 +356,8 @@ public async Task ExecuteAsync_WithBatchSendingFailure_MarkAsFailedForRetry() using var cts = new CancellationTokenSource(); await service.StartAsync(cts.Token).ConfigureAwait(false); - // Wait for first polling cycle to process the batch failure - await Task.Delay(100).ConfigureAwait(false); + // Wait for first polling cycle to process the batch failure and complete marking + await Task.Delay(300).ConfigureAwait(false); await cts.CancelAsync().ConfigureAwait(false); await service.StopAsync(CancellationToken.None).ConfigureAwait(false);