feat: implement WorkProcessor and search execution services

- SearchRepository: Search table operations with Dapper
- SearchExecutionService: Search pipeline with proper cancellation handling
- WorkProcessor: Unified BackgroundService for syncs and searches
- SearchNotificationService: SignalR notifications in Api layer

All 45 new tests pass. Proper shutdown vs timeout distinction
prevents marking searches as error on host shutdown.
This commit is contained in:
Joseph Doherty
2026-01-07 06:18:35 -05:00
parent ca4cf9d3ec
commit 91b516e197
7 changed files with 1906 additions and 0 deletions
@@ -0,0 +1,546 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.Models.SearchResults;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using MsOptions = Microsoft.Extensions.Options.Options;
namespace JdeScoping.DataSync.Tests.Services;
/// <summary>
/// Unit tests for SearchExecutionService.
/// Tests cancellation handling, error paths, and success scenarios.
/// </summary>
public class SearchExecutionServiceTests
{
private readonly ISearchRepository _searchRepository;
private readonly ISearchProcessor _searchProcessor;
private readonly IExcelExportService _excelExportService;
private readonly ISearchNotificationService _notificationService;
private readonly IOptions<WorkProcessorOptions> _options;
private readonly ILogger<SearchExecutionService> _logger;
public SearchExecutionServiceTests()
{
_searchRepository = Substitute.For<ISearchRepository>();
_searchProcessor = Substitute.For<ISearchProcessor>();
_excelExportService = Substitute.For<IExcelExportService>();
_notificationService = Substitute.For<ISearchNotificationService>();
_options = MsOptions.Create(new WorkProcessorOptions { SearchTimeout = TimeSpan.FromSeconds(30) });
_logger = Substitute.For<ILogger<SearchExecutionService>>();
}
#region Constructor Tests
[Fact]
public void Constructor_WithNullSearchRepository_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
new SearchExecutionService(
null!,
_searchProcessor,
_excelExportService,
_notificationService,
_options,
_logger));
}
[Fact]
public void Constructor_WithNullSearchProcessor_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
new SearchExecutionService(
_searchRepository,
null!,
_excelExportService,
_notificationService,
_options,
_logger));
}
[Fact]
public void Constructor_WithNullExcelExportService_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
new SearchExecutionService(
_searchRepository,
_searchProcessor,
null!,
_notificationService,
_options,
_logger));
}
[Fact]
public void Constructor_WithNullNotificationService_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
new SearchExecutionService(
_searchRepository,
_searchProcessor,
_excelExportService,
null!,
_options,
_logger));
}
[Fact]
public void Constructor_WithNullOptions_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
new SearchExecutionService(
_searchRepository,
_searchProcessor,
_excelExportService,
_notificationService,
null!,
_logger));
}
[Fact]
public void Constructor_WithNullLogger_ThrowsArgumentNullException()
{
// Act & Assert
Should.Throw<ArgumentNullException>(() =>
new SearchExecutionService(
_searchRepository,
_searchProcessor,
_excelExportService,
_notificationService,
_options,
null!));
}
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var sut = CreateService();
// Assert
sut.ShouldNotBeNull();
}
#endregion
#region ExecuteSearchAsync Success Path
[Fact]
public async Task ExecuteSearchAsync_Success_StartsSearch()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
var model = new SearchModel { Id = 1, Results = [] };
var excelBytes = new byte[] { 1, 2, 3 };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns(model);
_excelExportService.GenerateAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.Returns(excelBytes);
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert
await _searchRepository.Received(1).StartSearchAsync(1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteSearchAsync_Success_ExecutesSearchProcessor()
{
// Arrange
var search = new Search { Id = 42, Status = SearchStatus.Queued };
var model = new SearchModel { Id = 42, Results = [] };
var excelBytes = new byte[] { 1, 2, 3 };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns(model);
_excelExportService.GenerateAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.Returns(excelBytes);
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert
await _searchProcessor.Received(1).ExecuteSearchToModelAsync(
Arg.Is<SearchModel>(m => m.Id == 42),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteSearchAsync_Success_GeneratesExcel()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
var model = new SearchModel { Id = 1, Results = [] };
var excelBytes = new byte[] { 1, 2, 3 };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns(model);
_excelExportService.GenerateAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.Returns(excelBytes);
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert
await _excelExportService.Received(1).GenerateAsync(
Arg.Any<object>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteSearchAsync_Success_CompletesWithEndedStatus()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
var model = new SearchModel { Id = 1, Results = [] };
var excelBytes = new byte[] { 1, 2, 3 };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns(model);
_excelExportService.GenerateAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.Returns(excelBytes);
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert
await _searchRepository.Received(1).CompleteSearchAsync(1, true, excelBytes, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteSearchAsync_Success_SendsNotifications()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
var model = new SearchModel { Id = 1, Results = [] };
var excelBytes = new byte[] { 1, 2, 3 };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns(model);
_excelExportService.GenerateAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.Returns(excelBytes);
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert - Should receive 2 notifications: one for Running, one for Ended
await _notificationService.Received(2).NotifySearchUpdateAsync(search, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteSearchAsync_Success_UpdatesSearchStatus()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
var model = new SearchModel { Id = 1, Results = [] };
var excelBytes = new byte[] { 1, 2, 3 };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns(model);
_excelExportService.GenerateAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.Returns(excelBytes);
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert
search.Status.ShouldBe(SearchStatus.Ended);
search.EndDt.ShouldNotBeNull();
}
#endregion
#region ExecuteSearchAsync Error Path
[Fact]
public async Task ExecuteSearchAsync_ProcessorThrows_MarksAsError()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Test error"));
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert
await _searchRepository.Received(1).CompleteSearchAsync(1, false, null, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteSearchAsync_ExcelGeneratorThrows_MarksAsError()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
var model = new SearchModel { Id = 1, Results = [] };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns(model);
_excelExportService.GenerateAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Excel generation failed"));
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert
await _searchRepository.Received(1).CompleteSearchAsync(1, false, null, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteSearchAsync_ProcessorThrows_UpdatesSearchStatusToError()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Test error"));
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert
search.Status.ShouldBe(SearchStatus.Error);
search.EndDt.ShouldNotBeNull();
}
[Fact]
public async Task ExecuteSearchAsync_ProcessorThrows_SendsErrorNotification()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Test error"));
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert - Should receive 2 notifications: one for Running, one for Error
await _notificationService.Received(2).NotifySearchUpdateAsync(search, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteSearchAsync_ErrorDuringComplete_DoesNotThrow()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Test error"));
_searchRepository.CompleteSearchAsync(Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<byte[]?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("DB error"));
var sut = CreateService();
// Act & Assert - Should not throw
await Should.NotThrowAsync(() => sut.ExecuteSearchAsync(search));
}
#endregion
#region ExecuteSearchAsync Host Shutdown
[Fact]
public async Task ExecuteSearchAsync_HostShutdown_DoesNotMarkAsError()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
using var cts = new CancellationTokenSource();
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns<SearchModel>(callInfo =>
{
cts.Cancel();
throw new OperationCanceledException(cts.Token);
});
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search, cts.Token);
// Assert - Should NOT call CompleteSearchAsync with success=false
await _searchRepository.DidNotReceive().CompleteSearchAsync(
Arg.Any<int>(), false, Arg.Any<byte[]?>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteSearchAsync_HostShutdown_DoesNotUpdateStatusToError()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
using var cts = new CancellationTokenSource();
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns<SearchModel>(callInfo =>
{
cts.Cancel();
throw new OperationCanceledException(cts.Token);
});
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search, cts.Token);
// Assert - Status should still be Running (not Error)
search.Status.ShouldBe(SearchStatus.Running);
}
[Fact]
public async Task ExecuteSearchAsync_HostShutdown_DoesNotThrow()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
using var cts = new CancellationTokenSource();
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns<SearchModel>(callInfo =>
{
cts.Cancel();
throw new OperationCanceledException(cts.Token);
});
var sut = CreateService();
// Act & Assert - Should not throw
await Should.NotThrowAsync(() => sut.ExecuteSearchAsync(search, cts.Token));
}
#endregion
#region ExecuteSearchAsync Timeout
[Fact]
public async Task ExecuteSearchAsync_Timeout_MarksAsError()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
var shortTimeoutOptions = MsOptions.Create(new WorkProcessorOptions { SearchTimeout = TimeSpan.FromMilliseconds(50) });
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns(async callInfo =>
{
var ct = callInfo.Arg<CancellationToken>();
await Task.Delay(TimeSpan.FromSeconds(5), ct); // Will be canceled by timeout
return new SearchModel();
});
var sut = new SearchExecutionService(
_searchRepository,
_searchProcessor,
_excelExportService,
_notificationService,
shortTimeoutOptions,
_logger);
// Act
await sut.ExecuteSearchAsync(search);
// Assert
await _searchRepository.Received(1).CompleteSearchAsync(1, false, null, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteSearchAsync_Timeout_UpdatesStatusToError()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
var shortTimeoutOptions = MsOptions.Create(new WorkProcessorOptions { SearchTimeout = TimeSpan.FromMilliseconds(50) });
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns(async callInfo =>
{
var ct = callInfo.Arg<CancellationToken>();
await Task.Delay(TimeSpan.FromSeconds(5), ct); // Will be canceled by timeout
return new SearchModel();
});
var sut = new SearchExecutionService(
_searchRepository,
_searchProcessor,
_excelExportService,
_notificationService,
shortTimeoutOptions,
_logger);
// Act
await sut.ExecuteSearchAsync(search);
// Assert
search.Status.ShouldBe(SearchStatus.Error);
}
#endregion
#region Notification Resilience
[Fact]
public async Task ExecuteSearchAsync_NotificationFails_ContinuesExecution()
{
// Arrange
var search = new Search { Id = 1, Status = SearchStatus.Queued };
var model = new SearchModel { Id = 1, Results = [] };
var excelBytes = new byte[] { 1, 2, 3 };
_searchProcessor.ExecuteSearchToModelAsync(Arg.Any<SearchModel>(), Arg.Any<CancellationToken>())
.Returns(model);
_excelExportService.GenerateAsync(Arg.Any<object>(), Arg.Any<CancellationToken>())
.Returns(excelBytes);
_notificationService.NotifySearchUpdateAsync(Arg.Any<Search>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("SignalR error"));
var sut = CreateService();
// Act
await sut.ExecuteSearchAsync(search);
// Assert - Should still complete successfully despite notification failures
await _searchRepository.Received(1).CompleteSearchAsync(1, true, excelBytes, Arg.Any<CancellationToken>());
}
#endregion
private SearchExecutionService CreateService()
{
return new SearchExecutionService(
_searchRepository,
_searchProcessor,
_excelExportService,
_notificationService,
_options,
_logger);
}
}
@@ -0,0 +1,129 @@
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Tests.Services;
/// <summary>
/// Unit tests for SearchRepository.
/// Tests constructor validation and interface contract compliance.
/// Integration tests with actual database are required for full coverage.
/// </summary>
public class SearchRepositoryTests
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly ILogger<SearchRepository> _logger;
public SearchRepositoryTests()
{
_connectionFactory = Substitute.For<IDbConnectionFactory>();
_logger = NullLogger<SearchRepository>.Instance;
}
#region Constructor Tests
[Fact]
public void Constructor_WithNullConnectionFactory_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Should.Throw<ArgumentNullException>(() =>
new SearchRepository(null!, _logger));
exception.ParamName.ShouldBe("connectionFactory");
}
[Fact]
public void Constructor_WithNullLogger_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Should.Throw<ArgumentNullException>(() =>
new SearchRepository(_connectionFactory, null!));
exception.ParamName.ShouldBe("logger");
}
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var repository = new SearchRepository(_connectionFactory, _logger);
// Assert
repository.ShouldNotBeNull();
}
#endregion
#region Interface Contract Tests
[Fact]
public void SearchRepository_ImplementsISearchRepository()
{
// Arrange & Act
var repository = new SearchRepository(_connectionFactory, _logger);
// Assert
repository.ShouldBeAssignableTo<JdeScoping.DataSync.Contracts.ISearchRepository>();
}
[Fact]
public void GetNextQueuedSearchAsync_HasCorrectSignature()
{
// Arrange
var repository = new SearchRepository(_connectionFactory, _logger);
// Act - Verify method exists with correct return type
var methodInfo = typeof(SearchRepository).GetMethod(nameof(SearchRepository.GetNextQueuedSearchAsync));
// Assert
methodInfo.ShouldNotBeNull();
methodInfo.ReturnType.ShouldBe(typeof(Task<JdeScoping.Core.Models.Search.Search?>));
}
[Fact]
public void ResetPartialSearchesAsync_HasCorrectSignature()
{
// Arrange
var repository = new SearchRepository(_connectionFactory, _logger);
// Act - Verify method exists with correct return type
var methodInfo = typeof(SearchRepository).GetMethod(nameof(SearchRepository.ResetPartialSearchesAsync));
// Assert
methodInfo.ShouldNotBeNull();
methodInfo.ReturnType.ShouldBe(typeof(Task<int>));
}
[Fact]
public void StartSearchAsync_HasCorrectSignature()
{
// Arrange
var repository = new SearchRepository(_connectionFactory, _logger);
// Act - Verify method exists with correct return type
var methodInfo = typeof(SearchRepository).GetMethod(nameof(SearchRepository.StartSearchAsync));
// Assert
methodInfo.ShouldNotBeNull();
methodInfo.ReturnType.ShouldBe(typeof(Task));
}
[Fact]
public void CompleteSearchAsync_HasCorrectSignature()
{
// Arrange
var repository = new SearchRepository(_connectionFactory, _logger);
// Act - Verify method exists with correct return type
var methodInfo = typeof(SearchRepository).GetMethod(nameof(SearchRepository.CompleteSearchAsync));
// Assert
methodInfo.ShouldNotBeNull();
methodInfo.ReturnType.ShouldBe(typeof(Task));
}
#endregion
}
@@ -0,0 +1,672 @@
using System.Diagnostics.Metrics;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Search;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Models;
using JdeScoping.DataSync.Options;
using JdeScoping.DataSync.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using MsOptions = Microsoft.Extensions.Options.Options;
namespace JdeScoping.DataSync.Tests;
/// <summary>
/// Unit tests for WorkProcessor background service.
/// </summary>
public class WorkProcessorTests
{
#region Disabled Service
[Fact]
public async Task ExecuteAsync_WhenDisabled_StopsImmediately()
{
// Arrange
var options = MsOptions.Create(new WorkProcessorOptions { Enabled = false });
var scopeFactory = Substitute.For<IServiceScopeFactory>();
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
// Act
await sut.StartAsync(cts.Token);
await Task.Delay(50);
await sut.StopAsync(CancellationToken.None);
// Assert - should not throw and scope factory should not be called
scopeFactory.DidNotReceive().CreateAsyncScope();
}
#endregion
#region Startup Cleanup
[Fact]
public async Task ExecuteAsync_CallsStartupCleanup_CloseOpenUpdateEntries()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
var scheduleChecker = Substitute.For<IScheduleChecker>();
var notificationService = Substitute.For<ISearchNotificationService>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask>());
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(100)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Act
await sut.StartAsync(cts.Token);
await Task.Delay(150);
await sut.StopAsync(CancellationToken.None);
// Assert
await dataUpdateRepo.Received().CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_CallsStartupCleanup_ResetPartialSearches()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
var scheduleChecker = Substitute.For<IScheduleChecker>();
var notificationService = Substitute.For<ISearchNotificationService>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask>());
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(100)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Act
await sut.StartAsync(cts.Token);
await Task.Delay(150);
await sut.StopAsync(CancellationToken.None);
// Assert
await searchRepo.Received().ResetPartialSearchesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_WhenCloseOpenEntriesThrows_ContinuesStarting()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
dataUpdateRepo.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("Database error"));
var searchRepo = Substitute.For<ISearchRepository>();
var scheduleChecker = Substitute.For<IScheduleChecker>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask>());
var notificationService = Substitute.For<ISearchNotificationService>();
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(100)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Act - should not throw
await sut.StartAsync(cts.Token);
await Task.Delay(150);
await sut.StopAsync(CancellationToken.None);
// Assert - search repo should still be called (startup continues)
await searchRepo.Received().ResetPartialSearchesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_WhenResetPartialSearchesThrows_ContinuesRunning()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
searchRepo.ResetPartialSearchesAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("Database error"));
var scheduleChecker = Substitute.For<IScheduleChecker>();
var callCount = 0;
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
callCount++;
return new List<DataUpdateTask>();
});
var notificationService = Substitute.For<ISearchNotificationService>();
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(50)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Act - should not throw
await sut.StartAsync(cts.Token);
await Task.Delay(150);
await sut.StopAsync(CancellationToken.None);
// Assert - service continues running after startup error
callCount.ShouldBeGreaterThan(0);
}
#endregion
#region Priority Processing
[Fact]
public async Task DoWorkAsync_WhenPendingTasks_ExecutesSyncs()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var scheduleChecker = Substitute.For<IScheduleChecker>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask> { CreateTask("TestTable", UpdateTypes.Daily) });
var notificationService = Substitute.For<ISearchNotificationService>();
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService,
orchestrator: orchestrator);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(100)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Act
await sut.StartAsync(cts.Token);
await Task.Delay(150);
await sut.StopAsync(CancellationToken.None);
// Assert
await orchestrator.Received().ExecutePendingSyncsAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task DoWorkAsync_WhenNoPendingTasks_ChecksForQueuedSearches()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
var scheduleChecker = Substitute.For<IScheduleChecker>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask>());
var notificationService = Substitute.For<ISearchNotificationService>();
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(100)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Act
await sut.StartAsync(cts.Token);
await Task.Delay(150);
await sut.StopAsync(CancellationToken.None);
// Assert
await searchRepo.Received().GetNextQueuedSearchAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task DoWorkAsync_WhenQueuedSearchExists_ExecutesSearch()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
var queuedSearch = new Search { Id = 42 };
searchRepo.GetNextQueuedSearchAsync(Arg.Any<CancellationToken>())
.Returns(queuedSearch);
var searchExecution = Substitute.For<ISearchExecutionService>();
var scheduleChecker = Substitute.For<IScheduleChecker>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask>());
var notificationService = Substitute.For<ISearchNotificationService>();
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService,
searchExecution: searchExecution);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(100)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Act
await sut.StartAsync(cts.Token);
await Task.Delay(150);
await sut.StopAsync(CancellationToken.None);
// Assert
await searchExecution.Received().ExecuteSearchAsync(
Arg.Is<Search>(s => s.Id == 42),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task DoWorkAsync_WhenPendingTasks_DoesNotProcessSearches()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var scheduleChecker = Substitute.For<IScheduleChecker>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask> { CreateTask("TestTable", UpdateTypes.Daily) });
var notificationService = Substitute.For<ISearchNotificationService>();
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService,
orchestrator: orchestrator);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(100)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Act
await sut.StartAsync(cts.Token);
await Task.Delay(150);
await sut.StopAsync(CancellationToken.None);
// Assert - when syncs are pending, searches are not processed
await searchRepo.DidNotReceive().GetNextQueuedSearchAsync(Arg.Any<CancellationToken>());
}
#endregion
#region Error Handling
[Fact]
public async Task ExecuteAsync_WhenDoWorkThrows_ContinuesLoop()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
var callCount = 0;
var scheduleChecker = Substitute.For<IScheduleChecker>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
callCount++;
if (callCount == 1)
{
throw new Exception("Test error");
}
return new List<DataUpdateTask>();
});
var notificationService = Substitute.For<ISearchNotificationService>();
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(50)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
// Act
await sut.StartAsync(cts.Token);
await Task.Delay(250);
await sut.StopAsync(CancellationToken.None);
// Assert - should have been called multiple times despite first error
callCount.ShouldBeGreaterThan(1);
}
#endregion
#region Status Notifications
[Fact]
public async Task NotifyStatusSafeAsync_WhenNotificationThrows_DoesNotCrash()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
var scheduleChecker = Substitute.For<IScheduleChecker>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask>());
var notificationService = Substitute.For<ISearchNotificationService>();
notificationService.NotifyStatusAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("SignalR error"));
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(100)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Act - should not throw
await sut.StartAsync(cts.Token);
await Task.Delay(150);
await sut.StopAsync(CancellationToken.None);
// Assert - no exception thrown, service runs
}
#endregion
#region Graceful Shutdown
[Fact]
public async Task ExecuteAsync_WhenCancelled_StopsGracefully()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
var scheduleChecker = Substitute.For<IScheduleChecker>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask>());
var notificationService = Substitute.For<ISearchNotificationService>();
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromSeconds(10) // Long interval
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await sut.StartAsync(cts.Token);
await Task.Delay(50);
cts.Cancel();
var stopTask = sut.StopAsync(CancellationToken.None);
var completed = await Task.WhenAny(stopTask, Task.Delay(2000));
// Assert - should complete without hanging
completed.ShouldBe(stopTask);
}
[Fact]
public async Task ExecuteAsync_WhenCancelledDuringWork_HandlesOperationCanceledException()
{
// Arrange
var dataUpdateRepo = Substitute.For<IDataUpdateRepository>();
var searchRepo = Substitute.For<ISearchRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var cts = new CancellationTokenSource();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(async x =>
{
cts.Cancel();
await Task.Delay(100, x.Arg<CancellationToken>());
});
var scheduleChecker = Substitute.For<IScheduleChecker>();
scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask> { CreateTask("TestTable", UpdateTypes.Daily) });
var notificationService = Substitute.For<ISearchNotificationService>();
var scopeFactory = SetupScopeFactory(
dataUpdateRepo,
searchRepo,
scheduleChecker,
notificationService,
orchestrator: orchestrator);
var options = MsOptions.Create(new WorkProcessorOptions
{
Enabled = true,
WorkInterval = TimeSpan.FromMilliseconds(100)
});
var metrics = CreateMetrics();
var sut = new WorkProcessor(
scopeFactory,
options,
NullLogger<WorkProcessor>.Instance,
metrics);
// Act
await sut.StartAsync(cts.Token);
await Task.Delay(200);
var stopTask = sut.StopAsync(CancellationToken.None);
var completed = await Task.WhenAny(stopTask, Task.Delay(2000));
// Assert - should complete gracefully
completed.ShouldBe(stopTask);
cts.Dispose();
}
#endregion
#region Helper Methods
private static DataSyncMetrics CreateMetrics()
{
var services = new ServiceCollection();
services.AddMetrics();
var provider = services.BuildServiceProvider();
var meterFactory = provider.GetRequiredService<IMeterFactory>();
return new DataSyncMetrics(meterFactory);
}
private static IServiceScopeFactory SetupScopeFactory(
IDataUpdateRepository dataUpdateRepo,
ISearchRepository searchRepo,
IScheduleChecker scheduleChecker,
ISearchNotificationService notificationService,
ISyncOrchestrator? orchestrator = null,
ISearchExecutionService? searchExecution = null)
{
orchestrator ??= Substitute.For<ISyncOrchestrator>();
searchExecution ??= Substitute.For<ISearchExecutionService>();
var services = new ServiceCollection();
services.AddScoped(_ => dataUpdateRepo);
services.AddScoped(_ => searchRepo);
services.AddScoped(_ => scheduleChecker);
services.AddScoped(_ => notificationService);
services.AddScoped(_ => orchestrator);
services.AddScoped(_ => searchExecution);
var serviceProvider = services.BuildServiceProvider();
return serviceProvider.GetRequiredService<IServiceScopeFactory>();
}
private static DataUpdateTask CreateTask(string tableName, UpdateTypes updateType)
{
return new DataUpdateTask
{
TableName = tableName,
SourceSystem = "JDE",
SourceData = tableName.ToUpper(),
UpdateType = updateType,
MinimumDt = null,
Config = new DataSourceConfig
{
TableName = tableName,
SourceSystem = "JDE",
SourceData = tableName.ToUpper(),
IsEnabled = true,
MassConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 10080 },
DailyConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 1440 },
HourlyConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 60 }
}
};
}
#endregion
}