diff --git a/README.md b/README.md index 0980a64..c6128f0 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,7 @@ The `InboxAndOutbox` is the main section for setting of the Outbox and Inbox fun ``` "InboxAndOutbox": { + "SecondsToDelayBeforeCreateEventStoreTables": 0, "Inbox": { //Your inbox settings }, @@ -371,7 +372,6 @@ The `InboxAndOutbox` is the main section for setting of the Outbox and Inbox fun "TryAfterMinutes": 20, "TryAfterMinutesIfEventNotFound": 60, "SecondsToDelayProcessEvents": 2, - "SecondsToDelayBeforeCreateEventStoreTables": 0, "DaysToCleanUpEvents": 30, "HoursToDelayCleanUpEvents": 2, "ConnectionString": "Connection string of the SQL database" diff --git a/src/BackgroundServices/BaseCleanUpProcessedEventsJob.cs b/src/BackgroundServices/BaseCleanUpProcessedEventsJob.cs index 3450312..663b7b2 100644 --- a/src/BackgroundServices/BaseCleanUpProcessedEventsJob.cs +++ b/src/BackgroundServices/BaseCleanUpProcessedEventsJob.cs @@ -1,61 +1,62 @@ using EventStorage.Configurations; using EventStorage.Models; using EventStorage.Repositories; +using EventStorage.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace EventStorage.BackgroundServices; -internal abstract class BaseCleanUpProcessedEventsJob : BackgroundService +internal abstract class BaseCleanUpProcessedEventsJob( + IServiceScopeFactory scopeFactory, + InboxOrOutboxStructure settings, + ILogger logger) + : BackgroundService where TEventBox : class, IBaseMessageBox where TEventRepository : IBaseEventRepository { - private readonly IServiceProvider _services; - private readonly ILogger _logger; - private readonly InboxOrOutboxStructure _settings; - - protected BaseCleanUpProcessedEventsJob(IServiceProvider services, - InboxOrOutboxStructure settings, ILogger logger) - { - _services = services; - _logger = logger; - _settings = settings; - } - - public override Task StartAsync(CancellationToken cancellationToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - using var scope = _services.CreateScope(); - var repository = scope.ServiceProvider.GetRequiredService(); - repository.CreateTableIfNotExists(); + if (settings.DaysToCleanUpEvents <= 0) return; - return base.StartAsync(cancellationToken); - } + await CreateEventStoreTablesIfNotExistsAsync(stoppingToken); - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - if (_settings.DaysToCleanUpEvents <= 0) return; - - var timeToDelay = TimeSpan.FromHours(_settings.HoursToDelayCleanUpEvents); + var timeToDelay = TimeSpan.FromHours(settings.HoursToDelayCleanUpEvents); while (!stoppingToken.IsCancellationRequested) { try { - using var scope = _services.CreateScope(); + using var scope = scopeFactory.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); - var processedAt = DateTime.Now.AddDays(-_settings.DaysToCleanUpEvents); + var processedAt = DateTime.Now.AddDays(-settings.DaysToCleanUpEvents); await repository.DeleteProcessedEventsAsync(processedAt); } catch (Exception e) { - _logger.LogCritical(e, + logger.LogCritical(e, "Something is wrong while cleaning up the processed events from the {TableName} table. Happened at: {time}", - _settings.TableName, DateTime.Now); + settings.TableName, DateTime.Now); } finally { - await Task.Delay(timeToDelay, stoppingToken); + if (!stoppingToken.IsCancellationRequested) + await Task.Delay(timeToDelay, stoppingToken); } } } + + #region CreateEventStoreTablesIfNotExists + + /// + /// Creates event store tables if they do not already exist. It waits for a configured delay before attempting to create the tables. + /// + private async Task CreateEventStoreTablesIfNotExistsAsync(CancellationToken cancellationToken) + { + using var scope = scopeFactory.CreateScope(); + var eventStoreTablesCreator = scope.ServiceProvider.GetRequiredService(); + await eventStoreTablesCreator.CreateTablesIfNotExistsAsync(cancellationToken); + } + + #endregion } \ No newline at end of file diff --git a/src/BackgroundServices/BaseEventsProcessorJob.cs b/src/BackgroundServices/BaseEventsProcessorJob.cs index 1dbfef9..aa5fff2 100644 --- a/src/BackgroundServices/BaseEventsProcessorJob.cs +++ b/src/BackgroundServices/BaseEventsProcessorJob.cs @@ -44,7 +44,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } finally { - await Task.Delay(_timeToDelay, stoppingToken); + if (!stoppingToken.IsCancellationRequested) + await Task.Delay(_timeToDelay, stoppingToken); } } } @@ -53,34 +54,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) #region CreateEventStoreTablesIfNotExists - /// - /// Semaphore to limit the number of concurrent table creation to 1. - /// This is to prevent multiple instances of the application from trying to run migrations at the same time. - /// - private static readonly SemaphoreSlim LimitToExecuteTableCreation = new(1, 1); - /// /// Creates event store tables if they do not already exist. It waits for a configured delay before attempting to create the tables. /// private async Task CreateEventStoreTablesIfNotExistsAsync(CancellationToken cancellationToken) { - Console.WriteLine("Starting IEventStoreTablesCreator. Seconds to delay: {0}" , functionalitySettings.SecondsToDelayBeforeCreateEventStoreTables); - var timeToDelay = TimeSpan.FromSeconds(functionalitySettings.SecondsToDelayBeforeCreateEventStoreTables); - await Task.Delay(timeToDelay, cancellationToken); - await LimitToExecuteTableCreation.WaitAsync(cancellationToken); - - try - { - Console.WriteLine("Started IEventStoreTablesCreator..."); - using var scope = scopeFactory.CreateScope(); - var eventStoreTablesCreator = scope.ServiceProvider.GetRequiredService(); - eventStoreTablesCreator.CreateTablesIfNotExists(); - Console.WriteLine("Finished IEventStoreTablesCreator..."); - } - finally - { - LimitToExecuteTableCreation.Release(); - } + using var scope = scopeFactory.CreateScope(); + var eventStoreTablesCreator = scope.ServiceProvider.GetRequiredService(); + await eventStoreTablesCreator.CreateTablesIfNotExistsAsync(cancellationToken); } #endregion diff --git a/src/Configurations/InboxAndOutboxSettings.cs b/src/Configurations/InboxAndOutboxSettings.cs index 89924cc..0fc1979 100644 --- a/src/Configurations/InboxAndOutboxSettings.cs +++ b/src/Configurations/InboxAndOutboxSettings.cs @@ -2,6 +2,12 @@ namespace EventStorage.Configurations; public class InboxAndOutboxSettings { + /// + /// Seconds to delay before creating event store tables. Default value is "0". + /// Sometime, we may need to wait for other systems to create database itself before start processing events. + /// + public int SecondsToDelayBeforeCreateEventStoreTables { get; init; } + /// /// For getting settings of an Inbox. /// diff --git a/src/Configurations/InboxOrOutboxStructure.cs b/src/Configurations/InboxOrOutboxStructure.cs index 9f67d0c..bfd30dc 100644 --- a/src/Configurations/InboxOrOutboxStructure.cs +++ b/src/Configurations/InboxOrOutboxStructure.cs @@ -42,12 +42,6 @@ public record InboxOrOutboxStructure /// public int SecondsToDelayProcessEvents { get; init; } = 1; - /// - /// Seconds to delay before creating event store tables. Default value is "0". - /// Sometime, we may need to wait for other systems to create database itself before start processing events. - /// - public int SecondsToDelayBeforeCreateEventStoreTables { get; init; } = 0; - /// /// Days to cleaning up the processed events. Default value is "0". It will work when value is higher than or equal 1. /// diff --git a/src/Inbox/BackgroundServices/CleanUpProcessedInboxEventsJob.cs b/src/Inbox/BackgroundServices/CleanUpProcessedInboxEventsJob.cs index 959aeb0..6791b13 100644 --- a/src/Inbox/BackgroundServices/CleanUpProcessedInboxEventsJob.cs +++ b/src/Inbox/BackgroundServices/CleanUpProcessedInboxEventsJob.cs @@ -3,15 +3,14 @@ using EventStorage.Inbox.Models; using EventStorage.Inbox.Repositories; using EventStorage.Outbox.BackgroundServices; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace EventStorage.Inbox.BackgroundServices; -internal class CleanUpProcessedInboxEventsJob : BaseCleanUpProcessedEventsJob -{ - public CleanUpProcessedInboxEventsJob(IServiceProvider services, - InboxAndOutboxSettings settings, ILogger logger) : base(services, settings.Inbox, - logger) - { - } -} \ No newline at end of file +internal class CleanUpProcessedInboxEventsJob( + IServiceScopeFactory serviceScopeFactory, + InboxAndOutboxSettings settings, + ILogger logger) + : BaseCleanUpProcessedEventsJob(serviceScopeFactory, settings.Inbox, + logger); \ No newline at end of file diff --git a/src/Outbox/BackgroundServices/CleanUpProcessedOutboxEventsJob.cs b/src/Outbox/BackgroundServices/CleanUpProcessedOutboxEventsJob.cs index da133cd..aa7229a 100644 --- a/src/Outbox/BackgroundServices/CleanUpProcessedOutboxEventsJob.cs +++ b/src/Outbox/BackgroundServices/CleanUpProcessedOutboxEventsJob.cs @@ -2,15 +2,14 @@ using EventStorage.Configurations; using EventStorage.Outbox.Models; using EventStorage.Outbox.Repositories; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace EventStorage.Outbox.BackgroundServices; -internal class CleanUpProcessedOutboxEventsJob : BaseCleanUpProcessedEventsJob -{ - public CleanUpProcessedOutboxEventsJob(IServiceProvider services, - InboxAndOutboxSettings settings, ILogger logger) : base(services, settings.Outbox, - logger) - { - } -} \ No newline at end of file +internal class CleanUpProcessedOutboxEventsJob( + IServiceScopeFactory scopeFactory, + InboxAndOutboxSettings settings, + ILogger logger) + : BaseCleanUpProcessedEventsJob(scopeFactory, settings.Outbox, + logger); \ No newline at end of file diff --git a/src/Services/EventStoreTablesCreator.cs b/src/Services/EventStoreTablesCreator.cs index 1cceb29..0bb90d5 100644 --- a/src/Services/EventStoreTablesCreator.cs +++ b/src/Services/EventStoreTablesCreator.cs @@ -1,15 +1,35 @@ -using EventStorage.Inbox.Repositories; +using EventStorage.Configurations; +using EventStorage.Inbox.Repositories; using EventStorage.Outbox.Repositories; namespace EventStorage.Services; internal class EventStoreTablesCreator( + InboxAndOutboxSettings settings, IInboxRepository inboxRepository = null, IOutboxRepository outboxRepository = null) : IEventStoreTablesCreator { - public void CreateTablesIfNotExists() + /// + /// Semaphore to limit the number of concurrent table creation to 1. + /// This is to prevent multiple instances of the application from trying to run migrations at the same time. + /// + private static readonly SemaphoreSlim LimitToExecuteTableCreation = new(1, 1); + + public async Task CreateTablesIfNotExistsAsync(CancellationToken cancellationToken) { - inboxRepository?.CreateTableIfNotExists(); - outboxRepository?.CreateTableIfNotExists(); + var timeToDelay = TimeSpan.FromSeconds(settings.SecondsToDelayBeforeCreateEventStoreTables); + await Task.Delay(timeToDelay, cancellationToken); + + await LimitToExecuteTableCreation.WaitAsync(cancellationToken); + + try + { + inboxRepository?.CreateTableIfNotExists(); + outboxRepository?.CreateTableIfNotExists(); + } + finally + { + LimitToExecuteTableCreation.Release(); + } } } \ No newline at end of file diff --git a/src/Services/IEventStoreTablesCreator.cs b/src/Services/IEventStoreTablesCreator.cs index a7033b1..28f28e4 100644 --- a/src/Services/IEventStoreTablesCreator.cs +++ b/src/Services/IEventStoreTablesCreator.cs @@ -8,5 +8,5 @@ public interface IEventStoreTablesCreator /// /// Creates Inbox and Outbox tables if they do not already exist. /// - void CreateTablesIfNotExists(); + Task CreateTablesIfNotExistsAsync(CancellationToken cancellationToken); } \ No newline at end of file diff --git a/tests/Services/CleanUpProcessedEventsJob.cs b/tests/Services/CleanUpProcessedEventsJob.cs index 846099c..ff097de 100644 --- a/tests/Services/CleanUpProcessedEventsJob.cs +++ b/tests/Services/CleanUpProcessedEventsJob.cs @@ -2,15 +2,15 @@ using EventStorage.Configurations; using EventStorage.Models; using EventStorage.Repositories; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace EventStorage.Tests.Services; -internal class CleanUpProcessedEventsJob : BaseCleanUpProcessedEventsJob +internal class CleanUpProcessedEventsJob( + IServiceScopeFactory scopeFactory, + InboxOrOutboxStructure settings, + ILogger logger) + : BaseCleanUpProcessedEventsJob(scopeFactory, settings, logger) where TEventBox : class, IBaseMessageBox - where TEventRepository : IBaseEventRepository -{ - public CleanUpProcessedEventsJob(IServiceProvider services, InboxOrOutboxStructure settings, ILogger logger) : base(services, settings, logger) - { - } -} \ No newline at end of file + where TEventRepository : IBaseEventRepository; \ No newline at end of file diff --git a/tests/UnitTests/BaseCleanUpProcessedEventsJobTests.cs b/tests/UnitTests/BaseCleanUpProcessedEventsJobTests.cs index f37cbb3..79b09de 100644 --- a/tests/UnitTests/BaseCleanUpProcessedEventsJobTests.cs +++ b/tests/UnitTests/BaseCleanUpProcessedEventsJobTests.cs @@ -3,8 +3,10 @@ using EventStorage.Exceptions; using EventStorage.Models; using EventStorage.Repositories; +using EventStorage.Services; using EventStorage.Tests.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NSubstitute; @@ -16,6 +18,7 @@ internal abstract class BaseCleanUpProcessedEventsJobTests { private IServiceProvider _serviceProvider; + private IServiceScopeFactory _serviceScopeFactory; private TEventRepository _eventRepository; private ILogger _logger; private InboxOrOutboxStructure _settings; @@ -24,7 +27,13 @@ internal abstract class BaseCleanUpProcessedEventsJobTests(); + var scope = Substitute.For(); + _serviceScopeFactory = Substitute.For(); + _serviceScopeFactory.CreateScope().Returns(scope); + scope.ServiceProvider.Returns(_serviceProvider); + _eventRepository = Substitute.For(); + _serviceProvider.GetService(typeof(TEventRepository)).Returns(_eventRepository); _logger = Substitute.For(); _settings = new InboxOrOutboxStructure { DaysToCleanUpEvents = 1 }; } @@ -34,21 +43,19 @@ public void Setup() [Test] public async Task StartAsync_SettingsDaysToCleanIsOne_ShouldDelete() { - var scope = Substitute.For(); - var serviceScopeFactory = Substitute.For(); - _serviceProvider.GetService(typeof(IServiceScopeFactory)).Returns(serviceScopeFactory); - serviceScopeFactory.CreateScope().Returns(scope); - scope.ServiceProvider.GetService(typeof(TEventRepository)).Returns(_eventRepository); - + var eventStoreTablesCreator = Substitute.For(); + _serviceProvider.GetService(typeof(IEventStoreTablesCreator)).Returns(eventStoreTablesCreator); var cleanUpProcessedEventsService = new CleanUpProcessedEventsJob( - services: _serviceProvider, + scopeFactory: _serviceScopeFactory, settings: _settings, logger: _logger ); var cancellationToken = CancellationToken.None; - await cleanUpProcessedEventsService.StartAsync(cancellationToken); + _ = ExecuteBackgroundServiceAsync(cleanUpProcessedEventsService, cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + await eventStoreTablesCreator.Received(1).CreateTablesIfNotExistsAsync(cancellationToken); await _eventRepository.Received().DeleteProcessedEventsAsync( Arg.Is(d => d.Day == DateTime.Now.AddDays(-1).Day) ); @@ -61,36 +68,29 @@ public async Task StartAsync_SettingsDaysToCleanIsZero_ShouldNotDelete() { DaysToCleanUpEvents = 0 }; - var scope = Substitute.For(); - var serviceScopeFactory = Substitute.For(); - _serviceProvider.GetService(typeof(IServiceScopeFactory)).Returns(serviceScopeFactory); - serviceScopeFactory.CreateScope().Returns(scope); - scope.ServiceProvider.GetService(typeof(TEventRepository)).Returns(_eventRepository); - + var eventStoreTablesCreator = Substitute.For(); + _serviceProvider.GetService(typeof(IEventStoreTablesCreator)).Returns(eventStoreTablesCreator); var cleanUpProcessedEventsService = new CleanUpProcessedEventsJob( - services: _serviceProvider, + scopeFactory: _serviceScopeFactory, settings: _settings, logger: _logger ); var cancellationToken = CancellationToken.None; - await cleanUpProcessedEventsService.StartAsync(cancellationToken); + _ = ExecuteBackgroundServiceAsync(cleanUpProcessedEventsService, cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); await _eventRepository.DidNotReceive().DeleteProcessedEventsAsync(Arg.Any()); } [Test] public void StartAsync_WhenReceiveExceptionWhileDeleting_ShouldLogException() { - var scope = Substitute.For(); - var serviceScopeFactory = Substitute.For(); - _serviceProvider.GetService(typeof(IServiceScopeFactory)).Returns(serviceScopeFactory); - serviceScopeFactory.CreateScope().Returns(scope); - scope.ServiceProvider.GetService(typeof(TEventRepository)).Returns(_eventRepository); + var eventStoreTablesCreator = Substitute.For(); + _serviceProvider.GetService(typeof(IEventStoreTablesCreator)).Returns(eventStoreTablesCreator); var stoppingTokenSource = new CancellationTokenSource(); - var cleanUpProcessedEventsService = new CleanUpProcessedEventsJob( - services: _serviceProvider, + scopeFactory: _serviceScopeFactory, settings: _settings, logger: _logger ); @@ -102,14 +102,7 @@ public void StartAsync_WhenReceiveExceptionWhileDeleting_ShouldLogException() throw new EventStoreException("Simulated exception"); }); - Assert.ThrowsAsync(async () => - { - var executeAsyncTask = - (Task)cleanUpProcessedEventsService.GetType().BaseType!.GetMethod("ExecuteAsync", - BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(cleanUpProcessedEventsService, [stoppingTokenSource.Token])!; - await executeAsyncTask; - }); + _ = ExecuteBackgroundServiceAsync(cleanUpProcessedEventsService, stoppingTokenSource.Token); _logger.Received(1).Log( LogLevel.Critical, @@ -121,4 +114,20 @@ public void StartAsync_WhenReceiveExceptionWhileDeleting_ShouldLogException() } #endregion + + #region Helper methods + + /// + /// Executes the background service's ExecuteAsync method immediately. + /// + private async Task ExecuteBackgroundServiceAsync(BackgroundService service, CancellationToken cancellationToken) + { + var executeAsyncTask = + (Task)service.GetType().BaseType!.GetMethod("ExecuteAsync", + BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(service, [cancellationToken])!; + await executeAsyncTask; + } + + #endregion } \ No newline at end of file diff --git a/tests/UnitTests/EventStoreTablesCreatorTests.cs b/tests/UnitTests/EventStoreTablesCreatorTests.cs index a6f5fc3..536e402 100644 --- a/tests/UnitTests/EventStoreTablesCreatorTests.cs +++ b/tests/UnitTests/EventStoreTablesCreatorTests.cs @@ -1,4 +1,5 @@ -using EventStorage.Inbox.Repositories; +using EventStorage.Configurations; +using EventStorage.Inbox.Repositories; using EventStorage.Outbox.Repositories; using EventStorage.Services; using NSubstitute; @@ -7,47 +8,61 @@ namespace EventStorage.Tests.UnitTests; public class EventStoreTablesCreatorTests : BaseTestEntity { + private InboxAndOutboxSettings _settings; + + #region Setup + + [SetUp] + public void Setup() + { + _settings = new InboxAndOutboxSettings(); + } + + #endregion + #region CreateTablesIfNotExists [Test] public void CreateTablesIfNotExists_BothRepositoriesAreNull_ShouldNotThrowException() { - var tablesCreator = new EventStoreTablesCreator(); + var tablesCreator = new EventStoreTablesCreator(_settings); - Assert.DoesNotThrow(() => tablesCreator.CreateTablesIfNotExists()); + Assert.DoesNotThrowAsync(() => tablesCreator.CreateTablesIfNotExistsAsync(CancellationToken.None)); } - + [Test] - public void CreateTablesIfNotExists_InboxRepositoryIsNotNullButOutboxRepositoryIsNull_ShouldNotThrowExceptionAndCallInboxRepository() + public void + CreateTablesIfNotExists_InboxRepositoryIsNotNullButOutboxRepositoryIsNull_ShouldNotThrowExceptionAndCallInboxRepository() { var inboxRepository = Substitute.For(); - var tablesCreator = new EventStoreTablesCreator(inboxRepository); + var tablesCreator = new EventStoreTablesCreator(_settings, inboxRepository); + + Assert.DoesNotThrowAsync(() => tablesCreator.CreateTablesIfNotExistsAsync(CancellationToken.None)); - Assert.DoesNotThrow(() => tablesCreator.CreateTablesIfNotExists()); - inboxRepository.Received(1).CreateTableIfNotExists(); } - + [Test] - public void CreateTablesIfNotExists_OutboxRepositoryIsNotNullButInboxRepositoryIsNull_ShouldNotThrowExceptionAndCallOutboxRepository() + public void + CreateTablesIfNotExists_OutboxRepositoryIsNotNullButInboxRepositoryIsNull_ShouldNotThrowExceptionAndCallOutboxRepository() { var outboxRepository = Substitute.For(); - var tablesCreator = new EventStoreTablesCreator(null, outboxRepository); + var tablesCreator = new EventStoreTablesCreator(_settings, null, outboxRepository); + + Assert.DoesNotThrowAsync(() => tablesCreator.CreateTablesIfNotExistsAsync(CancellationToken.None)); - Assert.DoesNotThrow(() => tablesCreator.CreateTablesIfNotExists()); - outboxRepository.Received(1).CreateTableIfNotExists(); } - + [Test] public void CreateTablesIfNotExists_BothRepositoriesAreNotNull_ShouldNotThrowExceptionAndCallBothRepositories() { var inboxRepository = Substitute.For(); var outboxRepository = Substitute.For(); - var tablesCreator = new EventStoreTablesCreator(inboxRepository, outboxRepository); + var tablesCreator = new EventStoreTablesCreator(_settings, inboxRepository, outboxRepository); + + Assert.DoesNotThrowAsync(() => tablesCreator.CreateTablesIfNotExistsAsync(CancellationToken.None)); - Assert.DoesNotThrow(() => tablesCreator.CreateTablesIfNotExists()); - outboxRepository.Received(1).CreateTableIfNotExists(); outboxRepository.Received(1).CreateTableIfNotExists(); } diff --git a/tests/UnitTests/Inbox/InboxEventsProcessorJobTests.cs b/tests/UnitTests/Inbox/InboxEventsProcessorJobTests.cs index b2845cc..f96d596 100644 --- a/tests/UnitTests/Inbox/InboxEventsProcessorJobTests.cs +++ b/tests/UnitTests/Inbox/InboxEventsProcessorJobTests.cs @@ -5,6 +5,7 @@ using EventStorage.Inbox.Repositories; using EventStorage.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NSubstitute; @@ -14,6 +15,7 @@ namespace EventStorage.Tests.UnitTests.Inbox; public class InboxEventsProcessorJobTests { private IServiceProvider _serviceProvider; + private IServiceScopeFactory _serviceScopeFactory; private IInboxEventsProcessor _inboxEventsProcessor; private ILogger _logger; private InboxAndOutboxSettings _settings; @@ -24,6 +26,11 @@ public class InboxEventsProcessorJobTests public void Setup() { _serviceProvider = Substitute.For(); + var scope = Substitute.For(); + _serviceScopeFactory = Substitute.For(); + _serviceScopeFactory.CreateScope().Returns(scope); + scope.ServiceProvider.Returns(_serviceProvider); + _inboxEventsProcessor = Substitute.For(); _logger = Substitute.For>(); _settings = new InboxAndOutboxSettings @@ -40,39 +47,30 @@ public void Setup() [Test] public async Task StartAsync_WithDefaultSettings_ShouldWork() { - var scope = Substitute.For(); - var serviceScopeFactory = Substitute.For(); - serviceScopeFactory.CreateScope().Returns(scope); - scope.ServiceProvider.Returns(_serviceProvider); var eventStoreTablesCreator = Substitute.For(); _serviceProvider.GetService(typeof(IEventStoreTablesCreator)).Returns(eventStoreTablesCreator); var eventsReceiverService = new InboxEventsProcessorJob( - scopeFactory: serviceScopeFactory, + scopeFactory: _serviceScopeFactory, inboxEventsProcessor: _inboxEventsProcessor, settings: _settings, logger: _logger ); var cancellationToken = CancellationToken.None; - await eventsReceiverService.StartAsync(cancellationToken); - - eventStoreTablesCreator.Received(1).CreateTablesIfNotExists(); + _ = ExecuteBackgroundServiceAsync(eventsReceiverService, cancellationToken); + await eventStoreTablesCreator.Received(1).CreateTablesIfNotExistsAsync(cancellationToken); //We cannot test this because it is an asynchronous method await _inboxEventsProcessor.ExecuteUnprocessedEvents(cancellationToken); } [Test] - public async Task StartAsync_ThrowingExceptionOnExecutingUnprocessedEvents_ShouldLogException() + public void StartAsync_ThrowingExceptionOnExecutingUnprocessedEvents_ShouldLogException() { - var scope = Substitute.For(); - var serviceScopeFactory = Substitute.For(); - serviceScopeFactory.CreateScope().Returns(scope); - scope.ServiceProvider.Returns(_serviceProvider); var eventStoreTablesCreator = Substitute.For(); _serviceProvider.GetService(typeof(IEventStoreTablesCreator)).Returns(eventStoreTablesCreator); var eventsReceiverService = new InboxEventsProcessorJob( - scopeFactory: serviceScopeFactory, + scopeFactory: _serviceScopeFactory, inboxEventsProcessor: _inboxEventsProcessor, settings: _settings, logger: _logger @@ -83,9 +81,8 @@ public async Task StartAsync_ThrowingExceptionOnExecutingUnprocessedEvents_Shoul .When(x => x.ExecuteUnprocessedEvents(Arg.Any())) .Do(_ => throw new Exception("Test exception")); - await eventsReceiverService.StartAsync(CancellationToken.None); + _ = ExecuteBackgroundServiceAsync(eventsReceiverService, CancellationToken.None); - await Task.Delay(TimeSpan.FromSeconds(1)); _logger.Received(1).Log( LogLevel.Critical, Arg.Any(), @@ -98,14 +95,10 @@ public async Task StartAsync_ThrowingExceptionOnExecutingUnprocessedEvents_Shoul #endregion #region ExecuteAsync - + [Test] public async Task ExecuteAsync_CancellationRequested_ShouldStopProcessing() { - var scope = Substitute.For(); - var serviceScopeFactory = Substitute.For(); - serviceScopeFactory.CreateScope().Returns(scope); - scope.ServiceProvider.Returns(_serviceProvider); var eventStoreTablesCreator = Substitute.For(); _serviceProvider.GetService(typeof(IEventStoreTablesCreator)).Returns(eventStoreTablesCreator); var stoppingTokenSource = new CancellationTokenSource(); @@ -114,20 +107,13 @@ public async Task ExecuteAsync_CancellationRequested_ShouldStopProcessing() .Do(_ => stoppingTokenSource.Cancel()); var eventsReceiverService = new InboxEventsProcessorJob( - scopeFactory: serviceScopeFactory, + scopeFactory: _serviceScopeFactory, inboxEventsProcessor: _inboxEventsProcessor, settings: _settings, logger: _logger ); - Assert.ThrowsAsync(async () => - { - var executeAsyncTask = - (Task)eventsReceiverService.GetType().BaseType!.GetMethod("ExecuteAsync", - BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(eventsReceiverService, [stoppingTokenSource.Token])!; - await executeAsyncTask; - }); + _ = ExecuteBackgroundServiceAsync(eventsReceiverService, stoppingTokenSource.Token); await _inboxEventsProcessor.Received(1).ExecuteUnprocessedEvents( Arg.Is(ct => ct.IsCancellationRequested) @@ -135,4 +121,20 @@ await _inboxEventsProcessor.Received(1).ExecuteUnprocessedEvents( } #endregion + + #region Helper methods + + /// + /// Executes the background service's ExecuteAsync method immediately. + /// + private async Task ExecuteBackgroundServiceAsync(BackgroundService service, CancellationToken cancellationToken) + { + var executeAsyncTask = + (Task)service.GetType().BaseType!.GetMethod("ExecuteAsync", + BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(service, [cancellationToken])!; + await executeAsyncTask; + } + + #endregion } \ No newline at end of file diff --git a/tests/UnitTests/Outbox/OutboxEventsProcessorJobTests.cs b/tests/UnitTests/Outbox/OutboxEventsProcessorJobTests.cs index 43afd90..69bca46 100644 --- a/tests/UnitTests/Outbox/OutboxEventsProcessorJobTests.cs +++ b/tests/UnitTests/Outbox/OutboxEventsProcessorJobTests.cs @@ -1,8 +1,10 @@ +using System.Reflection; using EventStorage.Configurations; using EventStorage.Outbox; using EventStorage.Outbox.BackgroundServices; using EventStorage.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NSubstitute; @@ -11,6 +13,7 @@ namespace EventStorage.Tests.UnitTests.Outbox; public class OutboxEventsProcessorJobTests { private IServiceProvider _serviceProvider; + private IServiceScopeFactory _serviceScopeFactory; private IOutboxEventsProcessor _outboxEventsProcessor; private ILogger _logger; private InboxAndOutboxSettings _settings; @@ -21,6 +24,11 @@ public class OutboxEventsProcessorJobTests public void SetUp() { _serviceProvider = Substitute.For(); + var scope = Substitute.For(); + _serviceScopeFactory = Substitute.For(); + _serviceScopeFactory.CreateScope().Returns(scope); + scope.ServiceProvider.Returns(_serviceProvider); + _outboxEventsProcessor = Substitute.For(); _logger = Substitute.For>(); _settings = new InboxAndOutboxSettings @@ -37,43 +45,32 @@ public void SetUp() [Test] public async Task StartAsync_WithDefaultSettings_ShouldWork() { - var scope = Substitute.For(); - var serviceScopeFactory = Substitute.For(); - serviceScopeFactory.CreateScope().Returns(scope); - scope.ServiceProvider.Returns(_serviceProvider); var eventStoreTablesCreator = Substitute.For(); _serviceProvider.GetService(typeof(IEventStoreTablesCreator)).Returns(eventStoreTablesCreator); var eventsReceiverService = new OutboxEventsProcessorJob( - scopeFactory: serviceScopeFactory, + scopeFactory: _serviceScopeFactory, outboxEventsProcessor: _outboxEventsProcessor, settings: _settings, logger: _logger ); var cancellationToken = CancellationToken.None; - await eventsReceiverService.StartAsync(cancellationToken); - - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - eventStoreTablesCreator.Received(1).CreateTablesIfNotExists(); + _ = ExecuteBackgroundServiceAsync(eventsReceiverService, cancellationToken); + await eventStoreTablesCreator.Received(1).CreateTablesIfNotExistsAsync(cancellationToken); //We cannot test this because it is an asynchronous method await _outboxEventsProcessor.ExecuteUnprocessedEvents(cancellationToken); } [Test] - public async Task StartAsync_ThrowingExceptionOnExecutingUnprocessedEvents_ShouldLogException() + public void StartAsync_ThrowingExceptionOnExecutingUnprocessedEvents_ShouldLogException() { - var scope = Substitute.For(); - var serviceScopeFactory = Substitute.For(); - serviceScopeFactory.CreateScope().Returns(scope); - scope.ServiceProvider.Returns(_serviceProvider); var eventStoreTablesCreator = Substitute.For(); _serviceProvider.GetService(typeof(IEventStoreTablesCreator)).Returns(eventStoreTablesCreator); var stoppingToken = new CancellationTokenSource(); stoppingToken.CancelAfter(100); - var eventsPublisherService = new OutboxEventsProcessorJob( - scopeFactory: serviceScopeFactory, + scopeFactory: _serviceScopeFactory, outboxEventsProcessor: _outboxEventsProcessor, settings: _settings, logger: _logger @@ -84,7 +81,7 @@ public async Task StartAsync_ThrowingExceptionOnExecutingUnprocessedEvents_Shoul .When(x => x.ExecuteUnprocessedEvents(Arg.Any())) .Do(_ => throw new Exception("Test exception")); - await eventsPublisherService.StartAsync(CancellationToken.None); + _ = ExecuteBackgroundServiceAsync(eventsPublisherService, CancellationToken.None); _logger.Received(1).Log( LogLevel.Critical, @@ -98,28 +95,21 @@ public async Task StartAsync_ThrowingExceptionOnExecutingUnprocessedEvents_Shoul [Test] public async Task StartAsync_CancellationRequested_ShouldStopWithCancellationRequestTrue() { - var scope = Substitute.For(); - var serviceScopeFactory = Substitute.For(); - serviceScopeFactory.CreateScope().Returns(scope); - scope.ServiceProvider.Returns(_serviceProvider); var eventStoreTablesCreator = Substitute.For(); _serviceProvider.GetService(typeof(IEventStoreTablesCreator)).Returns(eventStoreTablesCreator); - var stoppingToken = new CancellationTokenSource(); - CancellationToken cancellationToken = stoppingToken.Token; + var stoppingTokenSource = new CancellationTokenSource(); _outboxEventsProcessor - .ExecuteUnprocessedEvents(cancellationToken) - .Returns(async _ => await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken)); + .When(x => x.ExecuteUnprocessedEvents(Arg.Any())) + .Do(_ => stoppingTokenSource.Cancel()); var eventsPublisherService = new OutboxEventsProcessorJob( - scopeFactory: serviceScopeFactory, + scopeFactory: _serviceScopeFactory, outboxEventsProcessor: _outboxEventsProcessor, settings: _settings, logger: _logger ); - _ = eventsPublisherService.StartAsync(cancellationToken); - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - await stoppingToken.CancelAsync(); + _ = ExecuteBackgroundServiceAsync(eventsPublisherService, stoppingTokenSource.Token); await _outboxEventsProcessor.Received().ExecuteUnprocessedEvents( Arg.Is(ct => ct.IsCancellationRequested == true) @@ -127,4 +117,20 @@ await _outboxEventsProcessor.Received().ExecuteUnprocessedEvents( } #endregion + + #region Helper methods + + /// + /// Executes the background service's ExecuteAsync method immediately. + /// + private async Task ExecuteBackgroundServiceAsync(BackgroundService service, CancellationToken cancellationToken) + { + var executeAsyncTask = + (Task)service.GetType().BaseType!.GetMethod("ExecuteAsync", + BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(service, [cancellationToken])!; + await executeAsyncTask; + } + + #endregion } \ No newline at end of file