From fdd308cd18de34aa292df5c7f9f60293c00c8a2e Mon Sep 17 00:00:00 2001 From: Farshad Dashti <78855469+FrostyApeOne@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:10:21 +0100 Subject: [PATCH 1/2] test: cover new BackgroundServiceFactory behavior --- .../ServiceCollectionExtensions.cs | 9 ++- .../Services/BackgroundServiceFactory.cs | 43 ++++++++++----- .../ServiceCollectionExtensions.cs | 5 +- .../ServiceCollectionExtensions.cs | 18 +++++- .../Extensions/StringExtensions.cs | 6 +- .../Services/BackgroundServiceFactoryTests.cs | 55 ++++++++++++++++++- 6 files changed, 110 insertions(+), 26 deletions(-) diff --git a/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs b/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs index bec34b36..168914ff 100644 --- a/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs +++ b/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs @@ -19,11 +19,16 @@ public static IServiceCollection AddBackgroundService( // If no configuration delegate is provided, use defaults if (configureOptions != null) { - services.Configure(configureOptions); + services.AddOptions() + .Configure(configureOptions) + .ValidateDataAnnotations() + .ValidateOnStart(); } else { - services.Configure(_ => { }); + services.AddOptions() + .Configure(_ => { }) + .ValidateOnStart(); } services.AddSingleton(); diff --git a/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs b/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs index 3613a162..df4d4ada 100644 --- a/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs +++ b/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs @@ -2,15 +2,19 @@ using MediatR; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; using System.Collections.Concurrent; -using System.Diagnostics; using DfE.CoreLibs.AsyncProcessing.Configurations; namespace DfE.CoreLibs.AsyncProcessing.Services { - public class BackgroundServiceFactory(IMediator mediator, IOptions options) : BackgroundService, IBackgroundServiceFactory + public class BackgroundServiceFactory( + IMediator mediator, + IOptions options, + ILogger logger) : BackgroundService, IBackgroundServiceFactory { private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private readonly ConcurrentDictionary>> _taskQueues = new(); @@ -18,6 +22,8 @@ public class BackgroundServiceFactory(IMediator mediator, IOptions public Task EnqueueTask( Func> taskFunc, @@ -79,24 +85,30 @@ public Task EnqueueTask( } }); - _ = StartProcessingQueue(taskType); + var processingTask = StartProcessingQueue(taskType); + _ = processingTask.ContinueWith(t => + { + if (t.Exception != null) + { + _logger.LogError(t.Exception, "Error occurred while processing background queue for type {TaskType}", taskType); + } + }, TaskContinuationOptions.OnlyOnFaulted); return tcs.Task; } private async Task StartProcessingQueue(Type taskType) { - Console.WriteLine("StartProcessingQueue triggered"); + _logger.LogDebug("StartProcessingQueue triggered for {TaskType}", taskType); var queue = _taskQueues[taskType]; var semaphore = _semaphores[taskType]; - while (options.Value.UseGlobalStoppingToken && _serviceStoppingToken == CancellationToken.None) + if (options.Value.UseGlobalStoppingToken) { - Console.WriteLine("Waiting for ExecuteAsync to initialize _serviceStoppingToken..."); - await Task.Delay(100); + await _tokenInitialisedTcs.Task.ConfigureAwait(false); } - Console.WriteLine($"_serviceStoppingToken IsCancellationRequested: {_serviceStoppingToken.IsCancellationRequested}"); + _logger.LogDebug("_serviceStoppingToken IsCancellationRequested: {IsCancellationRequested}", _serviceStoppingToken.IsCancellationRequested); await semaphore.WaitAsync(CancellationToken.None).ConfigureAwait(false); @@ -106,7 +118,7 @@ private async Task StartProcessingQueue(Type taskType) { var token = options.Value.UseGlobalStoppingToken ? _serviceStoppingToken : CancellationToken.None; - Console.WriteLine($"Processing task with token IsCancellationRequested: {token.IsCancellationRequested}"); + _logger.LogDebug("Processing task with token IsCancellationRequested: {IsCancellationRequested}", token.IsCancellationRequested); await taskToProcess(token).ConfigureAwait(false); } } @@ -119,32 +131,33 @@ private async Task StartProcessingQueue(Type taskType) protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - Debug.WriteLine("ExecuteAsync started"); + _logger.LogDebug("ExecuteAsync started"); if (options.Value.UseGlobalStoppingToken) { _serviceStoppingToken = stoppingToken; - Debug.WriteLine("_serviceStoppingToken assigned"); + _tokenInitialisedTcs.TrySetResult(); + _logger.LogDebug("_serviceStoppingToken assigned"); } while (!stoppingToken.IsCancellationRequested) { - Debug.WriteLine("ExecuteAsync loop running..."); + _logger.LogDebug("ExecuteAsync loop running..."); await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken).ConfigureAwait(false); } - Debug.WriteLine("ExecuteAsync detected cancellation."); + _logger.LogDebug("ExecuteAsync detected cancellation."); } public override async Task StartAsync(CancellationToken cancellationToken) { - Debug.WriteLine("Background service is starting..."); + _logger.LogDebug("Background service is starting..."); var executeTask = base.StartAsync(cancellationToken); await Task.Delay(500); - Debug.WriteLine("Background service started, ExecuteAsync is now running."); + _logger.LogDebug("Background service started, ExecuteAsync is now running."); await executeTask; } diff --git a/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs b/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs index 7bf7eb48..2f39a49e 100644 --- a/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs +++ b/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs @@ -10,7 +10,10 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddServiceCaching( this IServiceCollection services, IConfiguration config) { - services.Configure(config.GetSection("CacheSettings")); + services.AddOptions() + .Bind(config.GetSection("CacheSettings")) + .ValidateDataAnnotations() + .ValidateOnStart(); services.AddSingleton, MemoryCacheService>(); return services; diff --git a/src/DfE.CoreLibs.Security/ServiceCollectionExtensions.cs b/src/DfE.CoreLibs.Security/ServiceCollectionExtensions.cs index ca27db80..42c75ff9 100644 --- a/src/DfE.CoreLibs.Security/ServiceCollectionExtensions.cs +++ b/src/DfE.CoreLibs.Security/ServiceCollectionExtensions.cs @@ -20,7 +20,10 @@ public static class ServiceCollectionExtensions /// The updated . public static IServiceCollection AddUserTokenService(this IServiceCollection services, IConfiguration configuration) { - services.Configure(configuration.GetSection("Authorization:TokenSettings")); + services.AddOptions() + .Bind(configuration.GetSection("Authorization:TokenSettings")) + .ValidateDataAnnotations() + .ValidateOnStart(); services.AddScoped(); services.AddHttpContextAccessor(); return services; @@ -34,7 +37,10 @@ public static IServiceCollection AddUserTokenService(this IServiceCollection ser /// The updated . public static IServiceCollection AddApiOboTokenService(this IServiceCollection services, IConfiguration configuration) { - services.Configure(configuration.GetSection("Authorization:TokenSettings")); + services.AddOptions() + .Bind(configuration.GetSection("Authorization:TokenSettings")) + .ValidateDataAnnotations() + .ValidateOnStart(); services.AddScoped(); services.AddHttpContextAccessor(); return services; @@ -56,7 +62,13 @@ public static IServiceCollection AddApiOboTokenService(this IServiceCollection s /// Thrown when the TokenSettings section is missing in configuration. public static IServiceCollection AddCustomJwtAuthentication(this IServiceCollection services, IConfiguration configuration, string authenticationScheme, AuthenticationBuilder authenticationBuilder, JwtBearerEvents? jwtBearerEvents = null) { - var tokenSettings = configuration.GetSection("Authorization:TokenSettings").Get(); + var tokenSettingsSection = configuration.GetSection("Authorization:TokenSettings"); + services.AddOptions() + .Bind(tokenSettingsSection) + .ValidateDataAnnotations() + .ValidateOnStart(); + + var tokenSettings = tokenSettingsSection.Get(); if (tokenSettings == null) { diff --git a/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs b/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs index 14e052c4..cfb0559b 100644 --- a/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs +++ b/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs @@ -19,7 +19,7 @@ public static string SplitPascalCase(this T source) return source == null ? string.Empty : Regex.Replace(source.ToString() ?? string.Empty, "[A-Z]", " $0", RegexOptions.None, - TimeSpan.FromSeconds(1)).Trim(); + TimeSpan.FromSeconds(5)).Trim(); } /// @@ -154,7 +154,7 @@ public static string ToFirstUpper(this string input) /// A string. public static string ToHyphenated(this string str) { - var whitespaceRegex = new Regex(@"\s+", RegexOptions.None, TimeSpan.FromSeconds(1)); + var whitespaceRegex = new Regex(@"\s+", RegexOptions.None, TimeSpan.FromSeconds(5)); return whitespaceRegex.Replace(str, "-"); } @@ -165,7 +165,7 @@ public static string ToHyphenated(this string str) /// A string. public static string RemoveNonAlphanumericOrWhiteSpace(this string str) { - var notAlphanumericWhiteSpaceOrHyphen = new Regex(@"[^\w\s-]", RegexOptions.None, TimeSpan.FromSeconds(1)); + var notAlphanumericWhiteSpaceOrHyphen = new Regex(@"[^\w\s-]", RegexOptions.None, TimeSpan.FromSeconds(5)); return notAlphanumericWhiteSpaceOrHyphen.Replace(str, string.Empty); } } \ No newline at end of file diff --git a/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs b/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs index 6c994e1a..8304794e 100644 --- a/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs +++ b/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs @@ -5,12 +5,14 @@ using MediatR; using NSubstitute; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; namespace DfE.CoreLibs.AsyncProcessing.Tests.Services { public class BackgroundServiceFactoryTests : IDisposable { private readonly IMediator _mediator; + private readonly ILogger _logger; private readonly BackgroundServiceFactory _factoryNoGlobalToken; private readonly BackgroundServiceFactory _factoryWithGlobalToken; @@ -20,6 +22,7 @@ public class BackgroundServiceFactoryTests : IDisposable public BackgroundServiceFactoryTests() { _mediator = Substitute.For(); + _logger = Substitute.For>(); _cts = new CancellationTokenSource(); @@ -33,8 +36,8 @@ public BackgroundServiceFactoryTests() UseGlobalStoppingToken = true }); - _factoryNoGlobalToken = new BackgroundServiceFactory(_mediator, optionsNoGlobalToken); - _factoryWithGlobalToken = new BackgroundServiceFactory(_mediator, optionsGlobalToken); + _factoryNoGlobalToken = new BackgroundServiceFactory(_mediator, optionsNoGlobalToken, _logger); + _factoryWithGlobalToken = new BackgroundServiceFactory(_mediator, optionsGlobalToken, _logger); } public void Dispose() @@ -391,5 +394,53 @@ public async Task Task_ShouldCompleteSuccessfully_IfNoTokenIsCanceled() Assert.True(result); } + [Fact] + public async Task Task_ShouldNotProcessUntilServiceStarted_WhenUseGlobalStoppingToken() + { + var processed = false; + + _factoryWithGlobalToken.EnqueueTask(token => + { + processed = true; + return Task.FromResult(true); + }, null); + + await Task.Delay(200); + + Assert.False(processed, "Task should not execute before StartAsync assigns the global token."); + + var runTask = _factoryWithGlobalToken.StartAsync(_cts.Token); + + await Task.Delay(200); + + _cts.Cancel(); + await runTask; + + Assert.True(processed, "Task should execute after the service starts and global token is assigned."); + } + + [Fact] + public async Task EnqueueTask_ShouldLogError_WhenTaskThrows() + { + var exception = new InvalidOperationException("boom"); + + var enqueuedTask = _factoryNoGlobalToken.EnqueueTask(_ => throw exception, null); + + var cts = new CancellationTokenSource(); + var runTask = _factoryNoGlobalToken.StartAsync(cts.Token); + + await Assert.ThrowsAsync(async () => await enqueuedTask); + + cts.Cancel(); + await runTask; + + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains("Error occurred while processing background queue")), + Arg.Is(ex => ex.Message == "boom"), + Arg.Any>()!); + } + } } \ No newline at end of file From f531e6be9ffd15e87b7511c20574b286ffa4b234 Mon Sep 17 00:00:00 2001 From: FrostyApeOne Date: Fri, 6 Jun 2025 11:01:38 +0100 Subject: [PATCH 2/2] Fixed missing references and failing tests --- .../DfE.CoreLibs.AsyncProcessing.csproj | 1 + .../Services/BackgroundServiceFactory.cs | 1 + .../DfE.CoreLibs.Caching.csproj | 1 + .../DfE.CoreLibs.Security.csproj | 1 + .../Services/BackgroundServiceFactoryTests.cs | 15 ++++++++------- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj b/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj index 7d101d8d..bebf6d17 100644 --- a/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj +++ b/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj @@ -19,6 +19,7 @@ + diff --git a/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs b/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs index df4d4ada..0f3e2e0b 100644 --- a/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs +++ b/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs @@ -14,6 +14,7 @@ public class BackgroundServiceFactory( ILogger logger) : BackgroundService, IBackgroundServiceFactory { private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private readonly ConcurrentDictionary>> _taskQueues = new(); diff --git a/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj b/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj index 1b2ae5f3..56352fc7 100644 --- a/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj +++ b/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj @@ -21,6 +21,7 @@ + diff --git a/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj b/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj index 24404a23..ca604580 100644 --- a/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj +++ b/src/DfE.CoreLibs.Security/DfE.CoreLibs.Security.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs b/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs index 8304794e..950ddfac 100644 --- a/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs +++ b/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs @@ -422,25 +422,26 @@ public async Task Task_ShouldNotProcessUntilServiceStarted_WhenUseGlobalStopping [Fact] public async Task EnqueueTask_ShouldLogError_WhenTaskThrows() { - var exception = new InvalidOperationException("boom"); - - var enqueuedTask = _factoryNoGlobalToken.EnqueueTask(_ => throw exception, null); + // Arrange + _factoryNoGlobalToken.EnqueueTask(token => throw new InvalidOperationException("boom")); var cts = new CancellationTokenSource(); var runTask = _factoryNoGlobalToken.StartAsync(cts.Token); - await Assert.ThrowsAsync(async () => await enqueuedTask); - + await Task.Delay(200); cts.Cancel(); await runTask; + // Assert _logger.Received(1).Log( LogLevel.Error, Arg.Any(), Arg.Is(v => v.ToString()!.Contains("Error occurred while processing background queue")), - Arg.Is(ex => ex.Message == "boom"), - Arg.Any>()!); + Arg.Is(ex => + ex.InnerException.Message == "boom"), + Arg.Any>()); } + } } \ No newline at end of file