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(async () => - await interceptor.HandleAsync(command, null!).ConfigureAwait(false) + await interceptor.HandleAsync(command, null!, cancellationToken).ConfigureAwait(false) ); } [Test] - public async Task HandleAsync_WithTimeoutRequest_WhenCompletesWithinDeadline_ReturnsResult() + public async Task HandleAsync_WithTimeoutRequest_WhenCompletesWithinDeadline_ReturnsResult( + CancellationToken cancellationToken + ) { var options = Options.Create(new TimeoutRequestInterceptorOptions()); var interceptor = new TimeoutRequestInterceptor(options); var command = new TestTimeoutCommand(TimeSpan.FromSeconds(5)); - var result = await interceptor.HandleAsync(command, (_, _) => Task.FromResult("success")).ConfigureAwait(false); + var result = await interceptor + .HandleAsync(command, (_, _) => Task.FromResult("success"), cancellationToken) + .ConfigureAwait(false); _ = await Assert.That(result).IsEqualTo("success"); } [Test] - public async Task HandleAsync_WithTimeoutRequest_WhenExceedsDeadline_ThrowsTimeoutException() + public async Task HandleAsync_WithTimeoutRequest_WhenExceedsDeadline_ThrowsTimeoutException( + CancellationToken cancellationToken + ) { var options = Options.Create(new TimeoutRequestInterceptorOptions()); var interceptor = new TimeoutRequestInterceptor(options); @@ -51,7 +57,8 @@ await interceptor { await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); return "never"; - } + }, + cancellationToken ) .ConfigureAwait(false) ); @@ -62,12 +69,14 @@ await interceptor } [Test] - public async Task HandleAsync_WithOriginalTokenCancelled_ThrowsOperationCanceledException_NotTimeoutException() + public async Task HandleAsync_WithOriginalTokenCancelled_ThrowsOperationCanceledException_NotTimeoutException( + CancellationToken cancellationToken + ) { var options = Options.Create(new TimeoutRequestInterceptorOptions()); var interceptor = new TimeoutRequestInterceptor(options); var command = new TestTimeoutCommand(TimeSpan.FromSeconds(5)); - using var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromMilliseconds(50)); var exception = await Assert.ThrowsAsync(async () => @@ -89,7 +98,9 @@ await interceptor } [Test] - public async Task HandleAsync_WithNonTimeoutRequest_AlwaysPassesThrough_RegardlessOfGlobalTimeout() + public async Task HandleAsync_WithNonTimeoutRequest_AlwaysPassesThrough_RegardlessOfGlobalTimeout( + CancellationToken cancellationToken + ) { var options = Options.Create( new TimeoutRequestInterceptorOptions { GlobalTimeout = TimeSpan.FromMilliseconds(1) } @@ -99,42 +110,48 @@ public async Task HandleAsync_WithNonTimeoutRequest_AlwaysPassesThrough_Regardle // Even though GlobalTimeout is 1ms, the non-ITimeoutRequest should pass through immediately. var result = await interceptor - .HandleAsync(command, (_, _) => Task.FromResult("passed-through")) + .HandleAsync(command, (_, _) => Task.FromResult("passed-through"), cancellationToken) .ConfigureAwait(false); _ = await Assert.That(result).IsEqualTo("passed-through"); } [Test] - public async Task HandleAsync_WithTimeoutRequest_NullTimeout_AndNoGlobalTimeout_PassesThrough() + public async Task HandleAsync_WithTimeoutRequest_NullTimeout_AndNoGlobalTimeout_PassesThrough( + CancellationToken cancellationToken + ) { var options = Options.Create(new TimeoutRequestInterceptorOptions()); var interceptor = new TimeoutRequestInterceptor(options); var command = new TestTimeoutCommand(null); var result = await interceptor - .HandleAsync(command, (_, _) => Task.FromResult("passed-through")) + .HandleAsync(command, (_, _) => Task.FromResult("passed-through"), cancellationToken) .ConfigureAwait(false); _ = await Assert.That(result).IsEqualTo("passed-through"); } [Test] - public async Task HandleAsync_WithTimeoutRequest_NullTimeout_AndGlobalTimeout_WhenCompletesWithinDeadline_ReturnsResult() + public async Task HandleAsync_WithTimeoutRequest_NullTimeout_AndGlobalTimeout_WhenCompletesWithinDeadline_ReturnsResult( + CancellationToken cancellationToken + ) { var options = Options.Create(new TimeoutRequestInterceptorOptions { GlobalTimeout = TimeSpan.FromSeconds(5) }); var interceptor = new TimeoutRequestInterceptor(options); var command = new TestTimeoutCommand(null); var result = await interceptor - .HandleAsync(command, (_, _) => Task.FromResult("global-fallback-success")) + .HandleAsync(command, (_, _) => Task.FromResult("global-fallback-success"), cancellationToken) .ConfigureAwait(false); _ = await Assert.That(result).IsEqualTo("global-fallback-success"); } [Test] - public async Task HandleAsync_WithTimeoutRequest_NullTimeout_AndGlobalTimeout_WhenExceedsDeadline_ThrowsTimeoutException() + public async Task HandleAsync_WithTimeoutRequest_NullTimeout_AndGlobalTimeout_WhenExceedsDeadline_ThrowsTimeoutException( + CancellationToken cancellationToken + ) { var options = Options.Create( new TimeoutRequestInterceptorOptions { GlobalTimeout = TimeSpan.FromMilliseconds(50) } @@ -150,7 +167,8 @@ await interceptor { await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); return "never"; - } + }, + cancellationToken ) .ConfigureAwait(false) ); @@ -160,7 +178,9 @@ await interceptor } [Test] - public async Task HandleAsync_WithTimeoutRequest_ExplicitTimeoutOverridesGlobalTimeout() + public async Task HandleAsync_WithTimeoutRequest_ExplicitTimeoutOverridesGlobalTimeout( + CancellationToken cancellationToken + ) { // Per-request timeout (50ms) should take precedence over global (5s), // so the request should time out. @@ -176,7 +196,8 @@ await interceptor { await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); return "never"; - } + }, + cancellationToken ) .ConfigureAwait(false) ); @@ -185,7 +206,7 @@ await interceptor } [Test] - public async Task HandleAsync_DisposesLinkedCts_EvenWhenHandlerThrows() + public async Task HandleAsync_DisposesLinkedCts_EvenWhenHandlerThrows(CancellationToken cancellationToken) { var options = Options.Create(new TimeoutRequestInterceptorOptions()); var interceptor = new TimeoutRequestInterceptor(options); @@ -193,7 +214,7 @@ public async Task HandleAsync_DisposesLinkedCts_EvenWhenHandlerThrows() _ = await Assert.ThrowsAsync(async () => await interceptor - .HandleAsync(command, (_, _) => throw new InvalidOperationException("handler error")) + .HandleAsync(command, (_, _) => throw new InvalidOperationException("handler error"), cancellationToken) .ConfigureAwait(false) ); diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Internals/PulseMediatorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Internals/PulseMediatorTests.cs index 48c058c5..27716a21 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Internals/PulseMediatorTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Internals/PulseMediatorTests.cs @@ -77,7 +77,7 @@ public async Task Constructor_WithValidParameters_CreatesInstance() } [Test] - public async Task PublishAsync_WithNullMessage_ThrowsArgumentNullException() + public async Task PublishAsync_WithNullMessage_ThrowsArgumentNullException(CancellationToken cancellationToken) { var services = new ServiceCollection(); _ = services.AddLogging(); @@ -88,12 +88,12 @@ public async Task PublishAsync_WithNullMessage_ThrowsArgumentNullException() _ = await Assert.ThrowsAsync( "message", - async () => await mediator.PublishAsync(null!).ConfigureAwait(false) + async () => await mediator.PublishAsync(null!, cancellationToken).ConfigureAwait(false) ); } [Test] - public async Task PublishAsync_WithNoHandlers_CompletesSuccessfully() + public async Task PublishAsync_WithNoHandlers_CompletesSuccessfully(CancellationToken cancellationToken) { var services = new ServiceCollection(); _ = services.AddLogging(); @@ -103,13 +103,13 @@ public async Task PublishAsync_WithNoHandlers_CompletesSuccessfully() var mediator = new PulseMediator(logger, serviceProvider, timeProvider); var testEvent = new TestEvent(); - await mediator.PublishAsync(testEvent).ConfigureAwait(false); + await mediator.PublishAsync(testEvent, cancellationToken).ConfigureAwait(false); _ = await Assert.That(testEvent.PublishedAt).IsNotNull(); } [Test] - public async Task PublishAsync_WithHandlers_InvokesAllHandlers() + public async Task PublishAsync_WithHandlers_InvokesAllHandlers(CancellationToken cancellationToken) { var handler1 = new TestEventHandler(); var handler2 = new TestEventHandler(); @@ -123,7 +123,7 @@ public async Task PublishAsync_WithHandlers_InvokesAllHandlers() var mediator = new PulseMediator(logger, serviceProvider, timeProvider); var testEvent = new TestEvent(); - await mediator.PublishAsync(testEvent).ConfigureAwait(false); + await mediator.PublishAsync(testEvent, cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -135,7 +135,9 @@ public async Task PublishAsync_WithHandlers_InvokesAllHandlers() } [Test] - public async Task PublishAsync_WithHandlerException_ContinuesExecutingOtherHandlersAndThrowsAggregate() + public async Task PublishAsync_WithHandlerException_ContinuesExecutingOtherHandlersAndThrowsAggregate( + CancellationToken cancellationToken + ) { var handler1 = new ThrowingEventHandler(); var handler2 = new TestEventHandler(); @@ -151,7 +153,7 @@ public async Task PublishAsync_WithHandlerException_ContinuesExecutingOtherHandl // Act & Assert - PublishAsync throws AggregateException containing the handler failure var exception = await Assert.ThrowsAsync(async () => - await mediator.PublishAsync(testEvent).ConfigureAwait(false) + await mediator.PublishAsync(testEvent, cancellationToken).ConfigureAwait(false) ); // Verify the exception contains the handler failure @@ -163,7 +165,7 @@ await mediator.PublishAsync(testEvent).ConfigureAwait(false) } [Test] - public async Task PublishAsync_SetsPublishedAtTimestamp() + public async Task PublishAsync_SetsPublishedAtTimestamp(CancellationToken cancellationToken) { var services = new ServiceCollection().AddLogging(); var serviceProvider = services.BuildServiceProvider(); @@ -173,7 +175,7 @@ public async Task PublishAsync_SetsPublishedAtTimestamp() var testEvent = new TestEvent(); var beforePublish = timeProvider.GetUtcNow(); - await mediator.PublishAsync(testEvent).ConfigureAwait(false); + await mediator.PublishAsync(testEvent, cancellationToken).ConfigureAwait(false); var afterPublish = timeProvider.GetUtcNow(); var publishedAt = testEvent.PublishedAt; @@ -186,7 +188,7 @@ public async Task PublishAsync_SetsPublishedAtTimestamp() } [Test] - public async Task QueryAsync_WithNullQuery_ThrowsArgumentNullException() + public async Task QueryAsync_WithNullQuery_ThrowsArgumentNullException(CancellationToken cancellationToken) { var services = new ServiceCollection(); _ = services.AddLogging(); @@ -197,12 +199,12 @@ public async Task QueryAsync_WithNullQuery_ThrowsArgumentNullException() _ = await Assert.ThrowsAsync( "query", - async () => await mediator.QueryAsync(null!).ConfigureAwait(false) + async () => await mediator.QueryAsync(null!, cancellationToken).ConfigureAwait(false) ); } [Test] - public async Task QueryAsync_WithNoHandler_ThrowsInvalidOperationException() + public async Task QueryAsync_WithNoHandler_ThrowsInvalidOperationException(CancellationToken cancellationToken) { var services = new ServiceCollection(); _ = services.AddLogging(); @@ -213,12 +215,12 @@ public async Task QueryAsync_WithNoHandler_ThrowsInvalidOperationException() var query = new TestQuery(); _ = await Assert.ThrowsAsync(async () => - await mediator.QueryAsync(query).ConfigureAwait(false) + await mediator.QueryAsync(query, cancellationToken).ConfigureAwait(false) ); } [Test] - public async Task QueryAsync_WithHandler_InvokesHandlerAndReturnsResult() + public async Task QueryAsync_WithHandler_InvokesHandlerAndReturnsResult(CancellationToken cancellationToken) { var handler = new TestQueryHandler("test-result"); var services = new ServiceCollection(); @@ -230,7 +232,7 @@ public async Task QueryAsync_WithHandler_InvokesHandlerAndReturnsResult() var mediator = new PulseMediator(logger, serviceProvider, timeProvider); var query = new TestQuery(); - var result = await mediator.QueryAsync(query).ConfigureAwait(false); + var result = await mediator.QueryAsync(query, cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -241,7 +243,7 @@ public async Task QueryAsync_WithHandler_InvokesHandlerAndReturnsResult() } [Test] - public async Task SendAsync_WithNullCommand_ThrowsArgumentNullException() + public async Task SendAsync_WithNullCommand_ThrowsArgumentNullException(CancellationToken cancellationToken) { var services = new ServiceCollection(); _ = services.AddLogging(); @@ -252,12 +254,12 @@ public async Task SendAsync_WithNullCommand_ThrowsArgumentNullException() _ = await Assert.ThrowsAsync( "command", - async () => await mediator.SendAsync(null!).ConfigureAwait(false) + async () => await mediator.SendAsync(null!, cancellationToken).ConfigureAwait(false) ); } [Test] - public async Task SendAsync_WithNoHandler_ThrowsInvalidOperationException() + public async Task SendAsync_WithNoHandler_ThrowsInvalidOperationException(CancellationToken cancellationToken) { var services = new ServiceCollection(); _ = services.AddLogging(); @@ -268,12 +270,12 @@ public async Task SendAsync_WithNoHandler_ThrowsInvalidOperationException() var command = new TestCommand(); _ = await Assert.ThrowsAsync(async () => - await mediator.SendAsync(command).ConfigureAwait(false) + await mediator.SendAsync(command, cancellationToken).ConfigureAwait(false) ); } [Test] - public async Task SendAsync_WithHandler_InvokesHandlerAndReturnsResult() + public async Task SendAsync_WithHandler_InvokesHandlerAndReturnsResult(CancellationToken cancellationToken) { var handler = new TestCommandHandler("test-result"); var services = new ServiceCollection(); @@ -285,7 +287,7 @@ public async Task SendAsync_WithHandler_InvokesHandlerAndReturnsResult() var mediator = new PulseMediator(logger, serviceProvider, timeProvider); var command = new TestCommand(); - var result = await mediator.SendAsync(command).ConfigureAwait(false); + var result = await mediator.SendAsync(command, cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -296,7 +298,7 @@ public async Task SendAsync_WithHandler_InvokesHandlerAndReturnsResult() } [Test] - public async Task SendAsync_WithInterceptor_InvokesInterceptorBeforeHandler() + public async Task SendAsync_WithInterceptor_InvokesInterceptorBeforeHandler(CancellationToken cancellationToken) { var handler = new TestCommandHandler("test-result"); var interceptor = new TestCommandInterceptor(); @@ -310,7 +312,7 @@ public async Task SendAsync_WithInterceptor_InvokesInterceptorBeforeHandler() var mediator = new PulseMediator(logger, serviceProvider, timeProvider); var command = new TestCommand(); - var result = await mediator.SendAsync(command).ConfigureAwait(false); + var result = await mediator.SendAsync(command, cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -321,7 +323,7 @@ public async Task SendAsync_WithInterceptor_InvokesInterceptorBeforeHandler() } [Test] - public async Task QueryAsync_WithInterceptor_InvokesInterceptorBeforeHandler() + public async Task QueryAsync_WithInterceptor_InvokesInterceptorBeforeHandler(CancellationToken cancellationToken) { var handler = new TestQueryHandler("test-result"); var interceptor = new TestQueryInterceptor(); @@ -335,7 +337,7 @@ public async Task QueryAsync_WithInterceptor_InvokesInterceptorBeforeHandler() var mediator = new PulseMediator(logger, serviceProvider, timeProvider); var query = new TestQuery(); - var result = await mediator.QueryAsync(query).ConfigureAwait(false); + var result = await mediator.QueryAsync(query, cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -346,7 +348,7 @@ public async Task QueryAsync_WithInterceptor_InvokesInterceptorBeforeHandler() } [Test] - public async Task PublishAsync_WithInterceptor_InvokesInterceptorBeforeHandlers() + public async Task PublishAsync_WithInterceptor_InvokesInterceptorBeforeHandlers(CancellationToken cancellationToken) { var handler = new TestEventHandler(); var interceptor = new TestEventInterceptor(); @@ -360,7 +362,7 @@ public async Task PublishAsync_WithInterceptor_InvokesInterceptorBeforeHandlers( var mediator = new PulseMediator(logger, serviceProvider, timeProvider); var testEvent = new TestEvent(); - await mediator.PublishAsync(testEvent).ConfigureAwait(false); + await mediator.PublishAsync(testEvent, cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -524,7 +526,7 @@ public async IAsyncEnumerable HandleAsync( // StreamQueryAsync tests [Test] - public async Task StreamQueryAsync_WithNullQuery_ThrowsArgumentNullException() + public async Task StreamQueryAsync_WithNullQuery_ThrowsArgumentNullException(CancellationToken cancellationToken) { var services = new ServiceCollection(); _ = services.AddLogging(); @@ -535,12 +537,14 @@ public async Task StreamQueryAsync_WithNullQuery_ThrowsArgumentNullException() _ = Assert.Throws( "query", - () => mediator.StreamQueryAsync(null!) + () => mediator.StreamQueryAsync(null!, cancellationToken) ); } [Test] - public async Task StreamQueryAsync_WithNoHandler_ThrowsInvalidOperationException() + public async Task StreamQueryAsync_WithNoHandler_ThrowsInvalidOperationException( + CancellationToken cancellationToken + ) { var services = new ServiceCollection(); _ = services.AddLogging(); @@ -552,7 +556,7 @@ public async Task StreamQueryAsync_WithNoHandler_ThrowsInvalidOperationException _ = await Assert.ThrowsAsync(async () => { - await foreach (var _ in mediator.StreamQueryAsync(query)) + await foreach (var _ in mediator.StreamQueryAsync(query, cancellationToken)) { // consume } @@ -560,7 +564,7 @@ public async Task StreamQueryAsync_WithNoHandler_ThrowsInvalidOperationException } [Test] - public async Task StreamQueryAsync_WithHandler_YieldsAllItems() + public async Task StreamQueryAsync_WithHandler_YieldsAllItems(CancellationToken cancellationToken) { var expectedItems = new[] { "first", "second", "third" }; var handler = new TestStreamQueryHandler(expectedItems); @@ -574,7 +578,7 @@ public async Task StreamQueryAsync_WithHandler_YieldsAllItems() var query = new TestStreamQuery(); var results = new List(); - await foreach (var item in mediator.StreamQueryAsync(query)) + await foreach (var item in mediator.StreamQueryAsync(query, cancellationToken)) { results.Add(item); } @@ -587,7 +591,9 @@ public async Task StreamQueryAsync_WithHandler_YieldsAllItems() } [Test] - public async Task StreamQueryAsync_WithInterceptor_InvokesInterceptorAndYieldsAllItems() + public async Task StreamQueryAsync_WithInterceptor_InvokesInterceptorAndYieldsAllItems( + CancellationToken cancellationToken + ) { var expectedItems = new[] { "alpha", "beta" }; var handler = new TestStreamQueryHandler(expectedItems); @@ -603,7 +609,7 @@ public async Task StreamQueryAsync_WithInterceptor_InvokesInterceptorAndYieldsAl var query = new TestStreamQuery(); var results = new List(); - await foreach (var item in mediator.StreamQueryAsync(query)) + await foreach (var item in mediator.StreamQueryAsync(query, cancellationToken)) { results.Add(item); } diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Kafka/KafkaMessageTransportTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Kafka/KafkaMessageTransportTests.cs index 8da5b922..71eae4a8 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Kafka/KafkaMessageTransportTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Kafka/KafkaMessageTransportTests.cs @@ -19,14 +19,14 @@ public sealed class KafkaMessageTransportTests { [Test] - public async Task SendAsync_Maps_outbox_message_to_kafka_message() + public async Task SendAsync_Maps_outbox_message_to_kafka_message(CancellationToken cancellationToken) { using var producer = new FakeProducer(); using var admin = new FakeAdminClient { BrokerCount = 1 }; var transport = CreateTransport(producer, admin); var outboxMessage = CreateOutboxMessage(); - await transport.SendAsync(outboxMessage); + await transport.SendAsync(outboxMessage, cancellationToken); var kafkaMessage = producer.ProducedMessages.Single(); using (Assert.Multiple()) @@ -42,7 +42,7 @@ public async Task SendAsync_Maps_outbox_message_to_kafka_message() } [Test] - public async Task SendAsync_Propagates_ProduceException_on_delivery_failure() + public async Task SendAsync_Propagates_ProduceException_on_delivery_failure(CancellationToken cancellationToken) { var expectedError = new Error(ErrorCode.BrokerNotAvailable, "broker unavailable"); using var producer = new FakeProducer { ProduceAsyncError = expectedError }; @@ -51,7 +51,7 @@ public async Task SendAsync_Propagates_ProduceException_on_delivery_failure() var outboxMessage = CreateOutboxMessage(); var exception = await Assert.ThrowsAsync>(() => - transport.SendAsync(outboxMessage) + transport.SendAsync(outboxMessage, cancellationToken) ); _ = await Assert.That(exception).IsNotNull(); @@ -59,47 +59,47 @@ public async Task SendAsync_Propagates_ProduceException_on_delivery_failure() } [Test] - public async Task SendAsync_Uses_topic_name_resolver_to_determine_topic() + public async Task SendAsync_Uses_topic_name_resolver_to_determine_topic(CancellationToken cancellationToken) { using var producer = new FakeProducer(); using var admin = new FakeAdminClient { BrokerCount = 1 }; var transport = CreateTransport(producer, admin, topicName: "resolved-topic"); var outboxMessage = CreateOutboxMessage(); - await transport.SendAsync(outboxMessage); + await transport.SendAsync(outboxMessage, cancellationToken); _ = await Assert.That(producer.ProducedTopics.Single()).IsEqualTo("resolved-topic"); } [Test] - public async Task SendAsync_Routes_to_topic_from_resolver() + public async Task SendAsync_Routes_to_topic_from_resolver(CancellationToken cancellationToken) { using var producer = new FakeProducer(); using var admin = new FakeAdminClient { BrokerCount = 1 }; var transport = CreateTransport(producer, admin, topicName: "test-topic"); var outboxMessage = CreateOutboxMessage(); - await transport.SendAsync(outboxMessage); + await transport.SendAsync(outboxMessage, cancellationToken); _ = await Assert.That(producer.ProducedTopics.Single()).IsEqualTo("test-topic"); } [Test] - public async Task SendBatchAsync_Enqueues_all_messages_and_flushes() + public async Task SendBatchAsync_Enqueues_all_messages_and_flushes(CancellationToken cancellationToken) { using var producer = new FakeProducer(); using var admin = new FakeAdminClient { BrokerCount = 1 }; var transport = CreateTransport(producer, admin); var messages = Enumerable.Range(0, 3).Select(_ => CreateOutboxMessage()).ToArray(); - await transport.SendBatchAsync(messages); + await transport.SendBatchAsync(messages, cancellationToken); _ = await Assert.That(producer.EnqueuedMessages.Count).IsEqualTo(messages.Length); _ = await Assert.That(producer.FlushCallCount).IsEqualTo(1); } [Test] - public async Task SendBatchAsync_Collects_delivery_errors_as_AggregateException() + public async Task SendBatchAsync_Collects_delivery_errors_as_AggregateException(CancellationToken cancellationToken) { var error = new Error(ErrorCode.BrokerNotAvailable, "broker down"); using var producer = new FakeProducer { DeliveryError = error }; @@ -107,45 +107,49 @@ public async Task SendBatchAsync_Collects_delivery_errors_as_AggregateException( var transport = CreateTransport(producer, admin); var messages = new[] { CreateOutboxMessage(), CreateOutboxMessage() }; - var exception = await Assert.ThrowsAsync(() => transport.SendBatchAsync(messages)); + var exception = await Assert.ThrowsAsync(() => + transport.SendBatchAsync(messages, cancellationToken) + ); _ = await Assert.That(exception).IsNotNull(); _ = await Assert.That(exception!.InnerExceptions.Count).IsEqualTo(messages.Length); } [Test] - public async Task IsHealthyAsync_Returns_true_when_broker_metadata_is_available() + public async Task IsHealthyAsync_Returns_true_when_broker_metadata_is_available(CancellationToken cancellationToken) { using var producer = new FakeProducer(); using var admin = new FakeAdminClient { BrokerCount = 1 }; var transport = CreateTransport(producer, admin); - var healthy = await transport.IsHealthyAsync(); + var healthy = await transport.IsHealthyAsync(cancellationToken); _ = await Assert.That(healthy).IsTrue(); _ = await Assert.That(admin.GetMetadataCallCount).IsEqualTo(1); } [Test] - public async Task IsHealthyAsync_Returns_false_when_no_brokers_in_metadata() + public async Task IsHealthyAsync_Returns_false_when_no_brokers_in_metadata(CancellationToken cancellationToken) { using var producer = new FakeProducer(); using var admin = new FakeAdminClient { BrokerCount = 0 }; var transport = CreateTransport(producer, admin); - var healthy = await transport.IsHealthyAsync(); + var healthy = await transport.IsHealthyAsync(cancellationToken); _ = await Assert.That(healthy).IsFalse(); } [Test] - public async Task IsHealthyAsync_Returns_false_without_throwing_when_broker_is_unreachable() + public async Task IsHealthyAsync_Returns_false_without_throwing_when_broker_is_unreachable( + CancellationToken cancellationToken + ) { using var producer = new FakeProducer(); using var admin = new FakeAdminClient { ThrowOnGetMetadata = true }; var transport = CreateTransport(producer, admin); - var healthy = await transport.IsHealthyAsync(); + var healthy = await transport.IsHealthyAsync(cancellationToken); _ = await Assert.That(healthy).IsFalse(); } diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/ExponentialBackoffTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/ExponentialBackoffTests.cs index 608fc66e..31d53486 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/ExponentialBackoffTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/ExponentialBackoffTests.cs @@ -1,4 +1,4 @@ -namespace NetEvolve.Pulse.Tests.Unit.Outbox; +namespace NetEvolve.Pulse.Tests.Unit.Outbox; using NetEvolve.Extensions.TUnit; using NetEvolve.Pulse.Outbox; diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/InMemoryMessageTransportTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/InMemoryMessageTransportTests.cs deleted file mode 100644 index abb7526a..00000000 --- a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/InMemoryMessageTransportTests.cs +++ /dev/null @@ -1,300 +0,0 @@ -namespace NetEvolve.Pulse.Tests.Unit.Outbox; - -using System.Collections.Generic; -using System.Text.Json; -using Microsoft.Extensions.Options; -using NetEvolve.Extensions.TUnit; -using NetEvolve.Pulse.Extensibility; -using NetEvolve.Pulse.Extensibility.Outbox; -using NetEvolve.Pulse.Outbox; -using TUnit.Core; - -/// -/// Unit tests for . -/// Tests constructor validation, event deserialization, and mediator dispatch. -/// -[TestGroup("Outbox")] -public sealed class InMemoryMessageTransportTests -{ - [Test] - public async Task Constructor_WithNullMediator_ThrowsArgumentNullException() - { - IMediator? mediator = null; - var options = Options.Create(new OutboxOptions()); - - _ = Assert.Throws( - "mediator", - () => _ = new InMemoryMessageTransport(mediator!, options) - ); - } - - [Test] - public async Task Constructor_WithNullOptions_ThrowsArgumentNullException() - { - var mediator = new TestMediator(); - IOptions? options = null; - - _ = Assert.Throws("options", () => _ = new InMemoryMessageTransport(mediator, options!)); - } - - [Test] - public async Task Constructor_WithValidParameters_CreatesInstance() - { - var mediator = new TestMediator(); - var options = Options.Create(new OutboxOptions()); - - var transport = new InMemoryMessageTransport(mediator, options); - - _ = await Assert.That(transport).IsNotNull(); - } - - [Test] - public async Task SendAsync_WithNullMessage_ThrowsArgumentNullException() - { - var mediator = new TestMediator(); - var options = Options.Create(new OutboxOptions()); - var transport = new InMemoryMessageTransport(mediator, options); - - _ = await Assert.ThrowsAsync(() => transport.SendAsync(null!)); - } - - [Test] - public async Task SendAsync_WithValidMessage_DeserializesAndPublishesEvent() - { - var mediator = new TestMediator(); - var options = Options.Create(new OutboxOptions()); - var transport = new InMemoryMessageTransport(mediator, options); - - var originalEvent = new TestTransportEvent("test-id", "test data"); - var message = CreateOutboxMessage(originalEvent); - - await transport.SendAsync(message).ConfigureAwait(false); - - using (Assert.Multiple()) - { - _ = await Assert.That(mediator.PublishedEvents).HasSingleItem(); - _ = await Assert.That(mediator.PublishedEvents[0]).IsTypeOf(); - - var publishedEvent = (TestTransportEvent)mediator.PublishedEvents[0]; - _ = await Assert.That(publishedEvent.Id).IsEqualTo(originalEvent.Id); - _ = await Assert.That(publishedEvent.Data).IsEqualTo(originalEvent.Data); - } - } - - [Test] - public async Task SendAsync_WithInvalidPayload_ThrowsInvalidOperationException() - { - var mediator = new TestMediator(); - var options = Options.Create(new OutboxOptions()); - var transport = new InMemoryMessageTransport(mediator, options); - - var message = new OutboxMessage - { - Id = Guid.NewGuid(), - EventType = typeof(TestTransportEvent), - Payload = "null", // This will deserialize to null - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - Status = OutboxMessageStatus.Processing, - }; - - var exception = await Assert.ThrowsAsync(() => transport.SendAsync(message)); - - _ = await Assert.That(exception!.Message).Contains("Failed to deserialize"); - } - - [Test] - public async Task SendAsync_WithNonEventType_ThrowsInvalidOperationException() - { - var mediator = new TestMediator(); - var options = Options.Create(new OutboxOptions()); - var transport = new InMemoryMessageTransport(mediator, options); - - // Use a type that is not an IEvent - var message = new OutboxMessage - { - Id = Guid.NewGuid(), - EventType = typeof(NonEventClass), - Payload = """{"Value":"test"}""", - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - Status = OutboxMessageStatus.Processing, - }; - - var exception = await Assert.ThrowsAsync(() => transport.SendAsync(message)); - - _ = await Assert.That(exception!.Message).Contains("is not an IEvent"); - } - - [Test] - public async Task SendAsync_WithCustomJsonOptions_UsesProvidedOptions() - { - var mediator = new TestMediator(); - var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - var options = Options.Create(new OutboxOptions { JsonSerializerOptions = jsonOptions }); - var transport = new InMemoryMessageTransport(mediator, options); - - // Create payload with camelCase property names - const string payload = """{"id":"custom-id","data":"custom data","correlationId":null,"publishedAt":null}"""; - var message = new OutboxMessage - { - Id = Guid.NewGuid(), - EventType = typeof(TestTransportEvent), - Payload = payload, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - Status = OutboxMessageStatus.Processing, - }; - - await transport.SendAsync(message).ConfigureAwait(false); - - using (Assert.Multiple()) - { - _ = await Assert.That(mediator.PublishedEvents).HasSingleItem(); - var publishedEvent = (TestTransportEvent)mediator.PublishedEvents[0]; - _ = await Assert.That(publishedEvent.Id).IsEqualTo("custom-id"); - _ = await Assert.That(publishedEvent.Data).IsEqualTo("custom data"); - } - } - - [Test] - public async Task SendAsync_WithCancellationToken_PropagatesToken() - { - var mediator = new TestMediator(); - var options = Options.Create(new OutboxOptions()); - var transport = new InMemoryMessageTransport(mediator, options); - - using var cts = new CancellationTokenSource(); - var originalEvent = new TestTransportEvent("test-id", "test data"); - var message = CreateOutboxMessage(originalEvent); - - await transport.SendAsync(message, cts.Token).ConfigureAwait(false); - - _ = await Assert.That(mediator.LastCancellationToken).IsEqualTo(cts.Token); - } - - [Test] - public async Task SendAsync_WhenMediatorThrows_PropagatesException() - { - var mediator = new ThrowingMediator(new InvalidOperationException("Mediator error")); - var options = Options.Create(new OutboxOptions()); - var transport = new InMemoryMessageTransport(mediator, options); - - var originalEvent = new TestTransportEvent("test-id", "test data"); - var message = CreateOutboxMessage(originalEvent); - - // Reflection-based invocation wraps exceptions in TargetInvocationException - var exception = await Assert.ThrowsAsync(() => transport.SendAsync(message)); - - using (Assert.Multiple()) - { - _ = await Assert.That(exception).IsNotNull(); - _ = await Assert.That(exception.Message).IsEqualTo("Mediator error"); - } - } - - private static OutboxMessage CreateOutboxMessage(TestTransportEvent @event) - { - var payload = JsonSerializer.Serialize(@event, @event.GetType()); - - return new OutboxMessage - { - Id = Guid.NewGuid(), - EventType = @event.GetType(), - Payload = payload, - CorrelationId = @event.CorrelationId, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - Status = OutboxMessageStatus.Processing, - }; - } - -#pragma warning disable CS8767 // Nullability of reference types in type of parameter doesn't match - private sealed class TestMediator : IMediator - { - public List PublishedEvents { get; } = []; - public CancellationToken LastCancellationToken { get; private set; } - - public Task PublishAsync(TEvent message, CancellationToken cancellationToken = default) - where TEvent : notnull, IEvent - { - PublishedEvents.Add(message); - LastCancellationToken = cancellationToken; - return Task.CompletedTask; - } - - public Task QueryAsync( - TQuery query, - CancellationToken cancellationToken = default - ) - where TQuery : notnull, IQuery => throw new NotSupportedException(); - - public IAsyncEnumerable StreamQueryAsync( - TQuery query, - CancellationToken cancellationToken = default - ) - where TQuery : notnull, IStreamQuery => throw new NotSupportedException(); - - public Task SendAsync( - TCommand command, - CancellationToken cancellationToken = default - ) - where TCommand : notnull, ICommand => throw new NotSupportedException(); - - public Task SendAsync(TCommand command, CancellationToken cancellationToken = default) - where TCommand : notnull, ICommand => throw new NotSupportedException(); - } - - private sealed class ThrowingMediator : IMediator - { - private readonly Exception _exception; - - public ThrowingMediator(Exception exception) => _exception = exception; - - public Task PublishAsync(TEvent message, CancellationToken cancellationToken = default) - where TEvent : notnull, IEvent => throw _exception; - - public Task QueryAsync( - TQuery query, - CancellationToken cancellationToken = default - ) - where TQuery : notnull, IQuery => throw new NotSupportedException(); - - public IAsyncEnumerable StreamQueryAsync( - TQuery query, - CancellationToken cancellationToken = default - ) - where TQuery : notnull, IStreamQuery => throw new NotSupportedException(); - - public Task SendAsync( - TCommand command, - CancellationToken cancellationToken = default - ) - where TCommand : notnull, ICommand => throw new NotSupportedException(); - - public Task SendAsync(TCommand command, CancellationToken cancellationToken = default) - where TCommand : notnull, ICommand => throw new NotSupportedException(); - } -#pragma warning restore CS8767 - - private sealed class TestTransportEvent : IEvent - { - public TestTransportEvent() { } - - public TestTransportEvent(string id, string data) - { - Id = id; - Data = data; - } - - public string Id { get; set; } = string.Empty; - public string Data { get; set; } = string.Empty; - public string? CorrelationId { get; set; } - public DateTimeOffset? PublishedAt { get; set; } - } - - private sealed class NonEventClass - { - public string Value { get; set; } = string.Empty; - } -} diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/NullMessageTransportTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/NullMessageTransportTests.cs new file mode 100644 index 00000000..58af9c66 --- /dev/null +++ b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/NullMessageTransportTests.cs @@ -0,0 +1,50 @@ +namespace NetEvolve.Pulse.Tests.Unit.Outbox; + +using NetEvolve.Extensions.TUnit; +using NetEvolve.Pulse.Extensibility.Outbox; +using NetEvolve.Pulse.Outbox; +using TUnit.Core; + +/// +/// Unit tests for . +/// Verifies that the no-op transport completes silently for any input. +/// +[TestGroup("Outbox")] +public sealed class NullMessageTransportTests +{ + [Test] + public async Task SendAsync_WithValidMessage_CompletesSuccessfully(CancellationToken cancellationToken) + { + var transport = new NullMessageTransport(); + var message = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = typeof(object), + Payload = "{}", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + Status = OutboxMessageStatus.Processing, + }; + + await transport.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + + [Test] + public async Task SendAsync_WithCancelledToken_CompletesSuccessfully(CancellationToken cancellationToken) + { + var transport = new NullMessageTransport(); + var message = new OutboxMessage + { + Id = Guid.NewGuid(), + EventType = typeof(object), + Payload = "{}", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + Status = OutboxMessageStatus.Processing, + }; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + await cts.CancelAsync().ConfigureAwait(false); + + await transport.SendAsync(message, cts.Token).ConfigureAwait(false); + } +} diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxEventHandlerTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxEventHandlerTests.cs index 6cc46066..e1c0cc64 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxEventHandlerTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxEventHandlerTests.cs @@ -15,13 +15,13 @@ namespace NetEvolve.Pulse.Tests.Unit.Outbox; public sealed class OutboxEventHandlerTests { [Test] - public async Task HandleAsync_WithRegularEvent_StoresEventInOutbox() + public async Task HandleAsync_WithRegularEvent_StoresEventInOutbox(CancellationToken cancellationToken) { var outbox = new TrackingEventOutbox(); var handler = new OutboxEventHandler(outbox); var @event = new TestRegularEvent(); - await handler.HandleAsync(@event).ConfigureAwait(false); + await handler.HandleAsync(@event, cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -31,25 +31,27 @@ public async Task HandleAsync_WithRegularEvent_StoresEventInOutbox() } [Test] - public async Task HandleAsync_WithInProcessEvent_SkipsOutbox() + public async Task HandleAsync_WithInProcessEvent_SkipsOutbox(CancellationToken cancellationToken) { var outbox = new TrackingEventOutbox(); var handler = new OutboxEventHandler(outbox); var @event = new TestInProcessEvent(); - await handler.HandleAsync(@event).ConfigureAwait(false); + await handler.HandleAsync(@event, cancellationToken).ConfigureAwait(false); _ = await Assert.That(outbox.StoredEvents).IsEmpty(); } [Test] - public async Task HandleAsync_WithInProcessEventAndHandleInProcessFalse_StoresEventInOutbox() + public async Task HandleAsync_WithInProcessEventAndHandleInProcessFalse_StoresEventInOutbox( + CancellationToken cancellationToken + ) { var outbox = new TrackingEventOutbox(); var handler = new OutboxEventHandler(outbox); var @event = new TestOptOutInProcessEvent(); - await handler.HandleAsync(@event).ConfigureAwait(false); + await handler.HandleAsync(@event, cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -59,12 +61,12 @@ public async Task HandleAsync_WithInProcessEventAndHandleInProcessFalse_StoresEv } [Test] - public async Task HandleAsync_WithCancellationToken_PassesTokenToOutbox() + public async Task HandleAsync_WithCancellationToken_PassesTokenToOutbox(CancellationToken cancellationToken) { var outbox = new TrackingEventOutbox(); var handler = new OutboxEventHandler(outbox); var @event = new TestRegularEvent(); - using var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await handler.HandleAsync(@event, cts.Token).ConfigureAwait(false); @@ -72,12 +74,12 @@ public async Task HandleAsync_WithCancellationToken_PassesTokenToOutbox() } [Test] - public async Task HandleAsync_WithCancelledToken_PropagatesCancellation() + public async Task HandleAsync_WithCancelledToken_PropagatesCancellation(CancellationToken cancellationToken) { var outbox = new TrackingEventOutbox(); var handler = new OutboxEventHandler(outbox); var @event = new TestRegularEvent(); - using var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await cts.CancelAsync().ConfigureAwait(false); _ = await Assert.That(() => handler.HandleAsync(@event, cts.Token)).Throws(); diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorHostedServiceTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorHostedServiceTests.cs index 4331a88c..49e51059 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorHostedServiceTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Outbox/OutboxProcessorHostedServiceTests.cs @@ -35,7 +35,7 @@ public async Task Constructor_WithNullRepository_ThrowsArgumentNullException() [Test] public async Task Constructor_WithNullTransport_ThrowsArgumentNullException() { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); IMessageTransport? transport = null; var options = Options.Create(new OutboxProcessorOptions()); var logger = CreateLogger(); @@ -49,7 +49,7 @@ public async Task Constructor_WithNullTransport_ThrowsArgumentNullException() [Test] public async Task Constructor_WithNullOptions_ThrowsArgumentNullException() { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); IOptions? options = null; var logger = CreateLogger(); @@ -63,7 +63,7 @@ public async Task Constructor_WithNullOptions_ThrowsArgumentNullException() [Test] public async Task Constructor_WithNullLogger_ThrowsArgumentNullException() { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions()); ILogger? logger = null; @@ -77,7 +77,7 @@ public async Task Constructor_WithNullLogger_ThrowsArgumentNullException() [Test] public async Task Constructor_WithNullLifetime_ThrowsArgumentNullException() { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); IHostApplicationLifetime? lifetime = null; var options = Options.Create(new OutboxProcessorOptions()); @@ -92,7 +92,7 @@ public async Task Constructor_WithNullLifetime_ThrowsArgumentNullException() [Test] public async Task Constructor_WithValidParameters_CreatesInstance() { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions()); var logger = CreateLogger(); @@ -103,53 +103,55 @@ public async Task Constructor_WithValidParameters_CreatesInstance() } [Test] - public async Task StartAsync_WithCancellationToken_StartsProcessing() + public async Task StartAsync_WithCancellationToken_StartsProcessing(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }); var logger = CreateLogger(); using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); - using var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await service.StartAsync(cts.Token).ConfigureAwait(false); // Give it a moment to start - await Task.Delay(100).ConfigureAwait(false); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // Test passes if no exceptions thrown - verify the service started by checking poll count _ = await Assert.That(repository.GetPendingCallCount).IsGreaterThanOrEqualTo(1); } [Test] - public async Task StopAsync_WhenRunning_StopsGracefully() + public async Task StopAsync_WhenRunning_StopsGracefully(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }); var logger = CreateLogger(); using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); - using var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(100).ConfigureAwait(false); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // Test passes if no exceptions thrown during graceful shutdown _ = await Assert.That(repository.GetPendingCallCount).IsGreaterThanOrEqualTo(1); } [Test] - public async Task ExecuteAsync_WithPendingMessages_ProcessesAndCompletesMessages() + public async Task ExecuteAsync_WithPendingMessages_ProcessesAndCompletesMessages( + CancellationToken cancellationToken + ) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }); var logger = CreateLogger(); @@ -157,17 +159,12 @@ public async Task ExecuteAsync_WithPendingMessages_ProcessesAndCompletesMessages // Add a pending message var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - - await service.StartAsync(cts.Token).ConfigureAwait(false); - - // Wait for processing - await Task.Delay(200).ConfigureAwait(false); - - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -178,9 +175,9 @@ public async Task ExecuteAsync_WithPendingMessages_ProcessesAndCompletesMessages } [Test] - public async Task ExecuteAsync_WithMultipleMessages_ProcessesAllMessages() + public async Task ExecuteAsync_WithMultipleMessages_ProcessesAllMessages(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }); var logger = CreateLogger(); @@ -190,17 +187,14 @@ public async Task ExecuteAsync_WithMultipleMessages_ProcessesAllMessages() var message1 = CreateMessage(); var message2 = CreateMessage(); var message3 = CreateMessage(); - await repository.AddAsync(message1).ConfigureAwait(false); - await repository.AddAsync(message2).ConfigureAwait(false); - await repository.AddAsync(message3).ConfigureAwait(false); + await repository.AddAsync(message1, cancellationToken).ConfigureAwait(false); + await repository.AddAsync(message2, cancellationToken).ConfigureAwait(false); + await repository.AddAsync(message3, cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(300).ConfigureAwait(false); - - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(3, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -210,30 +204,30 @@ public async Task ExecuteAsync_WithMultipleMessages_ProcessesAllMessages() } [Test] - public async Task ExecuteAsync_WithNoMessages_WaitsForPollingInterval() + public async Task ExecuteAsync_WithNoMessages_WaitsForPollingInterval(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(200) }); var logger = CreateLogger(); using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); - using var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(1000).ConfigureAwait(false); + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // Should have polled at least twice _ = await Assert.That(repository.GetPendingCallCount).IsGreaterThanOrEqualTo(2); } [Test] - public async Task ExecuteAsync_WithTransportFailure_MarksMessageAsFailed() + public async Task ExecuteAsync_WithTransportFailure_MarksMessageAsFailed(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new FailingMessageTransport(failCount: int.MaxValue); var options = Options.Create( new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50), MaxRetryCount = 3 } @@ -242,23 +236,20 @@ public async Task ExecuteAsync_WithTransportFailure_MarksMessageAsFailed() using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); - - using var cts = new CancellationTokenSource(); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(200).ConfigureAwait(false); - - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); _ = await Assert.That(repository.FailedMessageIds).Contains(message.Id); } [Test] - public async Task ExecuteAsync_WithExceededRetries_MovesToDeadLetter() + public async Task ExecuteAsync_WithExceededRetries_MovesToDeadLetter(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new FailingMessageTransport(failCount: int.MaxValue); var options = Options.Create( new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50), MaxRetryCount = 2 } @@ -269,23 +260,20 @@ public async Task ExecuteAsync_WithExceededRetries_MovesToDeadLetter() // Add a message that has already been retried once var message = CreateMessage(); message.RetryCount = 1; // One retry already attempted - await repository.AddAsync(message).ConfigureAwait(false); - - using var cts = new CancellationTokenSource(); - - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(200).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); _ = await Assert.That(repository.DeadLetterMessageIds).Contains(message.Id); } [Test] - public async Task ExecuteAsync_WithTransientFailure_RetriesAndSucceeds() + public async Task ExecuteAsync_WithTransientFailure_RetriesAndSucceeds(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new FailingMessageTransport(failCount: 1); // Fail once, then succeed var options = Options.Create( new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50), MaxRetryCount = 3 } @@ -294,24 +282,21 @@ public async Task ExecuteAsync_WithTransientFailure_RetriesAndSucceeds() using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); - - using var cts = new CancellationTokenSource(); - - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(400).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(2, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // Message should eventually be completed after retry _ = await Assert.That(repository.CompletedMessageIds).Contains(message.Id); } [Test] - public async Task ExecuteAsync_WithBatchSendingEnabled_SendsInBatch() + public async Task ExecuteAsync_WithBatchSendingEnabled_SendsInBatch(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create( new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50), EnableBatchSending = true } @@ -321,16 +306,13 @@ public async Task ExecuteAsync_WithBatchSendingEnabled_SendsInBatch() var message1 = CreateMessage(); var message2 = CreateMessage(); - await repository.AddAsync(message1).ConfigureAwait(false); - await repository.AddAsync(message2).ConfigureAwait(false); - - using var cts = new CancellationTokenSource(); + await repository.AddAsync(message1, cancellationToken).ConfigureAwait(false); + await repository.AddAsync(message2, cancellationToken).ConfigureAwait(false); - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(500).ConfigureAwait(false); - - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(2, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -340,9 +322,9 @@ public async Task ExecuteAsync_WithBatchSendingEnabled_SendsInBatch() } [Test] - public async Task ExecuteAsync_WithBatchSendingFailure_MarkAsFailedForRetry() + public async Task ExecuteAsync_WithBatchSendingFailure_MarkAsFailedForRetry(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new BatchFailingMessageTransport(); var options = Options.Create( new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50), EnableBatchSending = true } @@ -352,17 +334,17 @@ public async Task ExecuteAsync_WithBatchSendingFailure_MarkAsFailedForRetry() var message1 = CreateMessage(); var message2 = CreateMessage(); - await repository.AddAsync(message1).ConfigureAwait(false); - await repository.AddAsync(message2).ConfigureAwait(false); + await repository.AddAsync(message1, cancellationToken).ConfigureAwait(false); + await repository.AddAsync(message2, cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + // Wait until at least 2 messages have been marked (failed or dead-letter) instead of + // relying on a fixed delay, which is unreliable under CI thread-pool saturation. + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(2, timeoutCts.Token).ConfigureAwait(false); - await service.StartAsync(cts.Token).ConfigureAwait(false); - // Wait for first polling cycle to process the batch failure and complete marking - await Task.Delay(300).ConfigureAwait(false); - - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // Verify that batch send was not followed by individual send (no fallback to ProcessIndividuallyAsync) // Messages should be marked as failed or deadlettered (depending on retry cycles that ran), @@ -378,9 +360,9 @@ public async Task ExecuteAsync_WithBatchSendingFailure_MarkAsFailedForRetry() } [Test] - public async Task ExecuteAsync_WithBatchSize_RespectsLimit() + public async Task ExecuteAsync_WithBatchSize_RespectsLimit(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create( new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50), BatchSize = 2 } @@ -391,25 +373,24 @@ public async Task ExecuteAsync_WithBatchSize_RespectsLimit() // Add more messages than batch size for (var i = 0; i < 5; i++) { - await repository.AddAsync(CreateMessage()).ConfigureAwait(false); + await repository.AddAsync(CreateMessage(), cancellationToken).ConfigureAwait(false); } - using var cts = new CancellationTokenSource(); - - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(100).ConfigureAwait(false); - - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(2, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // First batch should have processed 2 messages _ = await Assert.That(repository.LastBatchSizeRequested).IsEqualTo(2); } [Test] - public async Task ExecuteAsync_WithPerEventTypeMaxRetryCount_UsesOverrideForMatchingEventType() + public async Task ExecuteAsync_WithPerEventTypeMaxRetryCount_UsesOverrideForMatchingEventType( + CancellationToken cancellationToken + ) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new FailingMessageTransport(failCount: int.MaxValue); var options = Options.Create( new OutboxProcessorOptions @@ -428,41 +409,21 @@ public async Task ExecuteAsync_WithPerEventTypeMaxRetryCount_UsesOverrideForMatc // Message of type CriticalEvent should use override (MaxRetryCount = 1) var message = CreateMessage(typeof(CriticalEvent)); message.RetryCount = 0; // First attempt - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(200).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // With MaxRetryCount=1, retryCount+1 (1) >= 1, so it should be dead-lettered _ = await Assert.That(repository.DeadLetterMessageIds).Contains(message.Id); } [Test] - public async Task ExecuteAsync_WithPerEventTypeMaxRetryCount_FallsBackToGlobalForOtherEventTypes() - { - var options = new OutboxProcessorOptions - { - MaxRetryCount = 3, // Global default - EventTypeOverrides = { [typeof(CriticalEvent)] = new OutboxEventTypeOptions { MaxRetryCount = 1 } }, - }; - - using (Assert.Multiple()) - { - // "CriticalEvent" uses the override - _ = await Assert.That(options.GetEffectiveMaxRetryCount(typeof(CriticalEvent))).IsEqualTo(1); - // Other event types fall back to the global default - _ = await Assert.That(options.GetEffectiveMaxRetryCount(typeof(OtherEvent))).IsEqualTo(3); - _ = await Assert.That(options.GetEffectiveMaxRetryCount(typeof(TestOutboxEvent))).IsEqualTo(3); - } - } - - [Test] - public async Task ExecuteAsync_WithPerEventTypeProcessingTimeout_UsesOverride() + public async Task ExecuteAsync_WithPerEventTypeProcessingTimeout_UsesOverride(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new SlowMessageTransport(delay: TimeSpan.FromMilliseconds(200)); var options = Options.Create( new OutboxProcessorOptions @@ -484,22 +445,23 @@ public async Task ExecuteAsync_WithPerEventTypeProcessingTimeout_UsesOverride() // SlowEvent message should time out and be marked as failed/dead-lettered var message = CreateMessage(typeof(SlowEvent)); - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(400).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // The message should not have been sent successfully _ = await Assert.That(transport.SentMessages).IsEmpty(); } [Test] - public async Task ExecuteAsync_WithPerEventTypeBatchSending_UsesOverrideForMatchingEventType() + public async Task ExecuteAsync_WithPerEventTypeBatchSending_UsesOverrideForMatchingEventType( + CancellationToken cancellationToken + ) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create( new OutboxProcessorOptions @@ -518,14 +480,13 @@ public async Task ExecuteAsync_WithPerEventTypeBatchSending_UsesOverrideForMatch // Add two messages of the overridden type: should be batch-sent var message1 = CreateMessage(typeof(BatchEvent)); var message2 = CreateMessage(typeof(BatchEvent)); - await repository.AddAsync(message1).ConfigureAwait(false); - await repository.AddAsync(message2).ConfigureAwait(false); + await repository.AddAsync(message1, cancellationToken).ConfigureAwait(false); + await repository.AddAsync(message2, cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(500).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(2, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -618,7 +579,7 @@ public async Task OutboxEventTypeOptions_WithNullOverrideProperties_FallsBackToG [Test] [NotInParallel("OutboxMetrics")] - public async Task ExecuteAsync_WithPendingMessages_RecordsProcessedMetric() + public async Task ExecuteAsync_WithPendingMessages_RecordsProcessedMetric(CancellationToken cancellationToken) { using var meterListener = new MeterListener(); meterListener.InstrumentPublished = (instrument, listener) => @@ -641,20 +602,19 @@ public async Task ExecuteAsync_WithPendingMessages_RecordsProcessedMetric() ); meterListener.Start(); - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }); var logger = CreateLogger(); using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(200).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); meterListener.RecordObservableInstruments(); @@ -663,7 +623,7 @@ public async Task ExecuteAsync_WithPendingMessages_RecordsProcessedMetric() [Test] [NotInParallel("OutboxMetrics")] - public async Task ExecuteAsync_WithTransportFailure_RecordsFailedMetric() + public async Task ExecuteAsync_WithTransportFailure_RecordsFailedMetric(CancellationToken cancellationToken) { using var meterListener = new MeterListener(); meterListener.InstrumentPublished = (instrument, listener) => @@ -686,7 +646,7 @@ public async Task ExecuteAsync_WithTransportFailure_RecordsFailedMetric() ); meterListener.Start(); - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new FailingMessageTransport(failCount: int.MaxValue); var options = Options.Create( new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50), MaxRetryCount = 3 } @@ -695,20 +655,19 @@ public async Task ExecuteAsync_WithTransportFailure_RecordsFailedMetric() using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(200).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); _ = await Assert.That(Volatile.Read(ref failedTotal)).IsGreaterThanOrEqualTo(1L); } [Test] [NotInParallel("OutboxMetrics")] - public async Task ExecuteAsync_WithExceededRetries_RecordsDeadLetterMetric() + public async Task ExecuteAsync_WithExceededRetries_RecordsDeadLetterMetric(CancellationToken cancellationToken) { using var meterListener = new MeterListener(); meterListener.InstrumentPublished = (instrument, listener) => @@ -731,7 +690,7 @@ public async Task ExecuteAsync_WithExceededRetries_RecordsDeadLetterMetric() ); meterListener.Start(); - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new FailingMessageTransport(failCount: int.MaxValue); var options = Options.Create( new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50), MaxRetryCount = 2 } @@ -741,20 +700,19 @@ public async Task ExecuteAsync_WithExceededRetries_RecordsDeadLetterMetric() var message = CreateMessage(); message.RetryCount = 1; - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(200).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); _ = await Assert.That(Volatile.Read(ref deadLetterTotal)).IsGreaterThanOrEqualTo(1L); } [Test] [NotInParallel("OutboxMetrics")] - public async Task ExecuteAsync_AfterProcessingCycle_RecordsProcessingDuration() + public async Task ExecuteAsync_AfterProcessingCycle_RecordsProcessingDuration(CancellationToken cancellationToken) { using var meterListener = new MeterListener(); meterListener.InstrumentPublished = (instrument, listener) => @@ -777,24 +735,26 @@ public async Task ExecuteAsync_AfterProcessingCycle_RecordsProcessingDuration() ); meterListener.Start(); - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }); var logger = CreateLogger(); using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); - using var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(200).ConfigureAwait(false); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); _ = await Assert.That(Volatile.Read(ref durationRecorded)).IsTrue(); } [Test] [NotInParallel("OutboxMetrics")] - public async Task ExecuteAsync_WithPendingMessages_ObservableGaugeReflectsPendingCount() + public async Task ExecuteAsync_WithPendingMessages_ObservableGaugeReflectsPendingCount( + CancellationToken cancellationToken + ) { using var meterListener = new MeterListener(); meterListener.InstrumentPublished = (instrument, listener) => @@ -817,29 +777,28 @@ public async Task ExecuteAsync_WithPendingMessages_ObservableGaugeReflectsPendin ); meterListener.Start(); - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }); var logger = CreateLogger(); using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); // Add 3 pending messages before starting - await repository.AddAsync(CreateMessage()).ConfigureAwait(false); - await repository.AddAsync(CreateMessage()).ConfigureAwait(false); - await repository.AddAsync(CreateMessage()).ConfigureAwait(false); + await repository.AddAsync(CreateMessage(), cancellationToken).ConfigureAwait(false); + await repository.AddAsync(CreateMessage(), cancellationToken).ConfigureAwait(false); + await repository.AddAsync(CreateMessage(), cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - await service.StartAsync(cts.Token).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); // Wait at least one polling cycle so the gauge is refreshed before observing - await Task.Delay(75).ConfigureAwait(false); + await Task.Delay(75, cancellationToken).ConfigureAwait(false); meterListener.RecordObservableInstruments(); var earlyObservation = Volatile.Read(ref pendingObserved); - await Task.Delay(400).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(3, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // After processing all messages, the pending count should be 0 meterListener.RecordObservableInstruments(); @@ -855,9 +814,9 @@ public async Task ExecuteAsync_WithPendingMessages_ObservableGaugeReflectsPendin } [Test] - public async Task ExecuteAsync_WithExponentialBackoffEnabled_SetsNextRetryAt() + public async Task ExecuteAsync_WithExponentialBackoffEnabled_SetsNextRetryAt(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new FailingMessageTransport(failCount: int.MaxValue); var options = Options.Create( new OutboxProcessorOptions @@ -873,26 +832,14 @@ public async Task ExecuteAsync_WithExponentialBackoffEnabled_SetsNextRetryAt() using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - // Capture reference time before starting so the assertion is not sensitive to - // how long StopAsync or pre-assertion overhead takes to complete. var startTime = DateTimeOffset.UtcNow; - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await service.StartAsync(cts.Token).ConfigureAwait(false); - // Wait long enough for at least one processing cycle (~50ms poll interval + margin). - await Task.Delay(200).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - - try - { - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected during shutdown - } + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // Get the failed message from the repository var failedMessage = repository._messages.FirstOrDefault(m => m.Status == OutboxMessageStatus.Failed); @@ -906,9 +853,11 @@ public async Task ExecuteAsync_WithExponentialBackoffEnabled_SetsNextRetryAt() } [Test] - public async Task ExecuteAsync_WithExponentialBackoffDisabled_DoesNotSetNextRetryAt() + public async Task ExecuteAsync_WithExponentialBackoffDisabled_DoesNotSetNextRetryAt( + CancellationToken cancellationToken + ) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new FailingMessageTransport(failCount: int.MaxValue); var options = Options.Create( new OutboxProcessorOptions @@ -922,21 +871,12 @@ public async Task ExecuteAsync_WithExponentialBackoffDisabled_DoesNotSetNextRetr using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(300).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - try - { - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Expected during shutdown - } + await service.StartAsync(cancellationToken).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // Get the failed message from the repository var failedMessage = repository._messages.FirstOrDefault(m => m.Status == OutboxMessageStatus.Failed); @@ -949,90 +889,91 @@ public async Task ExecuteAsync_WithExponentialBackoffDisabled_DoesNotSetNextRetr } [Test] - public async Task GetPendingAsync_WithFutureNextRetryAt_ExcludesMessage() + public async Task GetPendingAsync_WithFutureNextRetryAt_ExcludesMessage(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var futureTime = DateTimeOffset.UtcNow.AddSeconds(10); var message = CreateMessage(); message.NextRetryAt = futureTime; - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - var pending = await repository.GetPendingAsync(batchSize: 10).ConfigureAwait(false); + var pending = await repository.GetPendingAsync(batchSize: 10, cancellationToken).ConfigureAwait(false); _ = await Assert.That(pending.Count).IsEqualTo(0); } [Test] - public async Task GetPendingAsync_WithPastNextRetryAt_IncludesMessage() + public async Task GetPendingAsync_WithPastNextRetryAt_IncludesMessage(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var pastTime = DateTimeOffset.UtcNow.AddSeconds(-10); var message = CreateMessage(); message.NextRetryAt = pastTime; - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - var pending = await repository.GetPendingAsync(batchSize: 10).ConfigureAwait(false); + var pending = await repository.GetPendingAsync(batchSize: 10, cancellationToken).ConfigureAwait(false); _ = await Assert.That(pending.Count).IsEqualTo(1); } [Test] - public async Task GetFailedForRetryAsync_WithFutureNextRetryAt_ExcludesMessage() + public async Task GetFailedForRetryAsync_WithFutureNextRetryAt_ExcludesMessage(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var futureTime = DateTimeOffset.UtcNow.AddSeconds(10); var message = CreateMessage(); message.Status = OutboxMessageStatus.Failed; message.RetryCount = 1; message.NextRetryAt = futureTime; - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); var failedForRetry = await repository - .GetFailedForRetryAsync(maxRetryCount: 3, batchSize: 10) + .GetFailedForRetryAsync(maxRetryCount: 3, batchSize: 10, cancellationToken) .ConfigureAwait(false); _ = await Assert.That(failedForRetry.Count).IsEqualTo(0); } [Test] - public async Task GetFailedForRetryAsync_WithPastNextRetryAt_IncludesMessage() + public async Task GetFailedForRetryAsync_WithPastNextRetryAt_IncludesMessage(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var pastTime = DateTimeOffset.UtcNow.AddSeconds(-10); var message = CreateMessage(); message.Status = OutboxMessageStatus.Failed; message.RetryCount = 1; message.NextRetryAt = pastTime; - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); var failedForRetry = await repository - .GetFailedForRetryAsync(maxRetryCount: 3, batchSize: 10) + .GetFailedForRetryAsync(maxRetryCount: 3, batchSize: 10, cancellationToken) .ConfigureAwait(false); _ = await Assert.That(failedForRetry.Count).IsEqualTo(1); } [Test] - public async Task ExecuteAsync_WaitsForApplicationStarted_BeforeProcessingMessages() + public async Task ExecuteAsync_WaitsForApplicationStarted_BeforeProcessingMessages( + CancellationToken cancellationToken + ) { - var repository = new InMemoryOutboxRepository(); + using var repository = new InMemoryOutboxRepository(); var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }); var logger = CreateLogger(); using var lifetime = new PendingStartLifetime(); using var service = new OutboxProcessorHostedService(repository, transport, lifetime, options, logger); - await repository.AddAsync(CreateMessage()).ConfigureAwait(false); + await repository.AddAsync(CreateMessage(), cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - await service.StartAsync(cts.Token).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); // Give the service ample time to run polling cycles if it were already started. - await Task.Delay(200).ConfigureAwait(false); + await Task.Delay(200, cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -1043,30 +984,30 @@ public async Task ExecuteAsync_WaitsForApplicationStarted_BeforeProcessingMessag // Now signal that the application has fully started. lifetime.SignalStarted(); - await Task.Delay(200).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); // Processing must have happened after ApplicationStarted fired. _ = await Assert.That(transport.SentMessages).HasSingleItem(); } [Test] - public async Task ExecuteAsync_WhenDatabaseIsUnhealthy_SkipsProcessingCycle() + public async Task ExecuteAsync_WhenDatabaseIsUnhealthy_SkipsProcessingCycle(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository { IsHealthy = false }; + using var repository = new InMemoryOutboxRepository { IsHealthy = false }; var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }); var logger = CreateLogger(); using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); - await repository.AddAsync(CreateMessage()).ConfigureAwait(false); + await repository.AddAsync(CreateMessage(), cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await service.StartAsync(cts.Token).ConfigureAwait(false); - await Task.Delay(300).ConfigureAwait(false); + await Task.Delay(300, cancellationToken).ConfigureAwait(false); await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -1076,30 +1017,29 @@ public async Task ExecuteAsync_WhenDatabaseIsUnhealthy_SkipsProcessingCycle() } [Test] - public async Task ExecuteAsync_WhenDatabaseBecomesHealthy_ResumesProcessing() + public async Task ExecuteAsync_WhenDatabaseBecomesHealthy_ResumesProcessing(CancellationToken cancellationToken) { - var repository = new InMemoryOutboxRepository { IsHealthy = false }; + using var repository = new InMemoryOutboxRepository { IsHealthy = false }; var transport = new InMemoryMessageTransport(); var options = Options.Create(new OutboxProcessorOptions { PollingInterval = TimeSpan.FromMilliseconds(50) }); var logger = CreateLogger(); using var service = new OutboxProcessorHostedService(repository, transport, CreateLifetime(), options, logger); - await repository.AddAsync(CreateMessage()).ConfigureAwait(false); + await repository.AddAsync(CreateMessage(), cancellationToken).ConfigureAwait(false); - using var cts = new CancellationTokenSource(); - await service.StartAsync(cts.Token).ConfigureAwait(false); + await service.StartAsync(cancellationToken).ConfigureAwait(false); // Allow several unhealthy cycles to pass. - await Task.Delay(150).ConfigureAwait(false); + await Task.Delay(150, cancellationToken).ConfigureAwait(false); _ = await Assert.That(transport.SentMessages).IsEmpty(); // Restore database health and allow processing to resume. repository.IsHealthy = true; - await Task.Delay(300).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); - await service.StopAsync(CancellationToken.None).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await repository.WaitForMarkingsAsync(1, timeoutCts.Token).ConfigureAwait(false); + await service.StopAsync(cancellationToken).ConfigureAwait(false); _ = await Assert.That(transport.SentMessages).HasSingleItem(); } @@ -1123,10 +1063,23 @@ private static OutboxMessage CreateMessage(Type? eventType = null) => Status = OutboxMessageStatus.Pending, }; - private sealed class InMemoryOutboxRepository : IOutboxRepository + private sealed class InMemoryOutboxRepository : IOutboxRepository, IDisposable { internal readonly List _messages = []; private readonly object _lock = new(); + private readonly SemaphoreSlim _markingEvent = new(0, int.MaxValue); + + /// + /// Waits until at least processing events (completed, failed, or dead-letter) + /// have been recorded, or the is cancelled. + /// + public async Task WaitForMarkingsAsync(int count, CancellationToken cancellationToken = default) + { + for (var i = 0; i < count; i++) + { + await _markingEvent.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } public List CompletedMessageIds { get; } = []; public List FailedMessageIds { get; } = []; @@ -1228,6 +1181,7 @@ public Task MarkAsCompletedAsync(Guid messageId, CancellationToken cancellationT } } + _ = _markingEvent.Release(); return Task.CompletedTask; } @@ -1248,6 +1202,7 @@ public Task MarkAsDeadLetterAsync( } } + _ = _markingEvent.Release(); return Task.CompletedTask; } @@ -1269,6 +1224,7 @@ public Task MarkAsFailedAsync( } } + _ = _markingEvent.Release(); return Task.CompletedTask; } @@ -1292,8 +1248,11 @@ public Task MarkAsFailedAsync( } } + _ = _markingEvent.Release(); return Task.CompletedTask; } + + public void Dispose() => _markingEvent.Dispose(); } private sealed class InMemoryMessageTransport : IMessageTransport diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Polly/Interceptors/PollyEventInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Polly/Interceptors/PollyEventInterceptorTests.cs index 395b98cc..4f72e223 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Polly/Interceptors/PollyEventInterceptorTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Polly/Interceptors/PollyEventInterceptorTests.cs @@ -89,7 +89,7 @@ public async Task Constructor_WithGlobalPipeline_ResolvesSuccessfully() } [Test] - public async Task HandleAsync_NullHandler_ThrowsArgumentNullException() + public async Task HandleAsync_NullHandler_ThrowsArgumentNullException(CancellationToken cancellationToken) { // Arrange var serviceProvider = CreateServiceProvider(); @@ -98,12 +98,12 @@ 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_WithSuccessfulHandler_CompletesSuccessfully() + public async Task HandleAsync_WithSuccessfulHandler_CompletesSuccessfully(CancellationToken cancellationToken) { // Arrange var serviceProvider = CreateServiceProvider(); @@ -119,7 +119,8 @@ await interceptor { handlerCalled = true; return Task.CompletedTask; - } + }, + cancellationToken ) .ConfigureAwait(false); @@ -128,7 +129,7 @@ await interceptor } [Test] - public async Task HandleAsync_WithRetryPolicy_RetriesOnFailure() + public async Task HandleAsync_WithRetryPolicy_RetriesOnFailure(CancellationToken cancellationToken) { // Arrange var attemptCount = 0; @@ -159,7 +160,8 @@ await interceptor throw new InvalidOperationException("Transient failure"); } return Task.CompletedTask; - } + }, + cancellationToken ) .ConfigureAwait(false); @@ -168,7 +170,7 @@ await interceptor } [Test] - public async Task HandleAsync_WithRetryPolicyExhausted_ThrowsException() + public async Task HandleAsync_WithRetryPolicyExhausted_ThrowsException(CancellationToken cancellationToken) { // Arrange var attemptCount = 0; @@ -197,7 +199,8 @@ await interceptor { attemptCount++; throw new InvalidOperationException("Persistent failure"); - } + }, + cancellationToken ) .ConfigureAwait(false) ) @@ -209,7 +212,7 @@ await interceptor } [Test] - public async Task HandleAsync_WithCombinedPolicies_ExecutesInOrder() + public async Task HandleAsync_WithCombinedPolicies_ExecutesInOrder(CancellationToken cancellationToken) { // Arrange var attemptCount = 0; @@ -241,7 +244,8 @@ await interceptor throw new InvalidOperationException("Transient failure"); } return Task.CompletedTask; - } + }, + cancellationToken ) .ConfigureAwait(false); @@ -250,7 +254,7 @@ await interceptor } [Test] - public async Task HandleAsync_WithCircuitBreaker_BlocksAfterFailureThreshold() + public async Task HandleAsync_WithCircuitBreaker_BlocksAfterFailureThreshold(CancellationToken cancellationToken) { // Arrange var attemptCount = 0; @@ -280,7 +284,8 @@ await interceptor { attemptCount++; throw new InvalidOperationException("Failure"); - } + }, + cancellationToken ) .ConfigureAwait(false) ) @@ -295,7 +300,8 @@ await interceptor { attemptCount++; throw new InvalidOperationException("Failure"); - } + }, + cancellationToken ) .ConfigureAwait(false) ) @@ -304,7 +310,9 @@ await interceptor // Circuit should be open now, next request should be rejected immediately _ = await Assert .That(async () => - await interceptor.HandleAsync(message, (_, _) => Task.CompletedTask).ConfigureAwait(false) + await interceptor + .HandleAsync(message, (_, _) => Task.CompletedTask, cancellationToken) + .ConfigureAwait(false) ) .Throws(); diff --git a/tests/NetEvolve.Pulse.Tests.Unit/Polly/Interceptors/PollyRequestInterceptorTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/Polly/Interceptors/PollyRequestInterceptorTests.cs index c0cb6f36..b9865b49 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/Polly/Interceptors/PollyRequestInterceptorTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/Polly/Interceptors/PollyRequestInterceptorTests.cs @@ -91,7 +91,7 @@ public async Task Constructor_WithGlobalPipeline_ResolvesSuccessfully() } [Test] - public async Task HandleAsync_NullHandler_ThrowsArgumentNullException() + public async Task HandleAsync_NullHandler_ThrowsArgumentNullException(CancellationToken cancellationToken) { // Arrange var serviceProvider = CreateServiceProvider(); @@ -100,12 +100,12 @@ 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_WithSuccessfulHandler_ReturnsResult() + public async Task HandleAsync_WithSuccessfulHandler_ReturnsResult(CancellationToken cancellationToken) { // Arrange var serviceProvider = CreateServiceProvider(); @@ -114,14 +114,16 @@ public async Task HandleAsync_WithSuccessfulHandler_ReturnsResult() const string expected = "success"; // Act - var result = await interceptor.HandleAsync(request, (_, _) => Task.FromResult(expected)).ConfigureAwait(false); + var result = await interceptor + .HandleAsync(request, (_, _) => Task.FromResult(expected), cancellationToken) + .ConfigureAwait(false); // Assert _ = await Assert.That(result).IsEqualTo(expected); } [Test] - public async Task HandleAsync_WithRetryPolicy_RetriesOnFailure() + public async Task HandleAsync_WithRetryPolicy_RetriesOnFailure(CancellationToken cancellationToken) { // Arrange var attemptCount = 0; @@ -152,7 +154,8 @@ public async Task HandleAsync_WithRetryPolicy_RetriesOnFailure() throw new InvalidOperationException("Transient failure"); } return Task.FromResult("success"); - } + }, + cancellationToken ) .ConfigureAwait(false); @@ -162,7 +165,7 @@ public async Task HandleAsync_WithRetryPolicy_RetriesOnFailure() } [Test] - public async Task HandleAsync_WithRetryPolicyExhausted_ThrowsException() + public async Task HandleAsync_WithRetryPolicyExhausted_ThrowsException(CancellationToken cancellationToken) { // Arrange var attemptCount = 0; @@ -191,7 +194,8 @@ await interceptor { attemptCount++; throw new InvalidOperationException("Persistent failure"); - } + }, + cancellationToken ) .ConfigureAwait(false) ) @@ -203,7 +207,7 @@ await interceptor } [Test] - public async Task HandleAsync_WithCombinedPolicies_ExecutesInOrder() + public async Task HandleAsync_WithCombinedPolicies_ExecutesInOrder(CancellationToken cancellationToken) { // Arrange var attemptCount = 0; @@ -235,7 +239,8 @@ public async Task HandleAsync_WithCombinedPolicies_ExecutesInOrder() throw new InvalidOperationException("Transient failure"); } return Task.FromResult("success"); - } + }, + cancellationToken ) .ConfigureAwait(false); @@ -245,7 +250,7 @@ public async Task HandleAsync_WithCombinedPolicies_ExecutesInOrder() } [Test] - public async Task HandleAsync_WithCircuitBreaker_BlocksAfterFailureThreshold() + public async Task HandleAsync_WithCircuitBreaker_BlocksAfterFailureThreshold(CancellationToken cancellationToken) { // Arrange var attemptCount = 0; @@ -275,7 +280,8 @@ await interceptor { attemptCount++; throw new InvalidOperationException("Failure"); - } + }, + cancellationToken ) .ConfigureAwait(false) ) @@ -290,7 +296,8 @@ await interceptor { attemptCount++; throw new InvalidOperationException("Failure"); - } + }, + cancellationToken ) .ConfigureAwait(false) ) @@ -299,7 +306,9 @@ await interceptor // Circuit should be open now, next request should be rejected immediately _ = await Assert .That(async () => - await interceptor.HandleAsync(request, (_, _) => Task.FromResult("success")).ConfigureAwait(false) + await interceptor + .HandleAsync(request, (_, _) => Task.FromResult("success"), cancellationToken) + .ConfigureAwait(false) ) .Throws(); diff --git a/tests/NetEvolve.Pulse.Tests.Unit/PostgreSql/PostgreSqlEventOutboxTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/PostgreSql/PostgreSqlEventOutboxTests.cs index da7d3792..d16bfc9f 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/PostgreSql/PostgreSqlEventOutboxTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/PostgreSql/PostgreSqlEventOutboxTests.cs @@ -76,7 +76,9 @@ public async Task StoreAsync_WithNullMessage_ThrowsArgumentNullException() } [Test] - public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException() + public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException( + CancellationToken cancellationToken + ) { await using var connection = new NpgsqlConnection("Host=localhost;"); var outbox = new PostgreSqlEventOutbox(connection, Options.Create(new OutboxOptions()), TimeProvider.System); @@ -86,7 +88,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/PostgreSql/PostgreSqlOutboxRepositoryTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/PostgreSql/PostgreSqlOutboxRepositoryTests.cs index f908e730..a09f432b 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/PostgreSql/PostgreSqlOutboxRepositoryTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/PostgreSql/PostgreSqlOutboxRepositoryTests.cs @@ -118,7 +118,7 @@ public async Task Constructor_WithEmptySchema_CreatesInstance() } [Test] - public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException() + public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException(CancellationToken cancellationToken) { var repository = new PostgreSqlOutboxRepository( Options.Create(new OutboxOptions { ConnectionString = ValidConnectionString }), @@ -126,7 +126,7 @@ public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException() ); _ = await Assert - .That(async () => await repository.AddAsync(null!).ConfigureAwait(false)) + .That(async () => await repository.AddAsync(null!, cancellationToken).ConfigureAwait(false)) .Throws(); } } diff --git a/tests/NetEvolve.Pulse.Tests.Unit/RabbitMQ/RabbitMqMessageTransportTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/RabbitMQ/RabbitMqMessageTransportTests.cs index 181931e0..125ec58a 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/RabbitMQ/RabbitMqMessageTransportTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/RabbitMQ/RabbitMqMessageTransportTests.cs @@ -60,27 +60,29 @@ public async Task Constructor_When_options_null_throws() } [Test] - public async Task SendAsync_When_message_null_throws() + public async Task SendAsync_When_message_null_throws(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter(); var topicNameResolver = new FakeTopicNameResolver(); using var transport = CreateTransport(connectionAdapter, topicNameResolver); - var exception = await Assert.ThrowsAsync(() => transport.SendAsync(null!)); + var exception = await Assert.ThrowsAsync(() => + transport.SendAsync(null!, cancellationToken) + ); _ = await Assert.That(exception).IsNotNull(); _ = await Assert.That(exception!.ParamName).IsEqualTo("message"); } [Test] - public async Task SendAsync_Publishes_message_with_correct_properties() + public async Task SendAsync_Publishes_message_with_correct_properties(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter(); var topicNameResolver = new FakeTopicNameResolver(); using var transport = CreateTransport(connectionAdapter, topicNameResolver, exchangeName: "test-exchange"); var outboxMessage = CreateOutboxMessage(); - await transport.SendAsync(outboxMessage); + await transport.SendAsync(outboxMessage, cancellationToken); _ = await Assert.That(connectionAdapter.CreateChannelCallCount).IsEqualTo(1); var channel = connectionAdapter.CreatedChannels.Single(); @@ -108,7 +110,7 @@ public async Task SendAsync_Publishes_message_with_correct_properties() } [Test] - public async Task SendAsync_Reuses_open_channel() + public async Task SendAsync_Reuses_open_channel(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter(); var topicNameResolver = new FakeTopicNameResolver(); @@ -116,8 +118,8 @@ public async Task SendAsync_Reuses_open_channel() var message1 = CreateOutboxMessage(); var message2 = CreateOutboxMessage(); - await transport.SendAsync(message1); - await transport.SendAsync(message2); + await transport.SendAsync(message1, cancellationToken); + await transport.SendAsync(message2, cancellationToken); _ = await Assert.That(connectionAdapter.CreateChannelCallCount).IsEqualTo(1); var channel = connectionAdapter.CreatedChannels.Single(); @@ -125,7 +127,7 @@ public async Task SendAsync_Reuses_open_channel() } [Test] - public async Task SendAsync_Creates_new_channel_when_previous_closed() + public async Task SendAsync_Creates_new_channel_when_previous_closed(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter(); var topicNameResolver = new FakeTopicNameResolver(); @@ -133,25 +135,25 @@ public async Task SendAsync_Creates_new_channel_when_previous_closed() var message1 = CreateOutboxMessage(); var message2 = CreateOutboxMessage(); - await transport.SendAsync(message1); + await transport.SendAsync(message1, cancellationToken); var firstChannel = connectionAdapter.CreatedChannels.Single(); firstChannel.IsOpen = false; // Simulate channel closure - await transport.SendAsync(message2); + await transport.SendAsync(message2, cancellationToken); _ = await Assert.That(connectionAdapter.CreateChannelCallCount).IsEqualTo(2); _ = await Assert.That(connectionAdapter.CreatedChannels.Count).IsEqualTo(2); } [Test] - public async Task SendAsync_Uses_topic_name_resolver_for_routing_key() + public async Task SendAsync_Uses_topic_name_resolver_for_routing_key(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter(); var topicNameResolver = new FakeTopicNameResolver { ResolvedName = "custom-routing-key" }; using var transport = CreateTransport(connectionAdapter, topicNameResolver); var outboxMessage = CreateOutboxMessage(); - await transport.SendAsync(outboxMessage); + await transport.SendAsync(outboxMessage, cancellationToken); var channel = connectionAdapter.CreatedChannels.Single(); var publishCall = channel.PublishCalls.Single(); @@ -160,81 +162,81 @@ public async Task SendAsync_Uses_topic_name_resolver_for_routing_key() } [Test] - public async Task IsHealthyAsync_When_connection_not_open_returns_false() + public async Task IsHealthyAsync_When_connection_not_open_returns_false(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter { IsOpen = false }; var topicNameResolver = new FakeTopicNameResolver(); using var transport = CreateTransport(connectionAdapter, topicNameResolver); - var healthy = await transport.IsHealthyAsync(); + var healthy = await transport.IsHealthyAsync(cancellationToken); _ = await Assert.That(healthy).IsFalse(); } [Test] - public async Task IsHealthyAsync_When_channel_not_created_returns_false() + public async Task IsHealthyAsync_When_channel_not_created_returns_false(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter { IsOpen = true }; var topicNameResolver = new FakeTopicNameResolver(); using var transport = CreateTransport(connectionAdapter, topicNameResolver); - var healthy = await transport.IsHealthyAsync(); + var healthy = await transport.IsHealthyAsync(cancellationToken); _ = await Assert.That(healthy).IsFalse(); } [Test] - public async Task IsHealthyAsync_When_channel_not_open_returns_false() + public async Task IsHealthyAsync_When_channel_not_open_returns_false(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter { IsOpen = true }; var topicNameResolver = new FakeTopicNameResolver(); using var transport = CreateTransport(connectionAdapter, topicNameResolver); // Create a channel - await transport.SendAsync(CreateOutboxMessage()); + await transport.SendAsync(CreateOutboxMessage(), cancellationToken); var channel = connectionAdapter.CreatedChannels.Single(); channel.IsOpen = false; - var healthy = await transport.IsHealthyAsync(); + var healthy = await transport.IsHealthyAsync(cancellationToken); _ = await Assert.That(healthy).IsFalse(); } [Test] - public async Task IsHealthyAsync_When_connection_and_channel_open_returns_true() + public async Task IsHealthyAsync_When_connection_and_channel_open_returns_true(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter { IsOpen = true }; var topicNameResolver = new FakeTopicNameResolver(); using var transport = CreateTransport(connectionAdapter, topicNameResolver); // Create a channel - await transport.SendAsync(CreateOutboxMessage()); + await transport.SendAsync(CreateOutboxMessage(), cancellationToken); - var healthy = await transport.IsHealthyAsync(); + var healthy = await transport.IsHealthyAsync(cancellationToken); _ = await Assert.That(healthy).IsTrue(); } [Test] - public async Task IsHealthyAsync_When_exception_thrown_returns_false() + public async Task IsHealthyAsync_When_exception_thrown_returns_false(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter { IsOpen = true, ThrowOnIsOpen = true }; var topicNameResolver = new FakeTopicNameResolver(); using var transport = CreateTransport(connectionAdapter, topicNameResolver); - var healthy = await transport.IsHealthyAsync(); + var healthy = await transport.IsHealthyAsync(cancellationToken); _ = await Assert.That(healthy).IsFalse(); } [Test] - public async Task Dispose_Disposes_channel_and_lock() + public async Task Dispose_Disposes_channel_and_lock(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter(); var topicNameResolver = new FakeTopicNameResolver(); using var transport = CreateTransport(connectionAdapter, topicNameResolver); - await transport.SendAsync(CreateOutboxMessage()); + await transport.SendAsync(CreateOutboxMessage(), cancellationToken); var channel = connectionAdapter.CreatedChannels.Single(); transport.Dispose(); @@ -243,13 +245,13 @@ public async Task Dispose_Disposes_channel_and_lock() } [Test] - public async Task Dispose_Is_idempotent() + public async Task Dispose_Is_idempotent(CancellationToken cancellationToken) { var connectionAdapter = new FakeConnectionAdapter(); var topicNameResolver = new FakeTopicNameResolver(); var transport = CreateTransport(connectionAdapter, topicNameResolver); - await transport.SendAsync(CreateOutboxMessage()); + await transport.SendAsync(CreateOutboxMessage(), cancellationToken); var channel = connectionAdapter.CreatedChannels.Single(); transport.Dispose(); diff --git a/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteEventOutboxTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteEventOutboxTests.cs index a18fb5f7..93fa6fbe 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteEventOutboxTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteEventOutboxTests.cs @@ -78,7 +78,9 @@ public async Task StoreAsync_WithNullMessage_ThrowsArgumentNullException() } [Test] - public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException() + public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException( + CancellationToken cancellationToken + ) { await using var connection = new SqliteConnection("Data Source=:memory:"); var outbox = new SQLiteEventOutbox(connection, Options.Create(new OutboxOptions()), TimeProvider.System); @@ -88,16 +90,16 @@ 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(); } [Test] - public async Task StoreAsync_WithValidEvent_PersistsRow() + public async Task StoreAsync_WithValidEvent_PersistsRow(CancellationToken cancellationToken) { var connectionString = $"Data Source=store_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; await using var keepAlive = new SqliteConnection(connectionString); - await keepAlive.OpenAsync().ConfigureAwait(false); + await keepAlive.OpenAsync(cancellationToken).ConfigureAwait(false); await using ( var create = new SqliteCommand( @@ -121,11 +123,11 @@ public async Task StoreAsync_WithValidEvent_PersistsRow() ) ) { - _ = await create.ExecuteNonQueryAsync().ConfigureAwait(false); + _ = await create.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } await using var connection = new SqliteConnection(connectionString); - await connection.OpenAsync().ConfigureAwait(false); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); var outbox = new SQLiteEventOutbox( connection, @@ -135,15 +137,15 @@ public async Task StoreAsync_WithValidEvent_PersistsRow() var evt = new TestEvent { CorrelationId = "corr" }; - await outbox.StoreAsync(evt).ConfigureAwait(false); + await outbox.StoreAsync(evt, cancellationToken).ConfigureAwait(false); await using var cmd = new SqliteCommand( "SELECT \"EventType\",\"CorrelationId\",\"Status\",\"Payload\" FROM \"OutboxMessage\" WHERE \"Id\" = @Id", keepAlive ); _ = cmd.Parameters.AddWithValue("@Id", evt.Id); - await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); - _ = await reader.ReadAsync().ConfigureAwait(false); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + _ = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -155,11 +157,13 @@ public async Task StoreAsync_WithValidEvent_PersistsRow() } [Test] - public async Task StoreAsync_WithOversizedEventType_ThrowsInvalidOperationException() + public async Task StoreAsync_WithOversizedEventType_ThrowsInvalidOperationException( + CancellationToken cancellationToken + ) { var connectionString = $"Data Source=type_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; await using var keepAlive = new SqliteConnection(connectionString); - await keepAlive.OpenAsync().ConfigureAwait(false); + await keepAlive.OpenAsync(cancellationToken).ConfigureAwait(false); await using ( var create = new SqliteCommand( """ @@ -182,11 +186,11 @@ public async Task StoreAsync_WithOversizedEventType_ThrowsInvalidOperationExcept ) ) { - _ = await create.ExecuteNonQueryAsync().ConfigureAwait(false); + _ = await create.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } await using var connection = new SqliteConnection(connectionString); - await connection.OpenAsync().ConfigureAwait(false); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); var outbox = new SQLiteEventOutbox( connection, @@ -197,7 +201,7 @@ public async Task StoreAsync_WithOversizedEventType_ThrowsInvalidOperationExcept var longEvent = CreateLongTypeEvent(); _ = await Assert - .That(async () => await outbox.StoreAsync(longEvent).ConfigureAwait(false)) + .That(async () => await outbox.StoreAsync(longEvent, cancellationToken).ConfigureAwait(false)) .Throws(); } diff --git a/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxManagementDatabaseTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxManagementDatabaseTests.cs index de6d06cf..0cd5c3ba 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxManagementDatabaseTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxManagementDatabaseTests.cs @@ -85,7 +85,7 @@ private static OutboxMessage CreateMessage( Status = status, }; - private async Task InsertAsync(OutboxMessage message) + private async Task InsertAsync(OutboxMessage message, CancellationToken cancellationToken) { await using var cmd = new SqliteCommand( """ @@ -115,31 +115,33 @@ INSERT INTO "OutboxMessage" _ = cmd.Parameters.AddWithValue("@Error", (object?)message.Error ?? DBNull.Value); _ = cmd.Parameters.AddWithValue("@Status", (int)message.Status); - _ = await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + _ = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } [Test] - public async Task GetDeadLetterCountAsync_ReturnsExpectedCount() + public async Task GetDeadLetterCountAsync_ReturnsExpectedCount(CancellationToken cancellationToken) { var management = CreateManagement(); - await InsertAsync(CreateMessage(OutboxMessageStatus.DeadLetter)).ConfigureAwait(false); - await InsertAsync(CreateMessage(OutboxMessageStatus.DeadLetter)).ConfigureAwait(false); + await InsertAsync(CreateMessage(OutboxMessageStatus.DeadLetter), cancellationToken).ConfigureAwait(false); + await InsertAsync(CreateMessage(OutboxMessageStatus.DeadLetter), cancellationToken).ConfigureAwait(false); - var count = await management.GetDeadLetterCountAsync().ConfigureAwait(false); + var count = await management.GetDeadLetterCountAsync(cancellationToken).ConfigureAwait(false); _ = await Assert.That(count).IsEqualTo(2L); } [Test] - public async Task GetDeadLetterMessagesAsync_ReturnsPagedOrdered() + public async Task GetDeadLetterMessagesAsync_ReturnsPagedOrdered(CancellationToken cancellationToken) { var management = CreateManagement(); var older = CreateMessage(OutboxMessageStatus.DeadLetter, createdAt: DateTimeOffset.UtcNow.AddMinutes(-5)); var newer = CreateMessage(OutboxMessageStatus.DeadLetter, createdAt: DateTimeOffset.UtcNow); - await InsertAsync(older).ConfigureAwait(false); - await InsertAsync(newer).ConfigureAwait(false); + await InsertAsync(older, cancellationToken).ConfigureAwait(false); + await InsertAsync(newer, cancellationToken).ConfigureAwait(false); - var messages = await management.GetDeadLetterMessagesAsync(pageSize: 1, page: 0).ConfigureAwait(false); + var messages = await management + .GetDeadLetterMessagesAsync(pageSize: 1, page: 0, cancellationToken: cancellationToken) + .ConfigureAwait(false); using (Assert.Multiple()) { @@ -149,19 +151,19 @@ public async Task GetDeadLetterMessagesAsync_ReturnsPagedOrdered() } [Test] - public async Task GetDeadLetterMessageAsync_ReturnsSingleMessage() + public async Task GetDeadLetterMessageAsync_ReturnsSingleMessage(CancellationToken cancellationToken) { var management = CreateManagement(); var target = CreateMessage(OutboxMessageStatus.DeadLetter); - await InsertAsync(target).ConfigureAwait(false); + await InsertAsync(target, cancellationToken).ConfigureAwait(false); - var message = await management.GetDeadLetterMessageAsync(target.Id).ConfigureAwait(false); + var message = await management.GetDeadLetterMessageAsync(target.Id, cancellationToken).ConfigureAwait(false); _ = await Assert.That(message!.Id).IsEqualTo(target.Id); } [Test] - public async Task ReplayMessageAsync_ResetsDeadLetterFields() + public async Task ReplayMessageAsync_ResetsDeadLetterFields(CancellationToken cancellationToken) { var management = CreateManagement(enableWal: true); var deadLetter = CreateMessage( @@ -171,44 +173,44 @@ public async Task ReplayMessageAsync_ResetsDeadLetterFields() error: "fatal", retryCount: 3 ); - await InsertAsync(deadLetter).ConfigureAwait(false); + await InsertAsync(deadLetter, cancellationToken).ConfigureAwait(false); - var result = await management.ReplayMessageAsync(deadLetter.Id).ConfigureAwait(false); + var result = await management.ReplayMessageAsync(deadLetter.Id, cancellationToken).ConfigureAwait(false); await using var cmd = new SqliteCommand( "SELECT \"Status\",\"Error\",\"ProcessedAt\",\"NextRetryAt\",\"RetryCount\" FROM \"OutboxMessage\" WHERE \"Id\" = @Id", _keepAlive ); _ = cmd.Parameters.AddWithValue("@Id", deadLetter.Id.ToString()); - await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); - _ = await reader.ReadAsync().ConfigureAwait(false); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + _ = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { _ = await Assert.That(result).IsTrue(); _ = await Assert.That(reader.GetInt64(0)).IsEqualTo((long)OutboxMessageStatus.Pending); - _ = await Assert.That(await reader.IsDBNullAsync(1).ConfigureAwait(false)).IsTrue(); - _ = await Assert.That(await reader.IsDBNullAsync(2).ConfigureAwait(false)).IsTrue(); - _ = await Assert.That(await reader.IsDBNullAsync(3).ConfigureAwait(false)).IsTrue(); + _ = await Assert.That(await reader.IsDBNullAsync(1, cancellationToken).ConfigureAwait(false)).IsTrue(); + _ = await Assert.That(await reader.IsDBNullAsync(2, cancellationToken).ConfigureAwait(false)).IsTrue(); + _ = await Assert.That(await reader.IsDBNullAsync(3, cancellationToken).ConfigureAwait(false)).IsTrue(); _ = await Assert.That(reader.GetInt64(4)).IsEqualTo(0); } } [Test] - public async Task ReplayAllDeadLetterAsync_ResetsAllMessages() + public async Task ReplayAllDeadLetterAsync_ResetsAllMessages(CancellationToken cancellationToken) { var management = CreateManagement(); - await InsertAsync(CreateMessage(OutboxMessageStatus.DeadLetter)).ConfigureAwait(false); - await InsertAsync(CreateMessage(OutboxMessageStatus.DeadLetter)).ConfigureAwait(false); + await InsertAsync(CreateMessage(OutboxMessageStatus.DeadLetter), cancellationToken).ConfigureAwait(false); + await InsertAsync(CreateMessage(OutboxMessageStatus.DeadLetter), cancellationToken).ConfigureAwait(false); - var updated = await management.ReplayAllDeadLetterAsync().ConfigureAwait(false); + var updated = await management.ReplayAllDeadLetterAsync(cancellationToken).ConfigureAwait(false); await using var cmd = new SqliteCommand( "SELECT COUNT(*) FROM \"OutboxMessage\" WHERE \"Status\" = @status", _keepAlive ); _ = cmd.Parameters.AddWithValue("@status", (int)OutboxMessageStatus.Pending); - var count = (long)(await cmd.ExecuteScalarAsync().ConfigureAwait(false))!; + var count = (long)(await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!; using (Assert.Multiple()) { @@ -218,16 +220,16 @@ public async Task ReplayAllDeadLetterAsync_ResetsAllMessages() } [Test] - public async Task GetStatisticsAsync_ReturnsAggregatedCounts() + public async Task GetStatisticsAsync_ReturnsAggregatedCounts(CancellationToken cancellationToken) { var management = CreateManagement(); - await InsertAsync(CreateMessage(OutboxMessageStatus.Pending)).ConfigureAwait(false); - await InsertAsync(CreateMessage(OutboxMessageStatus.Processing)).ConfigureAwait(false); - await InsertAsync(CreateMessage(OutboxMessageStatus.Completed)).ConfigureAwait(false); - await InsertAsync(CreateMessage(OutboxMessageStatus.Failed)).ConfigureAwait(false); - await InsertAsync(CreateMessage(OutboxMessageStatus.DeadLetter)).ConfigureAwait(false); + await InsertAsync(CreateMessage(OutboxMessageStatus.Pending), cancellationToken).ConfigureAwait(false); + await InsertAsync(CreateMessage(OutboxMessageStatus.Processing), cancellationToken).ConfigureAwait(false); + await InsertAsync(CreateMessage(OutboxMessageStatus.Completed), cancellationToken).ConfigureAwait(false); + await InsertAsync(CreateMessage(OutboxMessageStatus.Failed), cancellationToken).ConfigureAwait(false); + await InsertAsync(CreateMessage(OutboxMessageStatus.DeadLetter), cancellationToken).ConfigureAwait(false); - var stats = await management.GetStatisticsAsync().ConfigureAwait(false); + var stats = await management.GetStatisticsAsync(cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { diff --git a/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxRepositoryDatabaseTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxRepositoryDatabaseTests.cs index 91bf8000..6f1b25c8 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxRepositoryDatabaseTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxRepositoryDatabaseTests.cs @@ -80,33 +80,35 @@ private static OutboxMessage CreateMessage(Type? eventType = null) => }; [Test] - public async Task AddAsync_WithValidMessage_PersistsToDatabase() + public async Task AddAsync_WithValidMessage_PersistsToDatabase(CancellationToken cancellationToken) { var repository = CreateRepository(); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); await using var cmd = new SqliteCommand( "SELECT COUNT(*) FROM \"OutboxMessage\" WHERE \"Id\" = @Id", _keepAlive ); _ = cmd.Parameters.AddWithValue("@Id", message.Id.ToString()); - var count = (long)(await cmd.ExecuteScalarAsync().ConfigureAwait(false))!; + var count = (long)(await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!; _ = await Assert.That(count).IsEqualTo(1L); } [Test] - public async Task GetPendingAsync_WithPendingMessages_ReturnsAndMarksAsProcessing() + public async Task GetPendingAsync_WithPendingMessages_ReturnsAndMarksAsProcessing( + CancellationToken cancellationToken + ) { var repository = CreateRepository(); var message1 = CreateMessage(typeof(TestSQLiteRepoEvent)); var message2 = CreateMessage(); - await repository.AddAsync(message1).ConfigureAwait(false); - await repository.AddAsync(message2).ConfigureAwait(false); + await repository.AddAsync(message1, cancellationToken).ConfigureAwait(false); + await repository.AddAsync(message2, cancellationToken).ConfigureAwait(false); - var pending = await repository.GetPendingAsync(10).ConfigureAwait(false); + var pending = await repository.GetPendingAsync(10, cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -116,49 +118,51 @@ public async Task GetPendingAsync_WithPendingMessages_ReturnsAndMarksAsProcessin } [Test] - public async Task GetPendingAsync_WithEmptyTable_ReturnsEmptyList() + public async Task GetPendingAsync_WithEmptyTable_ReturnsEmptyList(CancellationToken cancellationToken) { var repository = CreateRepository(); - var pending = await repository.GetPendingAsync(10).ConfigureAwait(false); + var pending = await repository.GetPendingAsync(10, cancellationToken).ConfigureAwait(false); _ = await Assert.That(pending.Count).IsEqualTo(0); } [Test] - public async Task MarkAsCompletedAsync_SetsStatusToCompleted() + public async Task MarkAsCompletedAsync_SetsStatusToCompleted(CancellationToken cancellationToken) { var repository = CreateRepository(); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - await repository.MarkAsCompletedAsync(message.Id).ConfigureAwait(false); + await repository.MarkAsCompletedAsync(message.Id, cancellationToken).ConfigureAwait(false); await using var cmd = new SqliteCommand( "SELECT \"Status\" FROM \"OutboxMessage\" WHERE \"Id\" = @Id", _keepAlive ); _ = cmd.Parameters.AddWithValue("@Id", message.Id.ToString()); - var status = (long)(await cmd.ExecuteScalarAsync().ConfigureAwait(false))!; + var status = (long)(await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!; _ = await Assert.That(status).IsEqualTo((long)OutboxMessageStatus.Completed); } [Test] - public async Task MarkAsFailedAsync_SetsStatusToFailed() + public async Task MarkAsFailedAsync_SetsStatusToFailed(CancellationToken cancellationToken) { var repository = CreateRepository(); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - await repository.MarkAsFailedAsync(message.Id, "Test error").ConfigureAwait(false); + await repository + .MarkAsFailedAsync(message.Id, "Test error", cancellationToken: cancellationToken) + .ConfigureAwait(false); await using var cmd = new SqliteCommand( "SELECT \"Status\", \"Error\", \"RetryCount\" FROM \"OutboxMessage\" WHERE \"Id\" = @Id", _keepAlive ); _ = cmd.Parameters.AddWithValue("@Id", message.Id.ToString()); - await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); - _ = await reader.ReadAsync().ConfigureAwait(false); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + _ = await reader.ReadAsync(cancellationToken).ConfigureAwait(false); using (Assert.Multiple()) { @@ -169,101 +173,110 @@ public async Task MarkAsFailedAsync_SetsStatusToFailed() } [Test] - public async Task MarkAsDeadLetterAsync_SetsStatusToDeadLetter() + public async Task MarkAsDeadLetterAsync_SetsStatusToDeadLetter(CancellationToken cancellationToken) { var repository = CreateRepository(); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); - await repository.MarkAsDeadLetterAsync(message.Id, "Fatal error").ConfigureAwait(false); + await repository.MarkAsDeadLetterAsync(message.Id, "Fatal error", cancellationToken).ConfigureAwait(false); await using var cmd = new SqliteCommand( "SELECT \"Status\" FROM \"OutboxMessage\" WHERE \"Id\" = @Id", _keepAlive ); _ = cmd.Parameters.AddWithValue("@Id", message.Id.ToString()); - var status = (long)(await cmd.ExecuteScalarAsync().ConfigureAwait(false))!; + var status = (long)(await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!; _ = await Assert.That(status).IsEqualTo((long)OutboxMessageStatus.DeadLetter); } [Test] - public async Task GetPendingCountAsync_WithPendingMessages_ReturnsCorrectCount() + public async Task GetPendingCountAsync_WithPendingMessages_ReturnsCorrectCount(CancellationToken cancellationToken) { var repository = CreateRepository(); - await repository.AddAsync(CreateMessage()).ConfigureAwait(false); - await repository.AddAsync(CreateMessage()).ConfigureAwait(false); + await repository.AddAsync(CreateMessage(), cancellationToken).ConfigureAwait(false); + await repository.AddAsync(CreateMessage(), cancellationToken).ConfigureAwait(false); - var count = await repository.GetPendingCountAsync().ConfigureAwait(false); + var count = await repository.GetPendingCountAsync(cancellationToken).ConfigureAwait(false); _ = await Assert.That(count).IsGreaterThanOrEqualTo(2L); } [Test] - public async Task DeleteCompletedAsync_DeletesOldCompletedMessages() + public async Task DeleteCompletedAsync_DeletesOldCompletedMessages(CancellationToken cancellationToken) { var repository = CreateRepository(); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); - await repository.MarkAsCompletedAsync(message.Id).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); + await repository.MarkAsCompletedAsync(message.Id, cancellationToken).ConfigureAwait(false); - var deleted = await repository.DeleteCompletedAsync(TimeSpan.Zero).ConfigureAwait(false); + var deleted = await repository.DeleteCompletedAsync(TimeSpan.Zero, cancellationToken).ConfigureAwait(false); _ = await Assert.That(deleted).IsGreaterThanOrEqualTo(1); } [Test] - public async Task GetFailedForRetryAsync_WithFailedMessages_ReturnsEligibleMessages() + public async Task GetFailedForRetryAsync_WithFailedMessages_ReturnsEligibleMessages( + CancellationToken cancellationToken + ) { var repository = CreateRepository(); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); - await repository.MarkAsFailedAsync(message.Id, "First failure").ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); + await repository + .MarkAsFailedAsync(message.Id, "First failure", cancellationToken: cancellationToken) + .ConfigureAwait(false); - var forRetry = await repository.GetFailedForRetryAsync(maxRetryCount: 3, batchSize: 10).ConfigureAwait(false); + var forRetry = await repository + .GetFailedForRetryAsync(maxRetryCount: 3, batchSize: 10, cancellationToken) + .ConfigureAwait(false); _ = await Assert.That(forRetry.Count).IsGreaterThanOrEqualTo(1); } [Test] - public async Task MarkAsFailedAsync_WithNextRetryAt_SetsNextRetryAt() + public async Task MarkAsFailedAsync_WithNextRetryAt_SetsNextRetryAt(CancellationToken cancellationToken) { var repository = CreateRepository(); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); var nextRetry = DateTimeOffset.UtcNow.AddMinutes(5); - await repository.MarkAsFailedAsync(message.Id, "Error with retry", nextRetry).ConfigureAwait(false); + await repository + .MarkAsFailedAsync(message.Id, "Error with retry", nextRetry, cancellationToken) + .ConfigureAwait(false); await using var cmd = new SqliteCommand( "SELECT \"NextRetryAt\" FROM \"OutboxMessage\" WHERE \"Id\" = @Id", _keepAlive ); _ = cmd.Parameters.AddWithValue("@Id", message.Id.ToString()); - var nextRetryValue = await cmd.ExecuteScalarAsync().ConfigureAwait(false); + var nextRetryValue = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); _ = await Assert.That(nextRetryValue).IsNotNull(); } [Test] - public async Task AddAsync_UsesAmbientTransactionScope() + public async Task AddAsync_UsesAmbientTransactionScope(CancellationToken cancellationToken) { await using var connection = new SqliteConnection(_connectionString); - await connection.OpenAsync().ConfigureAwait(false); - await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync().ConfigureAwait(false); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var transaction = (SqliteTransaction) + await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); var scope = new StubTransactionScope(transaction); var repository = CreateRepositoryWithScope(scope); var message = CreateMessage(); - await repository.AddAsync(message).ConfigureAwait(false); - await transaction.RollbackAsync().ConfigureAwait(false); + await repository.AddAsync(message, cancellationToken).ConfigureAwait(false); + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); await using var cmd = new SqliteCommand( "SELECT COUNT(*) FROM \"OutboxMessage\" WHERE \"Id\" = @Id", _keepAlive ); _ = cmd.Parameters.AddWithValue("@Id", message.Id.ToString()); - var count = (long)(await cmd.ExecuteScalarAsync().ConfigureAwait(false))!; + var count = (long)(await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!; _ = await Assert.That(count).IsEqualTo(0L); } diff --git a/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxRepositoryTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxRepositoryTests.cs index 188586c7..8a9c28b3 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxRepositoryTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/SQLite/SQLiteOutboxRepositoryTests.cs @@ -85,7 +85,7 @@ public async Task Constructor_WithCustomTableName_CreatesInstance() } [Test] - public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException() + public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException(CancellationToken cancellationToken) { var repository = new SQLiteOutboxRepository( Options.Create(new OutboxOptions { ConnectionString = "Data Source=:memory:", EnableWalMode = false }), @@ -93,7 +93,7 @@ public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException() ); _ = await Assert - .That(async () => await repository.AddAsync(null!).ConfigureAwait(false)) + .That(async () => await repository.AddAsync(null!, cancellationToken).ConfigureAwait(false)) .Throws(); } } diff --git a/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerEventOutboxTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerEventOutboxTests.cs index 79cb4727..c47d1090 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerEventOutboxTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerEventOutboxTests.cs @@ -76,7 +76,9 @@ public async Task StoreAsync_WithNullMessage_ThrowsArgumentNullException() } [Test] - public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException() + public async Task StoreAsync_WithLongCorrelationId_ThrowsInvalidOperationException( + CancellationToken cancellationToken + ) { await using var connection = new SqlConnection("Server=.;Encrypt=true;"); var outbox = new SqlServerEventOutbox(connection, Options.Create(new OutboxOptions()), TimeProvider.System); @@ -86,7 +88,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/SqlServer/SqlServerOutboxRepositoryTests.cs b/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerOutboxRepositoryTests.cs index d40049bb..23bff679 100644 --- a/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerOutboxRepositoryTests.cs +++ b/tests/NetEvolve.Pulse.Tests.Unit/SqlServer/SqlServerOutboxRepositoryTests.cs @@ -118,7 +118,7 @@ public async Task Constructor_WithEmptySchema_CreatesInstance() } [Test] - public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException() + public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException(CancellationToken cancellationToken) { var repository = new SqlServerOutboxRepository( Options.Create(new OutboxOptions { ConnectionString = ValidConnectionString }), @@ -126,7 +126,7 @@ public async Task AddAsync_WithNullMessage_ThrowsArgumentNullException() ); _ = await Assert - .That(async () => await repository.AddAsync(null!).ConfigureAwait(false)) + .That(async () => await repository.AddAsync(null!, cancellationToken).ConfigureAwait(false)) .Throws(); } }