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; /// /// Unit tests for SearchExecutionService. /// Tests cancellation handling, error paths, and success scenarios. /// public class SearchExecutionServiceTests { private readonly ISearchRepository _searchRepository; private readonly ISearchProcessor _searchProcessor; private readonly IExcelExportService _excelExportService; private readonly ISearchNotificationService _notificationService; private readonly IOptions _options; private readonly ILogger _logger; public SearchExecutionServiceTests() { _searchRepository = Substitute.For(); _searchProcessor = Substitute.For(); _excelExportService = Substitute.For(); _notificationService = Substitute.For(); _options = MsOptions.Create(new WorkProcessorOptions { SearchTimeout = TimeSpan.FromSeconds(30) }); _logger = Substitute.For>(); } #region Constructor Tests [Fact] public void Constructor_WithNullSearchRepository_ThrowsArgumentNullException() { // Act & Assert Should.Throw(() => new SearchExecutionService( null!, _searchProcessor, _excelExportService, _notificationService, _options, _logger)); } [Fact] public void Constructor_WithNullSearchProcessor_ThrowsArgumentNullException() { // Act & Assert Should.Throw(() => new SearchExecutionService( _searchRepository, null!, _excelExportService, _notificationService, _options, _logger)); } [Fact] public void Constructor_WithNullExcelExportService_ThrowsArgumentNullException() { // Act & Assert Should.Throw(() => new SearchExecutionService( _searchRepository, _searchProcessor, null!, _notificationService, _options, _logger)); } [Fact] public void Constructor_WithNullNotificationService_ThrowsArgumentNullException() { // Act & Assert Should.Throw(() => new SearchExecutionService( _searchRepository, _searchProcessor, _excelExportService, null!, _options, _logger)); } [Fact] public void Constructor_WithNullOptions_ThrowsArgumentNullException() { // Act & Assert Should.Throw(() => new SearchExecutionService( _searchRepository, _searchProcessor, _excelExportService, _notificationService, null!, _logger)); } [Fact] public void Constructor_WithNullLogger_ThrowsArgumentNullException() { // Act & Assert Should.Throw(() => 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(), Arg.Any()) .Returns(model); _excelExportService.GenerateAsync(Arg.Any(), Arg.Any()) .Returns(excelBytes); var sut = CreateService(); // Act await sut.ExecuteSearchAsync(search); // Assert await _searchRepository.Received(1).StartSearchAsync(1, Arg.Any()); } [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(), Arg.Any()) .Returns(model); _excelExportService.GenerateAsync(Arg.Any(), Arg.Any()) .Returns(excelBytes); var sut = CreateService(); // Act await sut.ExecuteSearchAsync(search); // Assert await _searchProcessor.Received(1).ExecuteSearchToModelAsync( Arg.Is(m => m.Id == 42), Arg.Any()); } [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(), Arg.Any()) .Returns(model); _excelExportService.GenerateAsync(Arg.Any(), Arg.Any()) .Returns(excelBytes); var sut = CreateService(); // Act await sut.ExecuteSearchAsync(search); // Assert await _excelExportService.Received(1).GenerateAsync( Arg.Any(), Arg.Any()); } [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(), Arg.Any()) .Returns(model); _excelExportService.GenerateAsync(Arg.Any(), Arg.Any()) .Returns(excelBytes); var sut = CreateService(); // Act await sut.ExecuteSearchAsync(search); // Assert await _searchRepository.Received(1).CompleteSearchAsync(1, true, excelBytes, Arg.Any()); } [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(), Arg.Any()) .Returns(model); _excelExportService.GenerateAsync(Arg.Any(), Arg.Any()) .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()); } [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(), Arg.Any()) .Returns(model); _excelExportService.GenerateAsync(Arg.Any(), Arg.Any()) .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(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("Test error")); var sut = CreateService(); // Act await sut.ExecuteSearchAsync(search); // Assert await _searchRepository.Received(1).CompleteSearchAsync(1, false, null, Arg.Any()); } [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(), Arg.Any()) .Returns(model); _excelExportService.GenerateAsync(Arg.Any(), Arg.Any()) .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()); } [Fact] public async Task ExecuteSearchAsync_ProcessorThrows_UpdatesSearchStatusToError() { // Arrange var search = new Search { Id = 1, Status = SearchStatus.Queued }; _searchProcessor.ExecuteSearchToModelAsync(Arg.Any(), Arg.Any()) .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(), Arg.Any()) .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()); } [Fact] public async Task ExecuteSearchAsync_ErrorDuringComplete_DoesNotThrow() { // Arrange var search = new Search { Id = 1, Status = SearchStatus.Queued }; _searchProcessor.ExecuteSearchToModelAsync(Arg.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("Test error")); _searchRepository.CompleteSearchAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .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(), Arg.Any()) .Returns(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(), false, Arg.Any(), Arg.Any()); } [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(), Arg.Any()) .Returns(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(), Arg.Any()) .Returns(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(), Arg.Any()) .Returns(async callInfo => { var ct = callInfo.Arg(); 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()); } [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(), Arg.Any()) .Returns(async callInfo => { var ct = callInfo.Arg(); 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(), Arg.Any()) .Returns(model); _excelExportService.GenerateAsync(Arg.Any(), Arg.Any()) .Returns(excelBytes); _notificationService.NotifySearchUpdateAsync(Arg.Any(), Arg.Any()) .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()); } #endregion private SearchExecutionService CreateService() { return new SearchExecutionService( _searchRepository, _searchProcessor, _excelExportService, _notificationService, _options, _logger); } }