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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user