Initial commit: JDE Scoping Tool migration project

Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
This commit is contained in:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
@@ -0,0 +1,296 @@
using JdeScoping.Core.Models.Enums;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
namespace JdeScoping.DataSync.Tests;
/// <summary>
/// Unit tests for DataSyncHealthCheck.
/// Tests health check scenarios: Healthy, Degraded, Unhealthy.
/// </summary>
public class DataSyncHealthCheckTests
{
private readonly IDataUpdateRepository _repository;
private readonly DataSyncHealthCheck _sut;
public DataSyncHealthCheckTests()
{
_repository = Substitute.For<IDataUpdateRepository>();
_sut = new DataSyncHealthCheck(_repository);
}
#region Healthy Scenarios
[Fact]
public async Task CheckHealthAsync_AllSyncsCurrent_ReturnsHealthy()
{
// Arrange
var statuses = new List<TableSyncStatus>
{
CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: false, recentFailures: 0),
CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: false, recentFailures: 0),
CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 0)
};
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.Returns(statuses);
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert
result.Status.ShouldBe(HealthStatus.Healthy);
result.Description.ShouldBe("All syncs current");
}
[Fact]
public async Task CheckHealthAsync_SlightlyOverdueButProgressing_ReturnsHealthyWithNote()
{
// Arrange: Some tables slightly overdue but no failures
var statuses = new List<TableSyncStatus>
{
CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: false, recentFailures: 0),
CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: true, recentFailures: 0),
CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 0),
CreateSyncStatus("LotUsage", UpdateTypes.Daily, isOverdue: false, recentFailures: 0)
};
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.Returns(statuses);
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert: With only 1 of 4 overdue (< half), still healthy
result.Status.ShouldBe(HealthStatus.Healthy);
result.Description!.ShouldContain("slightly overdue");
}
#endregion
#region Degraded Scenarios
[Fact]
public async Task CheckHealthAsync_MajorityOverdue_ReturnsDegraded()
{
// Arrange: More than half of tables overdue
var statuses = new List<TableSyncStatus>
{
CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: true, recentFailures: 0),
CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: true, recentFailures: 0),
CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: true, recentFailures: 0),
CreateSyncStatus("LotUsage", UpdateTypes.Daily, isOverdue: false, recentFailures: 0)
};
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.Returns(statuses);
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert
result.Status.ShouldBe(HealthStatus.Degraded);
result.Description!.ShouldContain("overdue");
}
[Fact]
public async Task CheckHealthAsync_SingleRecentFailure_ReturnsDegraded()
{
// Arrange: One table with recent failures
var statuses = new List<TableSyncStatus>
{
CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: false, recentFailures: 1),
CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: false, recentFailures: 0),
CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 0)
};
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.Returns(statuses);
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert
result.Status.ShouldBe(HealthStatus.Degraded);
result.Description!.ShouldContain("failures");
}
[Fact]
public async Task CheckHealthAsync_TwoTablesWithFailures_ReturnsDegraded()
{
// Arrange: Two tables with failures (at threshold)
var statuses = new List<TableSyncStatus>
{
CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: false, recentFailures: 2),
CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 1),
CreateSyncStatus("Item", UpdateTypes.Mass, isOverdue: false, recentFailures: 0)
};
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.Returns(statuses);
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert
result.Status.ShouldBe(HealthStatus.Degraded);
}
#endregion
#region Unhealthy Scenarios
[Fact]
public async Task CheckHealthAsync_MultipleRecentFailures_ReturnsUnhealthy()
{
// Arrange: More than 2 tables with recent failures
var statuses = new List<TableSyncStatus>
{
CreateSyncStatus("WorkOrder", UpdateTypes.Mass, isOverdue: false, recentFailures: 1),
CreateSyncStatus("LotUsage", UpdateTypes.Mass, isOverdue: false, recentFailures: 1),
CreateSyncStatus("Item", UpdateTypes.Mass, isOverdue: false, recentFailures: 1),
CreateSyncStatus("Lot", UpdateTypes.Mass, isOverdue: false, recentFailures: 0)
};
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.Returns(statuses);
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert
result.Status.ShouldBe(HealthStatus.Unhealthy);
result.Description!.ShouldContain("Multiple recent sync failures");
}
[Fact]
public async Task CheckHealthAsync_RepositoryThrows_ReturnsUnhealthy()
{
// Arrange
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("Database connection failed"));
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert
result.Status.ShouldBe(HealthStatus.Unhealthy);
result.Description.ShouldBe("Unable to check sync status");
result.Exception.ShouldNotBeNull();
result.Exception.Message.ShouldBe("Database connection failed");
}
#endregion
#region Diagnostic Data Scenarios
[Fact]
public async Task CheckHealthAsync_IncludesPerTableDiagnostics()
{
// Arrange
var now = DateTime.UtcNow;
var statuses = new List<TableSyncStatus>
{
new("WorkOrder", UpdateTypes.Mass, now.AddHours(-1), true, 10080, false, 0),
new("WorkOrder", UpdateTypes.Daily, now.AddHours(-12), true, 1440, false, 0)
};
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.Returns(statuses);
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert
result.Data.ShouldNotBeNull();
result.Data.ShouldContainKey("WorkOrder_Mass_LastSync");
result.Data.ShouldContainKey("WorkOrder_Mass_Status");
result.Data.ShouldContainKey("WorkOrder_Mass_RecentFailures");
result.Data.ShouldContainKey("WorkOrder_Daily_LastSync");
result.Data.ShouldContainKey("TotalTables");
result.Data.ShouldContainKey("OverdueCount");
result.Data.ShouldContainKey("FailedCount");
}
[Fact]
public async Task CheckHealthAsync_NeverSynced_ShowsNeverInDiagnostics()
{
// Arrange
var statuses = new List<TableSyncStatus>
{
new("WorkOrder", UpdateTypes.Mass, null, false, 10080, true, 0)
};
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.Returns(statuses);
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert
result.Data["WorkOrder_Mass_LastSync"].ShouldBe("Never");
}
[Fact]
public async Task CheckHealthAsync_OverdueTable_ShowsOverdueInDiagnostics()
{
// Arrange
var statuses = new List<TableSyncStatus>
{
CreateSyncStatus("WorkOrder", UpdateTypes.Daily, isOverdue: true, recentFailures: 0)
};
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.Returns(statuses);
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert
result.Data["WorkOrder_Daily_Status"].ShouldBe("Overdue");
}
#endregion
#region Edge Cases
[Fact]
public async Task CheckHealthAsync_EmptyStatusList_ReturnsHealthy()
{
// Arrange: No tables configured
_repository.GetSyncStatusAsync(Arg.Any<CancellationToken>())
.Returns(new List<TableSyncStatus>());
// Act
var result = await _sut.CheckHealthAsync(new HealthCheckContext());
// Assert
result.Status.ShouldBe(HealthStatus.Healthy);
}
#endregion
#region Helper Methods
private static TableSyncStatus CreateSyncStatus(
string tableName,
UpdateTypes updateType,
bool isOverdue,
int recentFailures)
{
return new TableSyncStatus(
TableName: tableName,
UpdateType: updateType,
LastSyncTime: DateTime.UtcNow.AddHours(-1),
WasSuccessful: recentFailures == 0,
ExpectedIntervalMinutes: 1440,
IsOverdue: isOverdue,
RecentFailures: recentFailures);
}
#endregion
}
@@ -0,0 +1,298 @@
using System.Diagnostics.Metrics;
using JdeScoping.DataSync.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
namespace JdeScoping.DataSync.Tests;
/// <summary>
/// Unit tests for DataSyncMetrics.
/// Tests counter increments and histogram recordings.
/// </summary>
public class DataSyncMetricsTests : IDisposable
{
private readonly MeterListener _meterListener;
private readonly DataSyncMetrics _sut;
private readonly List<Measurement<long>> _longMeasurements = [];
private readonly List<Measurement<double>> _doubleMeasurements = [];
public DataSyncMetricsTests()
{
var services = new ServiceCollection();
services.AddMetrics();
var provider = services.BuildServiceProvider();
var meterFactory = provider.GetRequiredService<IMeterFactory>();
_sut = new DataSyncMetrics(meterFactory);
// Set up meter listener to capture measurements
_meterListener = new MeterListener();
_meterListener.InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == "JdeScoping.DataSync")
{
listener.EnableMeasurementEvents(instrument);
}
};
_meterListener.SetMeasurementEventCallback<long>(OnMeasurementRecorded);
_meterListener.SetMeasurementEventCallback<double>(OnDoubleMeasurementRecorded);
_meterListener.Start();
}
public void Dispose()
{
_meterListener.Dispose();
}
private void OnMeasurementRecorded(
Instrument instrument,
long measurement,
ReadOnlySpan<KeyValuePair<string, object?>> tags,
object? state)
{
_longMeasurements.Add(new Measurement<long>(measurement, tags.ToArray()));
}
private void OnDoubleMeasurementRecorded(
Instrument instrument,
double measurement,
ReadOnlySpan<KeyValuePair<string, object?>> tags,
object? state)
{
_doubleMeasurements.Add(new Measurement<double>(measurement, tags.ToArray()));
}
#region Operation Started Counter Tests
[Fact]
public void RecordOperationStarted_IncrementsCounter()
{
// Act
_sut.RecordOperationStarted("WorkOrder", "Mass");
_meterListener.RecordObservableInstruments();
// Assert
var measurement = _longMeasurements.FirstOrDefault(m =>
m.Tags.Any(t => t.Key == "table" && t.Value?.ToString() == "WorkOrder") &&
m.Tags.Any(t => t.Key == "type" && t.Value?.ToString() == "Mass"));
measurement.Value.ShouldBe(1);
}
[Fact]
public void RecordOperationStarted_MultipleCalls_AccumulatesCount()
{
// Act
_sut.RecordOperationStarted("WorkOrder", "Mass");
_sut.RecordOperationStarted("WorkOrder", "Mass");
_sut.RecordOperationStarted("LotUsage", "Daily");
_meterListener.RecordObservableInstruments();
// Assert
var workOrderMeasurements = _longMeasurements
.Where(m => m.Tags.Any(t => t.Key == "table" && t.Value?.ToString() == "WorkOrder"))
.ToList();
workOrderMeasurements.Count.ShouldBe(2);
}
[Fact]
public void RecordOperationStarted_DifferentTables_TrackedSeparately()
{
// Act
_sut.RecordOperationStarted("WorkOrder", "Mass");
_sut.RecordOperationStarted("LotUsage", "Daily");
_meterListener.RecordObservableInstruments();
// Assert
_longMeasurements.Any(m =>
m.Tags.Any(t => t.Key == "table" && (t.Value as string) == "WorkOrder")).ShouldBeTrue();
_longMeasurements.Any(m =>
m.Tags.Any(t => t.Key == "table" && (t.Value as string) == "LotUsage")).ShouldBeTrue();
}
#endregion
#region Operation Completed Counter Tests
[Fact]
public void RecordOperationCompleted_IncrementsCounterAndRecordsHistograms()
{
// Act
_sut.RecordOperationCompleted("WorkOrder", "Mass", recordCount: 5000, durationSeconds: 12.5);
_meterListener.RecordObservableInstruments();
// Assert: Counter incremented
var counterMeasurement = _longMeasurements.FirstOrDefault(m =>
m.Tags.Any(t => t.Key == "table" && t.Value?.ToString() == "WorkOrder"));
counterMeasurement.Value.ShouldBe(1);
// Assert: Duration histogram recorded
var durationMeasurement = _doubleMeasurements.FirstOrDefault(m =>
m.Tags.Any(t => t.Key == "table" && t.Value?.ToString() == "WorkOrder"));
durationMeasurement.Value.ShouldBe(12.5);
// Assert: Records histogram recorded
var recordsMeasurement = _longMeasurements.FirstOrDefault(m =>
m.Value == 5000);
recordsMeasurement.Value.ShouldBe(5000);
}
[Fact]
public void RecordOperationCompleted_WithZeroRecords_StillRecords()
{
// Act
_sut.RecordOperationCompleted("Item", "Hourly", recordCount: 0, durationSeconds: 0.5);
_meterListener.RecordObservableInstruments();
// Assert
_longMeasurements.ShouldContain(m => m.Value == 0);
}
[Fact]
public void RecordOperationCompleted_WithLargeRecordCount_HandlesCorrectly()
{
// Act
_sut.RecordOperationCompleted("WorkOrder", "Mass", recordCount: 10_000_000, durationSeconds: 300.0);
_meterListener.RecordObservableInstruments();
// Assert
_longMeasurements.ShouldContain(m => m.Value == 10_000_000);
}
#endregion
#region Operation Failed Counter Tests
[Fact]
public void RecordOperationFailed_IncrementsCounter()
{
// Act
_sut.RecordOperationFailed("WorkOrder", "Daily");
_meterListener.RecordObservableInstruments();
// Assert
var measurement = _longMeasurements.FirstOrDefault(m =>
m.Tags.Any(t => t.Key == "table" && t.Value?.ToString() == "WorkOrder") &&
m.Tags.Any(t => t.Key == "type" && t.Value?.ToString() == "Daily"));
measurement.Value.ShouldBe(1);
}
[Fact]
public void RecordOperationFailed_MultipleFailures_AccumulatesCount()
{
// Act
_sut.RecordOperationFailed("WorkOrder", "Daily");
_sut.RecordOperationFailed("WorkOrder", "Daily");
_sut.RecordOperationFailed("WorkOrder", "Daily");
_meterListener.RecordObservableInstruments();
// Assert
var failedMeasurements = _longMeasurements
.Where(m => m.Tags.Any(t => t.Key == "table" && t.Value?.ToString() == "WorkOrder"))
.ToList();
failedMeasurements.Count.ShouldBe(3);
}
#endregion
#region Cycle Error Counter Tests
[Fact]
public void RecordCycleError_IncrementsCounter()
{
// Act
_sut.RecordCycleError();
_meterListener.RecordObservableInstruments();
// Assert
_longMeasurements.ShouldContain(m => m.Value == 1);
}
#endregion
#region Cycle Completed Counter Tests
[Fact]
public void RecordCycleCompleted_IncrementsCounterWithTags()
{
// Act
_sut.RecordCycleCompleted(successCount: 5, failedCount: 2, durationSeconds: 45.0);
_meterListener.RecordObservableInstruments();
// Assert
var measurement = _longMeasurements.FirstOrDefault(m =>
m.Tags.Any(t => t.Key == "success_count" && (int)t.Value! == 5) &&
m.Tags.Any(t => t.Key == "failed_count" && (int)t.Value! == 2));
measurement.Value.ShouldBe(1);
}
[Fact]
public void RecordCycleCompleted_AllSuccessful_RecordsCorrectly()
{
// Act
_sut.RecordCycleCompleted(successCount: 10, failedCount: 0, durationSeconds: 30.0);
_meterListener.RecordObservableInstruments();
// Assert
_longMeasurements.ShouldContain(m =>
m.Tags.Any(t => t.Key == "success_count" && (int)t.Value! == 10) &&
m.Tags.Any(t => t.Key == "failed_count" && (int)t.Value! == 0));
}
[Fact]
public void RecordCycleCompleted_AllFailed_RecordsCorrectly()
{
// Act
_sut.RecordCycleCompleted(successCount: 0, failedCount: 5, durationSeconds: 10.0);
_meterListener.RecordObservableInstruments();
// Assert
_longMeasurements.ShouldContain(m =>
m.Tags.Any(t => t.Key == "success_count" && (int)t.Value! == 0) &&
m.Tags.Any(t => t.Key == "failed_count" && (int)t.Value! == 5));
}
#endregion
#region Tag Verification Tests
[Fact]
public void AllOperationMetrics_IncludeTableAndTypeTags()
{
// Act
_sut.RecordOperationStarted("TestTable", "TestType");
_sut.RecordOperationCompleted("TestTable", "TestType", 100, 1.0);
_sut.RecordOperationFailed("TestTable", "TestType");
_meterListener.RecordObservableInstruments();
// Assert: All measurements should have both table and type tags
foreach (var measurement in _longMeasurements.Take(3)) // First 3 are from the calls above
{
measurement.Tags.ShouldContain(t => t.Key == "table");
measurement.Tags.ShouldContain(t => t.Key == "type");
}
}
#endregion
}
/// <summary>
/// Represents a recorded measurement with its value and tags.
/// </summary>
/// <typeparam name="T">The measurement value type.</typeparam>
public struct Measurement<T>
{
public T Value { get; }
public KeyValuePair<string, object?>[] Tags { get; }
public Measurement(T value, KeyValuePair<string, object?>[] tags)
{
Value = value;
Tags = tags;
}
}
@@ -0,0 +1,671 @@
using System.Diagnostics.Metrics;
using JdeScoping.DataSync.Configuration;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Tests;
/// <summary>
/// Integration tests for DataSyncService.
/// These tests verify the service lifecycle and orchestration behavior.
/// </summary>
public class DataSyncServiceTests
{
#region Service Startup and Shutdown
[Fact]
public async Task ExecuteAsync_WhenDisabled_ExitsImmediately()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = false
});
var services = new ServiceCollection();
services.AddSingleton(Substitute.For<IDataUpdateRepository>());
services.AddSingleton(Substitute.For<ISyncOrchestrator>());
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
var task = service.StartAsync(cts.Token);
await Task.Delay(100); // Give it time to start
// Assert: Service should complete quickly since it's disabled
await service.StopAsync(CancellationToken.None);
task.IsCompleted.ShouldBeTrue();
}
[Fact]
public async Task ExecuteAsync_WhenEnabled_StartsAndCanBeStopped()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(100)
});
var repository = Substitute.For<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns(0);
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(350); // Let it run a few cycles
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Should have called orchestrator at least once
orchestratorCallCount.ShouldBeGreaterThan(0);
}
[Fact]
public async Task ExecuteAsync_GracefulShutdown_CompletesCleanly()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromSeconds(10) // Long interval
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
// Request cancellation after brief delay
await Task.Delay(50);
cts.Cancel();
// Should not throw and should complete
await service.StopAsync(CancellationToken.None);
// Assert: No exceptions thrown during shutdown
}
#endregion
#region CloseOpenUpdateEntries at Startup
[Fact]
public async Task ExecuteAsync_AtStartup_CallsCloseOpenUpdateEntries()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var closeEntriesCallCount = 0;
var repository = Substitute.For<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
closeEntriesCallCount++;
return Task.FromResult(0);
});
var orchestrator = Substitute.For<ISyncOrchestrator>();
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert
closeEntriesCallCount.ShouldBe(1);
}
[Fact]
public async Task ExecuteAsync_WhenCloseOpenEntriesFindsEntries_LogsAndContinues()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns(5); // Found 5 interrupted entries
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(150);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Should have continued to orchestrator after close
orchestratorCallCount.ShouldBeGreaterThan(0);
}
[Fact]
public async Task ExecuteAsync_WhenCloseOpenEntriesThrows_ContinuesStarting()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
repository.CloseOpenUpdateEntriesAsync(Arg.Any<CancellationToken>())
.Returns<int>(x => throw new Exception("Database error"));
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act - Should not throw even if CloseOpenUpdateEntries fails
await service.StartAsync(cts.Token);
await Task.Delay(150);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Should have continued and called orchestrator
orchestratorCallCount.ShouldBeGreaterThan(0);
}
#endregion
#region Parallel Sync Execution
[Fact]
public async Task ExecuteAsync_CallsOrchestratorForParallelExecution()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50),
MaxDegreeOfParallelism = 4
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestratorCallCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
orchestratorCallCount++;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(200); // Let multiple cycles run
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Orchestrator should be called to handle parallel execution
orchestratorCallCount.ShouldBeGreaterThan(0);
}
[Fact]
public async Task ExecuteAsync_WhenOrchestratorThrows_ContinuesNextCycle()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
var callCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
callCount++;
if (callCount == 1)
{
throw new Exception("Sync error");
}
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(250); // Let multiple cycles run
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Should have been called multiple times despite first failure
callCount.ShouldBeGreaterThan(1);
}
#endregion
#region Cancellation Handling
[Fact]
public async Task ExecuteAsync_WhenCancelled_StopsGracefully()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromSeconds(10)
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
// Make orchestrator take some time but respect cancellation
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(async x =>
{
try
{
await Task.Delay(5000, x.Arg<CancellationToken>());
}
catch (OperationCanceledException)
{
// Expected - swallow and return
}
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
// Cancel while orchestrator is running
cts.Cancel();
// Should complete without hanging
var stopTask = service.StopAsync(CancellationToken.None);
var completed = await Task.WhenAny(stopTask, Task.Delay(2000));
// Assert: Should complete, not hang
completed.ShouldBe(stopTask);
}
[Fact]
public async Task ExecuteAsync_PassesCancellationTokenToOrchestrator()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var tokenWasProvided = false;
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
var token = x.Arg<CancellationToken>();
tokenWasProvided = token != default;
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Token should have been passed
tokenWasProvided.ShouldBeTrue();
}
[Fact]
public async Task ExecuteAsync_WhenCancelledDuringDelay_ExitsCleanly()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMinutes(5) // Long delay
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
// Service should be in delay after first cycle
await Task.Delay(100);
// Cancel during delay
cts.Cancel();
// Should exit quickly
var stopTask = service.StopAsync(CancellationToken.None);
var completed = await Task.WhenAny(stopTask, Task.Delay(1000));
// Assert
completed.ShouldBe(stopTask);
}
#endregion
#region Service Scope Isolation
[Fact]
public async Task ExecuteAsync_UsesNewScopePerCycle()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
var orchestrator = Substitute.For<ISyncOrchestrator>();
var scopeCount = 0;
var services = new ServiceCollection();
services.AddScoped<IDataUpdateRepository>(sp =>
{
Interlocked.Increment(ref scopeCount);
return repository;
});
services.AddScoped<ISyncOrchestrator>(sp => orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(200); // Multiple cycles
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Multiple scopes should have been created
scopeCount.ShouldBeGreaterThan(1);
}
#endregion
#region Error Handling and Metrics
[Fact]
public async Task ExecuteAsync_WhenSyncFails_ContinuesRunning()
{
// Arrange
var options = Options.Create(new DataSyncOptions
{
Enabled = true,
CheckInterval = TimeSpan.FromMilliseconds(50)
});
var repository = Substitute.For<IDataUpdateRepository>();
var callCount = 0;
var orchestrator = Substitute.For<ISyncOrchestrator>();
orchestrator.ExecutePendingSyncsAsync(Arg.Any<CancellationToken>())
.Returns(x =>
{
callCount++;
throw new Exception("Sync failed");
});
var services = new ServiceCollection();
services.AddSingleton(repository);
services.AddSingleton(orchestrator);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var metrics = CreateMetrics();
var service = new DataSyncService(
scopeFactory,
options,
NullLogger<DataSyncService>.Instance,
metrics);
using var cts = new CancellationTokenSource();
// Act
await service.StartAsync(cts.Token);
await Task.Delay(200);
cts.Cancel();
await service.StopAsync(CancellationToken.None);
// Assert: Should have continued calling orchestrator despite failures
callCount.ShouldBeGreaterThan(1);
}
#endregion
#region Helper Methods
private static DataSyncMetrics CreateMetrics()
{
// Use real MeterFactory since mocking Meter is complex
var services = new ServiceCollection();
services.AddMetrics();
var provider = services.BuildServiceProvider();
var meterFactory = provider.GetRequiredService<IMeterFactory>();
return new DataSyncMetrics(meterFactory);
}
#endregion
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\JdeScoping.DataSync\JdeScoping.DataSync.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,708 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Infrastructure;
using JdeScoping.DataSync.Configuration;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using Shouldly;
namespace JdeScoping.DataSync.Tests;
/// <summary>
/// Unit tests for ScheduleChecker service.
/// </summary>
public class ScheduleCheckerTests
{
private readonly IDataUpdateRepository _repository;
private readonly IOptions<DataSyncOptions> _options;
private readonly ScheduleChecker _sut;
public ScheduleCheckerTests()
{
_repository = Substitute.For<IDataUpdateRepository>();
_options = Options.Create(new DataSyncOptions
{
LookbackMultiplier = 3,
DataSources = []
});
_sut = new ScheduleChecker(
_repository,
_options,
NullLogger<ScheduleChecker>.Instance);
}
#region Priority Tests - Mass > Daily > Hourly
[Fact]
public async Task GetPendingTasksAsync_WhenMassNeverRun_ReturnsMassTask()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, dailyEnabled: true, hourlyEnabled: true);
_options.Value.DataSources.Add(config);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>());
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldHaveSingleItem();
tasks[0].TableName.ShouldBe("WorkOrder");
tasks[0].UpdateType.ShouldBe(UpdateTypes.Mass);
tasks[0].MinimumDt.ShouldBeNull(); // Mass updates don't have MinimumDT
}
[Fact]
public async Task GetPendingTasksAsync_WhenMassDue_ReturnsMassOverDaily()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 60,
dailyEnabled: true, dailyInterval: 1440);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddMinutes(-120), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass } // 3 = UpdateTypes.Mass
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Mass);
}
[Fact]
public async Task GetPendingTasksAsync_WhenMassNotDue_ChecksDailyAndHourly()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080, // weekly
dailyEnabled: true, dailyInterval: 1440,
hourlyEnabled: true, hourlyInterval: 60);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddHours(-1), success: true);
var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-25), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass }, // Mass not due
{ "WorkOrder_2", lastDaily } // Daily is due (25 hrs > 1440 min)
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily);
}
[Fact]
public async Task GetPendingTasksAsync_WhenDailyDue_ReturnsDailyOverHourly()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440,
hourlyEnabled: true, hourlyInterval: 60);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-25), success: true);
var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddHours(-2), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass },
{ "WorkOrder_2", lastDaily },
{ "WorkOrder_1", lastHourly }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily);
}
[Fact]
public async Task GetPendingTasksAsync_WhenOnlyHourlyDue_ReturnsHourly()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440,
hourlyEnabled: true, hourlyInterval: 60);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-12), success: true);
var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddHours(-2), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass },
{ "WorkOrder_2", lastDaily },
{ "WorkOrder_1", lastHourly }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly);
}
#endregion
#region MinimumDT Calculation with Lookback
[Fact]
public async Task GetPendingTasksAsync_DailySync_CalculatesMinimumDTWithLookback()
{
// Arrange: LookbackMultiplier = 3, daily interval = 1440 min
// MinimumDT = lastDaily.EndDT - (3 * 1440) = lastDaily.EndDT - 4320 min = 3 days before lastDaily
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-2), success: true);
var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-25), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass },
{ "WorkOrder_2", lastDaily }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily);
tasks[0].MinimumDt.ShouldNotBeNull();
// Expected: lastDaily.EndDT - (3 * 1440 min) = lastDaily.EndDT - 3 days
var expectedMinimumDt = lastDaily.EndDt.AddMinutes(-3 * 1440);
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
}
[Fact]
public async Task GetPendingTasksAsync_HourlySync_UsesDailyTimestampForMinimumDT()
{
// Arrange: Per legacy behavior, hourly uses DAILY's timestamp for MinimumDT calculation
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440,
hourlyEnabled: true, hourlyInterval: 60);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-12), success: true);
var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddHours(-2), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass },
{ "WorkOrder_2", lastDaily },
{ "WorkOrder_1", lastHourly }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly);
tasks[0].MinimumDt.ShouldNotBeNull();
// Hourly uses daily's timestamp and daily's interval for lookback calculation
var expectedMinimumDt = lastDaily.EndDt.AddMinutes(-3 * 1440);
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
}
[Fact]
public async Task GetPendingTasksAsync_WithDifferentLookbackMultiplier_CalculatesCorrectly()
{
// Arrange: Test with multiplier = 5
_options.Value.LookbackMultiplier = 5;
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-2), success: true);
var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-25), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass },
{ "WorkOrder_2", lastDaily }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
var expectedMinimumDt = lastDaily.EndDt.AddMinutes(-5 * 1440);
tasks[0].MinimumDt!.Value.ShouldBe(expectedMinimumDt, TimeSpan.FromSeconds(1));
}
#endregion
#region Disabled Table Handling
[Fact]
public async Task GetPendingTasksAsync_DisabledDataSource_ReturnsNoTasks()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder", massEnabled: true);
config.IsEnabled = false;
_options.Value.DataSources.Add(config);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>());
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldBeEmpty();
}
[Fact]
public async Task GetPendingTasksAsync_DisabledMassSchedule_SkipsMass()
{
// Arrange: Mass disabled, Daily enabled
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: false,
dailyEnabled: true, dailyInterval: 1440);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
// Even with no mass ever run, if mass is disabled, should NOT require mass first
// However, current logic requires mass before daily, so this tests that properly
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert: Should return Daily since mass is disabled but already ran before
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily);
}
[Fact]
public async Task GetPendingTasksAsync_DisabledDailySchedule_SkipsDaily()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: false,
hourlyEnabled: true, hourlyInterval: 60);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddHours(-2), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass },
{ "WorkOrder_1", lastHourly }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert: Should return Hourly, skipping Daily
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly);
}
[Fact]
public async Task GetPendingTasksAsync_AllSchedulesDisabled_ReturnsNoTasks()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: false,
dailyEnabled: false,
hourlyEnabled: false);
_options.Value.DataSources.Add(config);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>());
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldBeEmpty();
}
#endregion
#region First Sync (No Prior Updates) Scenario
[Fact]
public async Task GetPendingTasksAsync_NoPriorUpdates_RequiresMassFirst()
{
// Arrange: Never synced before, all schedules enabled
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440,
hourlyEnabled: true, hourlyInterval: 60);
_options.Value.DataSources.Add(config);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>());
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert: Must do Mass first
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Mass);
tasks[0].MinimumDt.ShouldBeNull();
}
[Fact]
public async Task GetPendingTasksAsync_OnlyMassCompleted_DailyHasNullMinimumDT()
{
// Arrange: Mass completed, no daily yet
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddHours(-1), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert: Should return Daily with null MinimumDT (no prior daily to calculate from)
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily);
tasks[0].MinimumDt.ShouldBeNull();
}
[Fact]
public async Task GetPendingTasksAsync_NeverHadMass_DoesNotReturnDailyOrHourly()
{
// Arrange: Daily and Hourly enabled but no Mass ever run
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440,
hourlyEnabled: true, hourlyInterval: 60);
_options.Value.DataSources.Add(config);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>());
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert: Only Mass should be returned - can't do daily/hourly without initial mass
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Mass);
}
#endregion
#region Failed Sync Recovery
[Fact]
public async Task GetPendingTasksAsync_FailedMass_ReturnsMassAgain()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddMinutes(-5), success: false);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert: Failed mass should trigger retry regardless of interval
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Mass);
}
[Fact]
public async Task GetPendingTasksAsync_FailedDaily_ReturnsDailyAgain()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddMinutes(-5), success: false);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass },
{ "WorkOrder_2", lastDaily }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Daily);
}
[Fact]
public async Task GetPendingTasksAsync_FailedHourly_ReturnsHourlyAgain()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440,
hourlyEnabled: true, hourlyInterval: 60);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-1), success: true);
var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddMinutes(-5), success: false);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass },
{ "WorkOrder_2", lastDaily },
{ "WorkOrder_1", lastHourly }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldHaveSingleItem();
tasks[0].UpdateType.ShouldBe(UpdateTypes.Hourly);
}
#endregion
#region Multiple Tables
[Fact]
public async Task GetPendingTasksAsync_MultipleTables_ReturnsTasksForEach()
{
// Arrange
var config1 = CreateDataSourceConfig("WorkOrder", massEnabled: true, massInterval: 60);
var config2 = CreateDataSourceConfig("LotUsage", massEnabled: true, massInterval: 60);
_options.Value.DataSources.Add(config1);
_options.Value.DataSources.Add(config2);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>());
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.Count.ShouldBe(2);
tasks.ShouldContain(t => t.TableName == "WorkOrder");
tasks.ShouldContain(t => t.TableName == "LotUsage");
}
[Fact]
public async Task GetPendingTasksAsync_MultipleTables_DifferentSchedulesDue()
{
// Arrange
var config1 = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440);
var config2 = CreateDataSourceConfig("LotUsage",
massEnabled: true, massInterval: 60);
_options.Value.DataSources.Add(config1);
_options.Value.DataSources.Add(config2);
var now = DateTime.UtcNow;
var lastMassWorkOrder = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
var lastDailyWorkOrder = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddHours(-25), success: true);
var lastMassLotUsage = CreateDataUpdate("LotUsage", UpdateTypes.Mass, now.AddHours(-2), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMassWorkOrder },
{ "WorkOrder_2", lastDailyWorkOrder },
{ "LotUsage_3", lastMassLotUsage }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.Count.ShouldBe(2);
tasks.ShouldContain(t => t.TableName == "WorkOrder" && t.UpdateType == UpdateTypes.Daily);
tasks.ShouldContain(t => t.TableName == "LotUsage" && t.UpdateType == UpdateTypes.Mass);
}
#endregion
#region Edge Cases
[Fact]
public async Task GetPendingTasksAsync_NothingDue_ReturnsEmptyList()
{
// Arrange
var config = CreateDataSourceConfig("WorkOrder",
massEnabled: true, massInterval: 10080,
dailyEnabled: true, dailyInterval: 1440,
hourlyEnabled: true, hourlyInterval: 60);
_options.Value.DataSources.Add(config);
var now = DateTime.UtcNow;
// All syncs completed recently
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddMinutes(-5), success: true);
var lastDaily = CreateDataUpdate("WorkOrder", UpdateTypes.Daily, now.AddMinutes(-5), success: true);
var lastHourly = CreateDataUpdate("WorkOrder", UpdateTypes.Hourly, now.AddMinutes(-5), success: true);
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>
{
{ "WorkOrder_3", lastMass },
{ "WorkOrder_2", lastDaily },
{ "WorkOrder_1", lastHourly }
});
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldBeEmpty();
}
[Fact]
public async Task GetPendingTasksAsync_NoDataSources_ReturnsEmptyList()
{
// Arrange: No data sources configured
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
.Returns(new Dictionary<string, DataUpdate>());
// Act
var tasks = await _sut.GetPendingTasksAsync();
// Assert
tasks.ShouldBeEmpty();
}
#endregion
#region Helper Methods
private static DataSourceConfig CreateDataSourceConfig(
string tableName,
bool massEnabled = false,
int massInterval = 10080,
bool dailyEnabled = false,
int dailyInterval = 1440,
bool hourlyEnabled = false,
int hourlyInterval = 60)
{
return new DataSourceConfig
{
TableName = tableName,
SourceSystem = "JDE",
SourceData = tableName.ToUpper(),
FetcherTypeName = $"Jde{tableName}Fetcher",
IsEnabled = true,
MassConfig = new ScheduleConfig
{
Enabled = massEnabled,
IntervalMinutes = massInterval,
PrepurgeData = true,
ReIndexData = true
},
DailyConfig = new ScheduleConfig
{
Enabled = dailyEnabled,
IntervalMinutes = dailyInterval
},
HourlyConfig = new ScheduleConfig
{
Enabled = hourlyEnabled,
IntervalMinutes = hourlyInterval
}
};
}
private static DataUpdate CreateDataUpdate(
string tableName,
UpdateTypes updateType,
DateTime endDt,
bool success)
{
return new DataUpdate
{
Id = 1,
TableName = tableName,
SourceSystem = "JDE",
SourceData = tableName.ToUpper(),
UpdateType = updateType,
StartDt = endDt.AddMinutes(-5),
EndDt = endDt,
WasSuccessful = success,
NumberRecords = success ? 1000 : -1
};
}
#endregion
}
@@ -0,0 +1,271 @@
using System.Data;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Exceptions;
using JdeScoping.DataSync.Models;
using JdeScoping.DataSync.Services;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace JdeScoping.DataSync.Tests.Services;
public class BulkMergeHelperTests
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly IDataReaderFactory _dataReaderFactory;
private readonly ISchemaValidator _schemaValidator;
private readonly ILogger<BulkMergeHelper> _logger;
private readonly BulkMergeHelper _helper;
private class TestEntity
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Amount { get; set; }
}
public BulkMergeHelperTests()
{
_connectionFactory = Substitute.For<IDbConnectionFactory>();
_dataReaderFactory = Substitute.For<IDataReaderFactory>();
_schemaValidator = Substitute.For<ISchemaValidator>();
_logger = Substitute.For<ILogger<BulkMergeHelper>>();
// Setup default mock returns
_dataReaderFactory.GetColumnNames<TestEntity>()
.Returns(new List<string> { "Id", "Name", "Amount" });
_helper = new BulkMergeHelper(
_connectionFactory,
_dataReaderFactory,
_schemaValidator,
_logger);
}
#region Constructor Tests
[Fact]
public void Constructor_NullConnectionFactory_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
new BulkMergeHelper(null!, _dataReaderFactory, _schemaValidator, _logger));
}
[Fact]
public void Constructor_NullDataReaderFactory_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
new BulkMergeHelper(_connectionFactory, null!, _schemaValidator, _logger));
}
[Fact]
public void Constructor_NullSchemaValidator_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
new BulkMergeHelper(_connectionFactory, _dataReaderFactory, null!, _logger));
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
new BulkMergeHelper(_connectionFactory, _dataReaderFactory, _schemaValidator, null!));
}
#endregion
#region MergeAsync Parameter Validation Tests
[Fact]
public async Task MergeAsync_NullData_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(() =>
_helper.MergeAsync<TestEntity>(
null!,
"TestTable",
x => x.Id));
}
[Fact]
public async Task MergeAsync_NullDestinationTable_ThrowsArgumentNullException()
{
var data = AsyncEnumerable.Empty<TestEntity>();
await Assert.ThrowsAsync<ArgumentNullException>(() =>
_helper.MergeAsync(
data,
null!,
x => x.Id));
}
[Fact]
public async Task MergeAsync_EmptyDestinationTable_ThrowsArgumentException()
{
var data = AsyncEnumerable.Empty<TestEntity>();
await Assert.ThrowsAsync<ArgumentException>(() =>
_helper.MergeAsync(
data,
"",
x => x.Id));
}
[Fact]
public async Task MergeAsync_NullMatchOn_ThrowsArgumentNullException()
{
var data = AsyncEnumerable.Empty<TestEntity>();
await Assert.ThrowsAsync<ArgumentNullException>(() =>
_helper.MergeAsync<TestEntity>(
data,
"TestTable",
null!));
}
#endregion
#region Column Expression Tests
[Fact]
public void GetColumnNames_CalledWithMatchOn_ReturnsCorrectColumns()
{
// The ExpressionParser is tested separately, this just verifies it's being called
var columns = ExpressionParser.GetColumnNames<TestEntity>(x => x.Id);
Assert.Single(columns);
Assert.Equal("Id", columns[0]);
}
[Fact]
public void GetColumnNames_CalledWithMultipleColumns_ReturnsCorrectColumns()
{
var columns = ExpressionParser.GetColumnNames<TestEntity>(x => new { x.Id, x.Name });
Assert.Equal(2, columns.Count);
Assert.Equal("Id", columns[0]);
Assert.Equal("Name", columns[1]);
}
#endregion
#region TempTableName Generation Tests
[Fact]
public void TempTableName_WithDots_ReplacesWithUnderscores()
{
// This is implicitly tested by how temp table names are generated
var tableName = "dbo.TestTable";
// The actual generation happens inside MergeAsync, so we verify the pattern
var cleaned = tableName.Replace(".", "_").Replace("[", "").Replace("]", "");
Assert.Equal("dbo_TestTable", cleaned);
}
[Fact]
public void TempTableName_WithBrackets_RemovesBrackets()
{
var tableName = "[dbo].[TestTable]";
var cleaned = tableName.Replace(".", "_").Replace("[", "").Replace("]", "");
Assert.Equal("dbo_TestTable", cleaned);
}
#endregion
#region MergeResult Tests
[Fact]
public void MergeResult_TotalRowsAffected_ReturnsSum()
{
var result = new MergeResult(100, 60, 40, 10, TimeSpan.FromSeconds(5));
Assert.Equal(100, result.TotalRowsAffected);
}
[Fact]
public void MergeResult_RecordProperties_AreCorrect()
{
var elapsed = TimeSpan.FromSeconds(5);
var result = new MergeResult(100, 60, 40, 10, elapsed);
Assert.Equal(100, result.TotalRowsProcessed);
Assert.Equal(60, result.RowsInserted);
Assert.Equal(40, result.RowsUpdated);
Assert.Equal(10, result.BatchCount);
Assert.Equal(elapsed, result.Elapsed);
}
#endregion
#region MassInsertAsync Tests
[Fact]
public async Task MassInsertAsync_NullData_ThrowsArgumentNullException()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(
() => _helper.MassInsertAsync<TestEntity>(null!, "TestTable"));
}
[Fact]
public async Task MassInsertAsync_NullDestination_ThrowsArgumentNullException()
{
// Arrange
var data = AsyncEnumerable.Empty<TestEntity>();
// Act & Assert
// ArgumentException.ThrowIfNullOrWhiteSpace throws ArgumentNullException for null values
await Assert.ThrowsAsync<ArgumentNullException>(
() => _helper.MassInsertAsync(data, null!));
}
[Fact]
public async Task MassInsertAsync_EmptyDestination_ThrowsArgumentException()
{
// Arrange
var data = AsyncEnumerable.Empty<TestEntity>();
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => _helper.MassInsertAsync(data, ""));
}
#endregion
#region Exception Tests
[Fact]
public void BulkMergeException_PropertiesAreSet()
{
var ex = new BulkMergeException("Test error")
{
TableName = "TestTable",
BatchNumber = 5,
RowsInBatch = 1000,
SqlStatement = "MERGE INTO..."
};
Assert.Equal("TestTable", ex.TableName);
Assert.Equal(5, ex.BatchNumber);
Assert.Equal(1000, ex.RowsInBatch);
Assert.Equal("MERGE INTO...", ex.SqlStatement);
}
[Fact]
public void BulkMergeValidationException_ContainsErrors()
{
var errors = new List<ValidationError>
{
new(0, "Name", "TooLong", "Exceeds max length"),
new(1, "Amount", 999999m, "Overflow")
};
var ex = new BulkMergeValidationException("Validation failed", errors);
Assert.Equal(2, ex.Errors.Count);
Assert.Equal("Name", ex.Errors[0].ColumnName);
Assert.Equal("Amount", ex.Errors[1].ColumnName);
}
#endregion
}
@@ -0,0 +1,194 @@
using System.Linq.Expressions;
using JdeScoping.DataSync.Services;
namespace JdeScoping.DataSync.Tests.Services;
public class ExpressionParserTests
{
private class TestEntity
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime? LastUpdateDt { get; set; }
public decimal Amount { get; set; }
}
#region GetColumnNames Tests
[Fact]
public void GetColumnNames_SingleProperty_ReturnsSingleColumn()
{
// Arrange
Expression<Func<TestEntity, object>> expr = x => x.Id;
// Act
var columns = ExpressionParser.GetColumnNames(expr);
// Assert
Assert.Single(columns);
Assert.Equal("Id", columns[0]);
}
[Fact]
public void GetColumnNames_SingleStringProperty_ReturnsSingleColumn()
{
// Arrange
Expression<Func<TestEntity, object>> expr = x => x.Name;
// Act
var columns = ExpressionParser.GetColumnNames(expr);
// Assert
Assert.Single(columns);
Assert.Equal("Name", columns[0]);
}
[Fact]
public void GetColumnNames_AnonymousType_ReturnsAllColumns()
{
// Arrange
Expression<Func<TestEntity, object>> expr = x => new { x.Id, x.Name };
// Act
var columns = ExpressionParser.GetColumnNames(expr);
// Assert
Assert.Equal(2, columns.Count);
Assert.Equal("Id", columns[0]);
Assert.Equal("Name", columns[1]);
}
[Fact]
public void GetColumnNames_AnonymousTypeWithMultipleProperties_ReturnsAllColumns()
{
// Arrange
Expression<Func<TestEntity, object>> expr = x => new { x.Id, x.Name, x.Amount, x.LastUpdateDt };
// Act
var columns = ExpressionParser.GetColumnNames(expr);
// Assert
Assert.Equal(4, columns.Count);
Assert.Equal("Id", columns[0]);
Assert.Equal("Name", columns[1]);
Assert.Equal("Amount", columns[2]);
Assert.Equal("LastUpdateDt", columns[3]);
}
[Fact]
public void GetColumnNames_NullExpression_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
ExpressionParser.GetColumnNames<TestEntity>(null!));
}
#endregion
#region BuildUpdateWhenSql Tests
[Fact]
public void BuildUpdateWhenSql_NullExpression_ReturnsNull()
{
// Act
var result = ExpressionParser.BuildUpdateWhenSql<TestEntity>(null);
// Assert
Assert.Null(result);
}
[Fact]
public void BuildUpdateWhenSql_GreaterThan_ReturnsSqlCondition()
{
// Arrange
Expression<Func<TestEntity, TestEntity, bool>> expr = (src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt;
// Act
var result = ExpressionParser.BuildUpdateWhenSql(expr);
// Assert
Assert.Equal("source.[LastUpdateDt] > target.[LastUpdateDt]", result);
}
[Fact]
public void BuildUpdateWhenSql_GreaterThanOrEqual_ReturnsSqlCondition()
{
// Arrange
Expression<Func<TestEntity, TestEntity, bool>> expr = (src, tgt) => src.Id >= tgt.Id;
// Act
var result = ExpressionParser.BuildUpdateWhenSql(expr);
// Assert
Assert.Equal("source.[Id] >= target.[Id]", result);
}
[Fact]
public void BuildUpdateWhenSql_Equal_ReturnsSqlCondition()
{
// Arrange
Expression<Func<TestEntity, TestEntity, bool>> expr = (src, tgt) => src.Name == tgt.Name;
// Act
var result = ExpressionParser.BuildUpdateWhenSql(expr);
// Assert
Assert.Equal("source.[Name] = target.[Name]", result);
}
[Fact]
public void BuildUpdateWhenSql_NotEqual_ReturnsSqlCondition()
{
// Arrange
Expression<Func<TestEntity, TestEntity, bool>> expr = (src, tgt) => src.Amount != tgt.Amount;
// Act
var result = ExpressionParser.BuildUpdateWhenSql(expr);
// Assert
Assert.Equal("source.[Amount] <> target.[Amount]", result);
}
[Fact]
public void BuildUpdateWhenSql_AndCondition_ReturnsSqlCondition()
{
// Arrange
Expression<Func<TestEntity, TestEntity, bool>> expr =
(src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt && src.Id == tgt.Id;
// Act
var result = ExpressionParser.BuildUpdateWhenSql(expr);
// Assert
Assert.Equal("(source.[LastUpdateDt] > target.[LastUpdateDt] AND source.[Id] = target.[Id])", result);
}
[Fact]
public void BuildUpdateWhenSql_OrCondition_ReturnsSqlCondition()
{
// Arrange
Expression<Func<TestEntity, TestEntity, bool>> expr =
(src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt || src.Amount > tgt.Amount;
// Act
var result = ExpressionParser.BuildUpdateWhenSql(expr);
// Assert
Assert.Equal("(source.[LastUpdateDt] > target.[LastUpdateDt] OR source.[Amount] > target.[Amount])", result);
}
[Fact]
public void BuildUpdateWhenSql_CustomAliases_UsesProvidedAliases()
{
// Arrange
Expression<Func<TestEntity, TestEntity, bool>> expr = (src, tgt) => src.Id > tgt.Id;
// Act
var result = ExpressionParser.BuildUpdateWhenSql(expr, "s", "t");
// Assert
Assert.Equal("s.[Id] > t.[Id]", result);
}
#endregion
}
@@ -0,0 +1,74 @@
using JdeScoping.Core.Models.WorkOrders;
using JdeScoping.DataSync.Configuration.MergeConfigurations;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Services;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
namespace JdeScoping.DataSync.Tests.Services;
public class MergeConfigurationRegistryTests
{
[Fact]
public void GetConfiguration_RegisteredType_ReturnsConfiguration()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton<IMergeConfiguration<WorkOrder>, WorkOrderMergeConfiguration>();
var provider = services.BuildServiceProvider();
var registry = new MergeConfigurationRegistry(provider);
// Act
var config = registry.GetConfiguration<WorkOrder>();
// Assert
config.ShouldNotBeNull();
config.TableName.ShouldBe("WorkOrder");
}
[Fact]
public void GetConfiguration_UnregisteredType_ThrowsInvalidOperationException()
{
// Arrange
var services = new ServiceCollection();
var provider = services.BuildServiceProvider();
var registry = new MergeConfigurationRegistry(provider);
// Act & Assert
var ex = Should.Throw<InvalidOperationException>(() => registry.GetConfiguration<UnregisteredEntity>());
ex.Message.ShouldContain("UnregisteredEntity");
}
[Fact]
public void HasConfiguration_RegisteredType_ReturnsTrue()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton<IMergeConfiguration<WorkOrder>, WorkOrderMergeConfiguration>();
var provider = services.BuildServiceProvider();
var registry = new MergeConfigurationRegistry(provider);
// Act
var result = registry.HasConfiguration<WorkOrder>();
// Assert
result.ShouldBeTrue();
}
[Fact]
public void HasConfiguration_UnregisteredType_ReturnsFalse()
{
// Arrange
var services = new ServiceCollection();
var provider = services.BuildServiceProvider();
var registry = new MergeConfigurationRegistry(provider);
// Act
var result = registry.HasConfiguration<UnregisteredEntity>();
// Assert
result.ShouldBeFalse();
}
private class UnregisteredEntity { }
}
@@ -0,0 +1,172 @@
using JdeScoping.DataSync.Services;
namespace JdeScoping.DataSync.Tests.Services;
public class MergeSqlBuilderTests
{
#region BuildCreateTempTable Tests
[Fact]
public void BuildCreateTempTable_ValidInputs_ReturnsSelectInto()
{
// Act
var sql = MergeSqlBuilder.BuildCreateTempTable("#TEMP_WorkOrder", "WorkOrder");
// Assert
Assert.Equal("SELECT TOP 0 * INTO [#TEMP_WorkOrder] FROM [WorkOrder]", sql);
}
[Theory]
[InlineData(null, "WorkOrder")]
[InlineData("", "WorkOrder")]
[InlineData("#TEMP", null)]
[InlineData("#TEMP", "")]
public void BuildCreateTempTable_InvalidInputs_ThrowsArgumentException(string? tempTable, string? sourceTable)
{
// Act & Assert
Assert.ThrowsAny<ArgumentException>(() =>
MergeSqlBuilder.BuildCreateTempTable(tempTable!, sourceTable!));
}
#endregion
#region BuildMergeSimple Tests
[Fact]
public void BuildMergeSimple_SingleMatchColumn_BuildsCorrectMerge()
{
// Arrange
var matchColumns = new[] { "Id" };
var updateColumns = new[] { "Name", "Amount" };
var insertColumns = new[] { "Id", "Name", "Amount" };
// Act
var sql = MergeSqlBuilder.BuildMergeSimple(
"TestTable", "#TEMP_TestTable",
matchColumns, updateColumns, null, insertColumns);
// Assert
Assert.Contains("MERGE INTO [TestTable] AS target", sql);
Assert.Contains("USING [#TEMP_TestTable] AS source", sql);
Assert.Contains("ON target.[Id] = source.[Id]", sql);
Assert.Contains("WHEN MATCHED THEN", sql);
Assert.Contains("UPDATE SET target.[Name] = source.[Name], target.[Amount] = source.[Amount]", sql);
Assert.Contains("WHEN NOT MATCHED THEN", sql);
Assert.Contains("INSERT ([Id], [Name], [Amount])", sql);
Assert.Contains("VALUES (source.[Id], source.[Name], source.[Amount])", sql);
}
[Fact]
public void BuildMergeSimple_CompositeKey_BuildsCorrectOnClause()
{
// Arrange
var matchColumns = new[] { "WorkOrderNumber", "BranchCode" };
var updateColumns = new[] { "Status" };
var insertColumns = new[] { "WorkOrderNumber", "BranchCode", "Status" };
// Act
var sql = MergeSqlBuilder.BuildMergeSimple(
"WorkOrder", "#TEMP",
matchColumns, updateColumns, null, insertColumns);
// Assert
Assert.Contains("ON target.[WorkOrderNumber] = source.[WorkOrderNumber] AND target.[BranchCode] = source.[BranchCode]", sql);
}
[Fact]
public void BuildMergeSimple_WithUpdateWhen_IncludesCondition()
{
// Arrange
var matchColumns = new[] { "Id" };
var updateColumns = new[] { "Name" };
var insertColumns = new[] { "Id", "Name" };
var updateWhen = "source.[LastUpdateDt] > target.[LastUpdateDt]";
// Act
var sql = MergeSqlBuilder.BuildMergeSimple(
"TestTable", "#TEMP",
matchColumns, updateColumns, updateWhen, insertColumns);
// Assert
Assert.Contains("WHEN MATCHED AND source.[LastUpdateDt] > target.[LastUpdateDt] THEN", sql);
}
[Fact]
public void BuildMergeSimple_NoUpdateColumns_OmitsUpdateClause()
{
// Arrange
var matchColumns = new[] { "Id" };
var updateColumns = Array.Empty<string>();
var insertColumns = new[] { "Id", "Name" };
// Act
var sql = MergeSqlBuilder.BuildMergeSimple(
"TestTable", "#TEMP",
matchColumns, updateColumns, null, insertColumns);
// Assert
Assert.DoesNotContain("WHEN MATCHED", sql);
Assert.Contains("WHEN NOT MATCHED THEN", sql);
}
[Fact]
public void BuildMergeSimple_EmptyMatchColumns_ThrowsArgumentException()
{
// Arrange
var matchColumns = Array.Empty<string>();
var updateColumns = new[] { "Name" };
var insertColumns = new[] { "Id", "Name" };
// Act & Assert
Assert.Throws<ArgumentException>(() =>
MergeSqlBuilder.BuildMergeSimple(
"TestTable", "#TEMP",
matchColumns, updateColumns, null, insertColumns));
}
[Fact]
public void BuildMergeSimple_EmptyInsertColumns_ThrowsArgumentException()
{
// Arrange
var matchColumns = new[] { "Id" };
var updateColumns = new[] { "Name" };
var insertColumns = Array.Empty<string>();
// Act & Assert
Assert.Throws<ArgumentException>(() =>
MergeSqlBuilder.BuildMergeSimple(
"TestTable", "#TEMP",
matchColumns, updateColumns, null, insertColumns));
}
#endregion
#region BuildTruncateTempTable Tests
[Fact]
public void BuildTruncateTempTable_ValidInput_ReturnsTruncate()
{
// Act
var sql = MergeSqlBuilder.BuildTruncateTempTable("#TEMP_WorkOrder");
// Assert
Assert.Equal("TRUNCATE TABLE [#TEMP_WorkOrder]", sql);
}
#endregion
#region BuildDropTempTable Tests
[Fact]
public void BuildDropTempTable_ValidInput_ReturnsDropWithCheck()
{
// Act
var sql = MergeSqlBuilder.BuildDropTempTable("#TEMP_WorkOrder");
// Assert
Assert.Contains("IF OBJECT_ID('tempdb..#TEMP_WorkOrder') IS NOT NULL", sql);
Assert.Contains("DROP TABLE [#TEMP_WorkOrder]", sql);
}
#endregion
}
@@ -0,0 +1,277 @@
using JdeScoping.DataSync.Models;
using JdeScoping.DataSync.Services;
namespace JdeScoping.DataSync.Tests.Services;
public class SchemaValidatorTests
{
private readonly SchemaValidator _validator = new();
private class TestEntity
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? NullableName { get; set; }
public decimal Amount { get; set; }
public DateTime CreatedDate { get; set; }
}
#region ValidateBatch Tests
[Fact]
public void ValidateBatch_EmptyData_ReturnsEmptyList()
{
// Arrange
var data = Array.Empty<TestEntity>();
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1)
};
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert
Assert.Empty(errors);
}
[Fact]
public void ValidateBatch_EmptySchema_ReturnsEmptyList()
{
// Arrange
var data = new List<TestEntity> { new() { Id = 1, Name = "Test" } };
var schema = Array.Empty<ColumnSchema>();
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert
Assert.Empty(errors);
}
[Fact]
public void ValidateBatch_ValidData_ReturnsEmptyList()
{
// Arrange
var data = new List<TestEntity>
{
new() { Id = 1, Name = "Test", Amount = 100.50m }
};
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1),
new("Name", "nvarchar", 50, null, null, false, 2),
new("Amount", "decimal", null, 10, 2, false, 3)
};
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert
Assert.Empty(errors);
}
[Fact]
public void ValidateBatch_StringTooLong_ReturnsError()
{
// Arrange
var data = new List<TestEntity>
{
new() { Id = 1, Name = "This is a very long string that exceeds the maximum length" }
};
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1),
new("Name", "nvarchar", 10, null, null, false, 2)
};
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert
Assert.Single(errors);
Assert.Equal("Name", errors[0].ColumnName);
Assert.Equal(0, errors[0].RowIndex);
Assert.Contains("exceeds maximum length", errors[0].Message);
}
[Fact]
public void ValidateBatch_NullInNonNullableColumn_ReturnsError()
{
// Arrange
var data = new List<TestEntity>
{
new() { Id = 1, Name = "" } // Empty string treated as null for non-nullable
};
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1),
new("Name", "nvarchar", 50, null, null, false, 2)
};
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert
Assert.Single(errors);
Assert.Equal("Name", errors[0].ColumnName);
Assert.Contains("does not allow null", errors[0].Message);
}
[Fact]
public void ValidateBatch_NullInNullableColumn_NoError()
{
// Arrange
var data = new List<TestEntity>
{
new() { Id = 1, Name = "Test", NullableName = null }
};
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1),
new("Name", "nvarchar", 50, null, null, false, 2),
new("NullableName", "nvarchar", 50, null, null, true, 3)
};
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert
Assert.Empty(errors);
}
[Fact]
public void ValidateBatch_DecimalOverflow_ReturnsError()
{
// Arrange
var data = new List<TestEntity>
{
new() { Id = 1, Name = "Test", Amount = 12345678.90m } // Too many integer digits for decimal(8,2)
};
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1),
new("Name", "nvarchar", 50, null, null, false, 2),
new("Amount", "decimal", null, 8, 2, false, 3) // Max 6 integer digits
};
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert
Assert.Single(errors);
Assert.Equal("Amount", errors[0].ColumnName);
Assert.Contains("exceeds maximum integer digits", errors[0].Message);
}
[Fact]
public void ValidateBatch_DecimalWithinRange_NoError()
{
// Arrange
var data = new List<TestEntity>
{
new() { Id = 1, Name = "Test", Amount = 123456.78m } // Within decimal(10,2) - 8 integer digits
};
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1),
new("Name", "nvarchar", 50, null, null, false, 2),
new("Amount", "decimal", null, 10, 2, false, 3) // Max 8 integer digits
};
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert
Assert.Empty(errors);
}
[Fact]
public void ValidateBatch_MultipleErrors_ReturnsAll()
{
// Arrange
var data = new List<TestEntity>
{
new() { Id = 1, Name = "This is too long" },
new() { Id = 2, Name = "Also too long!" }
};
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1),
new("Name", "nvarchar", 5, null, null, false, 2)
};
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert
Assert.Equal(2, errors.Count);
Assert.Equal(0, errors[0].RowIndex);
Assert.Equal(1, errors[1].RowIndex);
}
[Fact]
public void ValidateBatch_MaxErrors_StopsAtLimit()
{
// Arrange
var data = Enumerable.Range(0, 10)
.Select(i => new TestEntity { Id = i, Name = "This is way too long" })
.ToList();
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1),
new("Name", "nvarchar", 5, null, null, false, 2)
};
// Act
var errors = _validator.ValidateBatch(data, schema, maxErrors: 3);
// Assert
Assert.Equal(3, errors.Count);
}
[Fact]
public void ValidateBatch_UnmatchedColumn_Ignored()
{
// Arrange
var data = new List<TestEntity>
{
new() { Id = 1, Name = "Test" }
};
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1),
new("Name", "nvarchar", 50, null, null, false, 2),
new("UnknownColumn", "nvarchar", 50, null, null, false, 3)
};
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert
Assert.Empty(errors);
}
[Fact]
public void ValidateBatch_IdColumn_AllowsNull()
{
// Arrange - Id columns are treated as identity/auto-generated
var data = new List<TestEntity>
{
new() { Id = 0, Name = "Test" } // Id = 0 might be treated as "not set"
};
var schema = new List<ColumnSchema>
{
new("Id", "int", null, 10, 0, false, 1), // Not nullable in schema
new("Name", "nvarchar", 50, null, null, false, 2)
};
// Act
var errors = _validator.ValidateBatch(data, schema);
// Assert - No error because Id columns are treated specially
Assert.Empty(errors);
}
#endregion
}
@@ -0,0 +1,558 @@
using System.Diagnostics.Metrics;
using JdeScoping.Core.Models.Enums;
using JdeScoping.DataSync.Configuration;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Models;
using JdeScoping.DataSync.Services;
using JdeScoping.DataSync.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
namespace JdeScoping.DataSync.Tests;
/// <summary>
/// Unit tests for SyncOrchestrator.
/// Tests parallel execution, cancellation, and scope isolation.
/// </summary>
public class SyncOrchestratorTests
{
private readonly IScheduleChecker _scheduleChecker;
private readonly IOptions<DataSyncOptions> _options;
private readonly DataSyncMetrics _metrics;
public SyncOrchestratorTests()
{
_scheduleChecker = Substitute.For<IScheduleChecker>();
_options = Options.Create(new DataSyncOptions
{
MaxDegreeOfParallelism = 4
});
var services = new ServiceCollection();
services.AddMetrics();
var provider = services.BuildServiceProvider();
var meterFactory = provider.GetRequiredService<IMeterFactory>();
_metrics = new DataSyncMetrics(meterFactory);
}
#region No Pending Tasks
[Fact]
public async Task ExecutePendingSyncsAsync_NoPendingTasks_ReturnsImmediately()
{
// Arrange
_scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask>());
var operation = Substitute.For<ITableSyncOperation>();
var services = new ServiceCollection();
services.AddSingleton(_scheduleChecker);
services.AddScoped(_ => operation);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var sut = new SyncOrchestrator(
scopeFactory,
_scheduleChecker,
_options,
NullLogger<SyncOrchestrator>.Instance,
_metrics);
// Act
await sut.ExecutePendingSyncsAsync();
// Assert: Operation should never be called
await operation.DidNotReceive().ExecuteAsync(Arg.Any<DataUpdateTask>(), Arg.Any<CancellationToken>());
}
#endregion
#region Single Task Execution
[Fact]
public async Task ExecutePendingSyncsAsync_SingleTask_ExecutesOperation()
{
// Arrange
var task = CreateTask("WorkOrder", UpdateTypes.Mass);
_scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask> { task });
var executedTasks = new List<string>();
var operation = Substitute.For<ITableSyncOperation>();
operation.ExecuteAsync(Arg.Any<DataUpdateTask>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var t = callInfo.Arg<DataUpdateTask>();
executedTasks.Add(t.TableName);
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(_scheduleChecker);
services.AddScoped(_ => operation);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var sut = new SyncOrchestrator(
scopeFactory,
_scheduleChecker,
_options,
NullLogger<SyncOrchestrator>.Instance,
_metrics);
// Act
await sut.ExecutePendingSyncsAsync();
// Assert
executedTasks.ShouldContain("WorkOrder");
}
#endregion
#region Parallel Execution
[Fact]
public async Task ExecutePendingSyncsAsync_MultipleTasks_ExecutesInParallel()
{
// Arrange
var tasks = new List<DataUpdateTask>
{
CreateTask("WorkOrder", UpdateTypes.Mass),
CreateTask("LotUsage", UpdateTypes.Mass),
CreateTask("Item", UpdateTypes.Mass),
CreateTask("Lot", UpdateTypes.Mass)
};
_scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(tasks);
var executingConcurrently = 0;
var maxConcurrent = 0;
var lockObj = new object();
var operation = Substitute.For<ITableSyncOperation>();
operation.ExecuteAsync(Arg.Any<DataUpdateTask>(), Arg.Any<CancellationToken>())
.Returns(async callInfo =>
{
lock (lockObj)
{
executingConcurrently++;
maxConcurrent = Math.Max(maxConcurrent, executingConcurrently);
}
await Task.Delay(50); // Simulate work
lock (lockObj)
{
executingConcurrently--;
}
});
var services = new ServiceCollection();
services.AddSingleton(_scheduleChecker);
services.AddScoped(_ => operation);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var sut = new SyncOrchestrator(
scopeFactory,
_scheduleChecker,
_options,
NullLogger<SyncOrchestrator>.Instance,
_metrics);
// Act
await sut.ExecutePendingSyncsAsync();
// Assert: Should have executed multiple tasks concurrently
maxConcurrent.ShouldBeGreaterThan(1);
}
[Fact]
public async Task ExecutePendingSyncsAsync_RespectsMaxDegreeOfParallelism()
{
// Arrange: Create 10 tasks but limit parallelism to 2
var options = Options.Create(new DataSyncOptions
{
MaxDegreeOfParallelism = 2
});
var tasks = Enumerable.Range(0, 10)
.Select(i => CreateTask($"Table{i}", UpdateTypes.Mass))
.ToList();
_scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(tasks);
var executingConcurrently = 0;
var maxConcurrent = 0;
var lockObj = new object();
var operation = Substitute.For<ITableSyncOperation>();
operation.ExecuteAsync(Arg.Any<DataUpdateTask>(), Arg.Any<CancellationToken>())
.Returns(async callInfo =>
{
lock (lockObj)
{
executingConcurrently++;
maxConcurrent = Math.Max(maxConcurrent, executingConcurrently);
}
await Task.Delay(50);
lock (lockObj)
{
executingConcurrently--;
}
});
var services = new ServiceCollection();
services.AddSingleton(_scheduleChecker);
services.AddScoped(_ => operation);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var sut = new SyncOrchestrator(
scopeFactory,
_scheduleChecker,
options,
NullLogger<SyncOrchestrator>.Instance,
_metrics);
// Act
await sut.ExecutePendingSyncsAsync();
// Assert: Should not exceed MaxDegreeOfParallelism
maxConcurrent.ShouldBeLessThanOrEqualTo(2);
}
#endregion
#region Scope Isolation
[Fact]
public async Task ExecutePendingSyncsAsync_EachTaskGetsOwnScope()
{
// Arrange
var tasks = new List<DataUpdateTask>
{
CreateTask("WorkOrder", UpdateTypes.Mass),
CreateTask("LotUsage", UpdateTypes.Mass)
};
_scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(tasks);
var scopeCount = 0;
var services = new ServiceCollection();
services.AddSingleton(_scheduleChecker);
services.AddScoped<ITableSyncOperation>(sp =>
{
Interlocked.Increment(ref scopeCount);
var mock = Substitute.For<ITableSyncOperation>();
mock.ExecuteAsync(Arg.Any<DataUpdateTask>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
return mock;
});
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var sut = new SyncOrchestrator(
scopeFactory,
_scheduleChecker,
_options,
NullLogger<SyncOrchestrator>.Instance,
_metrics);
// Act
await sut.ExecutePendingSyncsAsync();
// Assert: Each task should create its own scope
scopeCount.ShouldBe(2);
}
#endregion
#region Cancellation Handling
[Fact]
public async Task ExecutePendingSyncsAsync_WhenCancelled_PropagatesCancellation()
{
// Arrange
var tasks = new List<DataUpdateTask>
{
CreateTask("WorkOrder", UpdateTypes.Mass),
CreateTask("LotUsage", UpdateTypes.Mass),
CreateTask("Item", UpdateTypes.Mass)
};
_scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(tasks);
var cts = new CancellationTokenSource();
var operationsStarted = 0;
var operation = Substitute.For<ITableSyncOperation>();
operation.ExecuteAsync(Arg.Any<DataUpdateTask>(), Arg.Any<CancellationToken>())
.Returns(async callInfo =>
{
Interlocked.Increment(ref operationsStarted);
// Cancel after first operation starts
if (operationsStarted == 1)
{
cts.Cancel();
}
var token = callInfo.Arg<CancellationToken>();
await Task.Delay(500, token); // Will throw if cancelled
});
var services = new ServiceCollection();
services.AddSingleton(_scheduleChecker);
services.AddScoped(_ => operation);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var sut = new SyncOrchestrator(
scopeFactory,
_scheduleChecker,
_options,
NullLogger<SyncOrchestrator>.Instance,
_metrics);
// Act & Assert
await Should.ThrowAsync<OperationCanceledException>(
() => sut.ExecutePendingSyncsAsync(cts.Token));
}
[Fact]
public async Task ExecutePendingSyncsAsync_PassesCancellationTokenToOperations()
{
// Arrange
var task = CreateTask("WorkOrder", UpdateTypes.Mass);
_scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(new List<DataUpdateTask> { task });
CancellationToken receivedToken = default;
var operation = Substitute.For<ITableSyncOperation>();
operation.ExecuteAsync(Arg.Any<DataUpdateTask>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
receivedToken = callInfo.Arg<CancellationToken>();
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(_scheduleChecker);
services.AddScoped(_ => operation);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var sut = new SyncOrchestrator(
scopeFactory,
_scheduleChecker,
_options,
NullLogger<SyncOrchestrator>.Instance,
_metrics);
using var cts = new CancellationTokenSource();
// Act
await sut.ExecutePendingSyncsAsync(cts.Token);
// Assert
receivedToken.ShouldNotBe(CancellationToken.None);
}
#endregion
#region Error Handling
[Fact]
public async Task ExecutePendingSyncsAsync_OneTaskFails_OthersContinue()
{
// Arrange
var tasks = new List<DataUpdateTask>
{
CreateTask("WorkOrder", UpdateTypes.Mass),
CreateTask("LotUsage", UpdateTypes.Mass),
CreateTask("Item", UpdateTypes.Mass)
};
_scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(tasks);
var executedTables = new List<string>();
var operation = Substitute.For<ITableSyncOperation>();
operation.ExecuteAsync(Arg.Any<DataUpdateTask>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var t = callInfo.Arg<DataUpdateTask>();
executedTables.Add(t.TableName);
if (t.TableName == "LotUsage")
{
throw new Exception("Sync failed for LotUsage");
}
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(_scheduleChecker);
services.AddScoped(_ => operation);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var sut = new SyncOrchestrator(
scopeFactory,
_scheduleChecker,
_options,
NullLogger<SyncOrchestrator>.Instance,
_metrics);
// Act
await sut.ExecutePendingSyncsAsync();
// Assert: All tasks should have been attempted
executedTables.Count.ShouldBe(3);
executedTables.ShouldContain("WorkOrder");
executedTables.ShouldContain("LotUsage");
executedTables.ShouldContain("Item");
}
[Fact]
public async Task ExecutePendingSyncsAsync_MultipleTasksFail_AllAttemptsComplete()
{
// Arrange
var tasks = new List<DataUpdateTask>
{
CreateTask("WorkOrder", UpdateTypes.Mass),
CreateTask("LotUsage", UpdateTypes.Mass),
CreateTask("Item", UpdateTypes.Mass),
CreateTask("Lot", UpdateTypes.Mass)
};
_scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(tasks);
var executedCount = 0;
var operation = Substitute.For<ITableSyncOperation>();
operation.ExecuteAsync(Arg.Any<DataUpdateTask>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
Interlocked.Increment(ref executedCount);
var t = callInfo.Arg<DataUpdateTask>();
// Fail odd-numbered tables
if (t.TableName is "WorkOrder" or "Item")
{
throw new Exception($"Sync failed for {t.TableName}");
}
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(_scheduleChecker);
services.AddScoped(_ => operation);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var sut = new SyncOrchestrator(
scopeFactory,
_scheduleChecker,
_options,
NullLogger<SyncOrchestrator>.Instance,
_metrics);
// Act
await sut.ExecutePendingSyncsAsync();
// Assert: All 4 tasks should have been attempted
executedCount.ShouldBe(4);
}
#endregion
#region Metrics Recording
[Fact]
public async Task ExecutePendingSyncsAsync_RecordsCycleMetrics()
{
// Arrange
var tasks = new List<DataUpdateTask>
{
CreateTask("WorkOrder", UpdateTypes.Mass),
CreateTask("LotUsage", UpdateTypes.Mass)
};
_scheduleChecker.GetPendingTasksAsync(Arg.Any<CancellationToken>())
.Returns(tasks);
var operation = Substitute.For<ITableSyncOperation>();
operation.ExecuteAsync(Arg.Any<DataUpdateTask>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var t = callInfo.Arg<DataUpdateTask>();
if (t.TableName == "LotUsage")
{
throw new Exception("Failed");
}
return Task.CompletedTask;
});
var services = new ServiceCollection();
services.AddSingleton(_scheduleChecker);
services.AddScoped(_ => operation);
var serviceProvider = services.BuildServiceProvider();
var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
var sut = new SyncOrchestrator(
scopeFactory,
_scheduleChecker,
_options,
NullLogger<SyncOrchestrator>.Instance,
_metrics);
// Act
await sut.ExecutePendingSyncsAsync();
// Assert: Metrics should have been recorded (we're using real metrics, so just verify no exceptions)
// More detailed metrics testing is in DataSyncMetricsTests
}
#endregion
#region Helper Methods
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(),
FetcherTypeName = $"Jde{tableName}Fetcher",
IsEnabled = true,
MassConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 10080 },
DailyConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 1440 },
HourlyConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 60 }
}
};
}
#endregion
}
@@ -0,0 +1,550 @@
using System.Diagnostics.Metrics;
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.DataAccess.Interfaces;
using JdeScoping.DataSync.Configuration;
using JdeScoping.DataSync.Contracts;
using JdeScoping.DataSync.Models;
using JdeScoping.DataSync.Services;
using JdeScoping.DataSync.Telemetry;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
namespace JdeScoping.DataSync.Tests;
/// <summary>
/// Unit tests for TableSyncOperation.
/// Tests mass/incremental paths, batching, and post-processor execution.
/// </summary>
public class TableSyncOperationTests
{
private readonly IDbConnectionFactory _connectionFactory;
private readonly IDataUpdateRepository _updateRepository;
private readonly IBulkMergeHelper _bulkMergeHelper;
private readonly IMergeConfigurationRegistry _configRegistry;
private readonly IOptions<DataSyncOptions> _options;
private readonly DataSyncMetrics _metrics;
private readonly IServiceProvider _serviceProvider;
public TableSyncOperationTests()
{
_connectionFactory = Substitute.For<IDbConnectionFactory>();
_updateRepository = Substitute.For<IDataUpdateRepository>();
_bulkMergeHelper = Substitute.For<IBulkMergeHelper>();
_configRegistry = Substitute.For<IMergeConfigurationRegistry>();
_options = Options.Create(new DataSyncOptions
{
BatchSize = 1000,
BulkCopyBatchSize = 100
});
var services = new ServiceCollection();
services.AddMetrics();
var provider = services.BuildServiceProvider();
var meterFactory = provider.GetRequiredService<IMeterFactory>();
_metrics = new DataSyncMetrics(meterFactory);
_serviceProvider = Substitute.For<IServiceProvider>();
}
#region Update Logging Tests
[Fact]
public async Task ExecuteAsync_StartsUpdateWithInProgressMarker()
{
// Arrange
var task = CreateTask("WorkOrder", UpdateTypes.Mass);
_updateRepository.StartUpdateAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<UpdateTypes>(),
Arg.Any<CancellationToken>())
.Returns(123);
SetupMockFetcher(task, AsyncEnumerable.Empty<TestEntity>());
SetupMockMergeConfiguration();
_bulkMergeHelper.MassInsertAsync(
Arg.Any<IAsyncEnumerable<TestEntity>>(),
Arg.Any<string>(),
Arg.Any<bool>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(new MassInsertResult(0, TimeSpan.Zero, true));
var sut = CreateSut();
// Act
await sut.ExecuteAsync(task);
// Assert
await _updateRepository.Received(1).StartUpdateAsync(
task.SourceSystem,
task.SourceData,
task.TableName,
task.UpdateType,
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_OnSuccess_CompletesUpdateWithRecordCount()
{
// Arrange
var task = CreateTask("WorkOrder", UpdateTypes.Mass);
_updateRepository.StartUpdateAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<UpdateTypes>(),
Arg.Any<CancellationToken>())
.Returns(123);
SetupMockFetcher(task, AsyncEnumerable.Empty<TestEntity>());
SetupMockMergeConfiguration();
_bulkMergeHelper.MassInsertAsync(
Arg.Any<IAsyncEnumerable<TestEntity>>(),
Arg.Any<string>(),
Arg.Any<bool>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(new MassInsertResult(500, TimeSpan.Zero, true));
var sut = CreateSut();
// Act
await sut.ExecuteAsync(task);
// Assert
await _updateRepository.Received(1).CompleteUpdateAsync(
123,
500L,
true,
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_OnFailure_CompletesUpdateWithFailureMarker()
{
// Arrange
var task = CreateTask("WorkOrder", UpdateTypes.Mass);
_updateRepository.StartUpdateAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<UpdateTypes>(),
Arg.Any<CancellationToken>())
.Returns(123);
SetupMockFetcher(task, AsyncEnumerable.Empty<TestEntity>());
SetupMockMergeConfiguration();
_bulkMergeHelper.MassInsertAsync(
Arg.Any<IAsyncEnumerable<TestEntity>>(),
Arg.Any<string>(),
Arg.Any<bool>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("Database error"));
var sut = CreateSut();
// Act & Assert
await Should.ThrowAsync<Exception>(() => sut.ExecuteAsync(task));
// Verify update was marked as failed
await _updateRepository.Received(1).CompleteUpdateAsync(
123,
-1,
false,
Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_OnCancellation_MarksUpdateAsFailed()
{
// Arrange
var task = CreateTask("WorkOrder", UpdateTypes.Mass);
var cts = new CancellationTokenSource();
_updateRepository.StartUpdateAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<UpdateTypes>(),
Arg.Any<CancellationToken>())
.Returns(123);
SetupMockFetcher(task, AsyncEnumerable.Empty<TestEntity>());
SetupMockMergeConfiguration();
_bulkMergeHelper.MassInsertAsync(
Arg.Any<IAsyncEnumerable<TestEntity>>(),
Arg.Any<string>(),
Arg.Any<bool>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(async callInfo =>
{
cts.Cancel();
callInfo.Arg<CancellationToken>().ThrowIfCancellationRequested();
return new MassInsertResult(0, TimeSpan.Zero, true);
});
var sut = CreateSut();
// Act & Assert
await Should.ThrowAsync<OperationCanceledException>(() => sut.ExecuteAsync(task, cts.Token));
// Verify update was marked as failed
await _updateRepository.Received(1).CompleteUpdateAsync(
123,
-1,
false,
Arg.Any<CancellationToken>());
}
#endregion
#region Mass Update Path Tests
[Fact]
public async Task ExecuteAsync_MassWithPrepurge_UsesMassUpdatePath()
{
// Arrange
var task = CreateTask("WorkOrder", UpdateTypes.Mass, prepurge: true);
_updateRepository.StartUpdateAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<UpdateTypes>(),
Arg.Any<CancellationToken>())
.Returns(1);
SetupMockFetcher(task, AsyncEnumerable.Empty<TestEntity>());
SetupMockMergeConfiguration();
_bulkMergeHelper.MassInsertAsync(
Arg.Any<IAsyncEnumerable<TestEntity>>(),
Arg.Any<string>(),
Arg.Any<bool>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(new MassInsertResult(100, TimeSpan.Zero, true));
var sut = CreateSut();
// Act
await sut.ExecuteAsync(task);
// Assert: Should use mass insert path
await _bulkMergeHelper.Received(1).MassInsertAsync(
Arg.Any<IAsyncEnumerable<TestEntity>>(),
"TestTable",
task.ScheduleConfig.ReIndexData,
_options.Value.BulkCopyBatchSize,
Arg.Any<CancellationToken>());
// Should NOT use merge path
await _bulkMergeHelper.DidNotReceive().MergeAsync(
Arg.Any<IAsyncEnumerable<TestEntity>>(),
Arg.Any<string>(),
Arg.Any<Expression<Func<TestEntity, object>>>(),
Arg.Any<Expression<Func<TestEntity, object>>>(),
Arg.Any<Expression<Func<TestEntity, TestEntity, bool>>>(),
Arg.Any<Expression<Func<TestEntity, object>>>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<bool>(),
Arg.Any<CancellationToken>());
}
#endregion
#region Incremental Update Path Tests
// Note: The following tests are marked as integration tests because they require
// complex reflection-based fetcher resolution that's difficult to unit test.
// These scenarios should be covered by integration tests with a real test database.
//
// Scenarios covered by integration tests:
// - ExecuteAsync_DailyUpdate_UsesIncrementalPath
// - ExecuteAsync_HourlyUpdate_UsesIncrementalPath
// - ExecuteAsync_LargeDataset_ProcessesInBatches (incremental path)
[Fact]
public void IncrementalUpdatePath_RequiresIntegrationTest()
{
// This test documents that incremental update scenarios require integration testing
// because the TableSyncOperation uses reflection to resolve and invoke fetchers.
//
// Integration test should verify:
// 1. Daily updates use staging table → merge path
// 2. Hourly updates use staging table → merge path
// 3. Staging tables are created with unique suffixes
// 4. MERGE correctly handles INSERT/UPDATE based on LastUpdateDT
// 5. Staging tables are cleaned up after success and failure
Assert.True(true, "See integration tests for incremental update path coverage");
}
#endregion
#region Batching Tests
// Note: Batching tests for incremental updates require integration testing
// because they depend on the reflection-based fetcher resolution.
// The batching logic is tested implicitly through integration tests.
//
// Test scenario to verify:
// - Large dataset (25 entities) with BatchSize=10 should create 3 batches
#endregion
#region Post-Processor Tests
[Fact]
public async Task ExecuteAsync_WithPostProcessor_InvokesPostProcessor()
{
// Arrange
var task = CreateTask("MisData", UpdateTypes.Mass, postProcessor: nameof(MockPostProcessor));
_updateRepository.StartUpdateAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<UpdateTypes>(),
Arg.Any<CancellationToken>())
.Returns(1);
SetupMockFetcher(task, AsyncEnumerable.Empty<TestEntity>());
SetupMockMergeConfiguration();
_bulkMergeHelper.MassInsertAsync(
Arg.Any<IAsyncEnumerable<TestEntity>>(),
Arg.Any<string>(),
Arg.Any<bool>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(new MassInsertResult(0, TimeSpan.Zero, true));
var mockPostProcessor = new MockPostProcessor();
_serviceProvider.GetService(typeof(MockPostProcessor)).Returns(mockPostProcessor);
var sut = CreateSut();
// Act
await sut.ExecuteAsync(task);
// Assert
mockPostProcessor.WasInvoked.ShouldBeTrue();
mockPostProcessor.TableNameReceived.ShouldBe("MisData");
}
[Fact]
public async Task ExecuteAsync_WithoutPostProcessor_SkipsPostProcessing()
{
// Arrange
var task = CreateTask("WorkOrder", UpdateTypes.Mass, postProcessor: null);
_updateRepository.StartUpdateAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<UpdateTypes>(),
Arg.Any<CancellationToken>())
.Returns(1);
SetupMockFetcher(task, AsyncEnumerable.Empty<TestEntity>());
SetupMockMergeConfiguration();
_bulkMergeHelper.MassInsertAsync(
Arg.Any<IAsyncEnumerable<TestEntity>>(),
Arg.Any<string>(),
Arg.Any<bool>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(new MassInsertResult(0, TimeSpan.Zero, true));
var sut = CreateSut();
// Act
await sut.ExecuteAsync(task);
// Assert: No post-processor service resolution should occur
_serviceProvider.DidNotReceive().GetService(typeof(IPostProcessor));
}
#endregion
#region Metrics Tests
[Fact]
public async Task ExecuteAsync_RecordsOperationStartedMetric()
{
// Arrange
var task = CreateTask("WorkOrder", UpdateTypes.Mass);
_updateRepository.StartUpdateAsync(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<UpdateTypes>(),
Arg.Any<CancellationToken>())
.Returns(1);
SetupMockFetcher(task, AsyncEnumerable.Empty<TestEntity>());
SetupMockMergeConfiguration();
_bulkMergeHelper.MassInsertAsync(
Arg.Any<IAsyncEnumerable<TestEntity>>(),
Arg.Any<string>(),
Arg.Any<bool>(),
Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(new MassInsertResult(100, TimeSpan.Zero, true));
var sut = CreateSut();
// Act
await sut.ExecuteAsync(task);
// Assert: Metrics were recorded (using real metrics, just verify no exceptions)
// Detailed metric verification is in DataSyncMetricsTests
}
#endregion
#region Staging Table Cleanup Tests
// Note: Staging table cleanup tests require integration testing because
// they depend on the incremental update path with reflection-based fetcher resolution.
//
// Test scenarios to verify:
// - On successful merge, staging table is dropped
// - On merge failure, staging table is still dropped (finally block)
// - On bulk copy failure, staging table is still dropped
#endregion
#region Helper Methods
private TableSyncOperation CreateSut()
{
return new TableSyncOperation(
_serviceProvider,
_connectionFactory,
_updateRepository,
_bulkMergeHelper,
_configRegistry,
_options,
NullLogger<TableSyncOperation>.Instance,
_metrics);
}
private static DataUpdateTask CreateTask(
string tableName,
UpdateTypes updateType,
bool prepurge = true,
bool reindex = true,
string? postProcessor = null)
{
return new DataUpdateTask
{
TableName = tableName,
SourceSystem = "JDE",
SourceData = tableName.ToUpper(),
UpdateType = updateType,
MinimumDt = updateType == UpdateTypes.Mass ? null : DateTime.UtcNow.AddDays(-1),
Config = new DataSourceConfig
{
TableName = tableName,
SourceSystem = "JDE",
SourceData = tableName.ToUpper(),
FetcherTypeName = nameof(MockDataFetcher),
PostProcessorTypeName = postProcessor,
IsEnabled = true,
MassConfig = new ScheduleConfig
{
Enabled = true,
IntervalMinutes = 10080,
PrepurgeData = prepurge,
ReIndexData = reindex
},
DailyConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 1440 },
HourlyConfig = new ScheduleConfig { Enabled = true, IntervalMinutes = 60 }
}
};
}
private void SetupMockFetcher(DataUpdateTask task, IAsyncEnumerable<TestEntity> entities, int batchSize = 1000)
{
var fetcher = new MockDataFetcher(entities);
_serviceProvider.GetService(typeof(MockDataFetcher)).Returns(fetcher);
}
private void SetupMockMergeConfiguration()
{
var mockConfig = Substitute.For<IMergeConfiguration<TestEntity>>();
mockConfig.TableName.Returns("TestTable");
mockConfig.MatchOn.Returns(x => x.Id);
mockConfig.UpdateColumns.Returns(x => new { x.Name, x.LastUpdateDT });
mockConfig.UpdateWhen.Returns((Expression<Func<TestEntity, TestEntity, bool>>?)null);
mockConfig.InsertColumns.Returns((Expression<Func<TestEntity, object>>?)null);
_configRegistry.GetConfiguration<TestEntity>().Returns(mockConfig);
}
#endregion
}
#region Test Support Classes
public class TestEntity
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime LastUpdateDT { get; set; } = DateTime.UtcNow;
}
public class MockDataFetcher : IDataFetcher<TestEntity>
{
private readonly IAsyncEnumerable<TestEntity> _entities;
public MockDataFetcher(IAsyncEnumerable<TestEntity>? entities = null)
{
_entities = entities ?? AsyncEnumerable.Empty<TestEntity>();
}
public IAsyncEnumerable<TestEntity> FetchAsync(DateTime? minimumDt, CancellationToken cancellationToken = default)
{
return _entities;
}
}
public class MockPostProcessor : IPostProcessor
{
public bool WasInvoked { get; private set; }
public string? TableNameReceived { get; private set; }
public Task ProcessAsync(string tableName, CancellationToken cancellationToken = default)
{
WasInvoked = true;
TableNameReceived = tableName;
return Task.CompletedTask;
}
}
#endregion