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