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);