604bfe919c
Apply comprehensive fixes from code reviews including: - Extract shared utilities (SqlFormatHelper, CellValueConverter, DbDestinationBase) - Add interface abstractions (IAuthenticationService, IDatabaseMigrator, IMisQueryBuilder) - Implement SecureStore for encrypted secrets storage - Fix error handling with proper HTTP status codes and logging - Optimize double enumeration in DevEtlRegistry - Add DataSync.Dev README for developer onboarding - Extract filter panel base classes to reduce duplication - Update code review docs to mark all issues as fixed
547 lines
19 KiB
C#
547 lines
19 KiB
C#
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<SearchModel>(), 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<SearchModel>(), 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<SearchModel>(), Arg.Any<CancellationToken>())
|
|
.Returns(excelBytes);
|
|
|
|
var sut = CreateService();
|
|
|
|
// Act
|
|
await sut.ExecuteSearchAsync(search);
|
|
|
|
// Assert
|
|
await _excelExportService.Received(1).GenerateAsync(
|
|
Arg.Any<SearchModel>(),
|
|
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<SearchModel>(), 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<SearchModel>(), 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<SearchModel>(), 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<SearchModel>(), 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<SearchModel>(), 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);
|
|
}
|
|
}
|