diff --git a/.editorconfig b/.editorconfig
index 83532c32..920b54c7 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -270,6 +270,10 @@ dotnet_diagnostic.IDE0046.severity = sugges
csharp_style_prefer_primary_constructors = false
dotnet_diagnostic.IDE0290.severity = suggestion
+# IDE0060: Remove unused parameter
+dotnet_diagnostic.IDE0060.severity = warning
+dotnet_code_quality_unused_parameters = all
+
# [CSharpier] Incompatible rules deactivated
# https://csharpier.com/docs/IntegratingWithLinters#code-analysis-rules
dotnet_diagnostic.IDE0055.severity = none
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6473ee32..ccf79609 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -30,23 +30,27 @@
+
+
-
+
-
-
-
+
+
+
+
+
@@ -54,20 +58,29 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlOutboxMessageConfiguration.cs b/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlOutboxMessageConfiguration.cs
index cd61866a..418df1fb 100644
--- a/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlOutboxMessageConfiguration.cs
+++ b/src/NetEvolve.Pulse.EntityFramework/Configurations/MySqlOutboxMessageConfiguration.cs
@@ -7,18 +7,26 @@
using NetEvolve.Pulse.Outbox;
///
-/// Entity Framework Core configuration for targeting MySQL.
-/// Supports both Pomelo (Pomelo.EntityFrameworkCore.MySql) and the Oracle
-/// (MySql.EntityFrameworkCore) providers.
+/// Entity Framework Core configuration for targeting MySQL
+/// via the Oracle provider (MySql.EntityFrameworkCore).
///
///
/// Column Types:
///
-/// - char(36) for (UUID string representation)
+/// - binary(16) for — raw 16-byte UUID with a byte[] value converter
/// - varchar(n) for bounded strings
/// - longtext for unbounded strings (Payload, Error)
-/// - datetime(6) for — Pomelo stores values as UTC
+/// - bigint for — stored as UTC ticks via a value converter
///
+/// Why binary(16) and bigint:
+/// The Oracle MySQL provider does not produce a valid type mapping for
+/// →char(36) SQL parameter binding (returns ,
+/// causing a in
+/// TypeMappedRelationalParameter.AddDbParameter).
+/// Using binary(16) with an explicit byte[] converter provides a working
+/// binding. Similarly, the provider lacks a proper SQL type
+/// mapping; converting to (UTC ticks) eliminates the broken
+/// provider-specific type resolution and ensures correct ordering and comparison semantics.
/// Filtered Indexes:
/// MySQL does not support partial/filtered indexes with a WHERE clause.
/// All filter properties inherit from the base class,
@@ -60,17 +68,48 @@ public MySqlOutboxMessageConfiguration(IOptions options)
///
protected override void ApplyColumnTypes(EntityTypeBuilder builder)
{
- // char(36) is the canonical UUID string format used by Pomelo and Oracle MySQL provider
- _ = builder.Property(m => m.Id).HasColumnType("char(36)");
+ // binary(16) stores the raw 16-byte UUID — half the storage of char(36), faster binary
+ // comparisons, and better index locality. Critically, the Oracle MySQL provider
+ // (MySql.EntityFrameworkCore) has a working byte[]→binary(16) type mapping for SQL
+ // parameter binding, whereas Guid→char(36) returns a null TypeMapping and throws
+ // NullReferenceException inside TypeMappedRelationalParameter.AddDbParameter.
+ _ = builder
+ .Property(m => m.Id)
+ .HasColumnType("binary(16)")
+ .HasConversion(v => v.ToByteArray(), v => new Guid(v));
_ = builder.Property(m => m.EventType).HasColumnType("varchar(500)");
// longtext covers MySQL's maximum row size for arbitrarily large JSON payloads
_ = builder.Property(m => m.Payload).HasColumnType("longtext");
_ = builder.Property(m => m.CorrelationId).HasColumnType("varchar(100)");
- // datetime(6) stores microsecond precision; Pomelo converts DateTimeOffset to UTC
- _ = builder.Property(m => m.CreatedAt).HasColumnType("datetime(6)");
- _ = builder.Property(m => m.UpdatedAt).HasColumnType("datetime(6)");
- _ = builder.Property(m => m.ProcessedAt).HasColumnType("datetime(6)");
- _ = builder.Property(m => m.NextRetryAt).HasColumnType("datetime(6)");
+
+ // DateTimeOffset columns are stored as BIGINT (UTC ticks), matching the SQLite
+ // approach (INTEGER / UTC ticks). The Oracle MySQL provider lacks a proper
+ // DateTimeOffset type mapping for parameterised operations (ExecuteUpdateAsync,
+ // IN clauses, etc.). Converting to long eliminates the broken provider-specific
+ // type resolution and ensures correct ordering and comparison semantics.
+ _ = builder
+ .Property(m => m.CreatedAt)
+ .HasColumnType("bigint")
+ .HasConversion(v => v.UtcTicks, v => new DateTimeOffset(v, TimeSpan.Zero));
+ _ = builder
+ .Property(m => m.UpdatedAt)
+ .HasColumnType("bigint")
+ .HasConversion(v => v.UtcTicks, v => new DateTimeOffset(v, TimeSpan.Zero));
+ _ = builder
+ .Property(m => m.ProcessedAt)
+ .HasColumnType("bigint")
+ .HasConversion(
+ v => v.HasValue ? (long?)v.Value.UtcTicks : null,
+ v => v.HasValue ? (DateTimeOffset?)new DateTimeOffset(v.Value, TimeSpan.Zero) : null
+ );
+ _ = builder
+ .Property(m => m.NextRetryAt)
+ .HasColumnType("bigint")
+ .HasConversion(
+ v => v.HasValue ? (long?)v.Value.UtcTicks : null,
+ v => v.HasValue ? (DateTimeOffset?)new DateTimeOffset(v.Value, TimeSpan.Zero) : null
+ );
+
_ = builder.Property(m => m.RetryCount).HasColumnType("int");
_ = builder.Property(m => m.Error).HasColumnType("longtext");
_ = builder.Property(m => m.Status).HasColumnType("int");
diff --git a/src/NetEvolve.Pulse.EntityFramework/Configurations/OutboxMessageConfigurationBase.cs b/src/NetEvolve.Pulse.EntityFramework/Configurations/OutboxMessageConfigurationBase.cs
index 4be80220..4822f9d0 100644
--- a/src/NetEvolve.Pulse.EntityFramework/Configurations/OutboxMessageConfigurationBase.cs
+++ b/src/NetEvolve.Pulse.EntityFramework/Configurations/OutboxMessageConfigurationBase.cs
@@ -28,6 +28,7 @@
///
internal abstract class OutboxMessageConfigurationBase : IEntityTypeConfiguration
{
+ private const uint FnvPrime = 16777619;
private readonly OutboxOptions _options;
///
@@ -93,10 +94,12 @@ public void Configure(EntityTypeBuilder builder)
var schema = string.IsNullOrWhiteSpace(_options.Schema)
? OutboxMessageSchema.DefaultSchema
: _options.Schema.Trim();
- _ = builder.ToTable(_options.TableName, schema);
+ var tableName = _options.TableName;
+
+ _ = builder.ToTable(tableName, schema);
// Primary key
- _ = builder.HasKey(m => m.Id);
+ _ = builder.HasKey(m => m.Id).HasName(TruncateIdentifier($"PK_{schema}_{tableName}"));
// Id column
_ = builder.Property(m => m.Id).HasColumnName(OutboxMessageSchema.Columns.Id).ValueGeneratedNever();
@@ -155,18 +158,62 @@ public void Configure(EntityTypeBuilder builder)
_ = builder
.HasIndex(m => new { m.Status, m.CreatedAt })
.HasFilter(PendingMessagesFilter)
- .HasDatabaseName("IX_OutboxMessage_Status_CreatedAt");
+ .HasDatabaseName(TruncateIdentifier($"IX_{schema}_{tableName}_Status_CreatedAt"));
// Index for retry-scheduled message polling (with exponential backoff)
_ = builder
.HasIndex(m => new { m.Status, m.NextRetryAt })
.HasFilter(RetryScheduledMessagesFilter)
- .HasDatabaseName("IX_OutboxMessage_Status_NextRetryAt");
+ .HasDatabaseName(TruncateIdentifier($"IX_{schema}_{tableName}_Status_NextRetryAt"));
// Index for completed message cleanup
_ = builder
.HasIndex(m => new { m.Status, m.ProcessedAt })
.HasFilter(CompletedMessagesFilter)
- .HasDatabaseName("IX_OutboxMessage_Status_ProcessedAt");
+ .HasDatabaseName(TruncateIdentifier($"IX_{schema}_{tableName}_Status_ProcessedAt"));
+ }
+
+ ///
+ /// Truncates a database identifier to the specified maximum length while maintaining uniqueness
+ /// by appending a stable hash suffix when the identifier exceeds the limit.
+ /// This is required for databases such as PostgreSQL that enforce a 63-character identifier limit.
+ ///
+ /// The full identifier name to potentially truncate.
+ /// The maximum allowed identifier length. Defaults to 63 (PostgreSQL limit).
+ ///
+ /// The original if it fits within ;
+ /// otherwise, a truncated prefix combined with an 8-character hexadecimal hash suffix
+ /// that uniquely identifies the original name.
+ ///
+ private static string TruncateIdentifier(string name, int maxLength = 63)
+ {
+ if (name.Length <= maxLength)
+ {
+ return name;
+ }
+
+ // Append a stable hash suffix to distinguish otherwise-identical truncated prefixes.
+ // The hash is computed over the full name, so two names that share a long common prefix
+ // but differ only in their suffix will produce different hashes.
+ var hash = ComputeFnv1aHash(name);
+ var hashSuffix = $"_{hash:x8}"; // "_" + 8 hex chars = 9 chars
+ var prefixLength = maxLength - hashSuffix.Length;
+ return name[..prefixLength] + hashSuffix;
+ }
+
+ ///
+ /// Computes a stable 32-bit FNV-1a hash of the given string.
+ /// FNV-1a is chosen for its simplicity, speed, and good distribution,
+ /// making it suitable for generating short disambiguation suffixes in identifier names.
+ ///
+ private static uint ComputeFnv1aHash(string value)
+ {
+ var hash = 2166136261;
+ foreach (var c in value)
+ {
+ hash ^= c;
+ hash *= FnvPrime;
+ }
+ return hash;
}
}
diff --git a/src/NetEvolve.Pulse.EntityFramework/Configurations/SqliteOutboxMessageConfiguration.cs b/src/NetEvolve.Pulse.EntityFramework/Configurations/SqliteOutboxMessageConfiguration.cs
index 74b5dd74..537c44c0 100644
--- a/src/NetEvolve.Pulse.EntityFramework/Configurations/SqliteOutboxMessageConfiguration.cs
+++ b/src/NetEvolve.Pulse.EntityFramework/Configurations/SqliteOutboxMessageConfiguration.cs
@@ -73,11 +73,35 @@ protected override void ApplyColumnTypes(EntityTypeBuilder builde
_ = builder.Property(m => m.EventType).HasColumnType("TEXT");
_ = builder.Property(m => m.Payload).HasColumnType("TEXT");
_ = builder.Property(m => m.CorrelationId).HasColumnType("TEXT");
- // DateTimeOffset is stored as ISO-8601 TEXT by EF Core SQLite.
- _ = builder.Property(m => m.CreatedAt).HasColumnType("TEXT");
- _ = builder.Property(m => m.UpdatedAt).HasColumnType("TEXT");
- _ = builder.Property(m => m.ProcessedAt).HasColumnType("TEXT");
- _ = builder.Property(m => m.NextRetryAt).HasColumnType("TEXT");
+
+ // DateTimeOffset columns are stored as INTEGER (UTC ticks) in SQLite.
+ // EF Core SQLite refuses to translate DateTimeOffset comparisons and ORDER BY
+ // when the column is TEXT because ISO-8601 string ordering is incorrect for
+ // values with non-UTC offsets. Storing as long (UTC ticks) allows EF Core to
+ // generate correct INTEGER comparisons and orderings in SQL.
+ _ = builder
+ .Property(m => m.CreatedAt)
+ .HasColumnType("INTEGER")
+ .HasConversion(v => v.UtcTicks, v => new DateTimeOffset(v, TimeSpan.Zero));
+ _ = builder
+ .Property(m => m.UpdatedAt)
+ .HasColumnType("INTEGER")
+ .HasConversion(v => v.UtcTicks, v => new DateTimeOffset(v, TimeSpan.Zero));
+ _ = builder
+ .Property(m => m.ProcessedAt)
+ .HasColumnType("INTEGER")
+ .HasConversion(
+ v => v.HasValue ? (long?)v.Value.UtcTicks : null,
+ v => v.HasValue ? (DateTimeOffset?)new DateTimeOffset(v.Value, TimeSpan.Zero) : null
+ );
+ _ = builder
+ .Property(m => m.NextRetryAt)
+ .HasColumnType("INTEGER")
+ .HasConversion(
+ v => v.HasValue ? (long?)v.Value.UtcTicks : null,
+ v => v.HasValue ? (DateTimeOffset?)new DateTimeOffset(v.Value, TimeSpan.Zero) : null
+ );
+
_ = builder.Property(m => m.RetryCount).HasColumnType("INTEGER");
_ = builder.Property(m => m.Error).HasColumnType("TEXT");
_ = builder.Property(m => m.Status).HasColumnType("INTEGER");
diff --git a/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkExtensions.cs b/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkExtensions.cs
index 6e53c000..f73c5bd4 100644
--- a/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkExtensions.cs
+++ b/src/NetEvolve.Pulse.EntityFramework/EntityFrameworkExtensions.cs
@@ -58,29 +58,22 @@ public static IMediatorBuilder AddEntityFrameworkOutbox(
{
ArgumentNullException.ThrowIfNull(configurator);
- var services = configurator.Services;
-
- _ = services.AddOptions();
-
- // Register options if configureOptions is provided
- if (configureOptions is not null)
- {
- _ = services.Configure(configureOptions);
- }
-
- // Ensure TimeProvider is registered
- services.TryAddSingleton(TimeProvider.System);
+ var services = configurator.AddOutbox(configureOptions).Services;
// Register the repository
+ _ = services.RemoveAll();
_ = services.AddScoped>();
// Register the event outbox (overrides the default OutboxEventStore)
+ _ = services.RemoveAll();
_ = services.AddScoped>();
// Register the transaction scope
+ _ = services.RemoveAll();
_ = services.AddScoped>();
// Register the management API
+ _ = services.RemoveAll();
_ = services.AddScoped>();
return configurator;
diff --git a/src/NetEvolve.Pulse.EntityFramework/ModelBuilderExtensions.cs b/src/NetEvolve.Pulse.EntityFramework/ModelBuilderExtensions.cs
index 53e5ea0d..23990d79 100644
--- a/src/NetEvolve.Pulse.EntityFramework/ModelBuilderExtensions.cs
+++ b/src/NetEvolve.Pulse.EntityFramework/ModelBuilderExtensions.cs
@@ -12,37 +12,6 @@
///
public static class ModelBuilderExtensions
{
- ///
- /// The provider name for the EF Core InMemory provider (Microsoft.EntityFrameworkCore.InMemory).
- /// Intended for testing only.
- ///
- private const string InMemoryProviderName = "Microsoft.EntityFrameworkCore.InMemory";
-
- ///
- /// The provider name for Npgsql (PostgreSQL).
- ///
- private const string NpgsqlProviderName = "Npgsql.EntityFrameworkCore.PostgreSQL";
-
- ///
- /// The provider name for Microsoft.EntityFrameworkCore.Sqlite.
- ///
- private const string SqliteProviderName = "Microsoft.EntityFrameworkCore.Sqlite";
-
- ///
- /// The provider name for Microsoft.EntityFrameworkCore.SqlServer.
- ///
- private const string SqlServerProviderName = "Microsoft.EntityFrameworkCore.SqlServer";
-
- ///
- /// The provider name for Pomelo MySQL (Pomelo.EntityFrameworkCore.MySql).
- ///
- private const string PomeloMySqlProviderName = "Pomelo.EntityFrameworkCore.MySql";
-
- ///
- /// The provider name for the Oracle MySQL provider (MySql.EntityFrameworkCore).
- ///
- private const string OracleMySqlProviderName = "MySql.EntityFrameworkCore";
-
///
/// Applies all Pulse-related entity configurations to the model builder.
///
@@ -98,11 +67,13 @@ private static IEntityTypeConfiguration GetOutboxConfiguration new PostgreSqlOutboxMessageConfiguration(resolvedOptions),
- SqliteProviderName => new SqliteOutboxMessageConfiguration(resolvedOptions),
- SqlServerProviderName => new SqlServerOutboxMessageConfiguration(resolvedOptions),
- PomeloMySqlProviderName or OracleMySqlProviderName => new MySqlOutboxMessageConfiguration(resolvedOptions),
- InMemoryProviderName => new InMemoryOutboxMessageConfiguration(resolvedOptions),
+ ProviderName.Npgsql => new PostgreSqlOutboxMessageConfiguration(resolvedOptions),
+ ProviderName.Sqlite => new SqliteOutboxMessageConfiguration(resolvedOptions),
+ ProviderName.SqlServer => new SqlServerOutboxMessageConfiguration(resolvedOptions),
+ ProviderName.PomeloMySql or ProviderName.OracleMySql => new MySqlOutboxMessageConfiguration(
+ resolvedOptions
+ ),
+ ProviderName.InMemory => new InMemoryOutboxMessageConfiguration(resolvedOptions),
_ => throw new NotSupportedException($"Unsupported EF Core provider: {providerName}"),
};
}
diff --git a/src/NetEvolve.Pulse.EntityFramework/Outbox/BulkOutboxOperationsExecutor.cs b/src/NetEvolve.Pulse.EntityFramework/Outbox/BulkOutboxOperationsExecutor.cs
new file mode 100644
index 00000000..9117afac
--- /dev/null
+++ b/src/NetEvolve.Pulse.EntityFramework/Outbox/BulkOutboxOperationsExecutor.cs
@@ -0,0 +1,160 @@
+namespace NetEvolve.Pulse.Outbox;
+
+using Microsoft.EntityFrameworkCore;
+using NetEvolve.Pulse.Extensibility.Outbox;
+
+///
+/// implementation that issues a single bulk
+/// ExecuteUpdateAsync / ExecuteDeleteAsync statement per operation.
+///
+///
+/// Suitable for any EF Core provider that supports these operations and can correctly
+/// translate a parameterised collection into a SQL IN clause
+/// (SQL Server, PostgreSQL, SQLite, and others).
+///
+/// The DbContext type that implements .
+internal sealed class BulkOutboxOperationsExecutor(TContext context, int maxDegreeOfParallelism)
+ : IOutboxOperationsExecutor
+ where TContext : DbContext, IOutboxDbContext
+{
+ private readonly SemaphoreSlim _semaphore = new(maxDegreeOfParallelism, maxDegreeOfParallelism);
+ private bool _disposedValue;
+
+ ///
+ public async Task FetchAndMarkAsync(
+ IQueryable baseQuery,
+ DateTimeOffset updatedAt,
+ OutboxMessageStatus newStatus,
+ CancellationToken cancellationToken
+ )
+ {
+ await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var ids = await baseQuery
+ .AsNoTracking()
+ .Select(m => m.Id)
+ .ToArrayAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (ids.Length == 0)
+ {
+ return [];
+ }
+
+ _ = await baseQuery
+ .Where(m => ids.Contains(m.Id))
+ .ExecuteUpdateAsync(
+ m => m.SetProperty(m => m.Status, newStatus).SetProperty(m => m.UpdatedAt, updatedAt),
+ cancellationToken
+ )
+ .ConfigureAwait(false);
+
+ return await context
+ .OutboxMessages.AsNoTracking()
+ .Where(m => ids.Contains(m.Id))
+ .ToArrayAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ _ = _semaphore.Release();
+ }
+ }
+
+ ///
+ public async Task UpdateByQueryAsync(
+ IQueryable query,
+ DateTimeOffset updatedAt,
+ DateTimeOffset? processedAt,
+ DateTimeOffset? nextRetryAt,
+ OutboxMessageStatus newStatus,
+ int retryIncrement,
+ string? errorMessage,
+ CancellationToken cancellationToken
+ )
+ {
+ await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ _ = await query
+ .ExecuteUpdateAsync(
+ m =>
+ m.SetProperty(p => p.UpdatedAt, updatedAt)
+ .SetProperty(
+ p => p.ProcessedAt,
+ p =>
+#pragma warning disable IDE0030, RCS1084 // Use coalesce expression instead of conditional expression
+ processedAt.HasValue ? processedAt.Value : p.ProcessedAt
+#pragma warning restore IDE0030, RCS1084 // Use coalesce expression instead of conditional expression
+ )
+ .SetProperty(p => p.NextRetryAt, nextRetryAt)
+ .SetProperty(p => p.Status, newStatus)
+ .SetProperty(p => p.RetryCount, p => p.RetryCount + retryIncrement)
+ .SetProperty(p => p.Error, errorMessage),
+ cancellationToken
+ )
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ _ = _semaphore.Release();
+ }
+ }
+
+ ///
+ public Task UpdateByIdsAsync(
+ IReadOnlyCollection ids,
+ DateTimeOffset updatedAt,
+ DateTimeOffset? processedAt,
+ DateTimeOffset? nextRetryAt,
+ OutboxMessageStatus newStatus,
+ int retryIncrement,
+ string? errorMessage,
+ CancellationToken cancellationToken
+ ) =>
+ UpdateByQueryAsync(
+ context.OutboxMessages.Where(m => ids.Contains(m.Id)),
+ updatedAt,
+ processedAt,
+ nextRetryAt,
+ newStatus,
+ retryIncrement,
+ errorMessage,
+ cancellationToken
+ );
+
+ ///
+ public async Task DeleteByQueryAsync(IQueryable query, CancellationToken cancellationToken)
+ {
+ await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ return await query.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _ = _semaphore.Release();
+ }
+ }
+
+ private void Dispose(bool disposing)
+ {
+ if (!_disposedValue)
+ {
+ if (disposing)
+ {
+ _semaphore.Dispose();
+ }
+
+ _disposedValue = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxManagement.cs b/src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxManagement.cs
index 72cb70fd..6ef0b7ce 100644
--- a/src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxManagement.cs
+++ b/src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxManagement.cs
@@ -12,7 +12,7 @@ internal sealed class EntityFrameworkOutboxManagement : IOutboxManagem
where TContext : DbContext, IOutboxDbContext
{
/// Pre-compiled paged dead-letter query; eliminates expression-tree overhead on every call.
- private static readonly Func> _deadLetterPageQuery =
+ private static readonly Func> DeadLetterPageQuery =
EF.CompileAsyncQuery(
(TContext ctx, int skip, int take) =>
ctx
@@ -24,7 +24,7 @@ internal sealed class EntityFrameworkOutboxManagement : IOutboxManagem
);
/// Pre-compiled single dead-letter lookup; eliminates expression-tree overhead on every call.
- private static readonly Func> _deadLetterByIdQuery = EF.CompileAsyncQuery(
+ private static readonly Func> DeadLetterByIdQuery = EF.CompileAsyncQuery(
(TContext ctx, Guid id) =>
ctx
.OutboxMessages.Where(m => m.Id == id && m.Status == OutboxMessageStatus.DeadLetter)
@@ -33,7 +33,7 @@ internal sealed class EntityFrameworkOutboxManagement : IOutboxManagem
);
/// Pre-compiled dead-letter count query; eliminates expression-tree overhead on every call.
- private static readonly Func> _deadLetterCountQuery = EF.CompileAsyncQuery(
+ private static readonly Func> DeadLetterCountQuery = EF.CompileAsyncQuery(
(TContext ctx) => ctx.OutboxMessages.LongCount(m => m.Status == OutboxMessageStatus.DeadLetter)
);
@@ -69,7 +69,7 @@ public async Task> GetDeadLetterMessagesAsync(
var result = new List(pageSize);
await foreach (
- var message in _deadLetterPageQuery(_context, page * pageSize, pageSize)
+ var message in DeadLetterPageQuery(_context, page * pageSize, pageSize)
.WithCancellation(cancellationToken)
.ConfigureAwait(false)
)
@@ -84,14 +84,14 @@ var message in _deadLetterPageQuery(_context, page * pageSize, pageSize)
public Task GetDeadLetterMessageAsync(Guid messageId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
- return _deadLetterByIdQuery(_context, messageId);
+ return DeadLetterByIdQuery(_context, messageId);
}
///
public Task GetDeadLetterCountAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
- return _deadLetterCountQuery(_context);
+ return DeadLetterCountQuery(_context);
}
///
diff --git a/src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxRepository.cs b/src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxRepository.cs
index f4174a21..4ed318d3 100644
--- a/src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxRepository.cs
+++ b/src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxRepository.cs
@@ -20,40 +20,18 @@
internal sealed class EntityFrameworkOutboxRepository : IOutboxRepository
where TContext : DbContext, IOutboxDbContext
{
- /// Pre-compiled pending ID-selection query; eliminates expression-tree overhead on every call.
- private static readonly Func> GetPendingIdsQuery =
- EF.CompileAsyncQuery(
- (TContext ctx, int batchSize, DateTimeOffset now) =>
- ctx
- .OutboxMessages.Where(m =>
- m.Status == OutboxMessageStatus.Pending && (m.NextRetryAt == null || m.NextRetryAt <= now)
- )
- .OrderBy(m => m.CreatedAt)
- .Take(batchSize)
- .Select(m => m.Id)
- );
-
- /// Pre-compiled failed-for-retry ID-selection query; eliminates expression-tree overhead on every call.
- private static readonly Func> GetFailedForRetryIdsQuery =
- EF.CompileAsyncQuery(
- (TContext ctx, int maxRetryCount, int batchSize, DateTimeOffset now) =>
- ctx
- .OutboxMessages.Where(m =>
- m.Status == OutboxMessageStatus.Failed
- && m.RetryCount < maxRetryCount
- && (m.NextRetryAt == null || m.NextRetryAt <= now)
- )
- .OrderBy(m => m.UpdatedAt)
- .Take(batchSize)
- .Select(m => m.Id)
- );
-
- /// The DbContext used for all LINQ-to-SQL query and update operations.
+ /// The DbContext used to build LINQ queries passed to the executor.
private readonly TContext _context;
/// The time provider used to generate consistent update and cutoff timestamps.
private readonly TimeProvider _timeProvider;
+ ///
+ /// Provider-specific strategy that handles how entities are persisted
+ /// (change-tracking + SaveChangesAsync vs. bulk ExecuteUpdate / ExecuteDelete).
+ ///
+ private readonly IOutboxOperationsExecutor _executor;
+
///
/// Initializes a new instance of the class.
///
@@ -66,6 +44,16 @@ public EntityFrameworkOutboxRepository(TContext context, TimeProvider timeProvid
_context = context;
_timeProvider = timeProvider;
+ _executor = context.Database.ProviderName switch
+ {
+ // InMemory does not support ExecuteUpdate/ExecuteDelete at all.
+ ProviderName.InMemory => new InMemoryOutboxOperationsExecutor(context, 1),
+ // Oracle MySQL cannot apply value converters in ExecuteUpdateAsync parameters and
+ // cannot translate a parameterised Guid collection into a SQL IN clause.
+ ProviderName.OracleMySql => new MySqlOutboxOperationsExecutor(context, 1),
+ ProviderName.Npgsql => new BulkOutboxOperationsExecutor(context, 1),
+ _ => new BulkOutboxOperationsExecutor(context, Environment.ProcessorCount - 1),
+ };
}
///
@@ -85,33 +73,15 @@ public async Task> GetPendingAsync(
{
var now = _timeProvider.GetUtcNow();
- var ids = new List(batchSize);
- await foreach (
- var id in GetPendingIdsQuery(_context, batchSize, now)
- .WithCancellation(cancellationToken)
- .ConfigureAwait(false)
- )
- {
- ids.Add(id);
- }
-
- if (ids.Count == 0)
- {
- return [];
- }
-
- _ = await _context
- .OutboxMessages.Where(m => ids.Contains(m.Id) && m.Status == OutboxMessageStatus.Pending)
- .ExecuteUpdateAsync(
- m => m.SetProperty(m => m.Status, OutboxMessageStatus.Processing).SetProperty(m => m.UpdatedAt, now),
- cancellationToken
+ var baseQuery = _context
+ .OutboxMessages.Where(m =>
+ m.Status == OutboxMessageStatus.Pending && (m.NextRetryAt == null || m.NextRetryAt <= now)
)
- .ConfigureAwait(false);
+ .OrderBy(m => m.CreatedAt)
+ .Take(batchSize);
- return await _context
- .OutboxMessages.Where(m => ids.Contains(m.Id))
- .AsNoTracking()
- .ToArrayAsync(cancellationToken)
+ return await _executor
+ .FetchAndMarkAsync(baseQuery, now, OutboxMessageStatus.Processing, cancellationToken)
.ConfigureAwait(false);
}
@@ -134,33 +104,17 @@ public async Task> GetFailedForRetryAsync(
{
var now = _timeProvider.GetUtcNow();
- var ids = new List(batchSize);
- await foreach (
- var id in GetFailedForRetryIdsQuery(_context, maxRetryCount, batchSize, now)
- .WithCancellation(cancellationToken)
- .ConfigureAwait(false)
- )
- {
- ids.Add(id);
- }
-
- if (ids.Count == 0)
- {
- return [];
- }
-
- _ = await _context
- .OutboxMessages.Where(m => ids.Contains(m.Id) && m.Status == OutboxMessageStatus.Failed)
- .ExecuteUpdateAsync(
- m => m.SetProperty(m => m.Status, OutboxMessageStatus.Processing).SetProperty(m => m.UpdatedAt, now),
- cancellationToken
+ var baseQuery = _context
+ .OutboxMessages.Where(m =>
+ m.Status == OutboxMessageStatus.Failed
+ && m.RetryCount < maxRetryCount
+ && (m.NextRetryAt == null || m.NextRetryAt <= now)
)
- .ConfigureAwait(false);
+ .OrderBy(m => m.UpdatedAt)
+ .Take(batchSize);
- return await _context
- .OutboxMessages.Where(m => ids.Contains(m.Id))
- .AsNoTracking()
- .ToArrayAsync(cancellationToken)
+ return await _executor
+ .FetchAndMarkAsync(baseQuery, now, OutboxMessageStatus.Processing, cancellationToken)
.ConfigureAwait(false);
}
@@ -169,13 +123,15 @@ public async Task MarkAsCompletedAsync(Guid messageId, CancellationToken cancell
{
var now = _timeProvider.GetUtcNow();
- _ = await _context
- .OutboxMessages.Where(m => m.Id == messageId && m.Status == OutboxMessageStatus.Processing)
- .ExecuteUpdateAsync(
- m =>
- m.SetProperty(m => m.Status, OutboxMessageStatus.Completed)
- .SetProperty(m => m.ProcessedAt, now)
- .SetProperty(m => m.UpdatedAt, now),
+ await _executor
+ .UpdateByQueryAsync(
+ _context.OutboxMessages.Where(m => m.Id == messageId && m.Status == OutboxMessageStatus.Processing),
+ now,
+ now,
+ null,
+ OutboxMessageStatus.Completed,
+ 0,
+ null,
cancellationToken
)
.ConfigureAwait(false);
@@ -194,15 +150,8 @@ public async Task MarkAsCompletedAsync(
var now = _timeProvider.GetUtcNow();
- _ = await _context
- .OutboxMessages.Where(m => messageIds.Contains(m.Id) && m.Status == OutboxMessageStatus.Processing)
- .ExecuteUpdateAsync(
- m =>
- m.SetProperty(m => m.Status, OutboxMessageStatus.Completed)
- .SetProperty(m => m.ProcessedAt, now)
- .SetProperty(m => m.UpdatedAt, now),
- cancellationToken
- )
+ await _executor
+ .UpdateByIdsAsync(messageIds, now, now, null, OutboxMessageStatus.Completed, 0, null, cancellationToken)
.ConfigureAwait(false);
}
@@ -215,14 +164,15 @@ public async Task MarkAsFailedAsync(
{
var now = _timeProvider.GetUtcNow();
- _ = await _context
- .OutboxMessages.Where(m => m.Id == messageId && m.Status == OutboxMessageStatus.Processing)
- .ExecuteUpdateAsync(
- m =>
- m.SetProperty(m => m.Status, OutboxMessageStatus.Failed)
- .SetProperty(m => m.Error, errorMessage)
- .SetProperty(m => m.UpdatedAt, now)
- .SetProperty(m => m.RetryCount, m => m.RetryCount + 1),
+ await _executor
+ .UpdateByQueryAsync(
+ _context.OutboxMessages.Where(m => m.Id == messageId && m.Status == OutboxMessageStatus.Processing),
+ now,
+ null,
+ null,
+ OutboxMessageStatus.Failed,
+ 1,
+ errorMessage,
cancellationToken
)
.ConfigureAwait(false);
@@ -238,15 +188,15 @@ public async Task MarkAsFailedAsync(
{
var now = _timeProvider.GetUtcNow();
- _ = await _context
- .OutboxMessages.Where(m => m.Id == messageId && m.Status == OutboxMessageStatus.Processing)
- .ExecuteUpdateAsync(
- m =>
- m.SetProperty(m => m.Status, OutboxMessageStatus.Failed)
- .SetProperty(m => m.Error, errorMessage)
- .SetProperty(m => m.UpdatedAt, now)
- .SetProperty(m => m.RetryCount, m => m.RetryCount + 1)
- .SetProperty(m => m.NextRetryAt, nextRetryAt),
+ await _executor
+ .UpdateByQueryAsync(
+ _context.OutboxMessages.Where(m => m.Id == messageId && m.Status == OutboxMessageStatus.Processing),
+ now,
+ null,
+ nextRetryAt,
+ OutboxMessageStatus.Failed,
+ 1,
+ errorMessage,
cancellationToken
)
.ConfigureAwait(false);
@@ -266,14 +216,15 @@ public async Task MarkAsFailedAsync(
var now = _timeProvider.GetUtcNow();
- _ = await _context
- .OutboxMessages.Where(m => messageIds.Contains(m.Id) && m.Status == OutboxMessageStatus.Processing)
- .ExecuteUpdateAsync(
- m =>
- m.SetProperty(m => m.Status, OutboxMessageStatus.Failed)
- .SetProperty(m => m.Error, errorMessage)
- .SetProperty(m => m.UpdatedAt, now)
- .SetProperty(m => m.RetryCount, m => m.RetryCount + 1),
+ await _executor
+ .UpdateByIdsAsync(
+ messageIds,
+ now,
+ null,
+ null,
+ OutboxMessageStatus.Failed,
+ 1,
+ errorMessage,
cancellationToken
)
.ConfigureAwait(false);
@@ -288,13 +239,15 @@ public async Task MarkAsDeadLetterAsync(
{
var now = _timeProvider.GetUtcNow();
- _ = await _context
- .OutboxMessages.Where(m => m.Id == messageId && m.Status == OutboxMessageStatus.Processing)
- .ExecuteUpdateAsync(
- m =>
- m.SetProperty(m => m.Status, OutboxMessageStatus.DeadLetter)
- .SetProperty(m => m.Error, errorMessage)
- .SetProperty(m => m.UpdatedAt, now),
+ await _executor
+ .UpdateByQueryAsync(
+ _context.OutboxMessages.Where(m => m.Id == messageId && m.Status == OutboxMessageStatus.Processing),
+ now,
+ null,
+ null,
+ OutboxMessageStatus.DeadLetter,
+ 0,
+ errorMessage,
cancellationToken
)
.ConfigureAwait(false);
@@ -314,13 +267,15 @@ public async Task MarkAsDeadLetterAsync(
var now = _timeProvider.GetUtcNow();
- _ = await _context
- .OutboxMessages.Where(m => messageIds.Contains(m.Id) && m.Status == OutboxMessageStatus.Processing)
- .ExecuteUpdateAsync(
- m =>
- m.SetProperty(m => m.Status, OutboxMessageStatus.DeadLetter)
- .SetProperty(m => m.Error, errorMessage)
- .SetProperty(m => m.UpdatedAt, now),
+ await _executor
+ .UpdateByIdsAsync(
+ messageIds,
+ now,
+ null,
+ null,
+ OutboxMessageStatus.DeadLetter,
+ 1,
+ errorMessage,
cancellationToken
)
.ConfigureAwait(false);
@@ -331,9 +286,13 @@ public async Task DeleteCompletedAsync(TimeSpan olderThan, CancellationToke
{
var cutoffTime = _timeProvider.GetUtcNow().Subtract(olderThan);
- return await _context
- .OutboxMessages.Where(m => m.Status == OutboxMessageStatus.Completed && m.ProcessedAt < cutoffTime)
- .ExecuteDeleteAsync(cancellationToken)
+ return await _executor
+ .DeleteByQueryAsync(
+ _context.OutboxMessages.Where(m =>
+ m.Status == OutboxMessageStatus.Completed && m.ProcessedAt < cutoffTime
+ ),
+ cancellationToken
+ )
.ConfigureAwait(false);
}
}
diff --git a/src/NetEvolve.Pulse.EntityFramework/Outbox/IOutboxOperationsExecutor.cs b/src/NetEvolve.Pulse.EntityFramework/Outbox/IOutboxOperationsExecutor.cs
new file mode 100644
index 00000000..49720078
--- /dev/null
+++ b/src/NetEvolve.Pulse.EntityFramework/Outbox/IOutboxOperationsExecutor.cs
@@ -0,0 +1,46 @@
+namespace NetEvolve.Pulse.Outbox;
+
+using NetEvolve.Pulse.Extensibility.Outbox;
+
+///
+/// Defines the provider-specific strategy for executing outbox entity operations.
+///
+internal interface IOutboxOperationsExecutor : IDisposable
+{
+ Task FetchAndMarkAsync(
+ IQueryable baseQuery,
+ DateTimeOffset updatedAt,
+ OutboxMessageStatus newStatus,
+ CancellationToken cancellationToken
+ );
+
+ Task UpdateByQueryAsync(
+ IQueryable query,
+ DateTimeOffset updatedAt,
+ DateTimeOffset? processedAt,
+ DateTimeOffset? nextRetryAt,
+ OutboxMessageStatus newStatus,
+ int retryIncrement,
+ string? errorMessage,
+ CancellationToken cancellationToken
+ );
+
+ Task UpdateByIdsAsync(
+ IReadOnlyCollection ids,
+ DateTimeOffset updatedAt,
+ DateTimeOffset? processedAt,
+ DateTimeOffset? nextRetryAt,
+ OutboxMessageStatus newStatus,
+ int retryIncrement,
+ string? errorMessage,
+ CancellationToken cancellationToken
+ );
+
+ ///
+ /// Deletes the entities matched by and returns the number of
+ /// rows removed.
+ ///
+ /// A pre-filtered query that targets only the rows to delete.
+ /// Propagates notification that operations should be cancelled.
+ Task DeleteByQueryAsync(IQueryable query, CancellationToken cancellationToken);
+}
diff --git a/src/NetEvolve.Pulse.EntityFramework/Outbox/InMemoryOutboxOperationsExecutor.cs b/src/NetEvolve.Pulse.EntityFramework/Outbox/InMemoryOutboxOperationsExecutor.cs
new file mode 100644
index 00000000..b4550327
--- /dev/null
+++ b/src/NetEvolve.Pulse.EntityFramework/Outbox/InMemoryOutboxOperationsExecutor.cs
@@ -0,0 +1,42 @@
+namespace NetEvolve.Pulse.Outbox;
+
+using Microsoft.EntityFrameworkCore;
+using NetEvolve.Pulse.Extensibility.Outbox;
+
+///
+/// implementation for the EF Core InMemory provider.
+///
+///
+/// The InMemory provider evaluates all LINQ in process, so any query — including those with
+/// Contains over a collection — works without type-mapping issues.
+/// ExecuteUpdate / ExecuteDelete are not supported; all mutations go through
+/// change tracking and SaveChangesAsync (inherited from
+/// ).
+///
+/// The DbContext type that implements .
+internal sealed class InMemoryOutboxOperationsExecutor(TContext context, int maxDegreeOfParallelism)
+ : TrackingOutboxOperationsExecutorBase(context, maxDegreeOfParallelism)
+ where TContext : DbContext, IOutboxDbContext
+{
+ ///
+ public override Task UpdateByIdsAsync(
+ IReadOnlyCollection ids,
+ DateTimeOffset updatedAt,
+ DateTimeOffset? processedAt,
+ DateTimeOffset? nextRetryAt,
+ OutboxMessageStatus newStatus,
+ int retryIncrement,
+ string? errorMessage,
+ CancellationToken cancellationToken
+ ) =>
+ UpdateByQueryAsync(
+ _context.OutboxMessages.Where(m => ids.Contains(m.Id)),
+ updatedAt,
+ processedAt,
+ nextRetryAt,
+ newStatus,
+ retryIncrement,
+ errorMessage,
+ cancellationToken
+ );
+}
diff --git a/src/NetEvolve.Pulse.EntityFramework/Outbox/MySqlOutboxOperationsExecutor.cs b/src/NetEvolve.Pulse.EntityFramework/Outbox/MySqlOutboxOperationsExecutor.cs
new file mode 100644
index 00000000..adecad23
--- /dev/null
+++ b/src/NetEvolve.Pulse.EntityFramework/Outbox/MySqlOutboxOperationsExecutor.cs
@@ -0,0 +1,84 @@
+namespace NetEvolve.Pulse.Outbox;
+
+using Microsoft.EntityFrameworkCore;
+using NetEvolve.Pulse.Extensibility.Outbox;
+
+///
+/// implementation for the Oracle MySQL EF Core provider
+/// (MySql.EntityFrameworkCore).
+///
+///
+/// The Oracle MySQL provider has two known limitations:
+///
+/// -
+/// It does not apply value converters (e.g. → bigint)
+/// when building ExecuteUpdateAsync parameters, causing a
+/// in TypeMappedRelationalParameter.AddDbParameter.
+///
+/// -
+/// It cannot assign a SQL type mapping to a parameterised collection used
+/// in a WHERE id IN (@ids) clause, causing an .
+///
+///
+/// All mutations go through change tracking and SaveChangesAsync (inherited from
+/// ). Only
+/// is overridden: it uses FindAsync per ID to avoid the
+/// broken Guid IN clause, while still benefiting from the Local
+/// cache for entities already loaded by FetchAndMarkAsync.
+///
+/// The DbContext type that implements .
+internal sealed class MySqlOutboxOperationsExecutor(TContext context, int maxDegreeOfParallelism)
+ : TrackingOutboxOperationsExecutorBase(context, maxDegreeOfParallelism)
+ where TContext : DbContext, IOutboxDbContext
+{
+ ///
+ public override async Task UpdateByIdsAsync(
+ IReadOnlyCollection ids,
+ DateTimeOffset updatedAt,
+ DateTimeOffset? processedAt,
+ DateTimeOffset? nextRetryAt,
+ OutboxMessageStatus newStatus,
+ int retryIncrement,
+ string? errorMessage,
+ CancellationToken cancellationToken
+ )
+ {
+ // bulkQuery contains a Guid IN clause that MySQL cannot type-map.
+ // Use FindAsync per ID instead: it resolves the column's own type mapping and
+ // checks the DbContext Local cache before hitting the database.
+ var entities = new List(ids.Count);
+
+ foreach (var id in ids)
+ {
+ var entity = await _context.OutboxMessages.FindAsync([id], cancellationToken).ConfigureAwait(false);
+
+ if (entity?.Status == OutboxMessageStatus.Processing)
+ {
+ entities.Add(entity);
+ }
+ }
+
+ if (entities.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var entity in entities)
+ {
+ entity.Status = newStatus;
+ entity.UpdatedAt = updatedAt;
+ if (processedAt.HasValue)
+ {
+ entity.ProcessedAt = processedAt.Value;
+ }
+ entity.NextRetryAt = nextRetryAt;
+ entity.RetryCount += retryIncrement;
+ if (!string.IsNullOrWhiteSpace(errorMessage))
+ {
+ entity.Error = errorMessage;
+ }
+ }
+
+ _ = await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/src/NetEvolve.Pulse.EntityFramework/Outbox/TrackingOutboxOperationsExecutorBase.cs b/src/NetEvolve.Pulse.EntityFramework/Outbox/TrackingOutboxOperationsExecutorBase.cs
new file mode 100644
index 00000000..0b8de80f
--- /dev/null
+++ b/src/NetEvolve.Pulse.EntityFramework/Outbox/TrackingOutboxOperationsExecutorBase.cs
@@ -0,0 +1,146 @@
+namespace NetEvolve.Pulse.Outbox;
+
+using Microsoft.EntityFrameworkCore;
+using NetEvolve.Pulse.Extensibility.Outbox;
+
+///
+/// Base class for implementations that persist changes
+/// through EF Core change tracking and SaveChangesAsync.
+///
+///
+/// Provides shared implementations of ,
+/// , and that load entities
+/// into the change tracker, apply in-memory mutations, and flush via SaveChangesAsync.
+/// Derived classes only need to implement , which varies by provider.
+///
+/// The DbContext type that implements .
+internal abstract class TrackingOutboxOperationsExecutorBase(TContext context, int maxDegreeOfParallelism)
+ : IOutboxOperationsExecutor
+ where TContext : DbContext, IOutboxDbContext
+{
+ /// The DbContext used for all tracking-based query and update operations.
+ protected readonly TContext _context = context;
+
+ protected readonly SemaphoreSlim _semaphore = new SemaphoreSlim(maxDegreeOfParallelism, maxDegreeOfParallelism);
+ private bool _disposedValue;
+
+ ///
+ public async Task FetchAndMarkAsync(
+ IQueryable baseQuery,
+ DateTimeOffset updatedAt,
+ OutboxMessageStatus newStatus,
+ CancellationToken cancellationToken
+ )
+ {
+ var entities = await baseQuery.ToArrayAsync(cancellationToken).ConfigureAwait(false);
+
+ if (entities.Length == 0)
+ {
+ return entities;
+ }
+
+ foreach (var entity in entities)
+ {
+ entity.Status = newStatus;
+ entity.UpdatedAt = updatedAt;
+ }
+
+ _ = await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ return entities;
+ }
+
+ ///
+ public async Task UpdateByQueryAsync(
+ IQueryable query,
+ DateTimeOffset updatedAt,
+ DateTimeOffset? processedAt,
+ DateTimeOffset? nextRetryAt,
+ OutboxMessageStatus newStatus,
+ int retryIncrement,
+ string? errorMessage,
+ CancellationToken cancellationToken
+ )
+ {
+ await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var entities = await query.ToArrayAsync(cancellationToken).ConfigureAwait(false);
+
+ if (entities.Length == 0)
+ {
+ return;
+ }
+
+ foreach (var entity in entities)
+ {
+ entity.Status = newStatus;
+ entity.UpdatedAt = updatedAt;
+ if (processedAt.HasValue)
+ {
+ entity.ProcessedAt = processedAt.Value;
+ }
+ entity.NextRetryAt = nextRetryAt;
+ entity.RetryCount += retryIncrement;
+ if (!string.IsNullOrWhiteSpace(errorMessage))
+ {
+ entity.Error = errorMessage;
+ }
+ }
+
+ _ = await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _ = _semaphore.Release();
+ }
+ }
+
+ ///
+ public abstract Task UpdateByIdsAsync(
+ IReadOnlyCollection ids,
+ DateTimeOffset updatedAt,
+ DateTimeOffset? processedAt,
+ DateTimeOffset? nextRetryAt,
+ OutboxMessageStatus newStatus,
+ int retryIncrement,
+ string? errorMessage,
+ CancellationToken cancellationToken
+ );
+
+ ///
+ public async Task DeleteByQueryAsync(IQueryable query, CancellationToken cancellationToken)
+ {
+ var entities = await query.ToArrayAsync(cancellationToken).ConfigureAwait(false);
+
+ if (entities.Length == 0)
+ {
+ return 0;
+ }
+
+ _context.OutboxMessages.RemoveRange(entities);
+ _ = await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ return entities.Length;
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposedValue)
+ {
+ if (disposing)
+ {
+ _semaphore.Dispose();
+ }
+
+ _disposedValue = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/NetEvolve.Pulse.EntityFramework/ProviderName.cs b/src/NetEvolve.Pulse.EntityFramework/ProviderName.cs
new file mode 100644
index 00000000..d05dac17
--- /dev/null
+++ b/src/NetEvolve.Pulse.EntityFramework/ProviderName.cs
@@ -0,0 +1,38 @@
+namespace NetEvolve.Pulse;
+
+///
+/// Static class containing constants for supported EF Core provider names, used for provider-specific
+///
+internal static class ProviderName
+{
+ ///
+ /// The provider name for the EF Core InMemory provider (Microsoft.EntityFrameworkCore.InMemory).
+ /// Intended for testing only.
+ ///
+ internal const string InMemory = "Microsoft.EntityFrameworkCore.InMemory";
+
+ ///
+ /// The provider name for Npgsql (PostgreSQL).
+ ///
+ internal const string Npgsql = "Npgsql.EntityFrameworkCore.PostgreSQL";
+
+ ///
+ /// The provider name for Microsoft.EntityFrameworkCore.Sqlite.
+ ///
+ internal const string Sqlite = "Microsoft.EntityFrameworkCore.Sqlite";
+
+ ///
+ /// The provider name for Microsoft.EntityFrameworkCore.SqlServer.
+ ///
+ internal const string SqlServer = "Microsoft.EntityFrameworkCore.SqlServer";
+
+ ///
+ /// The provider name for Pomelo MySQL (Pomelo.EntityFrameworkCore.MySql).
+ ///
+ internal const string PomeloMySql = "Pomelo.EntityFrameworkCore.MySql";
+
+ ///
+ /// The provider name for the Oracle MySQL provider (MySql.EntityFrameworkCore).
+ ///
+ internal const string OracleMySql = "MySql.EntityFrameworkCore";
+}
diff --git a/src/NetEvolve.Pulse/Outbox/InMemoryMessageTransport.cs b/src/NetEvolve.Pulse/Outbox/InMemoryMessageTransport.cs
deleted file mode 100644
index 0dca9e71..00000000
--- a/src/NetEvolve.Pulse/Outbox/InMemoryMessageTransport.cs
+++ /dev/null
@@ -1,100 +0,0 @@
-namespace NetEvolve.Pulse.Outbox;
-
-using System.Collections.Concurrent;
-using System.Linq.Expressions;
-using System.Text.Json;
-using Microsoft.Extensions.Options;
-using NetEvolve.Pulse.Extensibility;
-using NetEvolve.Pulse.Extensibility.Outbox;
-
-///
-/// Default message transport that dispatches outbox messages through the mediator.
-/// Deserializes events and publishes them in-process for handler execution.
-///
-///
-/// Use Case:
-/// Use this transport when events should be handled in the same process that stored them.
-/// The outbox pattern still provides reliability by persisting events before processing.
-/// Serialization:
-/// Events are deserialized using System.Text.Json with optional custom settings from .
-/// The property contains the runtime type of the event.
-/// Error Handling:
-/// Exceptions from event handlers propagate to the outbox processor for retry handling.
-///
-internal sealed class InMemoryMessageTransport : IMessageTransport
-{
- /// Cache of compiled publish delegates keyed by concrete event type, avoiding per-message reflection overhead.
- private static readonly ConcurrentDictionary<
- Type,
- Func
- > PublishDelegates = new();
-
- /// The mediator used to publish deserialized events in-process.
- private readonly IMediator _mediator;
-
- /// The resolved outbox options, providing JSON serialization settings for event deserialization.
- private readonly OutboxOptions _options;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The mediator for publishing events.
- /// The outbox options containing serialization settings.
- public InMemoryMessageTransport(IMediator mediator, IOptions options)
- {
- ArgumentNullException.ThrowIfNull(mediator);
- ArgumentNullException.ThrowIfNull(options);
-
- _mediator = mediator;
- _options = options.Value;
- }
-
- ///
- public async Task SendAsync(OutboxMessage message, CancellationToken cancellationToken = default)
- {
- ArgumentNullException.ThrowIfNull(message);
-
- var eventType = message.EventType;
-
- var @event =
- JsonSerializer.Deserialize(message.Payload, eventType, _options.JsonSerializerOptions)
- ?? throw new InvalidOperationException($"Failed to deserialize event payload for type: {eventType}");
-
- if (@event is not IEvent typedEvent)
- {
- throw new InvalidOperationException($"Deserialized object is not an IEvent: {eventType}");
- }
-
- await PublishEventAsync(typedEvent, cancellationToken).ConfigureAwait(false);
- }
-
- ///
- /// Resolves a compiled delegate for the concrete event type (cached after first call) and invokes
- /// , eliminating per-message reflection overhead.
- ///
- /// The deserialized event to publish through the mediator.
- /// A token to monitor for cancellation requests.
- /// A task representing the asynchronous publish operation.
- private Task PublishEventAsync(IEvent @event, CancellationToken cancellationToken) =>
- PublishDelegates.GetOrAdd(@event.GetType(), CreatePublishDelegate)(_mediator, @event, cancellationToken);
-
- ///
- /// Builds a compiled expression-tree delegate that calls
- /// with the given concrete . Called once per event type and cached.
- ///
- /// The concrete event type for which to create a publish delegate.
- /// A compiled delegate that can publish events of the specified type through the mediator.
- private static Func CreatePublishDelegate(Type eventType)
- {
- var mediatorParam = Expression.Parameter(typeof(IMediator), "mediator");
- var eventParam = Expression.Parameter(typeof(IEvent), "event");
- var tokenParam = Expression.Parameter(typeof(CancellationToken), "cancellationToken");
-
- var method = typeof(IMediator).GetMethod(nameof(IMediator.PublishAsync))!.MakeGenericMethod(eventType);
- var call = Expression.Call(mediatorParam, method, Expression.Convert(eventParam, eventType), tokenParam);
-
- return Expression
- .Lambda>(call, mediatorParam, eventParam, tokenParam)
- .Compile();
- }
-}
diff --git a/src/NetEvolve.Pulse/Outbox/NullMessageTransport.cs b/src/NetEvolve.Pulse/Outbox/NullMessageTransport.cs
new file mode 100644
index 00000000..eaaaff29
--- /dev/null
+++ b/src/NetEvolve.Pulse/Outbox/NullMessageTransport.cs
@@ -0,0 +1,22 @@
+namespace NetEvolve.Pulse.Outbox;
+
+using NetEvolve.Pulse.Extensibility.Outbox;
+
+///
+/// A no-op implementation that silently discards every message.
+///
+///
+/// Use Case:
+/// Registered as the default transport by AddOutbox.
+/// Replace it by calling with a concrete
+/// transport such as the Dapr or RabbitMQ transport.
+///
+internal sealed class NullMessageTransport : IMessageTransport
+{
+ ///
+ public Task SendAsync(OutboxMessage message, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs b/src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs
index e9e602f6..c26c825e 100644
--- a/src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs
+++ b/src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs
@@ -130,13 +130,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
while (!stoppingToken.IsCancellationRequested)
{
- if (_options.DisableProcessing)
- {
- await Task.Delay(_options.PollingInterval, stoppingToken).ConfigureAwait(false);
- }
-
try
{
+ if (_options.DisableProcessing)
+ {
+ await Task.Delay(_options.PollingInterval, stoppingToken).ConfigureAwait(false);
+ continue;
+ }
+
// Check database health before processing
var isDatabaseHealthy = await _repository.IsHealthyAsync(stoppingToken).ConfigureAwait(false);
if (!isDatabaseHealthy)
@@ -240,7 +241,7 @@ private async Task ProcessBatchAsync(CancellationToken cancellationToken)
IReadOnlyList messages = [];
- if (_pendingCount > 0)
+ if (Volatile.Read(ref _pendingCount) > 0)
{
messages = await _repository.GetPendingAsync(batchSize, cancellationToken).ConfigureAwait(false);
batchSize -= messages.Count;
diff --git a/src/NetEvolve.Pulse/OutboxExtensions.cs b/src/NetEvolve.Pulse/OutboxExtensions.cs
index 9e807a2c..c4971cd8 100644
--- a/src/NetEvolve.Pulse/OutboxExtensions.cs
+++ b/src/NetEvolve.Pulse/OutboxExtensions.cs
@@ -14,7 +14,7 @@ public static class OutboxExtensions
{
///
/// Adds core outbox services including the implementation,
- /// , and .
+ /// , and .
///
/// The mediator configurator.
/// Optional action to configure .
@@ -27,7 +27,7 @@ public static class OutboxExtensions
/// - - Processor options (Singleton)
/// - as (Scoped)
/// - as (Scoped, open-generic)
- /// - as (Singleton)
+ /// - as (Singleton)
/// - as (Singleton)
/// - (Hosted service)
/// - - System time provider (Singleton)
@@ -66,7 +66,7 @@ public static IMediatorBuilder AddOutbox(
// Register core services
services.TryAddScoped();
services.TryAddEnumerable(ServiceDescriptor.Scoped(typeof(IEventHandler<>), typeof(OutboxEventHandler<>)));
- services.TryAddSingleton();
+ services.TryAddSingleton();
services.TryAddSingleton();
// Register background processor (TryAddEnumerable prevents duplicate registrations)
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/DatabaseType.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/DatabaseType.cs
new file mode 100644
index 00000000..5d63f126
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/DatabaseType.cs
@@ -0,0 +1,10 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+public enum DatabaseType
+{
+ InMemory,
+ SqlServer,
+ SQLite,
+ PostgreSQL,
+ MySql,
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/EntityFrameworkInitializer.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/EntityFrameworkInitializer.cs
new file mode 100644
index 00000000..3d069e4e
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/EntityFrameworkInitializer.cs
@@ -0,0 +1,170 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NetEvolve.Pulse.Extensibility;
+using NetEvolve.Pulse.Extensibility.Outbox;
+using NetEvolve.Pulse.Outbox;
+
+public sealed class EntityFrameworkInitializer : IDatabaseInitializer
+{
+ public void Configure(IMediatorBuilder mediatorBuilder, IDatabaseServiceFixture databaseService) =>
+ mediatorBuilder.AddEntityFrameworkOutbox();
+
+ private static readonly SemaphoreSlim _gate = new(1, 1);
+
+ public async ValueTask CreateDatabaseAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)
+ {
+ using (var scope = serviceProvider.CreateScope())
+ {
+ var context = scope.ServiceProvider.GetRequiredService();
+ var databaseCreator = context.GetService();
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (databaseCreator is IRelationalDatabaseCreator relationalDatabaseCreator)
+ {
+ if (!await relationalDatabaseCreator.CanConnectAsync(cancellationToken).ConfigureAwait(false))
+ {
+ await relationalDatabaseCreator.CreateAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ if (await databaseCreator.CanConnectAsync(cancellationToken).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ await _gate.WaitAsync(cancellationToken);
+
+ try
+ {
+ _ = await databaseCreator.EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
+ return;
+ }
+ finally
+ {
+ _ = _gate.Release();
+ }
+ }
+
+ if (databaseCreator is IRelationalDatabaseCreator relationalTableCreator)
+ {
+ await _gate.WaitAsync(cancellationToken);
+ try
+ {
+ await relationalTableCreator.CreateTablesAsync(cancellationToken);
+ }
+ finally
+ {
+ _ = _gate.Release();
+ }
+ }
+ }
+ }
+
+ public void Initialize(IServiceCollection services, IDatabaseServiceFixture databaseService) =>
+ _ = services.AddDbContextFactory(options =>
+ {
+ var connectionString = databaseService.ConnectionString;
+
+ // Register a custom model-cache key factory that includes the per-test table name.
+ // Multiple tests share the same connection string (same container), so EF Core would
+ // otherwise cache the first test's model (with its table name) and reuse it for all
+ // subsequent tests — causing "Table '...' already exists" errors on CreateTablesAsync.
+ // This factory makes the cache key unique per (DbContext type, TableName), so each
+ // test gets its own model while still sharing the internal EF Core service provider
+ // (critical for correct type-mapping initialisation on providers like Oracle MySQL).
+ _ = options.ReplaceService();
+
+ _ = databaseService.DatabaseType switch
+ {
+ DatabaseType.InMemory => options.UseInMemoryDatabase(connectionString),
+ DatabaseType.MySql => options.UseMySQL(connectionString),
+ DatabaseType.PostgreSQL => options.UseNpgsql(connectionString),
+ // Add a busy-timeout interceptor so that concurrent SaveChangesAsync calls from
+ // parallel PublishAsync tasks wait and retry instead of failing with SQLITE_BUSY.
+ DatabaseType.SQLite => options
+ .UseSqlite(connectionString)
+ .AddInterceptors(new SQLiteBusyTimeoutInterceptor()),
+ DatabaseType.SqlServer => options.UseSqlServer(
+ connectionString,
+ sqlOptions => sqlOptions.EnableRetryOnFailure(maxRetryCount: 5)
+ ),
+ _ => throw new NotSupportedException($"Database type {databaseService.DatabaseType} is not supported."),
+ };
+ });
+
+ ///
+ /// Sets PRAGMA busy_timeout on every SQLite connection when it is opened so that
+ /// concurrent write operations wait and retry rather than immediately failing with SQLITE_BUSY.
+ ///
+ private sealed class SQLiteBusyTimeoutInterceptor : DbConnectionInterceptor
+ {
+ private const string Pragmas = "PRAGMA busy_timeout = 60000; PRAGMA journal_mode = WAL;";
+
+ public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = Pragmas;
+ _ = command.ExecuteNonQuery();
+ }
+
+ public override async Task ConnectionOpenedAsync(
+ DbConnection connection,
+ ConnectionEndEventData eventData,
+ CancellationToken cancellationToken = default
+ )
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = Pragmas;
+ _ = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Custom EF Core model-cache key factory that incorporates
+ /// into the cache key. Without this, all tests that share the same database connection string
+ /// receive the same cached EF Core model (keyed only by type), so the
+ /// second test picks up the first test's table name and CreateTablesAsync fails with
+ /// "Table '<first-test-name>' already exists".
+ ///
+ private sealed class TestTableModelCacheKeyFactory : IModelCacheKeyFactory
+ {
+ ///
+ public object Create(DbContext context, bool designTime)
+ {
+ string tableName;
+ try
+ {
+ tableName = context.GetService>()?.Value?.TableName ?? string.Empty;
+ }
+ catch (InvalidOperationException)
+ {
+ tableName = string.Empty;
+ }
+
+ return (context.GetType(), tableName, designTime);
+ }
+ }
+
+ private sealed class TestDbContext : DbContext, IOutboxDbContext
+ {
+ public DbSet OutboxMessages => Set();
+
+ public TestDbContext(DbContextOptions configuration)
+ : base(configuration) { }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+ _ = modelBuilder.ApplyPulseConfiguration(this);
+ }
+ }
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/IDatabaseInitializer.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/IDatabaseInitializer.cs
new file mode 100644
index 00000000..ab863e87
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/IDatabaseInitializer.cs
@@ -0,0 +1,13 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+using Microsoft.Extensions.DependencyInjection;
+using NetEvolve.Pulse.Extensibility;
+
+public interface IDatabaseInitializer
+{
+ void Configure(IMediatorBuilder mediatorBuilder, IDatabaseServiceFixture databaseService);
+
+ ValueTask CreateDatabaseAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken);
+
+ void Initialize(IServiceCollection services, IDatabaseServiceFixture databaseService);
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/IDatabaseServiceFixture.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/IDatabaseServiceFixture.cs
new file mode 100644
index 00000000..2431f197
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/IDatabaseServiceFixture.cs
@@ -0,0 +1,10 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+using TUnit.Core.Interfaces;
+
+public interface IDatabaseServiceFixture : IAsyncDisposable, IAsyncInitializer
+{
+ string ConnectionString { get; }
+
+ DatabaseType DatabaseType { get; }
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/InMemoryDatabaseServiceFixture.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/InMemoryDatabaseServiceFixture.cs
new file mode 100644
index 00000000..d2956987
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/InMemoryDatabaseServiceFixture.cs
@@ -0,0 +1,12 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+internal sealed class InMemoryDatabaseServiceFixture : IDatabaseServiceFixture
+{
+ public string ConnectionString { get; } = Guid.NewGuid().ToString("N");
+
+ public DatabaseType DatabaseType => DatabaseType.InMemory;
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ public Task InitializeAsync() => Task.CompletedTask;
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/MySqlContainerFixture.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/MySqlContainerFixture.cs
new file mode 100644
index 00000000..18e8aa6f
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/MySqlContainerFixture.cs
@@ -0,0 +1,29 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+using Microsoft.Extensions.Logging.Abstractions;
+using MySql.Data.MySqlClient;
+using Testcontainers.MySql;
+using TUnit.Core.Interfaces;
+
+///
+/// Manages the lifecycle of a MySQL Testcontainer shared across a test session.
+///
+public sealed class MySqlContainerFixture : IAsyncDisposable, IAsyncInitializer
+{
+ private readonly MySqlContainer _container = new MySqlBuilder(
+ /*dockerimage*/"mysql:8.0"
+ )
+ .WithLogger(NullLogger.Instance)
+ .WithUsername(UserName)
+ .WithPrivileged(true)
+ .Build();
+
+ public string ConnectionString =>
+ _container.GetConnectionString() + ";SslMode=Disabled;AllowPublicKeyRetrieval=True;ConnectionTimeout=30;";
+
+ public static string UserName => "root";
+
+ public ValueTask DisposeAsync() => _container.DisposeAsync();
+
+ public async Task InitializeAsync() => await _container.StartAsync().ConfigureAwait(false);
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/MySqlDatabaseServiceFixture.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/MySqlDatabaseServiceFixture.cs
new file mode 100644
index 00000000..902cc553
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/MySqlDatabaseServiceFixture.cs
@@ -0,0 +1,46 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+using MySql.Data.MySqlClient;
+
+///
+/// Provides a per-test backed by a MySQL Testcontainer,
+/// creating a unique database for each test to ensure isolation.
+///
+public sealed class MySqlDatabaseServiceFixture : IDatabaseServiceFixture
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public MySqlContainerFixture Container { get; set; } = default!;
+
+ public string ConnectionString =>
+ Container.ConnectionString.Replace(";Database=test;", $";Database={DatabaseName};", StringComparison.Ordinal);
+
+ internal string DatabaseName { get; } = $"{TestHelper.TargetFramework}{Guid.NewGuid():N}";
+
+ public DatabaseType DatabaseType => DatabaseType.MySql;
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ public async Task InitializeAsync()
+ {
+ try
+ {
+ // Create temporary database to ensure the container is fully initialized and ready to accept connections
+ await using var con = new MySqlConnection(Container.ConnectionString);
+ await con.OpenAsync();
+
+ await using var cmd = con.CreateCommand();
+#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
+ cmd.CommandText = $"CREATE DATABASE {DatabaseName}";
+#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
+
+ _ = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ "MySQL container failed to start within the expected time frame. Try restarting Rancher Desktop.",
+ ex
+ );
+ }
+ }
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/PostgreSqlContainerFixture.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/PostgreSqlContainerFixture.cs
new file mode 100644
index 00000000..69229c77
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/PostgreSqlContainerFixture.cs
@@ -0,0 +1,20 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+using Microsoft.Extensions.Logging.Abstractions;
+using Testcontainers.PostgreSql;
+using TUnit.Core.Interfaces;
+
+public sealed class PostgreSqlContainerFixture : IAsyncDisposable, IAsyncInitializer
+{
+ private readonly PostgreSqlContainer _container = new PostgreSqlBuilder(
+ /*dockerimage*/"postgres:15.17"
+ )
+ .WithLogger(NullLogger.Instance)
+ .Build();
+
+ public string ConnectionString => _container.GetConnectionString() + ";Include Error Detail=true;";
+
+ public ValueTask DisposeAsync() => _container.DisposeAsync();
+
+ public async Task InitializeAsync() => await _container.StartAsync().ConfigureAwait(false);
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/PostgreSqlDatabaseServiceFixture.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/PostgreSqlDatabaseServiceFixture.cs
new file mode 100644
index 00000000..181bddbc
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/PostgreSqlDatabaseServiceFixture.cs
@@ -0,0 +1,40 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+public sealed class PostgreSqlDatabaseServiceFixture : IDatabaseServiceFixture
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public PostgreSqlContainerFixture Container { get; set; } = default!;
+
+ public string ConnectionString =>
+ Container.ConnectionString.Replace("Database=postgres;", $"Database={DatabaseName};", StringComparison.Ordinal);
+
+ internal string DatabaseName { get; } = $"{TestHelper.TargetFramework}{Guid.NewGuid():N}";
+
+ public DatabaseType DatabaseType => DatabaseType.PostgreSQL;
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ public async Task InitializeAsync()
+ {
+ try
+ {
+ // Create temporary database to ensure the container is fully initialized and ready to accept connections
+ await using var con = new Npgsql.NpgsqlConnection(Container.ConnectionString);
+ await con.OpenAsync();
+
+ await using var cmd = con.CreateCommand();
+#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
+ cmd.CommandText = $"CREATE DATABASE \"{DatabaseName}\"";
+#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
+
+ _ = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ "PostgreSQL container failed to start within the expected time frame. Try restarting Rancher Desktop.",
+ ex
+ );
+ }
+ }
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/PulseTestsBase.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/PulseTestsBase.cs
new file mode 100644
index 00000000..ea3714cd
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/PulseTestsBase.cs
@@ -0,0 +1,83 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+using System.Runtime.CompilerServices;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using NetEvolve.Pulse.Extensibility;
+using NetEvolve.Pulse.Outbox;
+
+public abstract class PulseTestsBase
+{
+ protected IDatabaseServiceFixture DatabaseServiceFixture { get; }
+ protected IDatabaseInitializer DatabaseInitializer { get; }
+
+ protected static DateTimeOffset TestDateTime { get; } = new DateTimeOffset(2025, 1, 1, 12, 0, 0, 0, TimeSpan.Zero);
+
+ protected PulseTestsBase(IDatabaseServiceFixture databaseServiceFixture, IDatabaseInitializer databaseInitializer)
+ {
+ DatabaseServiceFixture = databaseServiceFixture;
+ DatabaseInitializer = databaseInitializer;
+ }
+
+ protected async ValueTask RunAndVerify(
+ Func testableCode,
+ CancellationToken cancellationToken,
+ Action? configureServices = null,
+ [CallerMemberName] string tableName = null!
+ )
+ {
+ ArgumentNullException.ThrowIfNull(testableCode);
+
+ using var host = new HostBuilder()
+ .ConfigureAppConfiguration((hostContext, configBuilder) => { })
+ .ConfigureServices(services =>
+ {
+ DatabaseInitializer.Initialize(services, DatabaseServiceFixture);
+ configureServices?.Invoke(services);
+ _ = services
+ .AddPulse(mediatorBuilder => DatabaseInitializer.Configure(mediatorBuilder, DatabaseServiceFixture))
+ .Configure(options =>
+ {
+ options.TableName = tableName;
+ options.Schema = TestHelper.TargetFramework;
+ });
+ })
+ .ConfigureWebHost(webBuilder => _ = webBuilder.UseTestServer().Configure(applicationBuilder => { }))
+ .Build();
+
+ await DatabaseInitializer.CreateDatabaseAsync(host.Services, cancellationToken).ConfigureAwait(false);
+ await host.StartAsync(cancellationToken).ConfigureAwait(false);
+
+ using var server = host.GetTestServer();
+
+ using (Assert.Multiple())
+ {
+ await using var scope = server.Services.CreateAsyncScope();
+ await testableCode.Invoke(scope.ServiceProvider, cancellationToken).ConfigureAwait(false);
+ }
+
+ await host.StopAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ protected static Task[] PublishEvents(IMediator mediator, int count, Func eventFactory)
+ where TEvent : IEvent => [.. Enumerable.Range(0, count).Select(x => mediator.PublishAsync(eventFactory(x)))];
+
+ protected static async Task PublishEventsAsync(
+ IMediator mediator,
+ int count,
+ Func eventFactory,
+ CancellationToken cancellationToken = default
+ )
+ where TEvent : IEvent
+ {
+ ArgumentNullException.ThrowIfNull(mediator);
+ ArgumentNullException.ThrowIfNull(eventFactory);
+
+ for (var i = 0; i < count; i++)
+ {
+ await mediator.PublishAsync(eventFactory(i), cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/SQLiteDatabaseServiceFixture.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/SQLiteDatabaseServiceFixture.cs
new file mode 100644
index 00000000..c3198143
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/SQLiteDatabaseServiceFixture.cs
@@ -0,0 +1,32 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+internal sealed class SQLiteDatabaseServiceFixture : IDatabaseServiceFixture
+{
+ public string ConnectionString => $"Data Source={DatabaseFile};";
+
+ public DatabaseType DatabaseType => DatabaseType.SQLite;
+
+ private string DatabaseFile { get; } =
+ Path.Combine(Path.GetTempPath(), $"{TestHelper.TargetFramework}{Guid.NewGuid():N}.sqlite");
+
+ public ValueTask DisposeAsync()
+ {
+ if (!File.Exists(DatabaseFile))
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ try
+ {
+ File.Delete(DatabaseFile);
+ }
+ catch (IOException)
+ {
+ // Best-effort cleanup for temporary test database files.
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ public Task InitializeAsync() => Task.CompletedTask;
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/SqlServerContainerFixture.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/SqlServerContainerFixture.cs
new file mode 100644
index 00000000..465be5f3
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/SqlServerContainerFixture.cs
@@ -0,0 +1,20 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+using Microsoft.Extensions.Logging.Abstractions;
+using Testcontainers.MsSql;
+using TUnit.Core.Interfaces;
+
+public sealed class SqlServerContainerFixture : IAsyncDisposable, IAsyncInitializer
+{
+ private readonly MsSqlContainer _container = new MsSqlBuilder(
+ /*dockerimage*/"mcr.microsoft.com/mssql/server:2022-RTM-ubuntu-20.04"
+ )
+ .WithLogger(NullLogger.Instance)
+ .Build();
+
+ public string ConnectionString => _container.GetConnectionString() + ";MultipleActiveResultSets=True;";
+
+ public ValueTask DisposeAsync() => _container.DisposeAsync();
+
+ public async Task InitializeAsync() => await _container.StartAsync().ConfigureAwait(false);
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/SqlServerDatabaseServiceFixture.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/SqlServerDatabaseServiceFixture.cs
new file mode 100644
index 00000000..eca409f8
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/SqlServerDatabaseServiceFixture.cs
@@ -0,0 +1,42 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+using Microsoft.Data.SqlClient;
+
+public sealed class SqlServerDatabaseServiceFixture : IDatabaseServiceFixture
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public SqlServerContainerFixture Container { get; set; } = default!;
+
+ public string ConnectionString =>
+ Container.ConnectionString.Replace("master", DatabaseName, StringComparison.Ordinal);
+
+ internal string DatabaseName { get; } = $"{TestHelper.TargetFramework}{Guid.NewGuid():N}";
+
+ public DatabaseType DatabaseType => DatabaseType.SqlServer;
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ public async Task InitializeAsync()
+ {
+ try
+ {
+ // Create temporary database to ensure the container is fully initialized and ready to accept connections
+ await using var con = new SqlConnection(Container.ConnectionString);
+ await con.OpenAsync();
+
+ await using var cmd = con.CreateCommand();
+#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
+ cmd.CommandText = $"CREATE DATABASE [{DatabaseName}]";
+#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
+
+ _ = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ "SQL Server container failed to start within the expected time frame. Try restarting Rancher Desktop.",
+ ex
+ );
+ }
+ }
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Internals/TestHelper.cs b/tests/NetEvolve.Pulse.Tests.Integration/Internals/TestHelper.cs
new file mode 100644
index 00000000..0ae4229a
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Internals/TestHelper.cs
@@ -0,0 +1,18 @@
+namespace NetEvolve.Pulse.Tests.Integration.Internals;
+
+internal static class TestHelper
+{
+ internal static string TargetFramework
+ {
+ get
+ {
+#if NET10_0
+ return "net10";
+#elif NET9_0
+ return "net9";
+#elif NET8_0
+ return "net8";
+#endif
+ }
+ }
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/NetEvolve.Pulse.Tests.Integration.csproj b/tests/NetEvolve.Pulse.Tests.Integration/NetEvolve.Pulse.Tests.Integration.csproj
index 33f48f6d..df6999ba 100644
--- a/tests/NetEvolve.Pulse.Tests.Integration/NetEvolve.Pulse.Tests.Integration.csproj
+++ b/tests/NetEvolve.Pulse.Tests.Integration/NetEvolve.Pulse.Tests.Integration.csproj
@@ -15,19 +15,27 @@
+
+
+
+
+
+
+
+
@@ -65,4 +73,8 @@
+
+
+
+
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Outbox/InMemoryEntityFrameworkOutboxTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/InMemoryEntityFrameworkOutboxTests.cs
new file mode 100644
index 00000000..812130f3
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/InMemoryEntityFrameworkOutboxTests.cs
@@ -0,0 +1,15 @@
+namespace NetEvolve.Pulse.Tests.Integration.Outbox;
+
+using NetEvolve.Extensions.TUnit;
+using NetEvolve.Pulse.Tests.Integration.Internals;
+
+[ClassDataSource(
+ Shared = [SharedType.None, SharedType.PerTestSession]
+)]
+[TestGroup("InMemory")]
+[TestGroup("EntityFramework")]
+[InheritsTests]
+public class InMemoryEntityFrameworkOutboxTests(
+ IDatabaseServiceFixture databaseServiceFixture,
+ IDatabaseInitializer databaseInitializer
+) : OutboxTestsBase(databaseServiceFixture, databaseInitializer);
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Outbox/MySqlEntityFrameworkOutboxTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/MySqlEntityFrameworkOutboxTests.cs
new file mode 100644
index 00000000..5c7e9b37
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/MySqlEntityFrameworkOutboxTests.cs
@@ -0,0 +1,13 @@
+namespace NetEvolve.Pulse.Tests.Integration.Outbox;
+
+using NetEvolve.Extensions.TUnit;
+using NetEvolve.Pulse.Tests.Integration.Internals;
+
+[ClassDataSource(Shared = [SharedType.None, SharedType.None])]
+[TestGroup("MySql")]
+[TestGroup("EntityFramework")]
+[InheritsTests]
+public class MySqlEntityFrameworkOutboxTests(
+ IDatabaseServiceFixture databaseServiceFixture,
+ IDatabaseInitializer databaseInitializer
+) : OutboxTestsBase(databaseServiceFixture, databaseInitializer);
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Outbox/OutboxTestsBase.cs b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/OutboxTestsBase.cs
new file mode 100644
index 00000000..23f173ae
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/OutboxTestsBase.cs
@@ -0,0 +1,518 @@
+namespace NetEvolve.Pulse.Tests.Integration.Outbox;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Time.Testing;
+using NetEvolve.Extensions.TUnit;
+using NetEvolve.Pulse.Extensibility;
+using NetEvolve.Pulse.Extensibility.Outbox;
+using NetEvolve.Pulse.Outbox;
+using NetEvolve.Pulse.Tests.Integration.Internals;
+
+[TestGroup("Outbox")]
+[Timeout(300_000)] // Increased timeout to accommodate potential delays in CI environments, especially when using SQL Server or MySQL containers that can take a long time to cold-start.
+public abstract class OutboxTestsBase(
+ IDatabaseServiceFixture databaseServiceFixture,
+ IDatabaseInitializer databaseInitializer
+) : PulseTestsBase(databaseServiceFixture, databaseInitializer)
+{
+ [Test]
+ public async Task Should_Persist_ExpectedMessageCount(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await PublishEventsAsync(mediator, 3, x => new TestEvent { Id = $"Test{x:D3}" }, token);
+
+ var outbox = services.GetRequiredService();
+
+ var result = await outbox.GetPendingCountAsync(token).ConfigureAwait(false);
+
+ _ = await Assert.That(result).IsEqualTo(3);
+ },
+ cancellationToken,
+ configureServices: services =>
+ services.Configure(options => options.DisableProcessing = true)
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_Persist_Expected_Messages(CancellationToken cancellationToken)
+ {
+ var timeProvider = new FakeTimeProvider();
+ timeProvider.AdjustTime(TestDateTime);
+
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await PublishEventsAsync(mediator, 3, x => new TestEvent { Id = $"Test{x:D3}" }, token);
+
+ var outbox = services.GetRequiredService();
+ var result = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Verify(result.OrderBy(x => x.Payload)).HashParameters().ConfigureAwait(false);
+ },
+ cancellationToken,
+ configureServices: services =>
+ services
+ .AddSingleton(timeProvider)
+ .Configure(options => options.DisableProcessing = true)
+ );
+ }
+
+ [Test]
+ public async Task Should_Return_Zero_PendingCount_When_Empty(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var outbox = services.GetRequiredService();
+
+ var result = await outbox.GetPendingCountAsync(token).ConfigureAwait(false);
+
+ _ = await Assert.That(result).IsEqualTo(0);
+ },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_Return_Empty_When_GetPending_NoMessages(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var outbox = services.GetRequiredService();
+
+ var result = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(result).IsEmpty();
+ },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_GetPendingAsync_Respects_BatchSize(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await PublishEventsAsync(mediator, 5, x => new TestEvent { Id = $"Test{x:D3}" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(3, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(3);
+
+ var remainingCount = await outbox.GetPendingCountAsync(token).ConfigureAwait(false);
+
+ _ = await Assert.That(remainingCount).IsEqualTo(2);
+ },
+ cancellationToken,
+ configureServices: services =>
+ services.Configure(options => options.DisableProcessing = true)
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_Mark_Single_Message_AsCompleted(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await mediator.PublishAsync(new TestEvent { Id = "Test001" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(1);
+
+ await outbox.MarkAsCompletedAsync(pending[0].Id, token).ConfigureAwait(false);
+
+ var pendingCount = await outbox.GetPendingCountAsync(token).ConfigureAwait(false);
+
+ _ = await Assert.That(pendingCount).IsEqualTo(0);
+ },
+ cancellationToken,
+ configureServices: services =>
+ services.Configure(options => options.DisableProcessing = true)
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_Mark_Multiple_Messages_AsCompleted(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await PublishEventsAsync(mediator, 3, x => new TestEvent { Id = $"Test{x:D3}" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(3);
+
+ var messageIds = pending.Select(m => m.Id).ToArray();
+ await outbox.MarkAsCompletedAsync(messageIds, token).ConfigureAwait(false);
+
+ var pendingCount = await outbox.GetPendingCountAsync(token).ConfigureAwait(false);
+
+ _ = await Assert.That(pendingCount).IsEqualTo(0);
+ },
+ cancellationToken,
+ configureServices: services =>
+ services.Configure(options => options.DisableProcessing = true)
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_Mark_Single_Message_AsFailed(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await mediator.PublishAsync(new TestEvent { Id = "Test001" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(1);
+
+ await outbox.MarkAsFailedAsync(pending[0].Id, "Test error", token).ConfigureAwait(false);
+
+ var pendingCount = await outbox.GetPendingCountAsync(token).ConfigureAwait(false);
+
+ _ = await Assert.That(pendingCount).IsEqualTo(0);
+
+ var failedForRetry = await outbox.GetFailedForRetryAsync(10, 50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(failedForRetry.Count).IsEqualTo(1);
+ },
+ cancellationToken,
+ configureServices: services =>
+ services.Configure(options => options.DisableProcessing = true)
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_Mark_Multiple_Messages_AsFailed(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await PublishEventsAsync(mediator, 3, x => new TestEvent { Id = $"Test{x:D3}" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(3);
+
+ var messageIds = pending.Select(m => m.Id).ToArray();
+ await outbox.MarkAsFailedAsync(messageIds, "Test error", token).ConfigureAwait(false);
+
+ var pendingCount = await outbox.GetPendingCountAsync(token).ConfigureAwait(false);
+
+ _ = await Assert.That(pendingCount).IsEqualTo(0);
+
+ var failedForRetry = await outbox.GetFailedForRetryAsync(10, 50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(failedForRetry.Count).IsEqualTo(3);
+ },
+ cancellationToken,
+ configureServices: services =>
+ services.Configure(options => options.DisableProcessing = true)
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_Mark_Single_Message_AsFailed_WithRetryScheduling(CancellationToken cancellationToken)
+ {
+ var timeProvider = new FakeTimeProvider();
+ timeProvider.AdjustTime(TestDateTime);
+
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await mediator.PublishAsync(new TestEvent { Id = "Test001" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(1);
+
+ await outbox
+ .MarkAsFailedAsync(pending[0].Id, "Test error", TestDateTime.AddHours(1), token)
+ .ConfigureAwait(false);
+
+ var failedForRetry = await outbox.GetFailedForRetryAsync(10, 50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(failedForRetry).IsEmpty();
+ },
+ cancellationToken,
+ configureServices: services =>
+ services
+ .AddSingleton(timeProvider)
+ .Configure(options => options.DisableProcessing = true)
+ );
+ }
+
+ [Test]
+ public async Task Should_Mark_Single_Message_AsDeadLetter(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await mediator.PublishAsync(new TestEvent { Id = "Test001" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(1);
+
+ await outbox.MarkAsDeadLetterAsync(pending[0].Id, "Fatal error", token).ConfigureAwait(false);
+
+ var pendingCount = await outbox.GetPendingCountAsync(token).ConfigureAwait(false);
+
+ _ = await Assert.That(pendingCount).IsEqualTo(0);
+
+ var failedForRetry = await outbox.GetFailedForRetryAsync(10, 50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(failedForRetry).IsEmpty();
+ },
+ cancellationToken,
+ configureServices: services =>
+ services.Configure(options => options.DisableProcessing = true)
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_Mark_Multiple_Messages_AsDeadLetter(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await PublishEventsAsync(mediator, 3, x => new TestEvent { Id = $"Test{x:D3}" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(3);
+
+ var messageIds = pending.Select(m => m.Id).ToArray();
+ await outbox.MarkAsDeadLetterAsync(messageIds, "Fatal error", token).ConfigureAwait(false);
+
+ var pendingCount = await outbox.GetPendingCountAsync(token).ConfigureAwait(false);
+
+ _ = await Assert.That(pendingCount).IsEqualTo(0);
+
+ var failedForRetry = await outbox.GetFailedForRetryAsync(10, 50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(failedForRetry).IsEmpty();
+ },
+ cancellationToken,
+ configureServices: services =>
+ services.Configure(options => options.DisableProcessing = true)
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_GetFailedForRetry_ExcludesScheduledMessages(CancellationToken cancellationToken)
+ {
+ var timeProvider = new FakeTimeProvider();
+ timeProvider.AdjustTime(TestDateTime);
+
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await PublishEventsAsync(mediator, 2, x => new TestEvent { Id = $"Test{x:D3}" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(2);
+
+ await Task.WhenAll(
+ outbox.MarkAsFailedAsync(pending[0].Id, "Scheduled error", TestDateTime.AddHours(1), token),
+ outbox.MarkAsFailedAsync(pending[1].Id, "Immediate error", token)
+ )
+ .ConfigureAwait(false);
+
+ var failedForRetry = await outbox.GetFailedForRetryAsync(10, 50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(failedForRetry.Count).IsEqualTo(1);
+ },
+ cancellationToken,
+ configureServices: services =>
+ services
+ .AddSingleton(timeProvider)
+ .Configure(options => options.DisableProcessing = true)
+ );
+ }
+
+ [Test]
+ public async Task Should_DeleteCompleted_ReturnsCorrectCount(CancellationToken cancellationToken)
+ {
+ var timeProvider = new FakeTimeProvider();
+ timeProvider.AdjustTime(TestDateTime);
+
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await PublishEventsAsync(mediator, 3, x => new TestEvent { Id = $"Test{x:D3}" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(3);
+
+ var messageIds = pending.Select(m => m.Id).ToArray();
+ await outbox.MarkAsCompletedAsync(messageIds, token).ConfigureAwait(false);
+
+ timeProvider.Advance(TimeSpan.FromMinutes(1));
+
+ var deleted = await outbox.DeleteCompletedAsync(TimeSpan.FromSeconds(30), token).ConfigureAwait(false);
+
+ _ = await Assert.That(deleted).IsEqualTo(3);
+ },
+ cancellationToken,
+ configureServices: services =>
+ services
+ .AddSingleton(timeProvider)
+ .Configure(options => options.DisableProcessing = true)
+ );
+ }
+
+ [Test]
+ public async Task Should_GetPendingAsync_ExcludesProcessingMessages(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await PublishEventsAsync(mediator, 3, x => new TestEvent { Id = $"Test{x:D3}" }, token);
+
+ var outbox = services.GetRequiredService();
+ _ = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ var secondBatch = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(secondBatch).IsEmpty();
+ },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_GetFailedForRetry_Returns_Empty_When_NoFailedMessages(
+ CancellationToken cancellationToken
+ ) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var outbox = services.GetRequiredService();
+
+ var result = await outbox.GetFailedForRetryAsync(10, 50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(result).IsEmpty();
+ },
+ cancellationToken
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_GetFailedForRetry_Excludes_MaxRetryCount_Exceeded(CancellationToken cancellationToken) =>
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await mediator.PublishAsync(new TestEvent { Id = "Test001" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(1);
+
+ await outbox.MarkAsFailedAsync(pending[0].Id, "Error 1", token).ConfigureAwait(false);
+
+ var firstRetry = await outbox.GetFailedForRetryAsync(3, 50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(firstRetry.Count).IsEqualTo(1);
+
+ await outbox.MarkAsFailedAsync(firstRetry[0].Id, "Error 2", token).ConfigureAwait(false);
+
+ var secondRetry = await outbox.GetFailedForRetryAsync(2, 50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(secondRetry).IsEmpty();
+ },
+ cancellationToken,
+ configureServices: services =>
+ services.Configure(options => options.DisableProcessing = true)
+ )
+ .ConfigureAwait(false);
+
+ [Test]
+ public async Task Should_DeleteCompleted_DoesNotDelete_NonCompletedMessages(CancellationToken cancellationToken)
+ {
+ var timeProvider = new FakeTimeProvider();
+ timeProvider.AdjustTime(TestDateTime);
+
+ await RunAndVerify(
+ async (services, token) =>
+ {
+ var mediator = services.GetRequiredService();
+
+ await PublishEventsAsync(mediator, 4, x => new TestEvent { Id = $"Test{x:D3}" }, token);
+
+ var outbox = services.GetRequiredService();
+ var pending = await outbox.GetPendingAsync(50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(pending.Count).IsEqualTo(4);
+
+ var completedIds = pending.Take(2).Select(m => m.Id).ToArray();
+ await outbox.MarkAsCompletedAsync(completedIds, token).ConfigureAwait(false);
+
+ var failedIds = pending.Skip(2).Select(m => m.Id).ToArray();
+ await outbox.MarkAsFailedAsync(failedIds, "Test error", token).ConfigureAwait(false);
+
+ timeProvider.Advance(TimeSpan.FromMinutes(1));
+
+ var deleted = await outbox.DeleteCompletedAsync(TimeSpan.FromSeconds(30), token).ConfigureAwait(false);
+
+ _ = await Assert.That(deleted).IsEqualTo(2);
+
+ var failedForRetry = await outbox.GetFailedForRetryAsync(10, 50, token).ConfigureAwait(false);
+
+ _ = await Assert.That(failedForRetry.Count).IsEqualTo(2);
+ },
+ cancellationToken,
+ configureServices: services =>
+ services
+ .AddSingleton(timeProvider)
+ .Configure(options => options.DisableProcessing = true)
+ );
+ }
+
+ private sealed class TestEvent : IEvent
+ {
+ public string? CorrelationId { get; set; }
+
+ public required string Id { get; init; }
+
+ public DateTimeOffset? PublishedAt { get; set; }
+ }
+}
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Outbox/PostgreSqlEntityFrameworkOutboxTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/PostgreSqlEntityFrameworkOutboxTests.cs
new file mode 100644
index 00000000..09bd1641
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/PostgreSqlEntityFrameworkOutboxTests.cs
@@ -0,0 +1,15 @@
+namespace NetEvolve.Pulse.Tests.Integration.Outbox;
+
+using NetEvolve.Extensions.TUnit;
+using NetEvolve.Pulse.Tests.Integration.Internals;
+
+[ClassDataSource(
+ Shared = [SharedType.None, SharedType.None]
+)]
+[TestGroup("PostgreSql")]
+[TestGroup("EntityFramework")]
+[InheritsTests]
+public class PostgreSqlEntityFrameworkOutboxTests(
+ IDatabaseServiceFixture databaseServiceFixture,
+ IDatabaseInitializer databaseInitializer
+) : OutboxTestsBase(databaseServiceFixture, databaseInitializer);
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Outbox/SQLiteEntityFrameworkOutboxTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/SQLiteEntityFrameworkOutboxTests.cs
new file mode 100644
index 00000000..0db61a12
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/SQLiteEntityFrameworkOutboxTests.cs
@@ -0,0 +1,13 @@
+namespace NetEvolve.Pulse.Tests.Integration.Outbox;
+
+using NetEvolve.Extensions.TUnit;
+using NetEvolve.Pulse.Tests.Integration.Internals;
+
+[ClassDataSource(Shared = [SharedType.None, SharedType.None])]
+[TestGroup("SQLite")]
+[TestGroup("EntityFramework")]
+[InheritsTests]
+public class SQLiteEntityFrameworkOutboxTests(
+ IDatabaseServiceFixture databaseServiceFixture,
+ IDatabaseInitializer databaseInitializer
+) : OutboxTestsBase(databaseServiceFixture, databaseInitializer);
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/Outbox/SqlServerEntityFrameworkOutboxTests.cs b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/SqlServerEntityFrameworkOutboxTests.cs
new file mode 100644
index 00000000..2d1a1c13
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/Outbox/SqlServerEntityFrameworkOutboxTests.cs
@@ -0,0 +1,15 @@
+namespace NetEvolve.Pulse.Tests.Integration.Outbox;
+
+using NetEvolve.Extensions.TUnit;
+using NetEvolve.Pulse.Tests.Integration.Internals;
+
+[ClassDataSource(
+ Shared = [SharedType.None, SharedType.None]
+)]
+[TestGroup("SqlServer")]
+[TestGroup("EntityFramework")]
+[InheritsTests]
+public class SqlServerEntityFrameworkOutboxTests(
+ IDatabaseServiceFixture databaseServiceFixture,
+ IDatabaseInitializer databaseInitializer
+) : OutboxTestsBase(databaseServiceFixture, databaseInitializer);
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/InMemoryEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_b0c5fb5f6a10ddef.verified.txt b/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/InMemoryEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_b0c5fb5f6a10ddef.verified.txt
new file mode 100644
index 00000000..4ee756e4
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/InMemoryEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_b0c5fb5f6a10ddef.verified.txt
@@ -0,0 +1,26 @@
+[
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_1,
+ Payload: {"CorrelationId":null,"Id":"Test000","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ },
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_2,
+ Payload: {"CorrelationId":null,"Id":"Test001","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ },
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_3,
+ Payload: {"CorrelationId":null,"Id":"Test002","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ }
+]
\ No newline at end of file
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/MySqlEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_2f3dd6e39de993fc.verified.txt b/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/MySqlEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_2f3dd6e39de993fc.verified.txt
new file mode 100644
index 00000000..4ee756e4
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/MySqlEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_2f3dd6e39de993fc.verified.txt
@@ -0,0 +1,26 @@
+[
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_1,
+ Payload: {"CorrelationId":null,"Id":"Test000","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ },
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_2,
+ Payload: {"CorrelationId":null,"Id":"Test001","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ },
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_3,
+ Payload: {"CorrelationId":null,"Id":"Test002","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ }
+]
\ No newline at end of file
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/PostgreSqlEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_93e6c214b197efdd.verified.txt b/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/PostgreSqlEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_93e6c214b197efdd.verified.txt
new file mode 100644
index 00000000..4ee756e4
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/PostgreSqlEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_93e6c214b197efdd.verified.txt
@@ -0,0 +1,26 @@
+[
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_1,
+ Payload: {"CorrelationId":null,"Id":"Test000","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ },
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_2,
+ Payload: {"CorrelationId":null,"Id":"Test001","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ },
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_3,
+ Payload: {"CorrelationId":null,"Id":"Test002","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ }
+]
\ No newline at end of file
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/SQLiteEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_dda0938409f8d1d7.verified.txt b/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/SQLiteEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_dda0938409f8d1d7.verified.txt
new file mode 100644
index 00000000..4ee756e4
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/SQLiteEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_dda0938409f8d1d7.verified.txt
@@ -0,0 +1,26 @@
+[
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_1,
+ Payload: {"CorrelationId":null,"Id":"Test000","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ },
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_2,
+ Payload: {"CorrelationId":null,"Id":"Test001","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ },
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_3,
+ Payload: {"CorrelationId":null,"Id":"Test002","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ }
+]
\ No newline at end of file
diff --git a/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/SqlServerEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_cde079fe90d49e58.verified.txt b/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/SqlServerEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_cde079fe90d49e58.verified.txt
new file mode 100644
index 00000000..4ee756e4
--- /dev/null
+++ b/tests/NetEvolve.Pulse.Tests.Integration/_snapshots/Outbox/SqlServerEntityFrameworkOutboxTests.Should_Persist_Expected_Messages_cde079fe90d49e58.verified.txt
@@ -0,0 +1,26 @@
+[
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_1,
+ Payload: {"CorrelationId":null,"Id":"Test000","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ },
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_2,
+ Payload: {"CorrelationId":null,"Id":"Test001","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ },
+ {
+ CreatedAt: DateTimeOffset_1,
+ EventType: OutboxTestsBase.TestEvent,
+ Id: Guid_3,
+ Payload: {"CorrelationId":null,"Id":"Test002","PublishedAt":"2025-01-01T12:00:00+00:00"},
+ Status: Processing,
+ UpdatedAt: DateTimeOffset_1
+ }
+]
\ No newline at end of file
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/AzureServiceBus/AzureServiceBusMessageTransportTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/AzureServiceBus/AzureServiceBusMessageTransportTests.cs
index b44b5229..a0382b48 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/AzureServiceBus/AzureServiceBusMessageTransportTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/AzureServiceBus/AzureServiceBusMessageTransportTests.cs
@@ -57,7 +57,7 @@ public async Task Constructor_When_options_is_null_throws_ArgumentNullException(
// ── IsHealthyAsync ────────────────────────────────────────────────────────
[Test]
- public async Task IsHealthyAsync_When_client_not_closed_returns_true()
+ public async Task IsHealthyAsync_When_client_not_closed_returns_true(CancellationToken cancellationToken)
{
await using var client = new ServiceBusClient(FakeConnectionString);
var resolver = new FakeTopicNameResolver();
@@ -65,13 +65,13 @@ public async Task IsHealthyAsync_When_client_not_closed_returns_true()
await using var transport = new AzureServiceBusMessageTransport(client, resolver, options);
- var healthy = await transport.IsHealthyAsync();
+ var healthy = await transport.IsHealthyAsync(cancellationToken);
_ = await Assert.That(healthy).IsTrue();
}
[Test]
- public async Task IsHealthyAsync_When_client_is_closed_returns_false()
+ public async Task IsHealthyAsync_When_client_is_closed_returns_false(CancellationToken cancellationToken)
{
// Note: DisposeAsync is a no-op (does not dispose injected dependencies).
// Close the underlying client directly to test the health check.
@@ -83,7 +83,7 @@ public async Task IsHealthyAsync_When_client_is_closed_returns_false()
await client.DisposeAsync();
- var healthy = await transport.IsHealthyAsync();
+ var healthy = await transport.IsHealthyAsync(cancellationToken);
_ = await Assert.That(healthy).IsFalse();
}
@@ -91,7 +91,7 @@ public async Task IsHealthyAsync_When_client_is_closed_returns_false()
// ── SendAsync null guard ──────────────────────────────────────────────────
[Test]
- public async Task SendAsync_When_message_is_null_throws_ArgumentNullException()
+ public async Task SendAsync_When_message_is_null_throws_ArgumentNullException(CancellationToken cancellationToken)
{
await using var client = new ServiceBusClient(FakeConnectionString);
var resolver = new FakeTopicNameResolver();
@@ -99,13 +99,13 @@ public async Task SendAsync_When_message_is_null_throws_ArgumentNullException()
await using var transport = new AzureServiceBusMessageTransport(client, resolver, options);
- _ = await Assert.ThrowsAsync(() => transport.SendAsync(null!));
+ _ = await Assert.ThrowsAsync(() => transport.SendAsync(null!, cancellationToken));
}
// ── SendAsync happy path ──────────────────────────────────────────────────
[Test]
- public async Task SendAsync_Routes_message_to_resolver_topic()
+ public async Task SendAsync_Routes_message_to_resolver_topic(CancellationToken cancellationToken)
{
await using var fakeClient = new FakeServiceBusClient();
var resolver = new FakeTopicNameResolver("orders");
@@ -114,14 +114,14 @@ public async Task SendAsync_Routes_message_to_resolver_topic()
await using var transport = new AzureServiceBusMessageTransport(fakeClient, resolver, options);
var message = CreateOutboxMessage();
- await transport.SendAsync(message);
+ await transport.SendAsync(message, cancellationToken);
_ = await Assert.That(fakeClient.GetSender("orders")).IsNotNull();
_ = await Assert.That(fakeClient.GetSender("orders")!.SentMessages).HasSingleItem();
}
[Test]
- public async Task SendAsync_Maps_required_fields_onto_ServiceBusMessage()
+ public async Task SendAsync_Maps_required_fields_onto_ServiceBusMessage(CancellationToken cancellationToken)
{
await using var fakeClient = new FakeServiceBusClient();
var resolver = new FakeTopicNameResolver("my-topic");
@@ -130,7 +130,7 @@ public async Task SendAsync_Maps_required_fields_onto_ServiceBusMessage()
await using var transport = new AzureServiceBusMessageTransport(fakeClient, resolver, options);
var outboxMessage = CreateOutboxMessage();
- await transport.SendAsync(outboxMessage);
+ await transport.SendAsync(outboxMessage, cancellationToken);
var sent = fakeClient.GetSender("my-topic")!.SentMessages[0];
using (Assert.Multiple())
@@ -150,7 +150,7 @@ public async Task SendAsync_Maps_required_fields_onto_ServiceBusMessage()
}
[Test]
- public async Task SendAsync_Maps_optional_ProcessedAt_when_set()
+ public async Task SendAsync_Maps_optional_ProcessedAt_when_set(CancellationToken cancellationToken)
{
await using var fakeClient = new FakeServiceBusClient();
var resolver = new FakeTopicNameResolver();
@@ -162,14 +162,14 @@ public async Task SendAsync_Maps_optional_ProcessedAt_when_set()
var outboxMessage = CreateOutboxMessage();
outboxMessage.ProcessedAt = processedAt;
- await transport.SendAsync(outboxMessage);
+ await transport.SendAsync(outboxMessage, cancellationToken);
var sent = fakeClient.GetSender("test-topic")!.SentMessages[0];
_ = await Assert.That(sent.ApplicationProperties["processedAt"]).IsEqualTo(processedAt);
}
[Test]
- public async Task SendAsync_Maps_optional_Error_when_set()
+ public async Task SendAsync_Maps_optional_Error_when_set(CancellationToken cancellationToken)
{
await using var fakeClient = new FakeServiceBusClient();
var resolver = new FakeTopicNameResolver();
@@ -180,14 +180,14 @@ public async Task SendAsync_Maps_optional_Error_when_set()
var outboxMessage = CreateOutboxMessage();
outboxMessage.Error = "Some processing error";
- await transport.SendAsync(outboxMessage);
+ await transport.SendAsync(outboxMessage, cancellationToken);
var sent = fakeClient.GetSender("test-topic")!.SentMessages[0];
_ = await Assert.That(sent.ApplicationProperties["error"]).IsEqualTo("Some processing error");
}
[Test]
- public async Task SendAsync_Does_not_add_processedAt_property_when_null()
+ public async Task SendAsync_Does_not_add_processedAt_property_when_null(CancellationToken cancellationToken)
{
await using var fakeClient = new FakeServiceBusClient();
var resolver = new FakeTopicNameResolver();
@@ -197,7 +197,7 @@ public async Task SendAsync_Does_not_add_processedAt_property_when_null()
var outboxMessage = CreateOutboxMessage(); // ProcessedAt is null by default
- await transport.SendAsync(outboxMessage);
+ await transport.SendAsync(outboxMessage, cancellationToken);
var sent = fakeClient.GetSender("test-topic")!.SentMessages[0];
_ = await Assert.That(sent.ApplicationProperties.ContainsKey("processedAt")).IsFalse();
@@ -207,7 +207,9 @@ public async Task SendAsync_Does_not_add_processedAt_property_when_null()
// ── SendBatchAsync null/empty guards ──────────────────────────────────────
[Test]
- public async Task SendBatchAsync_When_messages_is_null_throws_ArgumentNullException()
+ public async Task SendBatchAsync_When_messages_is_null_throws_ArgumentNullException(
+ CancellationToken cancellationToken
+ )
{
await using var client = new ServiceBusClient(FakeConnectionString);
var resolver = new FakeTopicNameResolver();
@@ -215,11 +217,11 @@ public async Task SendBatchAsync_When_messages_is_null_throws_ArgumentNullExcept
await using var transport = new AzureServiceBusMessageTransport(client, resolver, options);
- _ = await Assert.ThrowsAsync(() => transport.SendBatchAsync(null!));
+ _ = await Assert.ThrowsAsync(() => transport.SendBatchAsync(null!, cancellationToken));
}
[Test]
- public async Task SendBatchAsync_When_messages_is_empty_does_not_throw()
+ public async Task SendBatchAsync_When_messages_is_empty_does_not_throw(CancellationToken cancellationToken)
{
await using var client = new ServiceBusClient(FakeConnectionString);
var resolver = new FakeTopicNameResolver();
@@ -227,13 +229,15 @@ public async Task SendBatchAsync_When_messages_is_empty_does_not_throw()
await using var transport = new AzureServiceBusMessageTransport(client, resolver, options);
- await transport.SendBatchAsync([]);
+ await transport.SendBatchAsync([], cancellationToken);
}
// ── SendBatchAsync – batching disabled ────────────────────────────────────
[Test]
- public async Task SendBatchAsync_BatchingDisabled_Sends_each_message_individually()
+ public async Task SendBatchAsync_BatchingDisabled_Sends_each_message_individually(
+ CancellationToken cancellationToken
+ )
{
await using var fakeClient = new FakeServiceBusClient();
var resolver = new FakeTopicNameResolver("queue1");
@@ -242,7 +246,7 @@ public async Task SendBatchAsync_BatchingDisabled_Sends_each_message_individuall
await using var transport = new AzureServiceBusMessageTransport(fakeClient, resolver, options);
var messages = new[] { CreateOutboxMessage(), CreateOutboxMessage(), CreateOutboxMessage() };
- await transport.SendBatchAsync(messages);
+ await transport.SendBatchAsync(messages, cancellationToken);
var sender = fakeClient.GetSender("queue1")!;
_ = await Assert.That(sender.SentMessages.Count).IsEqualTo(3);
@@ -250,7 +254,7 @@ public async Task SendBatchAsync_BatchingDisabled_Sends_each_message_individuall
}
[Test]
- public async Task SendBatchAsync_BatchingDisabled_Groups_messages_by_topic()
+ public async Task SendBatchAsync_BatchingDisabled_Groups_messages_by_topic(CancellationToken cancellationToken)
{
await using var fakeClient = new FakeServiceBusClient();
var resolver = new TopicPerEventTypeResolver();
@@ -265,7 +269,7 @@ public async Task SendBatchAsync_BatchingDisabled_Groups_messages_by_topic()
CreateOutboxMessage(typeof(TopicBEvent)),
};
- await transport.SendBatchAsync(messages);
+ await transport.SendBatchAsync(messages, cancellationToken);
_ = await Assert.That(fakeClient.GetSender(nameof(TopicAEvent))!.SentMessages.Count).IsEqualTo(2);
_ = await Assert.That(fakeClient.GetSender(nameof(TopicBEvent))!.SentMessages.Count).IsEqualTo(1);
@@ -274,7 +278,7 @@ public async Task SendBatchAsync_BatchingDisabled_Groups_messages_by_topic()
// ── SendBatchAsync – batching enabled ─────────────────────────────────────
[Test]
- public async Task SendBatchAsync_BatchingEnabled_Sends_messages_as_batch()
+ public async Task SendBatchAsync_BatchingEnabled_Sends_messages_as_batch(CancellationToken cancellationToken)
{
await using var fakeClient = new FakeServiceBusClient();
var resolver = new FakeTopicNameResolver("orders");
@@ -283,7 +287,7 @@ public async Task SendBatchAsync_BatchingEnabled_Sends_messages_as_batch()
await using var transport = new AzureServiceBusMessageTransport(fakeClient, resolver, options);
var messages = new[] { CreateOutboxMessage(), CreateOutboxMessage() };
- await transport.SendBatchAsync(messages);
+ await transport.SendBatchAsync(messages, cancellationToken);
var sender = fakeClient.GetSender("orders")!;
_ = await Assert.That(sender.SentMessages.Count).IsEqualTo(0);
@@ -292,7 +296,7 @@ public async Task SendBatchAsync_BatchingEnabled_Sends_messages_as_batch()
}
[Test]
- public async Task SendBatchAsync_BatchingEnabled_Groups_messages_by_topic()
+ public async Task SendBatchAsync_BatchingEnabled_Groups_messages_by_topic(CancellationToken cancellationToken)
{
await using var fakeClient = new FakeServiceBusClient();
var resolver = new TopicPerEventTypeResolver();
@@ -307,7 +311,7 @@ public async Task SendBatchAsync_BatchingEnabled_Groups_messages_by_topic()
CreateOutboxMessage(typeof(BetaEvent)),
};
- await transport.SendBatchAsync(messages);
+ await transport.SendBatchAsync(messages, cancellationToken);
_ = await Assert.That(fakeClient.GetSender(nameof(AlphaEvent))!.BatchedMessages[0].Count).IsEqualTo(2);
_ = await Assert.That(fakeClient.GetSender(nameof(BetaEvent))!.BatchedMessages[0].Count).IsEqualTo(1);
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Dapr/DaprMessageTransportTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Dapr/DaprMessageTransportTests.cs
index 07775390..f5f43150 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Dapr/DaprMessageTransportTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Dapr/DaprMessageTransportTests.cs
@@ -59,7 +59,7 @@ public async Task Constructor_With_valid_arguments_creates_instance()
}
[Test]
- public async Task SendAsync_When_message_is_null_throws_ArgumentNullException()
+ public async Task SendAsync_When_message_is_null_throws_ArgumentNullException(CancellationToken cancellationToken)
{
using var daprClient = new DaprClientBuilder().Build();
var transport = new DaprMessageTransport(
@@ -68,11 +68,11 @@ public async Task SendAsync_When_message_is_null_throws_ArgumentNullException()
Options.Create(new DaprMessageTransportOptions())
);
- _ = await Assert.ThrowsAsync(() => transport.SendAsync(null!));
+ _ = await Assert.ThrowsAsync(() => transport.SendAsync(null!, cancellationToken));
}
[Test]
- public async Task IsHealthyAsync_Delegates_to_DaprClient()
+ public async Task IsHealthyAsync_Delegates_to_DaprClient(CancellationToken cancellationToken)
{
using var daprClient = new DaprClientBuilder().Build();
var transport = new DaprMessageTransport(
@@ -82,7 +82,7 @@ public async Task IsHealthyAsync_Delegates_to_DaprClient()
);
// Without a running Dapr sidecar, CheckHealthAsync returns false (connection refused → false, not throw)
- var result = await transport.IsHealthyAsync();
+ var result = await transport.IsHealthyAsync(cancellationToken);
_ = await Assert.That(result).IsTypeOf();
}
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/ParallelEventDispatcherTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/ParallelEventDispatcherTests.cs
index 6a57ea8e..8b8f8728 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/ParallelEventDispatcherTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/ParallelEventDispatcherTests.cs
@@ -9,7 +9,7 @@
public class ParallelEventDispatcherTests
{
[Test]
- public async Task DispatchAsync_WithMultipleHandlers_InvokesAllHandlers()
+ public async Task DispatchAsync_WithMultipleHandlers_InvokesAllHandlers(CancellationToken cancellationToken)
{
var dispatcher = new ParallelEventDispatcher();
var testEvent = new TestEvent();
@@ -26,7 +26,7 @@ await dispatcher
testEvent,
handlers,
async (handler, evt, ct) => await handler.HandleAsync(evt, ct).ConfigureAwait(false),
- CancellationToken.None
+ cancellationToken
)
.ConfigureAwait(false);
@@ -40,7 +40,7 @@ await dispatcher
}
[Test]
- public async Task DispatchAsync_WithNoHandlers_CompletesSuccessfully()
+ public async Task DispatchAsync_WithNoHandlers_CompletesSuccessfully(CancellationToken cancellationToken)
{
var dispatcher = new ParallelEventDispatcher();
var testEvent = new TestEvent();
@@ -51,13 +51,13 @@ await dispatcher
testEvent,
handlers,
async (handler, evt, ct) => await handler.HandleAsync(evt, ct).ConfigureAwait(false),
- CancellationToken.None
+ cancellationToken
)
.ConfigureAwait(false);
}
[Test]
- public async Task DispatchAsync_WithSingleHandler_InvokesHandler()
+ public async Task DispatchAsync_WithSingleHandler_InvokesHandler(CancellationToken cancellationToken)
{
var dispatcher = new ParallelEventDispatcher();
var testEvent = new TestEvent();
@@ -69,7 +69,7 @@ await dispatcher
testEvent,
handlers,
async (handler, evt, ct) => await handler.HandleAsync(evt, ct).ConfigureAwait(false),
- CancellationToken.None
+ cancellationToken
)
.ConfigureAwait(false);
@@ -77,13 +77,13 @@ await dispatcher
}
[Test]
- public async Task DispatchAsync_WithCancellation_RespectsToken()
+ public async Task DispatchAsync_WithCancellation_RespectsToken(CancellationToken cancellationToken)
{
var dispatcher = new ParallelEventDispatcher();
var testEvent = new TestEvent();
var invokedHandlers = new List();
var handlers = new List> { new TestEventHandler(1, invokedHandlers) };
- using var cts = new CancellationTokenSource();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
await cts.CancelAsync().ConfigureAwait(false);
_ = await Assert.ThrowsAsync(async () =>
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/PrioritizedEventDispatcherTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/PrioritizedEventDispatcherTests.cs
index 81cf9258..b4a6640f 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/PrioritizedEventDispatcherTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/PrioritizedEventDispatcherTests.cs
@@ -14,7 +14,7 @@
public class PrioritizedEventDispatcherTests
{
[Test]
- public async Task DispatchAsync_WithPrioritizedHandlers_ExecutesInPriorityOrder()
+ public async Task DispatchAsync_WithPrioritizedHandlers_ExecutesInPriorityOrder(CancellationToken cancellationToken)
{
var dispatcher = new PrioritizedEventDispatcher();
var message = new TestEvent();
@@ -27,12 +27,7 @@ public async Task DispatchAsync_WithPrioritizedHandlers_ExecutesInPriorityOrder(
};
await dispatcher
- .DispatchAsync(
- message,
- handlers,
- (handler, msg, ct) => handler.HandleAsync(msg, ct),
- CancellationToken.None
- )
+ .DispatchAsync(message, handlers, (handler, msg, ct) => handler.HandleAsync(msg, ct), cancellationToken)
.ConfigureAwait(false);
var order = executionOrder.ToArray();
@@ -46,7 +41,7 @@ await dispatcher
}
[Test]
- public async Task DispatchAsync_WithNonPrioritizedHandlers_ExecutesLast()
+ public async Task DispatchAsync_WithNonPrioritizedHandlers_ExecutesLast(CancellationToken cancellationToken)
{
var dispatcher = new PrioritizedEventDispatcher();
var message = new TestEvent();
@@ -60,12 +55,7 @@ public async Task DispatchAsync_WithNonPrioritizedHandlers_ExecutesLast()
};
await dispatcher
- .DispatchAsync(
- message,
- handlers,
- (handler, msg, ct) => handler.HandleAsync(msg, ct),
- CancellationToken.None
- )
+ .DispatchAsync(message, handlers, (handler, msg, ct) => handler.HandleAsync(msg, ct), cancellationToken)
.ConfigureAwait(false);
var order = executionOrder.ToArray();
@@ -80,7 +70,7 @@ await dispatcher
}
[Test]
- public async Task DispatchAsync_WithEqualPriority_PreservesRegistrationOrder()
+ public async Task DispatchAsync_WithEqualPriority_PreservesRegistrationOrder(CancellationToken cancellationToken)
{
var dispatcher = new PrioritizedEventDispatcher();
var message = new TestEvent();
@@ -93,12 +83,7 @@ public async Task DispatchAsync_WithEqualPriority_PreservesRegistrationOrder()
};
await dispatcher
- .DispatchAsync(
- message,
- handlers,
- (handler, msg, ct) => handler.HandleAsync(msg, ct),
- CancellationToken.None
- )
+ .DispatchAsync(message, handlers, (handler, msg, ct) => handler.HandleAsync(msg, ct), cancellationToken)
.ConfigureAwait(false);
var order = executionOrder.ToArray();
@@ -112,12 +97,12 @@ await dispatcher
}
[Test]
- public async Task DispatchAsync_WithCancellation_StopsExecution()
+ public async Task DispatchAsync_WithCancellation_StopsExecution(CancellationToken cancellationToken)
{
var dispatcher = new PrioritizedEventDispatcher();
var message = new TestEvent();
var executionOrder = new ConcurrentQueue();
- using var cts = new CancellationTokenSource();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var handlers = new List>
{
new PrioritizedTestHandler(1, 0, executionOrder),
@@ -142,19 +127,14 @@ await dispatcher
}
[Test]
- public async Task DispatchAsync_WithEmptyHandlers_CompletesSuccessfully()
+ public async Task DispatchAsync_WithEmptyHandlers_CompletesSuccessfully(CancellationToken cancellationToken)
{
var dispatcher = new PrioritizedEventDispatcher();
var message = new TestEvent();
var handlers = Enumerable.Empty>();
await dispatcher
- .DispatchAsync(
- message,
- handlers,
- (handler, msg, ct) => handler.HandleAsync(msg, ct),
- CancellationToken.None
- )
+ .DispatchAsync(message, handlers, (handler, msg, ct) => handler.HandleAsync(msg, ct), cancellationToken)
.ConfigureAwait(false);
}
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/RateLimitedEventDispatcherTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/RateLimitedEventDispatcherTests.cs
index c500cfa4..43282fca 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/RateLimitedEventDispatcherTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/RateLimitedEventDispatcherTests.cs
@@ -38,7 +38,7 @@ public async Task Constructor_WithNegativeConcurrency_ThrowsArgumentOutOfRangeEx
_ = Assert.Throws(() => _ = new RateLimitedEventDispatcher(maxConcurrency: -1));
[Test]
- public async Task DispatchAsync_WithHandlers_InvokesAllHandlers()
+ public async Task DispatchAsync_WithHandlers_InvokesAllHandlers(CancellationToken cancellationToken)
{
var dispatcher = new RateLimitedEventDispatcher(maxConcurrency: 2);
var message = new TestEvent();
@@ -51,12 +51,7 @@ public async Task DispatchAsync_WithHandlers_InvokesAllHandlers()
};
await dispatcher
- .DispatchAsync(
- message,
- handlers,
- (handler, msg, ct) => handler.HandleAsync(msg, ct),
- CancellationToken.None
- )
+ .DispatchAsync(message, handlers, (handler, msg, ct) => handler.HandleAsync(msg, ct), cancellationToken)
.ConfigureAwait(false);
using (Assert.Multiple())
@@ -69,7 +64,7 @@ await dispatcher
}
[Test]
- public async Task DispatchAsync_LimitsConcurrency()
+ public async Task DispatchAsync_LimitsConcurrency(CancellationToken cancellationToken)
{
var dispatcher = new RateLimitedEventDispatcher(maxConcurrency: 2);
var message = new TestEvent();
@@ -107,7 +102,7 @@ await dispatcher
message,
handlers,
async (handler, msg, ct) => await handler.HandleAsync(msg, ct).ConfigureAwait(false),
- CancellationToken.None
+ cancellationToken
)
.ConfigureAwait(false);
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/SequentialEventDispatcherTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/SequentialEventDispatcherTests.cs
index 985a520a..229ca784 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/SequentialEventDispatcherTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Dispatchers/SequentialEventDispatcherTests.cs
@@ -9,7 +9,7 @@
public class SequentialEventDispatcherTests
{
[Test]
- public async Task DispatchAsync_WithMultipleHandlers_InvokesAllHandlersInOrder()
+ public async Task DispatchAsync_WithMultipleHandlers_InvokesAllHandlersInOrder(CancellationToken cancellationToken)
{
var dispatcher = new SequentialEventDispatcher();
var testEvent = new TestEvent();
@@ -26,7 +26,7 @@ await dispatcher
testEvent,
handlers,
async (handler, evt, ct) => await handler.HandleAsync(evt, ct).ConfigureAwait(false),
- CancellationToken.None
+ cancellationToken
)
.ConfigureAwait(false);
@@ -40,7 +40,7 @@ await dispatcher
}
[Test]
- public async Task DispatchAsync_WithNoHandlers_CompletesSuccessfully()
+ public async Task DispatchAsync_WithNoHandlers_CompletesSuccessfully(CancellationToken cancellationToken)
{
var dispatcher = new SequentialEventDispatcher();
var testEvent = new TestEvent();
@@ -51,13 +51,13 @@ await dispatcher
testEvent,
handlers,
async (handler, evt, ct) => await handler.HandleAsync(evt, ct).ConfigureAwait(false),
- CancellationToken.None
+ cancellationToken
)
.ConfigureAwait(false);
}
[Test]
- public async Task DispatchAsync_WithSingleHandler_InvokesHandler()
+ public async Task DispatchAsync_WithSingleHandler_InvokesHandler(CancellationToken cancellationToken)
{
var dispatcher = new SequentialEventDispatcher();
var testEvent = new TestEvent();
@@ -69,7 +69,7 @@ await dispatcher
testEvent,
handlers,
async (handler, evt, ct) => await handler.HandleAsync(evt, ct).ConfigureAwait(false),
- CancellationToken.None
+ cancellationToken
)
.ConfigureAwait(false);
@@ -77,12 +77,12 @@ await dispatcher
}
[Test]
- public async Task DispatchAsync_WithCancellationBetweenHandlers_StopsExecution()
+ public async Task DispatchAsync_WithCancellationBetweenHandlers_StopsExecution(CancellationToken cancellationToken)
{
var dispatcher = new SequentialEventDispatcher();
var testEvent = new TestEvent();
var executionOrder = new List();
- using var cts = new CancellationTokenSource();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var handlers = new List>
{
new CancellingEventHandler(1, executionOrder, cts),
@@ -105,7 +105,7 @@ await dispatcher
}
[Test]
- public async Task DispatchAsync_ExecutesSequentially_NotInParallel()
+ public async Task DispatchAsync_ExecutesSequentially_NotInParallel(CancellationToken cancellationToken)
{
var dispatcher = new SequentialEventDispatcher();
var testEvent = new TestEvent();
@@ -151,7 +151,7 @@ await dispatcher
testEvent,
handlers,
async (handler, evt, ct) => await handler.HandleAsync(evt, ct).ConfigureAwait(false),
- CancellationToken.None
+ cancellationToken
)
.ConfigureAwait(false);
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkEventOutboxTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkEventOutboxTests.cs
index ca3de877..0c81de36 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkEventOutboxTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkEventOutboxTests.cs
@@ -87,7 +87,9 @@ public async Task StoreAsync_WithNullMessage_ThrowsArgumentNullException()
}
[Test]
- public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException()
+ public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException(
+ CancellationToken cancellationToken
+ )
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException))
@@ -104,7 +106,7 @@ public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationExcepti
};
_ = await Assert
- .That(async () => await outbox.StoreAsync(message).ConfigureAwait(false))
+ .That(async () => await outbox.StoreAsync(message, cancellationToken).ConfigureAwait(false))
.Throws();
}
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkOutboxManagementTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkOutboxManagementTests.cs
index 6d2a801e..164254c8 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkOutboxManagementTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkOutboxManagementTests.cs
@@ -75,7 +75,9 @@ public async Task GetDeadLetterMessagesAsync_WithZeroPageSize_ThrowsArgumentOutO
}
[Test]
- public async Task GetDeadLetterMessagesAsync_WithNegativePage_ThrowsArgumentOutOfRangeException()
+ public async Task GetDeadLetterMessagesAsync_WithNegativePage_ThrowsArgumentOutOfRangeException(
+ CancellationToken cancellationToken
+ )
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(GetDeadLetterMessagesAsync_WithNegativePage_ThrowsArgumentOutOfRangeException))
@@ -84,12 +86,16 @@ public async Task GetDeadLetterMessagesAsync_WithNegativePage_ThrowsArgumentOutO
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
_ = await Assert
- .That(async () => await management.GetDeadLetterMessagesAsync(page: -1).ConfigureAwait(false))
+ .That(async () =>
+ await management
+ .GetDeadLetterMessagesAsync(page: -1, cancellationToken: cancellationToken)
+ .ConfigureAwait(false)
+ )
.Throws();
}
[Test]
- public async Task GetDeadLetterMessagesAsync_EmptyDatabase_ReturnsEmptyList()
+ public async Task GetDeadLetterMessagesAsync_EmptyDatabase_ReturnsEmptyList(CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(GetDeadLetterMessagesAsync_EmptyDatabase_ReturnsEmptyList))
@@ -97,13 +103,17 @@ public async Task GetDeadLetterMessagesAsync_EmptyDatabase_ReturnsEmptyList()
await using var context = new TestDbContext(options);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var result = await management.GetDeadLetterMessagesAsync().ConfigureAwait(false);
+ var result = await management
+ .GetDeadLetterMessagesAsync(cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
_ = await Assert.That(result).IsEmpty();
}
[Test]
- public async Task GetDeadLetterMessagesAsync_WithDeadLetterMessages_ReturnsMessages()
+ public async Task GetDeadLetterMessagesAsync_WithDeadLetterMessages_ReturnsMessages(
+ CancellationToken cancellationToken
+ )
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(GetDeadLetterMessagesAsync_WithDeadLetterMessages_ReturnsMessages))
@@ -132,18 +142,22 @@ public async Task GetDeadLetterMessagesAsync_WithDeadLetterMessages_ReturnsMessa
Status = OutboxMessageStatus.Pending,
}
);
- _ = await context.SaveChangesAsync().ConfigureAwait(false);
+ _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var result = await management.GetDeadLetterMessagesAsync().ConfigureAwait(false);
+ var result = await management
+ .GetDeadLetterMessagesAsync(cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
_ = await Assert.That(result).Count().IsEqualTo(1);
_ = await Assert.That(result[0].Status).IsEqualTo(OutboxMessageStatus.DeadLetter);
}
[Test]
- public async Task GetDeadLetterMessageAsync_WithExistingDeadLetterMessage_ReturnsMessage()
+ public async Task GetDeadLetterMessageAsync_WithExistingDeadLetterMessage_ReturnsMessage(
+ CancellationToken cancellationToken
+ )
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(GetDeadLetterMessageAsync_WithExistingDeadLetterMessage_ReturnsMessage))
@@ -162,18 +176,20 @@ public async Task GetDeadLetterMessageAsync_WithExistingDeadLetterMessage_Return
Status = OutboxMessageStatus.DeadLetter,
}
);
- _ = await context.SaveChangesAsync().ConfigureAwait(false);
+ _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var result = await management.GetDeadLetterMessageAsync(messageId).ConfigureAwait(false);
+ var result = await management.GetDeadLetterMessageAsync(messageId, cancellationToken).ConfigureAwait(false);
_ = await Assert.That(result).IsNotNull();
_ = await Assert.That(result!.Id).IsEqualTo(messageId);
}
[Test]
- public async Task GetDeadLetterMessageAsync_WithNonDeadLetterMessage_ReturnsNull()
+ public async Task GetDeadLetterMessageAsync_WithNonDeadLetterMessage_ReturnsNull(
+ CancellationToken cancellationToken
+ )
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(GetDeadLetterMessageAsync_WithNonDeadLetterMessage_ReturnsNull))
@@ -192,17 +208,17 @@ public async Task GetDeadLetterMessageAsync_WithNonDeadLetterMessage_ReturnsNull
Status = OutboxMessageStatus.Completed,
}
);
- _ = await context.SaveChangesAsync().ConfigureAwait(false);
+ _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var result = await management.GetDeadLetterMessageAsync(messageId).ConfigureAwait(false);
+ var result = await management.GetDeadLetterMessageAsync(messageId, cancellationToken).ConfigureAwait(false);
_ = await Assert.That(result).IsNull();
}
[Test]
- public async Task GetDeadLetterMessageAsync_WithUnknownId_ReturnsNull()
+ public async Task GetDeadLetterMessageAsync_WithUnknownId_ReturnsNull(CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(GetDeadLetterMessageAsync_WithUnknownId_ReturnsNull))
@@ -210,13 +226,15 @@ public async Task GetDeadLetterMessageAsync_WithUnknownId_ReturnsNull()
await using var context = new TestDbContext(options);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var result = await management.GetDeadLetterMessageAsync(Guid.NewGuid()).ConfigureAwait(false);
+ var result = await management
+ .GetDeadLetterMessageAsync(Guid.NewGuid(), cancellationToken)
+ .ConfigureAwait(false);
_ = await Assert.That(result).IsNull();
}
[Test]
- public async Task GetDeadLetterCountAsync_EmptyDatabase_ReturnsZero()
+ public async Task GetDeadLetterCountAsync_EmptyDatabase_ReturnsZero(CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(GetDeadLetterCountAsync_EmptyDatabase_ReturnsZero))
@@ -224,13 +242,15 @@ public async Task GetDeadLetterCountAsync_EmptyDatabase_ReturnsZero()
await using var context = new TestDbContext(options);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var count = await management.GetDeadLetterCountAsync().ConfigureAwait(false);
+ var count = await management.GetDeadLetterCountAsync(cancellationToken).ConfigureAwait(false);
_ = await Assert.That(count).IsEqualTo(0L);
}
[Test]
- public async Task GetDeadLetterCountAsync_WithDeadLetterMessages_ReturnsCorrectCount()
+ public async Task GetDeadLetterCountAsync_WithDeadLetterMessages_ReturnsCorrectCount(
+ CancellationToken cancellationToken
+ )
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(GetDeadLetterCountAsync_WithDeadLetterMessages_ReturnsCorrectCount))
@@ -263,11 +283,11 @@ public async Task GetDeadLetterCountAsync_WithDeadLetterMessages_ReturnsCorrectC
Status = OutboxMessageStatus.Pending,
}
);
- _ = await context.SaveChangesAsync().ConfigureAwait(false);
+ _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var count = await management.GetDeadLetterCountAsync().ConfigureAwait(false);
+ var count = await management.GetDeadLetterCountAsync(cancellationToken).ConfigureAwait(false);
_ = await Assert.That(count).IsEqualTo(3L);
}
@@ -276,7 +296,9 @@ public async Task GetDeadLetterCountAsync_WithDeadLetterMessages_ReturnsCorrectC
[Skip(
"The EF Core InMemory provider does not support ExecuteUpdateAsync (bulk updates). Covered by integration tests."
)]
- public async Task ReplayMessageAsync_WithExistingDeadLetterMessage_ReturnsTrueAndResetsMessage()
+ public async Task ReplayMessageAsync_WithExistingDeadLetterMessage_ReturnsTrueAndResetsMessage(
+ CancellationToken cancellationToken
+ )
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(ReplayMessageAsync_WithExistingDeadLetterMessage_ReturnsTrueAndResetsMessage))
@@ -297,15 +319,15 @@ public async Task ReplayMessageAsync_WithExistingDeadLetterMessage_ReturnsTrueAn
Status = OutboxMessageStatus.DeadLetter,
}
);
- _ = await context.SaveChangesAsync().ConfigureAwait(false);
+ _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var result = await management.ReplayMessageAsync(messageId).ConfigureAwait(false);
+ var result = await management.ReplayMessageAsync(messageId, cancellationToken).ConfigureAwait(false);
_ = await Assert.That(result).IsTrue();
- var message = await context.OutboxMessages.FindAsync(messageId).ConfigureAwait(false);
+ var message = await context.OutboxMessages.FindAsync([messageId], cancellationToken).ConfigureAwait(false);
using (Assert.Multiple())
{
_ = await Assert.That(message!.Status).IsEqualTo(OutboxMessageStatus.Pending);
@@ -318,7 +340,7 @@ public async Task ReplayMessageAsync_WithExistingDeadLetterMessage_ReturnsTrueAn
[Skip(
"The EF Core InMemory provider does not support ExecuteUpdateAsync (bulk updates). Covered by integration tests."
)]
- public async Task ReplayMessageAsync_WithNonDeadLetterMessage_ReturnsFalse()
+ public async Task ReplayMessageAsync_WithNonDeadLetterMessage_ReturnsFalse(CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(ReplayMessageAsync_WithNonDeadLetterMessage_ReturnsFalse))
@@ -337,11 +359,11 @@ public async Task ReplayMessageAsync_WithNonDeadLetterMessage_ReturnsFalse()
Status = OutboxMessageStatus.Failed,
}
);
- _ = await context.SaveChangesAsync().ConfigureAwait(false);
+ _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var result = await management.ReplayMessageAsync(messageId).ConfigureAwait(false);
+ var result = await management.ReplayMessageAsync(messageId, cancellationToken).ConfigureAwait(false);
_ = await Assert.That(result).IsFalse();
}
@@ -350,7 +372,7 @@ public async Task ReplayMessageAsync_WithNonDeadLetterMessage_ReturnsFalse()
[Skip(
"The EF Core InMemory provider does not support ExecuteUpdateAsync (bulk updates). Covered by integration tests."
)]
- public async Task ReplayMessageAsync_WithUnknownId_ReturnsFalse()
+ public async Task ReplayMessageAsync_WithUnknownId_ReturnsFalse(CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(ReplayMessageAsync_WithUnknownId_ReturnsFalse))
@@ -358,7 +380,7 @@ public async Task ReplayMessageAsync_WithUnknownId_ReturnsFalse()
await using var context = new TestDbContext(options);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var result = await management.ReplayMessageAsync(Guid.NewGuid()).ConfigureAwait(false);
+ var result = await management.ReplayMessageAsync(Guid.NewGuid(), cancellationToken).ConfigureAwait(false);
_ = await Assert.That(result).IsFalse();
}
@@ -367,7 +389,7 @@ public async Task ReplayMessageAsync_WithUnknownId_ReturnsFalse()
[Skip(
"The EF Core InMemory provider does not support ExecuteUpdateAsync (bulk updates). Covered by integration tests."
)]
- public async Task ReplayAllDeadLetterAsync_EmptyDatabase_ReturnsZero()
+ public async Task ReplayAllDeadLetterAsync_EmptyDatabase_ReturnsZero(CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(ReplayAllDeadLetterAsync_EmptyDatabase_ReturnsZero))
@@ -375,7 +397,7 @@ public async Task ReplayAllDeadLetterAsync_EmptyDatabase_ReturnsZero()
await using var context = new TestDbContext(options);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var count = await management.ReplayAllDeadLetterAsync().ConfigureAwait(false);
+ var count = await management.ReplayAllDeadLetterAsync(cancellationToken).ConfigureAwait(false);
_ = await Assert.That(count).IsEqualTo(0);
}
@@ -384,7 +406,9 @@ public async Task ReplayAllDeadLetterAsync_EmptyDatabase_ReturnsZero()
[Skip(
"The EF Core InMemory provider does not support ExecuteUpdateAsync (bulk updates). Covered by integration tests."
)]
- public async Task ReplayAllDeadLetterAsync_WithDeadLetterMessages_ResetsAllAndReturnsCount()
+ public async Task ReplayAllDeadLetterAsync_WithDeadLetterMessages_ResetsAllAndReturnsCount(
+ CancellationToken cancellationToken
+ )
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(ReplayAllDeadLetterAsync_WithDeadLetterMessages_ResetsAllAndReturnsCount))
@@ -419,17 +443,17 @@ public async Task ReplayAllDeadLetterAsync_WithDeadLetterMessages_ResetsAllAndRe
Status = OutboxMessageStatus.Pending,
}
);
- _ = await context.SaveChangesAsync().ConfigureAwait(false);
+ _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var count = await management.ReplayAllDeadLetterAsync().ConfigureAwait(false);
+ var count = await management.ReplayAllDeadLetterAsync(cancellationToken).ConfigureAwait(false);
_ = await Assert.That(count).IsEqualTo(3);
}
[Test]
- public async Task GetStatisticsAsync_EmptyDatabase_ReturnsZeroStatistics()
+ public async Task GetStatisticsAsync_EmptyDatabase_ReturnsZeroStatistics(CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(GetStatisticsAsync_EmptyDatabase_ReturnsZeroStatistics))
@@ -437,7 +461,7 @@ public async Task GetStatisticsAsync_EmptyDatabase_ReturnsZeroStatistics()
await using var context = new TestDbContext(options);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var statistics = await management.GetStatisticsAsync().ConfigureAwait(false);
+ var statistics = await management.GetStatisticsAsync(cancellationToken).ConfigureAwait(false);
using (Assert.Multiple())
{
@@ -451,7 +475,7 @@ public async Task GetStatisticsAsync_EmptyDatabase_ReturnsZeroStatistics()
}
[Test]
- public async Task GetStatisticsAsync_WithMessages_ReturnsCorrectCounts()
+ public async Task GetStatisticsAsync_WithMessages_ReturnsCorrectCounts(CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(GetStatisticsAsync_WithMessages_ReturnsCorrectCounts))
@@ -487,11 +511,11 @@ public async Task GetStatisticsAsync_WithMessages_ReturnsCorrectCounts()
);
}
- _ = await context.SaveChangesAsync().ConfigureAwait(false);
+ _ = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var management = new EntityFrameworkOutboxManagement(context, TimeProvider.System);
- var statistics = await management.GetStatisticsAsync().ConfigureAwait(false);
+ var statistics = await management.GetStatisticsAsync(cancellationToken).ConfigureAwait(false);
using (Assert.Multiple())
{
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkOutboxRepositoryTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkOutboxRepositoryTests.cs
index f3ce0f1d..691279a6 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkOutboxRepositoryTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/EntityFrameworkOutboxRepositoryTests.cs
@@ -1,4 +1,4 @@
-namespace NetEvolve.Pulse.Tests.Unit.EntityFramework;
+namespace NetEvolve.Pulse.Tests.Unit.EntityFramework;
using System;
using System.Threading.Tasks;
@@ -43,7 +43,7 @@ public async Task Constructor_WithValidArguments_CreatesInstance()
}
[Test]
- public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException()
+ public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException(CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(AddAsync_WithNullMessage_ThrowsArgumentNullException))
@@ -52,12 +52,12 @@ public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException()
var repository = new EntityFrameworkOutboxRepository(context, TimeProvider.System);
_ = await Assert
- .That(async () => await repository.AddAsync(null!).ConfigureAwait(false))
+ .That(async () => await repository.AddAsync(null!, cancellationToken).ConfigureAwait(false))
.Throws();
}
[Test]
- public async Task IsHealthyAsync_WithInMemoryProvider_ReturnsTrue()
+ public async Task IsHealthyAsync_WithInMemoryProvider_ReturnsTrue(CancellationToken cancellationToken)
{
var options = new DbContextOptionsBuilder()
.UseInMemoryDatabase(nameof(IsHealthyAsync_WithInMemoryProvider_ReturnsTrue))
@@ -65,7 +65,7 @@ public async Task IsHealthyAsync_WithInMemoryProvider_ReturnsTrue()
await using var context = new TestDbContext(options);
var repository = new EntityFrameworkOutboxRepository(context, TimeProvider.System);
- var result = await repository.IsHealthyAsync(CancellationToken.None).ConfigureAwait(false);
+ var result = await repository.IsHealthyAsync(cancellationToken).ConfigureAwait(false);
_ = await Assert.That(result).IsTrue();
}
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/OutboxMessageConfigurationMetadataTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/OutboxMessageConfigurationMetadataTests.cs
index 7cdac5c3..60ff6665 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/OutboxMessageConfigurationMetadataTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/EntityFramework/OutboxMessageConfigurationMetadataTests.cs
@@ -19,9 +19,9 @@ public async Task Create_WithSqlServerProvider_AppliesSqlServerFiltersAndColumnT
{
var entityType = GetConfiguredEntityType("Microsoft.EntityFrameworkCore.SqlServer");
- var pendingIndex = GetIndex(entityType, "IX_OutboxMessage_Status_CreatedAt");
- var retryIndex = GetIndex(entityType, "IX_OutboxMessage_Status_NextRetryAt");
- var completedIndex = GetIndex(entityType, "IX_OutboxMessage_Status_ProcessedAt");
+ var pendingIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_CreatedAt");
+ var retryIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_NextRetryAt");
+ var completedIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_ProcessedAt");
using (Assert.Multiple())
{
@@ -46,9 +46,9 @@ public async Task Create_WithPostgreSqlProvider_AppliesPostgreSqlFiltersAndColum
{
var entityType = GetConfiguredEntityType("Npgsql.EntityFrameworkCore.PostgreSQL");
- var pendingIndex = GetIndex(entityType, "IX_OutboxMessage_Status_CreatedAt");
- var retryIndex = GetIndex(entityType, "IX_OutboxMessage_Status_NextRetryAt");
- var completedIndex = GetIndex(entityType, "IX_OutboxMessage_Status_ProcessedAt");
+ var pendingIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_CreatedAt");
+ var retryIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_NextRetryAt");
+ var completedIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_ProcessedAt");
using (Assert.Multiple())
{
@@ -71,9 +71,9 @@ public async Task Create_WithSqliteProvider_AppliesSqliteFiltersAndColumnTypes()
{
var entityType = GetConfiguredEntityType("Microsoft.EntityFrameworkCore.Sqlite");
- var pendingIndex = GetIndex(entityType, "IX_OutboxMessage_Status_CreatedAt");
- var retryIndex = GetIndex(entityType, "IX_OutboxMessage_Status_NextRetryAt");
- var completedIndex = GetIndex(entityType, "IX_OutboxMessage_Status_ProcessedAt");
+ var pendingIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_CreatedAt");
+ var retryIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_NextRetryAt");
+ var completedIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_ProcessedAt");
using (Assert.Multiple())
{
@@ -96,9 +96,9 @@ public async Task Create_WithMySqlProvider_AppliesMySqlColumnTypesAndNoFilteredI
{
var entityType = GetConfiguredEntityType("Pomelo.EntityFrameworkCore.MySql");
- var pendingIndex = GetIndex(entityType, "IX_OutboxMessage_Status_CreatedAt");
- var retryIndex = GetIndex(entityType, "IX_OutboxMessage_Status_NextRetryAt");
- var completedIndex = GetIndex(entityType, "IX_OutboxMessage_Status_ProcessedAt");
+ var pendingIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_CreatedAt");
+ var retryIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_NextRetryAt");
+ var completedIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_ProcessedAt");
using (Assert.Multiple())
{
@@ -108,10 +108,10 @@ public async Task Create_WithMySqlProvider_AppliesMySqlColumnTypesAndNoFilteredI
_ = await Assert
.That(entityType.FindProperty(nameof(OutboxMessage.Id))!.GetColumnType())
- .IsEqualTo("char(36)");
+ .IsEqualTo("binary(16)");
_ = await Assert
.That(entityType.FindProperty(nameof(OutboxMessage.CreatedAt))!.GetColumnType())
- .IsEqualTo("datetime(6)");
+ .IsEqualTo("bigint");
_ = await Assert
.That(entityType.FindProperty(nameof(OutboxMessage.Payload))!.GetColumnType())
.IsEqualTo("longtext");
@@ -123,9 +123,9 @@ public async Task Create_WithInMemoryProvider_UsesBaseDefaultsWithoutColumnTypeO
{
var entityType = GetConfiguredEntityType("Microsoft.EntityFrameworkCore.InMemory");
- var pendingIndex = GetIndex(entityType, "IX_OutboxMessage_Status_CreatedAt");
- var retryIndex = GetIndex(entityType, "IX_OutboxMessage_Status_NextRetryAt");
- var completedIndex = GetIndex(entityType, "IX_OutboxMessage_Status_ProcessedAt");
+ var pendingIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_CreatedAt");
+ var retryIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_NextRetryAt");
+ var completedIndex = GetIndex(entityType, "IX_pulse_OutboxMessage_Status_ProcessedAt");
using (Assert.Multiple())
{
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/FluentValidation/Interceptors/FluentValidationRequestInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/FluentValidation/Interceptors/FluentValidationRequestInterceptorTests.cs
index 126b3762..b0acebd9 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/FluentValidation/Interceptors/FluentValidationRequestInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/FluentValidation/Interceptors/FluentValidationRequestInterceptorTests.cs
@@ -17,7 +17,7 @@
public sealed class FluentValidationRequestInterceptorTests
{
[Test]
- public async Task HandleAsync_NullHandler_ThrowsArgumentNullException()
+ public async Task HandleAsync_NullHandler_ThrowsArgumentNullException(CancellationToken cancellationToken)
{
// Arrange
var services = new ServiceCollection();
@@ -26,12 +26,12 @@ public async Task HandleAsync_NullHandler_ThrowsArgumentNullException()
// Act & Assert
_ = await Assert
- .That(() => interceptor.HandleAsync(new TestCommand("valid"), null!, CancellationToken.None)!)
+ .That(() => interceptor.HandleAsync(new TestCommand("valid"), null!, cancellationToken)!)
.Throws();
}
[Test]
- public async Task HandleAsync_NoValidatorsRegistered_PassesThroughToHandler()
+ public async Task HandleAsync_NoValidatorsRegistered_PassesThroughToHandler(CancellationToken cancellationToken)
{
// Arrange — no IValidator registered
var services = new ServiceCollection();
@@ -47,7 +47,7 @@ public async Task HandleAsync_NoValidatorsRegistered_PassesThroughToHandler()
handlerCalled = true;
return Task.FromResult("ok");
},
- CancellationToken.None
+ cancellationToken
);
// Assert
@@ -59,7 +59,7 @@ public async Task HandleAsync_NoValidatorsRegistered_PassesThroughToHandler()
}
[Test]
- public async Task HandleAsync_ValidInput_PassesThroughToHandler()
+ public async Task HandleAsync_ValidInput_PassesThroughToHandler(CancellationToken cancellationToken)
{
// Arrange
var services = new ServiceCollection();
@@ -77,7 +77,7 @@ public async Task HandleAsync_ValidInput_PassesThroughToHandler()
handlerCalled = true;
return Task.FromResult("success");
},
- CancellationToken.None
+ cancellationToken
);
// Assert
@@ -89,7 +89,7 @@ public async Task HandleAsync_ValidInput_PassesThroughToHandler()
}
[Test]
- public async Task HandleAsync_InvalidInput_ThrowsValidationException()
+ public async Task HandleAsync_InvalidInput_ThrowsValidationException(CancellationToken cancellationToken)
{
// Arrange
var services = new ServiceCollection();
@@ -109,7 +109,7 @@ public async Task HandleAsync_InvalidInput_ThrowsValidationException()
handlerCalled = true;
return Task.FromResult("should not reach");
},
- CancellationToken.None
+ cancellationToken
)!
)
.Throws();
@@ -118,7 +118,7 @@ public async Task HandleAsync_InvalidInput_ThrowsValidationException()
}
[Test]
- public async Task HandleAsync_MultipleValidators_AggregatesAllFailures()
+ public async Task HandleAsync_MultipleValidators_AggregatesAllFailures(CancellationToken cancellationToken)
{
// Arrange
var services = new ServiceCollection();
@@ -134,7 +134,7 @@ public async Task HandleAsync_MultipleValidators_AggregatesAllFailures()
interceptor.HandleAsync(
new TestCommand("invalid"),
(_, _) => Task.FromResult("should not reach"),
- CancellationToken.None
+ cancellationToken
)!
)
.Throws();
@@ -144,7 +144,9 @@ public async Task HandleAsync_MultipleValidators_AggregatesAllFailures()
}
[Test]
- public async Task HandleAsync_MultipleValidatorsOneInvalid_ThrowsValidationException()
+ public async Task HandleAsync_MultipleValidatorsOneInvalid_ThrowsValidationException(
+ CancellationToken cancellationToken
+ )
{
// Arrange
var services = new ServiceCollection();
@@ -160,7 +162,7 @@ public async Task HandleAsync_MultipleValidatorsOneInvalid_ThrowsValidationExcep
interceptor.HandleAsync(
new TestCommand("input"),
(_, _) => Task.FromResult("should not reach"),
- CancellationToken.None
+ cancellationToken
)!
)
.Throws();
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationEventInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationEventInterceptorTests.cs
index f5aff385..1326b793 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationEventInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationEventInterceptorTests.cs
@@ -60,7 +60,7 @@ public async Task Constructor_WithAccessorRegistered_DoesNotThrow()
}
[Test]
- public async Task HandleAsync_NullHandler_ThrowsArgumentNullException()
+ public async Task HandleAsync_NullHandler_ThrowsArgumentNullException(CancellationToken cancellationToken)
{
// Arrange
var provider = new ServiceCollection().BuildServiceProvider();
@@ -69,12 +69,14 @@ public async Task HandleAsync_NullHandler_ThrowsArgumentNullException()
// Act & Assert
_ = await Assert
- .That(async () => await interceptor.HandleAsync(message, null!).ConfigureAwait(false))
+ .That(async () => await interceptor.HandleAsync(message, null!, cancellationToken).ConfigureAwait(false))
.Throws();
}
[Test]
- public async Task HandleAsync_NoAccessorRegistered_PassesThroughWithoutModification()
+ public async Task HandleAsync_NoAccessorRegistered_PassesThroughWithoutModification(
+ CancellationToken cancellationToken
+ )
{
// Arrange
var provider = new ServiceCollection().BuildServiceProvider();
@@ -90,7 +92,8 @@ await interceptor
{
handlerCalled = true;
return Task.CompletedTask;
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -103,7 +106,9 @@ await interceptor
}
[Test]
- public async Task HandleAsync_AccessorHasCorrelationId_MessageAlreadyHasCorrelationId_DoesNotOverwrite()
+ public async Task HandleAsync_AccessorHasCorrelationId_MessageAlreadyHasCorrelationId_DoesNotOverwrite(
+ CancellationToken cancellationToken
+ )
{
// Arrange
const string existingId = "existing-id";
@@ -118,14 +123,14 @@ public async Task HandleAsync_AccessorHasCorrelationId_MessageAlreadyHasCorrelat
var message = new TestEvent { CorrelationId = existingId };
// Act
- await interceptor.HandleAsync(message, (_, _) => Task.CompletedTask).ConfigureAwait(false);
+ await interceptor.HandleAsync(message, (_, _) => Task.CompletedTask, cancellationToken).ConfigureAwait(false);
// Assert
_ = await Assert.That(message.CorrelationId).IsEqualTo(existingId);
}
[Test]
- public async Task HandleAsync_AccessorCorrelationIdIsEmpty_DoesNotModifyMessage()
+ public async Task HandleAsync_AccessorCorrelationIdIsEmpty_DoesNotModifyMessage(CancellationToken cancellationToken)
{
// Arrange
var services = new ServiceCollection();
@@ -139,7 +144,7 @@ public async Task HandleAsync_AccessorCorrelationIdIsEmpty_DoesNotModifyMessage(
var message = new TestEvent { CorrelationId = null };
// Act
- await interceptor.HandleAsync(message, (_, _) => Task.CompletedTask).ConfigureAwait(false);
+ await interceptor.HandleAsync(message, (_, _) => Task.CompletedTask, cancellationToken).ConfigureAwait(false);
// Assert
_ = await Assert.That(message.CorrelationId).IsNull();
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationRequestInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationRequestInterceptorTests.cs
index 10dd212c..0c91074a 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationRequestInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationRequestInterceptorTests.cs
@@ -60,7 +60,7 @@ public async Task Constructor_WithAccessorRegistered_DoesNotThrow()
}
[Test]
- public async Task HandleAsync_NullHandler_ThrowsArgumentNullException()
+ public async Task HandleAsync_NullHandler_ThrowsArgumentNullException(CancellationToken cancellationToken)
{
// Arrange
var provider = new ServiceCollection().BuildServiceProvider();
@@ -69,12 +69,14 @@ public async Task HandleAsync_NullHandler_ThrowsArgumentNullException()
// Act & Assert
_ = await Assert
- .That(async () => await interceptor.HandleAsync(request, null!).ConfigureAwait(false))
+ .That(async () => await interceptor.HandleAsync(request, null!, cancellationToken).ConfigureAwait(false))
.Throws();
}
[Test]
- public async Task HandleAsync_NoAccessorRegistered_PassesThroughWithoutModification()
+ public async Task HandleAsync_NoAccessorRegistered_PassesThroughWithoutModification(
+ CancellationToken cancellationToken
+ )
{
// Arrange
var provider = new ServiceCollection().BuildServiceProvider();
@@ -90,7 +92,8 @@ public async Task HandleAsync_NoAccessorRegistered_PassesThroughWithoutModificat
{
handlerCalled = true;
return Task.FromResult("response");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -104,7 +107,9 @@ public async Task HandleAsync_NoAccessorRegistered_PassesThroughWithoutModificat
}
[Test]
- public async Task HandleAsync_AccessorHasCorrelationId_RequestAlreadyHasCorrelationId_DoesNotOverwrite()
+ public async Task HandleAsync_AccessorHasCorrelationId_RequestAlreadyHasCorrelationId_DoesNotOverwrite(
+ CancellationToken cancellationToken
+ )
{
// Arrange
const string existingId = "existing-id";
@@ -119,14 +124,16 @@ public async Task HandleAsync_AccessorHasCorrelationId_RequestAlreadyHasCorrelat
var request = new TestCommand { CorrelationId = existingId };
// Act
- _ = await interceptor.HandleAsync(request, (_, _) => Task.FromResult("response")).ConfigureAwait(false);
+ _ = await interceptor
+ .HandleAsync(request, (_, _) => Task.FromResult("response"), cancellationToken)
+ .ConfigureAwait(false);
// Assert
_ = await Assert.That(request.CorrelationId).IsEqualTo(existingId);
}
[Test]
- public async Task HandleAsync_AccessorCorrelationIdIsEmpty_DoesNotModifyRequest()
+ public async Task HandleAsync_AccessorCorrelationIdIsEmpty_DoesNotModifyRequest(CancellationToken cancellationToken)
{
// Arrange
var services = new ServiceCollection();
@@ -140,7 +147,9 @@ public async Task HandleAsync_AccessorCorrelationIdIsEmpty_DoesNotModifyRequest(
var request = new TestCommand { CorrelationId = null };
// Act
- _ = await interceptor.HandleAsync(request, (_, _) => Task.FromResult("response")).ConfigureAwait(false);
+ _ = await interceptor
+ .HandleAsync(request, (_, _) => Task.FromResult("response"), cancellationToken)
+ .ConfigureAwait(false);
// Assert
_ = await Assert.That(request.CorrelationId).IsNull();
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationStreamQueryInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationStreamQueryInterceptorTests.cs
index badfe5ce..71cd60d8 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationStreamQueryInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/HttpCorrelation/Interceptors/HttpCorrelationStreamQueryInterceptorTests.cs
@@ -65,7 +65,7 @@ public async Task Constructor_WithAccessorRegistered_DoesNotThrow()
}
[Test]
- public async Task HandleAsync_NullHandler_ThrowsArgumentNullException()
+ public async Task HandleAsync_NullHandler_ThrowsArgumentNullException(CancellationToken cancellationToken)
{
// Arrange
var provider = new ServiceCollection().BuildServiceProvider();
@@ -76,7 +76,9 @@ public async Task HandleAsync_NullHandler_ThrowsArgumentNullException()
_ = await Assert
.That(async () =>
{
- await foreach (var _ in interceptor.HandleAsync(request, null!).ConfigureAwait(false))
+ await foreach (
+ var _ in interceptor.HandleAsync(request, null!, cancellationToken).ConfigureAwait(false)
+ )
{
// consume — we expect the foreach to throw before yielding any items
}
@@ -85,7 +87,9 @@ public async Task HandleAsync_NullHandler_ThrowsArgumentNullException()
}
[Test]
- public async Task HandleAsync_NoAccessorRegistered_PassesThroughWithoutModification()
+ public async Task HandleAsync_NoAccessorRegistered_PassesThroughWithoutModification(
+ CancellationToken cancellationToken
+ )
{
// Arrange
var provider = new ServiceCollection().BuildServiceProvider();
@@ -96,7 +100,7 @@ public async Task HandleAsync_NoAccessorRegistered_PassesThroughWithoutModificat
var items = new List();
await foreach (
var item in interceptor
- .HandleAsync(request, (_, ct) => YieldItemsAsync(["a", "b"], ct))
+ .HandleAsync(request, (_, ct) => YieldItemsAsync(["a", "b"], ct), cancellationToken)
.ConfigureAwait(false)
)
{
@@ -112,7 +116,9 @@ var item in interceptor
}
[Test]
- public async Task HandleAsync_AccessorHasCorrelationId_RequestAlreadyHasCorrelationId_DoesNotOverwrite()
+ public async Task HandleAsync_AccessorHasCorrelationId_RequestAlreadyHasCorrelationId_DoesNotOverwrite(
+ CancellationToken cancellationToken
+ )
{
// Arrange
const string existingId = "existing-id";
@@ -128,7 +134,9 @@ public async Task HandleAsync_AccessorHasCorrelationId_RequestAlreadyHasCorrelat
// Act
await foreach (
- var _ in interceptor.HandleAsync(request, (_, ct) => YieldItemsAsync(["x"], ct)).ConfigureAwait(false)
+ var _ in interceptor
+ .HandleAsync(request, (_, ct) => YieldItemsAsync(["x"], ct), cancellationToken)
+ .ConfigureAwait(false)
)
{
// consume
@@ -139,7 +147,9 @@ var _ in interceptor.HandleAsync(request, (_, ct) => YieldItemsAsync(["x"], ct))
}
[Test]
- public async Task HandleAsync_AccessorHasCorrelationId_RequestHasNoCorrelationId_SetsCorrelationId()
+ public async Task HandleAsync_AccessorHasCorrelationId_RequestHasNoCorrelationId_SetsCorrelationId(
+ CancellationToken cancellationToken
+ )
{
// Arrange
const string httpId = "http-correlation-id";
@@ -162,7 +172,9 @@ public async Task HandleAsync_AccessorHasCorrelationId_RequestHasNoCorrelationId
// Act
await foreach (
- var _ in interceptor.HandleAsync(request, (_, ct) => YieldItemsAsync(["x"], ct)).ConfigureAwait(false)
+ var _ in interceptor
+ .HandleAsync(request, (_, ct) => YieldItemsAsync(["x"], ct), cancellationToken)
+ .ConfigureAwait(false)
)
{
// consume
@@ -173,7 +185,7 @@ var _ in interceptor.HandleAsync(request, (_, ct) => YieldItemsAsync(["x"], ct))
}
[Test]
- public async Task HandleAsync_AccessorCorrelationIdIsEmpty_DoesNotModifyRequest()
+ public async Task HandleAsync_AccessorCorrelationIdIsEmpty_DoesNotModifyRequest(CancellationToken cancellationToken)
{
// Arrange
var services = new ServiceCollection();
@@ -188,7 +200,9 @@ public async Task HandleAsync_AccessorCorrelationIdIsEmpty_DoesNotModifyRequest(
// Act
await foreach (
- var _ in interceptor.HandleAsync(request, (_, ct) => YieldItemsAsync(["x"], ct)).ConfigureAwait(false)
+ var _ in interceptor
+ .HandleAsync(request, (_, ct) => YieldItemsAsync(["x"], ct), cancellationToken)
+ .ConfigureAwait(false)
)
{
// consume
@@ -199,7 +213,7 @@ var _ in interceptor.HandleAsync(request, (_, ct) => YieldItemsAsync(["x"], ct))
}
[Test]
- public async Task HandleAsync_YieldsItemsUnchanged()
+ public async Task HandleAsync_YieldsItemsUnchanged(CancellationToken cancellationToken)
{
// Arrange
var provider = new ServiceCollection().BuildServiceProvider();
@@ -210,7 +224,9 @@ public async Task HandleAsync_YieldsItemsUnchanged()
// Act
var items = new List();
await foreach (
- var item in interceptor.HandleAsync(request, (_, ct) => YieldItemsAsync(expected, ct)).ConfigureAwait(false)
+ var item in interceptor
+ .HandleAsync(request, (_, ct) => YieldItemsAsync(expected, ct), cancellationToken)
+ .ConfigureAwait(false)
)
{
items.Add(item);
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsEventInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsEventInterceptorTests.cs
index 99f07a22..310da935 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsEventInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsEventInterceptorTests.cs
@@ -1,4 +1,4 @@
-namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
+namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
using System.Diagnostics;
using NetEvolve.Extensions.TUnit;
@@ -11,7 +11,7 @@ public class ActivityAndMetricsEventInterceptorTests
{
[Test]
[NotInParallel]
- public async Task HandleAsync_CreatesActivityWithCorrectTags()
+ public async Task HandleAsync_CreatesActivityWithCorrectTags(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -35,7 +35,8 @@ await interceptor
{
handlerCalled = true;
return Task.CompletedTask;
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -51,7 +52,7 @@ await interceptor
[Test]
[NotInParallel]
- public async Task HandleAsync_WhenHandlerSucceeds_SetsActivityStatusToOk()
+ public async Task HandleAsync_WhenHandlerSucceeds_SetsActivityStatusToOk(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -67,7 +68,7 @@ public async Task HandleAsync_WhenHandlerSucceeds_SetsActivityStatusToOk()
listener.ActivityStopped = activity => capturedActivity = activity;
- await interceptor.HandleAsync(testEvent, (_, _) => Task.CompletedTask).ConfigureAwait(false);
+ await interceptor.HandleAsync(testEvent, (_, _) => Task.CompletedTask, cancellationToken).ConfigureAwait(false);
using (Assert.Multiple())
{
@@ -81,7 +82,7 @@ public async Task HandleAsync_WhenHandlerSucceeds_SetsActivityStatusToOk()
[Test]
[NotInParallel]
- public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError()
+ public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -99,7 +100,9 @@ public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError()
listener.ActivityStopped = activity => capturedActivity = activity;
var exception = await Assert.ThrowsAsync(async () =>
- await interceptor.HandleAsync(testEvent, (_, _) => throw testException).ConfigureAwait(false)
+ await interceptor
+ .HandleAsync(testEvent, (_, _) => throw testException, cancellationToken)
+ .ConfigureAwait(false)
);
using (Assert.Multiple())
@@ -121,7 +124,7 @@ public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError()
[Test]
[NotInParallel]
- public async Task HandleAsync_SetsTimestamps()
+ public async Task HandleAsync_SetsTimestamps(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -137,7 +140,7 @@ public async Task HandleAsync_SetsTimestamps()
listener.ActivityStopped = activity => capturedActivity = activity;
- await interceptor.HandleAsync(testEvent, (_, _) => Task.CompletedTask).ConfigureAwait(false);
+ await interceptor.HandleAsync(testEvent, (_, _) => Task.CompletedTask, cancellationToken).ConfigureAwait(false);
using (Assert.Multiple())
{
@@ -148,7 +151,7 @@ public async Task HandleAsync_SetsTimestamps()
}
[Test]
- public async Task HandleAsync_InvokesHandlerWithCorrectEvent()
+ public async Task HandleAsync_InvokesHandlerWithCorrectEvent(CancellationToken cancellationToken)
{
var timeProvider = TimeProvider.System;
var interceptor = new ActivityAndMetricsEventInterceptor(timeProvider);
@@ -162,7 +165,8 @@ await interceptor
{
receivedEvent = evt;
return Task.CompletedTask;
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -171,7 +175,7 @@ await interceptor
[Test]
[NotInParallel]
- public async Task HandleAsync_WithDifferentEventTypes_CreatesCorrectActivities()
+ public async Task HandleAsync_WithDifferentEventTypes_CreatesCorrectActivities(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -187,8 +191,12 @@ public async Task HandleAsync_WithDifferentEventTypes_CreatesCorrectActivities()
listener.ActivityStarted = activity => activities.Add(activity);
- await interceptor1.HandleAsync(new TestEvent(), (_, _) => Task.CompletedTask).ConfigureAwait(false);
- await interceptor2.HandleAsync(new AnotherTestEvent(), (_, _) => Task.CompletedTask).ConfigureAwait(false);
+ await interceptor1
+ .HandleAsync(new TestEvent(), (_, _) => Task.CompletedTask, cancellationToken)
+ .ConfigureAwait(false);
+ await interceptor2
+ .HandleAsync(new AnotherTestEvent(), (_, _) => Task.CompletedTask, cancellationToken)
+ .ConfigureAwait(false);
using (Assert.Multiple())
{
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsRequestInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsRequestInterceptorTests.cs
index 405da4fe..776fd6f6 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsRequestInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsRequestInterceptorTests.cs
@@ -1,4 +1,4 @@
-namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
+namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
using System.Diagnostics;
using NetEvolve.Extensions.TUnit;
@@ -11,7 +11,7 @@ public class ActivityAndMetricsRequestInterceptorTests
{
[Test]
[NotInParallel]
- public async Task HandleAsync_WithCommand_CreatesActivityWithCorrectTags()
+ public async Task HandleAsync_WithCommand_CreatesActivityWithCorrectTags(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -35,7 +35,8 @@ public async Task HandleAsync_WithCommand_CreatesActivityWithCorrectTags()
{
handlerCalled = true;
return Task.FromResult("test-result");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -53,7 +54,7 @@ public async Task HandleAsync_WithCommand_CreatesActivityWithCorrectTags()
[Test]
[NotInParallel]
- public async Task HandleAsync_WithQuery_CreatesActivityWithCorrectTags()
+ public async Task HandleAsync_WithQuery_CreatesActivityWithCorrectTags(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -69,7 +70,9 @@ public async Task HandleAsync_WithQuery_CreatesActivityWithCorrectTags()
listener.ActivityStarted = activity => capturedActivity = activity;
- var result = await interceptor.HandleAsync(query, (_, _) => Task.FromResult(42)).ConfigureAwait(false);
+ var result = await interceptor
+ .HandleAsync(query, (_, _) => Task.FromResult(42), cancellationToken)
+ .ConfigureAwait(false);
using (Assert.Multiple())
{
@@ -84,7 +87,7 @@ public async Task HandleAsync_WithQuery_CreatesActivityWithCorrectTags()
[Test]
[NotInParallel]
- public async Task HandleAsync_WithGenericRequest_CreatesActivityWithCorrectTags()
+ public async Task HandleAsync_WithGenericRequest_CreatesActivityWithCorrectTags(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -100,7 +103,9 @@ public async Task HandleAsync_WithGenericRequest_CreatesActivityWithCorrectTags(
listener.ActivityStarted = activity => capturedActivity = activity;
- var result = await interceptor.HandleAsync(request, (_, _) => Task.FromResult(true)).ConfigureAwait(false);
+ var result = await interceptor
+ .HandleAsync(request, (_, _) => Task.FromResult(true), cancellationToken)
+ .ConfigureAwait(false);
using (Assert.Multiple())
{
@@ -115,7 +120,7 @@ public async Task HandleAsync_WithGenericRequest_CreatesActivityWithCorrectTags(
[Test]
[NotInParallel]
- public async Task HandleAsync_WhenHandlerSucceeds_SetsActivityStatusToOk()
+ public async Task HandleAsync_WhenHandlerSucceeds_SetsActivityStatusToOk(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -131,7 +136,9 @@ public async Task HandleAsync_WhenHandlerSucceeds_SetsActivityStatusToOk()
listener.ActivityStopped = activity => capturedActivity = activity;
- _ = await interceptor.HandleAsync(command, (_, _) => Task.FromResult("success")).ConfigureAwait(false);
+ _ = await interceptor
+ .HandleAsync(command, (_, _) => Task.FromResult("success"), cancellationToken)
+ .ConfigureAwait(false);
using (Assert.Multiple())
{
@@ -145,7 +152,7 @@ public async Task HandleAsync_WhenHandlerSucceeds_SetsActivityStatusToOk()
[Test]
[NotInParallel]
- public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError()
+ public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -163,7 +170,9 @@ public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError()
listener.ActivityStopped = activity => capturedActivity = activity;
var exception = await Assert.ThrowsAsync(async () =>
- await interceptor.HandleAsync(command, (_, _) => throw testException).ConfigureAwait(false)
+ await interceptor
+ .HandleAsync(command, (_, _) => throw testException, cancellationToken)
+ .ConfigureAwait(false)
);
using (Assert.Multiple())
@@ -185,7 +194,7 @@ public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError()
[Test]
[NotInParallel]
- public async Task HandleAsync_SetsTimestamps()
+ public async Task HandleAsync_SetsTimestamps(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -201,7 +210,9 @@ public async Task HandleAsync_SetsTimestamps()
listener.ActivityStopped = activity => capturedActivity = activity;
- _ = await interceptor.HandleAsync(command, (_, _) => Task.FromResult("result")).ConfigureAwait(false);
+ _ = await interceptor
+ .HandleAsync(command, (_, _) => Task.FromResult("result"), cancellationToken)
+ .ConfigureAwait(false);
using (Assert.Multiple())
{
@@ -212,7 +223,7 @@ public async Task HandleAsync_SetsTimestamps()
}
[Test]
- public async Task HandleAsync_InvokesHandlerWithCorrectRequest()
+ public async Task HandleAsync_InvokesHandlerWithCorrectRequest(CancellationToken cancellationToken)
{
var timeProvider = TimeProvider.System;
var interceptor = new ActivityAndMetricsRequestInterceptor(timeProvider);
@@ -226,7 +237,8 @@ public async Task HandleAsync_InvokesHandlerWithCorrectRequest()
{
receivedCommand = cmd;
return Task.FromResult("result");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsStreamQueryInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsStreamQueryInterceptorTests.cs
index af0db49a..b20c3ad1 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsStreamQueryInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/ActivityAndMetricsStreamQueryInterceptorTests.cs
@@ -1,4 +1,4 @@
-namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
+namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
using System.Diagnostics;
using System.Runtime.CompilerServices;
@@ -12,7 +12,7 @@ public class ActivityAndMetricsStreamQueryInterceptorTests
{
[Test]
[NotInParallel]
- public async Task HandleAsync_CreatesActivityWithCorrectTags()
+ public async Task HandleAsync_CreatesActivityWithCorrectTags(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -28,7 +28,7 @@ public async Task HandleAsync_CreatesActivityWithCorrectTags()
listener.ActivityStarted = activity => capturedActivity = activity;
- await foreach (var _ in interceptor.HandleAsync(query, (_, ct) => Items([1, 2, 3], ct)))
+ await foreach (var _ in interceptor.HandleAsync(query, (_, ct) => Items([1, 2, 3], ct), cancellationToken))
{
// consume items
}
@@ -45,7 +45,7 @@ public async Task HandleAsync_CreatesActivityWithCorrectTags()
[Test]
[NotInParallel]
- public async Task HandleAsync_WhenStreamCompletes_SetsActivityStatusToOk()
+ public async Task HandleAsync_WhenStreamCompletes_SetsActivityStatusToOk(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -61,7 +61,7 @@ public async Task HandleAsync_WhenStreamCompletes_SetsActivityStatusToOk()
listener.ActivityStopped = activity => capturedActivity = activity;
- await foreach (var _ in interceptor.HandleAsync(query, (_, ct) => Items([1, 2, 3], ct)))
+ await foreach (var _ in interceptor.HandleAsync(query, (_, ct) => Items([1, 2, 3], ct), cancellationToken))
{
// consume items
}
@@ -78,7 +78,7 @@ public async Task HandleAsync_WhenStreamCompletes_SetsActivityStatusToOk()
[Test]
[NotInParallel]
- public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError()
+ public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -97,7 +97,9 @@ public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError()
var exception = await Assert.ThrowsAsync(async () =>
{
- await foreach (var _ in interceptor.HandleAsync(query, (_, ct) => ThrowingItems(testException, ct)))
+ await foreach (
+ var _ in interceptor.HandleAsync(query, (_, ct) => ThrowingItems(testException, ct), cancellationToken)
+ )
{
// consume items until exception
}
@@ -124,7 +126,7 @@ public async Task HandleAsync_WhenHandlerThrows_SetsActivityStatusToError()
[Test]
[NotInParallel]
- public async Task HandleAsync_WithEmptyStream_SetsActivityStatusToOk()
+ public async Task HandleAsync_WithEmptyStream_SetsActivityStatusToOk(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -140,7 +142,9 @@ public async Task HandleAsync_WithEmptyStream_SetsActivityStatusToOk()
listener.ActivityStopped = activity => capturedActivity = activity;
- await foreach (var _ in interceptor.HandleAsync(query, (_, ct) => Items(Array.Empty(), ct)))
+ await foreach (
+ var _ in interceptor.HandleAsync(query, (_, ct) => Items(Array.Empty(), ct), cancellationToken)
+ )
{
// empty stream
}
@@ -156,7 +160,7 @@ public async Task HandleAsync_WithEmptyStream_SetsActivityStatusToOk()
}
[Test]
- public async Task HandleAsync_YieldsAllItemsUnchanged()
+ public async Task HandleAsync_YieldsAllItemsUnchanged(CancellationToken cancellationToken)
{
var timeProvider = TimeProvider.System;
var interceptor = new ActivityAndMetricsStreamQueryInterceptor(timeProvider);
@@ -164,7 +168,7 @@ public async Task HandleAsync_YieldsAllItemsUnchanged()
var expected = new[] { 10, 20, 30 };
var received = new List();
- await foreach (var item in interceptor.HandleAsync(query, (_, ct) => Items(expected, ct)))
+ await foreach (var item in interceptor.HandleAsync(query, (_, ct) => Items(expected, ct), cancellationToken))
{
received.Add(item);
}
@@ -174,7 +178,7 @@ public async Task HandleAsync_YieldsAllItemsUnchanged()
[Test]
[NotInParallel]
- public async Task HandleAsync_SetsTimestamps()
+ public async Task HandleAsync_SetsTimestamps(CancellationToken cancellationToken)
{
using var listener = new ActivityListener
{
@@ -190,7 +194,7 @@ public async Task HandleAsync_SetsTimestamps()
listener.ActivityStopped = activity => capturedActivity = activity;
- await foreach (var _ in interceptor.HandleAsync(query, (_, ct) => Items([1], ct)))
+ await foreach (var _ in interceptor.HandleAsync(query, (_, ct) => Items([1], ct), cancellationToken))
{
// consume
}
@@ -204,7 +208,7 @@ public async Task HandleAsync_SetsTimestamps()
}
[Test]
- public async Task HandleAsync_InvokesHandlerWithCorrectQuery()
+ public async Task HandleAsync_InvokesHandlerWithCorrectQuery(CancellationToken cancellationToken)
{
var timeProvider = TimeProvider.System;
var interceptor = new ActivityAndMetricsStreamQueryInterceptor(timeProvider);
@@ -218,7 +222,8 @@ var _ in interceptor.HandleAsync(
{
receivedQuery = q;
return Items([1], ct);
- }
+ },
+ cancellationToken
)
)
{
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/DistributedCacheQueryInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/DistributedCacheQueryInterceptorTests.cs
index a38d0357..af038b9f 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/DistributedCacheQueryInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/DistributedCacheQueryInterceptorTests.cs
@@ -1,4 +1,4 @@
-namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
+namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
@@ -16,7 +16,7 @@ public class DistributedCacheQueryInterceptorTests
private static IOptions DefaultOptions => Options.Create(new QueryCachingOptions());
[Test]
- public async Task HandleAsync_QueryNotCacheable_AlwaysCallsHandler()
+ public async Task HandleAsync_QueryNotCacheable_AlwaysCallsHandler(CancellationToken cancellationToken)
{
var services = new ServiceCollection();
_ = services.AddDistributedMemoryCache();
@@ -33,7 +33,8 @@ public async Task HandleAsync_QueryNotCacheable_AlwaysCallsHandler()
{
handlerCallCount++;
return Task.FromResult("handler-result");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -45,7 +46,7 @@ public async Task HandleAsync_QueryNotCacheable_AlwaysCallsHandler()
}
[Test]
- public async Task HandleAsync_CacheMiss_CallsHandlerAndStoresResult()
+ public async Task HandleAsync_CacheMiss_CallsHandlerAndStoresResult(CancellationToken cancellationToken)
{
var services = new ServiceCollection();
_ = services.AddDistributedMemoryCache();
@@ -62,7 +63,8 @@ public async Task HandleAsync_CacheMiss_CallsHandlerAndStoresResult()
{
handlerCallCount++;
return Task.FromResult("cached-value");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -74,14 +76,14 @@ public async Task HandleAsync_CacheMiss_CallsHandlerAndStoresResult()
// Verify the value was written to the cache
var cache = provider.GetRequiredService();
- var bytes = await cache.GetAsync("test-key").ConfigureAwait(false);
+ var bytes = await cache.GetAsync("test-key", cancellationToken).ConfigureAwait(false);
_ = await Assert.That(bytes).IsNotNull();
var deserialised = JsonSerializer.Deserialize(bytes!);
_ = await Assert.That(deserialised).IsEqualTo("cached-value");
}
[Test]
- public async Task HandleAsync_CacheHit_ReturnsCachedValueWithoutCallingHandler()
+ public async Task HandleAsync_CacheHit_ReturnsCachedValueWithoutCallingHandler(CancellationToken cancellationToken)
{
var services = new ServiceCollection();
_ = services.AddDistributedMemoryCache();
@@ -90,7 +92,7 @@ public async Task HandleAsync_CacheHit_ReturnsCachedValueWithoutCallingHandler()
// Pre-populate the cache
var cache = provider.GetRequiredService();
var serialized = JsonSerializer.SerializeToUtf8Bytes("cached-result");
- await cache.SetAsync("hit-key", serialized).ConfigureAwait(false);
+ await cache.SetAsync("hit-key", serialized, cancellationToken).ConfigureAwait(false);
var interceptor = new DistributedCacheQueryInterceptor(provider, DefaultOptions);
var query = new CacheableQuery("hit-key");
@@ -103,7 +105,8 @@ public async Task HandleAsync_CacheHit_ReturnsCachedValueWithoutCallingHandler()
{
handlerCallCount++;
return Task.FromResult("handler-result");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -115,7 +118,7 @@ public async Task HandleAsync_CacheHit_ReturnsCachedValueWithoutCallingHandler()
}
[Test]
- public async Task HandleAsync_WithExpiry_StoresEntryWithAbsoluteExpiration()
+ public async Task HandleAsync_WithExpiry_StoresEntryWithAbsoluteExpiration(CancellationToken cancellationToken)
{
var services = new ServiceCollection();
_ = services.AddDistributedMemoryCache();
@@ -128,18 +131,18 @@ public async Task HandleAsync_WithExpiry_StoresEntryWithAbsoluteExpiration()
var query = new CacheableQueryWithExpiry("expiry-key", TimeSpan.FromSeconds(60));
var result = await interceptor
- .HandleAsync(query, (_, _) => Task.FromResult("expiry-value"))
+ .HandleAsync(query, (_, _) => Task.FromResult("expiry-value"), cancellationToken)
.ConfigureAwait(false);
_ = await Assert.That(result).IsEqualTo("expiry-value");
var cache = provider.GetRequiredService();
- var bytes = await cache.GetAsync("expiry-key").ConfigureAwait(false);
+ var bytes = await cache.GetAsync("expiry-key", cancellationToken).ConfigureAwait(false);
_ = await Assert.That(bytes).IsNotNull();
}
[Test]
- public async Task HandleAsync_WithNullExpiry_StoresEntryWithoutExpiration()
+ public async Task HandleAsync_WithNullExpiry_StoresEntryWithoutExpiration(CancellationToken cancellationToken)
{
var services = new ServiceCollection();
_ = services.AddDistributedMemoryCache();
@@ -149,18 +152,18 @@ public async Task HandleAsync_WithNullExpiry_StoresEntryWithoutExpiration()
var query = new CacheableQuery("no-expiry-key");
var result = await interceptor
- .HandleAsync(query, (_, _) => Task.FromResult("no-expiry-value"))
+ .HandleAsync(query, (_, _) => Task.FromResult("no-expiry-value"), cancellationToken)
.ConfigureAwait(false);
_ = await Assert.That(result).IsEqualTo("no-expiry-value");
var cache = provider.GetRequiredService();
- var bytes = await cache.GetAsync("no-expiry-key").ConfigureAwait(false);
+ var bytes = await cache.GetAsync("no-expiry-key", cancellationToken).ConfigureAwait(false);
_ = await Assert.That(bytes).IsNotNull();
}
[Test]
- public async Task HandleAsync_NoCacheRegistered_FallsThroughToHandler()
+ public async Task HandleAsync_NoCacheRegistered_FallsThroughToHandler(CancellationToken cancellationToken)
{
var services = new ServiceCollection();
// Do NOT register IDistributedCache
@@ -177,7 +180,8 @@ public async Task HandleAsync_NoCacheRegistered_FallsThroughToHandler()
{
handlerCallCount++;
return Task.FromResult("fallthrough-result");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -189,7 +193,7 @@ public async Task HandleAsync_NoCacheRegistered_FallsThroughToHandler()
}
[Test]
- public async Task HandleAsync_ExpiredCacheEntry_CallsHandlerAndRefreshesCache()
+ public async Task HandleAsync_ExpiredCacheEntry_CallsHandlerAndRefreshesCache(CancellationToken cancellationToken)
{
var services = new ServiceCollection();
_ = services.AddDistributedMemoryCache();
@@ -202,11 +206,12 @@ await cache
.SetAsync(
"expired-key",
serialized,
- new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(1) }
+ new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(1) },
+ cancellationToken
)
.ConfigureAwait(false);
- await Task.Delay(50).ConfigureAwait(false); // Allow the entry to expire
+ await Task.Delay(50, cancellationToken).ConfigureAwait(false); // Allow the entry to expire
var interceptor = new DistributedCacheQueryInterceptor(provider, DefaultOptions);
var query = new CacheableQuery("expired-key");
@@ -219,7 +224,8 @@ await cache
{
handlerCallCount++;
return Task.FromResult("fresh-value");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -231,7 +237,9 @@ await cache
}
[Test]
- public async Task HandleAsync_SlidingExpirationMode_StoresEntryWithSlidingExpiration()
+ public async Task HandleAsync_SlidingExpirationMode_StoresEntryWithSlidingExpiration(
+ CancellationToken cancellationToken
+ )
{
var services = new ServiceCollection();
_ = services.AddDistributedMemoryCache();
@@ -242,19 +250,21 @@ public async Task HandleAsync_SlidingExpirationMode_StoresEntryWithSlidingExpira
var query = new CacheableQueryWithExpiry("sliding-key", TimeSpan.FromSeconds(60));
var result = await interceptor
- .HandleAsync(query, (_, _) => Task.FromResult("sliding-value"))
+ .HandleAsync(query, (_, _) => Task.FromResult("sliding-value"), cancellationToken)
.ConfigureAwait(false);
_ = await Assert.That(result).IsEqualTo("sliding-value");
// Entry should still be accessible after being stored with sliding expiration
var cache = provider.GetRequiredService();
- var bytes = await cache.GetAsync("sliding-key").ConfigureAwait(false);
+ var bytes = await cache.GetAsync("sliding-key", cancellationToken).ConfigureAwait(false);
_ = await Assert.That(bytes).IsNotNull();
}
[Test]
- public async Task HandleAsync_CustomJsonSerializerOptions_UsedForSerializationAndDeserialization()
+ public async Task HandleAsync_CustomJsonSerializerOptions_UsedForSerializationAndDeserialization(
+ CancellationToken cancellationToken
+ )
{
var services = new ServiceCollection();
_ = services.AddDistributedMemoryCache();
@@ -267,21 +277,21 @@ public async Task HandleAsync_CustomJsonSerializerOptions_UsedForSerializationAn
var query = new CacheableQuery("custom-json-key");
var result = await interceptor
- .HandleAsync(query, (_, _) => Task.FromResult("custom-json-value"))
+ .HandleAsync(query, (_, _) => Task.FromResult("custom-json-value"), cancellationToken)
.ConfigureAwait(false);
_ = await Assert.That(result).IsEqualTo("custom-json-value");
// Second call should return from cache using the same custom options
var cachedResult = await interceptor
- .HandleAsync(query, (_, _) => Task.FromResult("should-not-be-returned"))
+ .HandleAsync(query, (_, _) => Task.FromResult("should-not-be-returned"), cancellationToken)
.ConfigureAwait(false);
_ = await Assert.That(cachedResult).IsEqualTo("custom-json-value");
}
[Test]
- public async Task HandleAsync_DefaultExpiry_UsedWhenQueryExpiryIsNull()
+ public async Task HandleAsync_DefaultExpiry_UsedWhenQueryExpiryIsNull(CancellationToken cancellationToken)
{
var services = new ServiceCollection();
_ = services.AddDistributedMemoryCache();
@@ -292,19 +302,19 @@ public async Task HandleAsync_DefaultExpiry_UsedWhenQueryExpiryIsNull()
var query = new CacheableQuery("default-expiry-key");
var result = await interceptor
- .HandleAsync(query, (_, _) => Task.FromResult("default-expiry-value"))
+ .HandleAsync(query, (_, _) => Task.FromResult("default-expiry-value"), cancellationToken)
.ConfigureAwait(false);
_ = await Assert.That(result).IsEqualTo("default-expiry-value");
// Entry should be present (default expiry applied)
var cache = provider.GetRequiredService();
- var bytes = await cache.GetAsync("default-expiry-key").ConfigureAwait(false);
+ var bytes = await cache.GetAsync("default-expiry-key", cancellationToken).ConfigureAwait(false);
_ = await Assert.That(bytes).IsNotNull();
}
[Test]
- public async Task HandleAsync_DefaultExpiry_NotUsedWhenQueryExpiryIsProvided()
+ public async Task HandleAsync_DefaultExpiry_NotUsedWhenQueryExpiryIsProvided(CancellationToken cancellationToken)
{
var services = new ServiceCollection();
_ = services.AddDistributedMemoryCache();
@@ -316,14 +326,14 @@ public async Task HandleAsync_DefaultExpiry_NotUsedWhenQueryExpiryIsProvided()
var query = new CacheableQueryWithExpiry("query-expiry-key", TimeSpan.FromMinutes(10));
var result = await interceptor
- .HandleAsync(query, (_, _) => Task.FromResult("query-expiry-value"))
+ .HandleAsync(query, (_, _) => Task.FromResult("query-expiry-value"), cancellationToken)
.ConfigureAwait(false);
_ = await Assert.That(result).IsEqualTo("query-expiry-value");
// Entry should still be present because the query's own expiry (10 min) overrode DefaultExpiry (1 ms)
var cache = provider.GetRequiredService();
- var bytes = await cache.GetAsync("query-expiry-key").ConfigureAwait(false);
+ var bytes = await cache.GetAsync("query-expiry-key", cancellationToken).ConfigureAwait(false);
_ = await Assert.That(bytes).IsNotNull();
}
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/IdempotencyCommandInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/IdempotencyCommandInterceptorTests.cs
index 51793f76..739fd3cc 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/IdempotencyCommandInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/IdempotencyCommandInterceptorTests.cs
@@ -1,4 +1,4 @@
-namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
+namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
using System;
using System.Diagnostics.CodeAnalysis;
@@ -38,19 +38,21 @@ public async Task Constructor_NoStoreRegistered_DoesNotThrow()
}
[Test]
- public async Task HandleAsync_NullHandler_ThrowsArgumentNullException()
+ public async Task HandleAsync_NullHandler_ThrowsArgumentNullException(CancellationToken cancellationToken)
{
var provider = new ServiceCollection().BuildServiceProvider();
var interceptor = new IdempotencyCommandInterceptor(provider);
var command = new TestCommand();
_ = await Assert
- .That(async () => await interceptor.HandleAsync(command, null!).ConfigureAwait(false))
+ .That(async () => await interceptor.HandleAsync(command, null!, cancellationToken).ConfigureAwait(false))
.Throws();
}
[Test]
- public async Task HandleAsync_NonIdempotentCommand_PassesThroughWithoutStoreInteraction()
+ public async Task HandleAsync_NonIdempotentCommand_PassesThroughWithoutStoreInteraction(
+ CancellationToken cancellationToken
+ )
{
var store = new TrackingIdempotencyStore();
var services = new ServiceCollection();
@@ -67,7 +69,8 @@ public async Task HandleAsync_NonIdempotentCommand_PassesThroughWithoutStoreInte
{
handlerCalled = true;
return Task.FromResult("response");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -81,7 +84,7 @@ public async Task HandleAsync_NonIdempotentCommand_PassesThroughWithoutStoreInte
}
[Test]
- public async Task HandleAsync_NoStoreRegistered_PassesThroughWithoutError()
+ public async Task HandleAsync_NoStoreRegistered_PassesThroughWithoutError(CancellationToken cancellationToken)
{
var provider = new ServiceCollection().BuildServiceProvider();
var interceptor = new IdempotencyCommandInterceptor(provider);
@@ -95,7 +98,8 @@ public async Task HandleAsync_NoStoreRegistered_PassesThroughWithoutError()
{
handlerCalled = true;
return Task.FromResult("response");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -107,7 +111,7 @@ public async Task HandleAsync_NoStoreRegistered_PassesThroughWithoutError()
}
[Test]
- public async Task HandleAsync_NewKey_ExecutesHandlerAndStoresKey()
+ public async Task HandleAsync_NewKey_ExecutesHandlerAndStoresKey(CancellationToken cancellationToken)
{
var store = new TrackingIdempotencyStore();
var services = new ServiceCollection();
@@ -124,7 +128,8 @@ public async Task HandleAsync_NewKey_ExecutesHandlerAndStoresKey()
{
handlerCalled = true;
return Task.FromResult("response");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -139,7 +144,7 @@ public async Task HandleAsync_NewKey_ExecutesHandlerAndStoresKey()
}
[Test]
- public async Task HandleAsync_ExistingKey_ThrowsIdempotencyConflictException()
+ public async Task HandleAsync_ExistingKey_ThrowsIdempotencyConflictException(CancellationToken cancellationToken)
{
var store = new TrackingIdempotencyStore(existingKey: "key-dup");
var services = new ServiceCollection();
@@ -158,7 +163,8 @@ await interceptor
{
handlerCalled = true;
return Task.FromResult("response");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false)
)
@@ -173,7 +179,7 @@ await interceptor
}
[Test]
- public async Task HandleAsync_ExistingKey_DoesNotCallHandler()
+ public async Task HandleAsync_ExistingKey_DoesNotCallHandler(CancellationToken cancellationToken)
{
var store = new TrackingIdempotencyStore(existingKey: "key-exists");
var services = new ServiceCollection();
@@ -184,7 +190,9 @@ public async Task HandleAsync_ExistingKey_DoesNotCallHandler()
_ = await Assert
.That(async () =>
- await interceptor.HandleAsync(command, (_, _) => Task.FromResult("response")).ConfigureAwait(false)
+ await interceptor
+ .HandleAsync(command, (_, _) => Task.FromResult("response"), cancellationToken)
+ .ConfigureAwait(false)
)
.Throws();
@@ -192,7 +200,7 @@ await interceptor.HandleAsync(command, (_, _) => Task.FromResult("response")).Co
}
[Test]
- public async Task HandleAsync_HandlerThrows_DoesNotStoreKey()
+ public async Task HandleAsync_HandlerThrows_DoesNotStoreKey(CancellationToken cancellationToken)
{
var store = new TrackingIdempotencyStore();
var services = new ServiceCollection();
@@ -206,7 +214,8 @@ public async Task HandleAsync_HandlerThrows_DoesNotStoreKey()
await interceptor
.HandleAsync(
command,
- (_, _) => Task.FromException(new InvalidOperationException("handler error"))
+ (_, _) => Task.FromException(new InvalidOperationException("handler error")),
+ cancellationToken
)
.ConfigureAwait(false)
)
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/LoggingEventInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/LoggingEventInterceptorTests.cs
index 06c7ce92..a651e125 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/LoggingEventInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/LoggingEventInterceptorTests.cs
@@ -1,4 +1,4 @@
-namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
+namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -24,7 +24,7 @@ private static LoggingEventInterceptor CreateInterceptor(
}
[Test]
- public async Task HandleAsync_LogsBeginAndEndAtDebugLevel()
+ public async Task HandleAsync_LogsBeginAndEndAtDebugLevel(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger, new LoggingInterceptorOptions { LogLevel = LogLevel.Debug });
@@ -38,7 +38,8 @@ await interceptor
{
handlerCalled = true;
return Task.CompletedTask;
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -52,13 +53,13 @@ await interceptor
}
[Test]
- public async Task HandleAsync_LogsBeginAndEndAtInformationLevel()
+ public async Task HandleAsync_LogsBeginAndEndAtInformationLevel(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger, new LoggingInterceptorOptions { LogLevel = LogLevel.Information });
var testEvent = new TestEvent();
- await interceptor.HandleAsync(testEvent, (_, _) => Task.CompletedTask).ConfigureAwait(false);
+ await interceptor.HandleAsync(testEvent, (_, _) => Task.CompletedTask, cancellationToken).ConfigureAwait(false);
using (Assert.Multiple())
{
@@ -69,19 +70,19 @@ public async Task HandleAsync_LogsBeginAndEndAtInformationLevel()
}
[Test]
- public async Task HandleAsync_LogsEventNameInMessage()
+ public async Task HandleAsync_LogsEventNameInMessage(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger);
var testEvent = new TestEvent();
- await interceptor.HandleAsync(testEvent, (_, _) => Task.CompletedTask).ConfigureAwait(false);
+ await interceptor.HandleAsync(testEvent, (_, _) => Task.CompletedTask, cancellationToken).ConfigureAwait(false);
_ = await Assert.That(logger.Entries[0].Message).Contains("TestEvent");
}
[Test]
- public async Task HandleAsync_WithSlowEvent_LogsWarning()
+ public async Task HandleAsync_WithSlowEvent_LogsWarning(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(
@@ -91,7 +92,7 @@ public async Task HandleAsync_WithSlowEvent_LogsWarning()
var testEvent = new TestEvent();
await interceptor
- .HandleAsync(testEvent, async (_, ct) => await Task.Delay(50, ct).ConfigureAwait(false))
+ .HandleAsync(testEvent, async (_, ct) => await Task.Delay(50, ct).ConfigureAwait(false), cancellationToken)
.ConfigureAwait(false);
var warnings = logger.Entries.Where(e => e.LogLevel == LogLevel.Warning).ToList();
@@ -100,14 +101,14 @@ await interceptor
}
[Test]
- public async Task HandleAsync_WithDisabledSlowThreshold_DoesNotLogWarning()
+ public async Task HandleAsync_WithDisabledSlowThreshold_DoesNotLogWarning(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger, new LoggingInterceptorOptions { SlowRequestThreshold = null });
var testEvent = new TestEvent();
await interceptor
- .HandleAsync(testEvent, async (_, ct) => await Task.Delay(50, ct).ConfigureAwait(false))
+ .HandleAsync(testEvent, async (_, ct) => await Task.Delay(50, ct).ConfigureAwait(false), cancellationToken)
.ConfigureAwait(false);
var warnings = logger.Entries.Where(e => e.LogLevel == LogLevel.Warning).ToList();
@@ -115,7 +116,7 @@ await interceptor
}
[Test]
- public async Task HandleAsync_WhenHandlerThrows_LogsErrorAndRethrows()
+ public async Task HandleAsync_WhenHandlerThrows_LogsErrorAndRethrows(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger);
@@ -123,7 +124,9 @@ public async Task HandleAsync_WhenHandlerThrows_LogsErrorAndRethrows()
var expectedException = new InvalidOperationException("event error");
var exception = await Assert.ThrowsAsync(async () =>
- await interceptor.HandleAsync(testEvent, (_, _) => throw expectedException).ConfigureAwait(false)
+ await interceptor
+ .HandleAsync(testEvent, (_, _) => throw expectedException, cancellationToken)
+ .ConfigureAwait(false)
);
_ = await Assert.That(exception).IsSameReferenceAs(expectedException);
@@ -137,19 +140,19 @@ public async Task HandleAsync_WhenHandlerThrows_LogsErrorAndRethrows()
}
[Test]
- public async Task HandleAsync_LogsCorrelationId()
+ public async Task HandleAsync_LogsCorrelationId(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger);
var testEvent = new TestEvent { CorrelationId = "event-correlation-id" };
- await interceptor.HandleAsync(testEvent, (_, _) => Task.CompletedTask).ConfigureAwait(false);
+ await interceptor.HandleAsync(testEvent, (_, _) => Task.CompletedTask, cancellationToken).ConfigureAwait(false);
_ = await Assert.That(logger.Entries[0].Message).Contains("event-correlation-id");
}
[Test]
- public async Task HandleAsync_InvokesHandlerWithCorrectEvent()
+ public async Task HandleAsync_InvokesHandlerWithCorrectEvent(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger);
@@ -163,7 +166,8 @@ await interceptor
{
received = evt;
return Task.CompletedTask;
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/LoggingRequestInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/LoggingRequestInterceptorTests.cs
index 66f6d23a..ef45cc4c 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/LoggingRequestInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/LoggingRequestInterceptorTests.cs
@@ -1,4 +1,4 @@
-namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
+namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -24,13 +24,15 @@ private static LoggingRequestInterceptor CreateInterceptor<
}
[Test]
- public async Task HandleAsync_WithCommand_LogsBeginAndEndAtDebugLevel()
+ public async Task HandleAsync_WithCommand_LogsBeginAndEndAtDebugLevel(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger, new LoggingInterceptorOptions { LogLevel = LogLevel.Debug });
var command = new TestCommand { CorrelationId = "corr-123" };
- var result = await interceptor.HandleAsync(command, (_, _) => Task.FromResult("ok")).ConfigureAwait(false);
+ var result = await interceptor
+ .HandleAsync(command, (_, _) => Task.FromResult("ok"), cancellationToken)
+ .ConfigureAwait(false);
_ = await Assert.That(result).IsEqualTo("ok");
using (Assert.Multiple())
@@ -42,13 +44,15 @@ public async Task HandleAsync_WithCommand_LogsBeginAndEndAtDebugLevel()
}
[Test]
- public async Task HandleAsync_WithCommand_LogsBeginAndEndAtInformationLevel()
+ public async Task HandleAsync_WithCommand_LogsBeginAndEndAtInformationLevel(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger, new LoggingInterceptorOptions { LogLevel = LogLevel.Information });
var command = new TestCommand();
- _ = await interceptor.HandleAsync(command, (_, _) => Task.FromResult("ok")).ConfigureAwait(false);
+ _ = await interceptor
+ .HandleAsync(command, (_, _) => Task.FromResult("ok"), cancellationToken)
+ .ConfigureAwait(false);
using (Assert.Multiple())
{
@@ -59,13 +63,15 @@ public async Task HandleAsync_WithCommand_LogsBeginAndEndAtInformationLevel()
}
[Test]
- public async Task HandleAsync_WithQuery_LogsQueryInMessage()
+ public async Task HandleAsync_WithQuery_LogsQueryInMessage(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger);
var query = new TestQuery();
- _ = await interceptor.HandleAsync(query, (_, _) => Task.FromResult(42)).ConfigureAwait(false);
+ _ = await interceptor
+ .HandleAsync(query, (_, _) => Task.FromResult(42), cancellationToken)
+ .ConfigureAwait(false);
using (Assert.Multiple())
{
@@ -75,19 +81,21 @@ public async Task HandleAsync_WithQuery_LogsQueryInMessage()
}
[Test]
- public async Task HandleAsync_WithGenericRequest_LogsRequestInMessage()
+ public async Task HandleAsync_WithGenericRequest_LogsRequestInMessage(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger);
var request = new TestRequest();
- _ = await interceptor.HandleAsync(request, (_, _) => Task.FromResult(true)).ConfigureAwait(false);
+ _ = await interceptor
+ .HandleAsync(request, (_, _) => Task.FromResult(true), cancellationToken)
+ .ConfigureAwait(false);
_ = await Assert.That(logger.Entries[0].Message).Contains("Request");
}
[Test]
- public async Task HandleAsync_WithSlowRequest_LogsWarning()
+ public async Task HandleAsync_WithSlowRequest_LogsWarning(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(
@@ -103,7 +111,8 @@ public async Task HandleAsync_WithSlowRequest_LogsWarning()
{
await Task.Delay(50, ct).ConfigureAwait(false);
return "ok";
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -113,7 +122,9 @@ public async Task HandleAsync_WithSlowRequest_LogsWarning()
}
[Test]
- public async Task HandleAsync_WithDisabledSlowRequestThreshold_DoesNotLogWarning()
+ public async Task HandleAsync_WithDisabledSlowRequestThreshold_DoesNotLogWarning(
+ CancellationToken cancellationToken
+ )
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger, new LoggingInterceptorOptions { SlowRequestThreshold = null });
@@ -126,7 +137,8 @@ public async Task HandleAsync_WithDisabledSlowRequestThreshold_DoesNotLogWarning
{
await Task.Delay(50, ct).ConfigureAwait(false);
return "ok";
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
@@ -135,7 +147,7 @@ public async Task HandleAsync_WithDisabledSlowRequestThreshold_DoesNotLogWarning
}
[Test]
- public async Task HandleAsync_WhenHandlerThrows_LogsErrorAndRethrows()
+ public async Task HandleAsync_WhenHandlerThrows_LogsErrorAndRethrows(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger);
@@ -143,7 +155,9 @@ public async Task HandleAsync_WhenHandlerThrows_LogsErrorAndRethrows()
var expectedException = new InvalidOperationException("test error");
var exception = await Assert.ThrowsAsync(async () =>
- await interceptor.HandleAsync(command, (_, _) => throw expectedException).ConfigureAwait(false)
+ await interceptor
+ .HandleAsync(command, (_, _) => throw expectedException, cancellationToken)
+ .ConfigureAwait(false)
);
_ = await Assert.That(exception).IsSameReferenceAs(expectedException);
@@ -157,19 +171,21 @@ public async Task HandleAsync_WhenHandlerThrows_LogsErrorAndRethrows()
}
[Test]
- public async Task HandleAsync_LogsCorrelationId()
+ public async Task HandleAsync_LogsCorrelationId(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger);
var command = new TestCommand { CorrelationId = "my-correlation-id" };
- _ = await interceptor.HandleAsync(command, (_, _) => Task.FromResult("ok")).ConfigureAwait(false);
+ _ = await interceptor
+ .HandleAsync(command, (_, _) => Task.FromResult("ok"), cancellationToken)
+ .ConfigureAwait(false);
_ = await Assert.That(logger.Entries[0].Message).Contains("my-correlation-id");
}
[Test]
- public async Task HandleAsync_InvokesHandlerWithCorrectRequest()
+ public async Task HandleAsync_InvokesHandlerWithCorrectRequest(CancellationToken cancellationToken)
{
var logger = Mock.Logger>();
var interceptor = CreateInterceptor(logger);
@@ -183,7 +199,8 @@ public async Task HandleAsync_InvokesHandlerWithCorrectRequest()
{
received = cmd;
return Task.FromResult("ok");
- }
+ },
+ cancellationToken
)
.ConfigureAwait(false);
diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs
index 88bfa937..c4d928c1 100644
--- a/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs
+++ b/tests/NetEvolve.Pulse.Tests.Unit/Interceptors/TimeoutRequestInterceptorTests.cs
@@ -1,4 +1,4 @@
-namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
+namespace NetEvolve.Pulse.Tests.Unit.Interceptors;
using System;
using System.Threading;
@@ -13,31 +13,37 @@
public sealed class TimeoutRequestInterceptorTests
{
[Test]
- public async Task HandleAsync_WithNullHandler_ThrowsArgumentNullException()
+ public async Task HandleAsync_WithNullHandler_ThrowsArgumentNullException(CancellationToken cancellationToken)
{
var options = Options.Create(new TimeoutRequestInterceptorOptions());
var interceptor = new TimeoutRequestInterceptor(options);
var command = new TestTimeoutCommand(TimeSpan.FromSeconds(5));
_ = await Assert.ThrowsAsync