feat: implement ETL pipeline redesign and ConfigManager improvements
- Add pipeline registry with JSON-based configuration and hot-reload support - Implement manual sync request feature with API, client UI, and database - Improve ConfigManager: connection string dropdown in pipeline editor, step delete/reorder functionality, and fix JSON parsing for ConnectionStrings
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
using System.Security.Claims;
|
||||
using JdeScoping.Api.Contracts.ManualSync;
|
||||
using JdeScoping.Api.Controllers;
|
||||
using JdeScoping.DataAccess.Services;
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
using JdeScoping.DataSync.Services;
|
||||
using JdeScoping.Domain.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace JdeScoping.Api.Tests.Controllers;
|
||||
|
||||
public class ManualSyncControllerTests
|
||||
{
|
||||
private readonly IManualSyncRequestService _manualSyncRequestService;
|
||||
private readonly IPipelineRegistry _pipelineRegistry;
|
||||
private readonly ManualSyncController _controller;
|
||||
|
||||
public ManualSyncControllerTests()
|
||||
{
|
||||
_manualSyncRequestService = Substitute.For<IManualSyncRequestService>();
|
||||
_pipelineRegistry = Substitute.For<IPipelineRegistry>();
|
||||
_controller = new ManualSyncController(_manualSyncRequestService, _pipelineRegistry);
|
||||
SetupAuthenticatedUser("testuser");
|
||||
}
|
||||
|
||||
#region GetRequests Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetRequests_ReturnsOkWithRequests()
|
||||
{
|
||||
// Arrange
|
||||
var requests = new List<ManualSyncRequest>
|
||||
{
|
||||
CreateRequest(1, "Pipeline1", "mass", "user1", DateTime.UtcNow.AddHours(-2)),
|
||||
CreateRequest(2, "Pipeline2", "daily", "user2", DateTime.UtcNow.AddHours(-1))
|
||||
};
|
||||
_manualSyncRequestService.GetRequestsAsync(false, Arg.Any<CancellationToken>())
|
||||
.Returns(requests);
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetRequests(false, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Result.ShouldBeOfType<OkObjectResult>();
|
||||
var okResult = (OkObjectResult)result.Result!;
|
||||
var viewModels = okResult.Value.ShouldBeAssignableTo<List<ManualSyncRequestViewModel>>()!;
|
||||
viewModels.Count.ShouldBe(2);
|
||||
viewModels[0].Id.ShouldBe(1);
|
||||
viewModels[0].PipelineName.ShouldBe("Pipeline1");
|
||||
viewModels[1].Id.ShouldBe(2);
|
||||
viewModels[1].PipelineName.ShouldBe("Pipeline2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRequests_WithPendingOnlyTrue_PassesPendingOnlyToService()
|
||||
{
|
||||
// Arrange
|
||||
_manualSyncRequestService.GetRequestsAsync(true, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ManualSyncRequest>());
|
||||
|
||||
// Act
|
||||
await _controller.GetRequests(true, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await _manualSyncRequestService.Received(1)
|
||||
.GetRequestsAsync(true, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRequests_MapsRowVersionToBase64()
|
||||
{
|
||||
// Arrange
|
||||
var rowVersion = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
var request = CreateRequest(1, "Pipeline1", "mass", "user1", DateTime.UtcNow, rowVersion: rowVersion);
|
||||
_manualSyncRequestService.GetRequestsAsync(false, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<ManualSyncRequest> { request });
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetRequests(false, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var okResult = (OkObjectResult)result.Result!;
|
||||
var viewModels = okResult.Value.ShouldBeAssignableTo<List<ManualSyncRequestViewModel>>()!;
|
||||
viewModels[0].RowVersionBase64.ShouldBe(Convert.ToBase64String(rowVersion));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetPipelines Tests
|
||||
|
||||
[Fact]
|
||||
public void GetPipelines_ReturnsOkWithPipelines()
|
||||
{
|
||||
// Arrange
|
||||
var pipelines = new List<EtlPipelineConfig>
|
||||
{
|
||||
CreatePipeline("WorkOrders", massSyncInterval: 1440, dailySyncInterval: 60, hourlySyncInterval: 15),
|
||||
CreatePipeline("Items", massSyncInterval: 1440, dailySyncInterval: 60)
|
||||
};
|
||||
_pipelineRegistry.GetEnabledPipelines().Returns(pipelines);
|
||||
|
||||
// Act
|
||||
var result = _controller.GetPipelines();
|
||||
|
||||
// Assert
|
||||
result.Result.ShouldBeOfType<OkObjectResult>();
|
||||
var okResult = (OkObjectResult)result.Result!;
|
||||
var viewModels = okResult.Value.ShouldBeAssignableTo<List<PipelineInfoViewModel>>()!;
|
||||
viewModels.Count.ShouldBe(2);
|
||||
viewModels[0].Name.ShouldBe("WorkOrders");
|
||||
viewModels[0].SupportedSyncTypes.ShouldContain("mass");
|
||||
viewModels[0].SupportedSyncTypes.ShouldContain("daily");
|
||||
viewModels[0].SupportedSyncTypes.ShouldContain("hourly");
|
||||
viewModels[1].Name.ShouldBe("Items");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPipelines_WhenEmpty_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
_pipelineRegistry.GetEnabledPipelines().Returns(new List<EtlPipelineConfig>());
|
||||
|
||||
// Act
|
||||
var result = _controller.GetPipelines();
|
||||
|
||||
// Assert
|
||||
var okResult = (OkObjectResult)result.Result!;
|
||||
var viewModels = okResult.Value.ShouldBeAssignableTo<List<PipelineInfoViewModel>>()!;
|
||||
viewModels.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateRequest Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRequest_WithValidInput_ReturnsCreated()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new CreateManualSyncRequestDto
|
||||
{
|
||||
PipelineName = "WorkOrders",
|
||||
SyncType = "mass"
|
||||
};
|
||||
var createdRequest = CreateRequest(42, "WorkOrders", "mass", "testuser", DateTime.UtcNow);
|
||||
|
||||
_pipelineRegistry.IsValidPipelineAndSyncType("WorkOrders", "mass").Returns(true);
|
||||
_manualSyncRequestService.CreateRequestAsync("WorkOrders", "mass", "testuser", Arg.Any<CancellationToken>())
|
||||
.Returns(createdRequest);
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateRequest(dto, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var createdResult = (CreatedAtActionResult)result.Result!;
|
||||
var viewModel = createdResult.Value.ShouldBeOfType<ManualSyncRequestViewModel>();
|
||||
viewModel.Id.ShouldBe(42);
|
||||
viewModel.PipelineName.ShouldBe("WorkOrders");
|
||||
viewModel.SyncType.ShouldBe("mass");
|
||||
viewModel.RequestedBy.ShouldBe("testuser");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRequest_WithInvalidPipelineOrSyncType_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new CreateManualSyncRequestDto
|
||||
{
|
||||
PipelineName = "InvalidPipeline",
|
||||
SyncType = "invalid"
|
||||
};
|
||||
_pipelineRegistry.IsValidPipelineAndSyncType("InvalidPipeline", "invalid").Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateRequest(dto, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
var badRequestResult = (BadRequestObjectResult)result.Result!;
|
||||
badRequestResult.Value.ShouldBeOfType<string>();
|
||||
((string)badRequestResult.Value!).ShouldContain("Invalid pipeline/sync type combination");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRequest_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
// Arrange
|
||||
SetupUnauthenticatedUser();
|
||||
var dto = new CreateManualSyncRequestDto
|
||||
{
|
||||
PipelineName = "WorkOrders",
|
||||
SyncType = "mass"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateRequest(dto, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Result.ShouldBeOfType<UnauthorizedResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRequest_PassesCorrectUsernameToService()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new CreateManualSyncRequestDto
|
||||
{
|
||||
PipelineName = "WorkOrders",
|
||||
SyncType = "daily"
|
||||
};
|
||||
var createdRequest = CreateRequest(1, "WorkOrders", "daily", "testuser", DateTime.UtcNow);
|
||||
|
||||
_pipelineRegistry.IsValidPipelineAndSyncType("WorkOrders", "daily").Returns(true);
|
||||
_manualSyncRequestService.CreateRequestAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(createdRequest);
|
||||
|
||||
// Act
|
||||
await _controller.CreateRequest(dto, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await _manualSyncRequestService.Received(1)
|
||||
.CreateRequestAsync("WorkOrders", "daily", "testuser", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CancelRequest Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CancelRequest_WhenSuccessful_ReturnsOk()
|
||||
{
|
||||
// Arrange
|
||||
var rowVersion = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
var dto = new CancelManualSyncRequestDto
|
||||
{
|
||||
RowVersionBase64 = Convert.ToBase64String(rowVersion)
|
||||
};
|
||||
_manualSyncRequestService.CancelRequestAsync(1, "testuser", Arg.Any<byte[]>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _controller.CancelRequest(1, dto, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelRequest_WhenConcurrencyFails_ReturnsConflict()
|
||||
{
|
||||
// Arrange
|
||||
var rowVersion = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
var dto = new CancelManualSyncRequestDto
|
||||
{
|
||||
RowVersionBase64 = Convert.ToBase64String(rowVersion)
|
||||
};
|
||||
_manualSyncRequestService.CancelRequestAsync(1, "testuser", Arg.Any<byte[]>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await _controller.CancelRequest(1, dto, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ConflictObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelRequest_WithInvalidBase64_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new CancelManualSyncRequestDto
|
||||
{
|
||||
RowVersionBase64 = "not-valid-base64!!!"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.CancelRequest(1, dto, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
var badRequest = (BadRequestObjectResult)result;
|
||||
badRequest.Value.ShouldBeOfType<string>();
|
||||
((string)badRequest.Value!).ShouldContain("Invalid RowVersionBase64 format");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelRequest_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
// Arrange
|
||||
SetupUnauthenticatedUser();
|
||||
var dto = new CancelManualSyncRequestDto
|
||||
{
|
||||
RowVersionBase64 = Convert.ToBase64String(new byte[] { 1, 2, 3 })
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.CancelRequest(1, dto, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<UnauthorizedResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelRequest_PassesCorrectParametersToService()
|
||||
{
|
||||
// Arrange
|
||||
var rowVersion = new byte[] { 10, 20, 30, 40 };
|
||||
var dto = new CancelManualSyncRequestDto
|
||||
{
|
||||
RowVersionBase64 = Convert.ToBase64String(rowVersion)
|
||||
};
|
||||
_manualSyncRequestService.CancelRequestAsync(Arg.Any<int>(), Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _controller.CancelRequest(99, dto, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await _manualSyncRequestService.Received(1)
|
||||
.CancelRequestAsync(
|
||||
99,
|
||||
"testuser",
|
||||
Arg.Is<byte[]>(b => b.SequenceEqual(rowVersion)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupAuthenticatedUser(string username)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, username),
|
||||
new("dn", $"CN={username},DC=example,DC=com")
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext { User = principal };
|
||||
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
||||
}
|
||||
|
||||
private void SetupUnauthenticatedUser()
|
||||
{
|
||||
var identity = new ClaimsIdentity(); // No claims, not authenticated
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext { User = principal };
|
||||
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
||||
}
|
||||
|
||||
private static ManualSyncRequest CreateRequest(
|
||||
int id,
|
||||
string pipelineName,
|
||||
string syncType,
|
||||
string requestedBy,
|
||||
DateTime requestDT,
|
||||
DateTime? completedDT = null,
|
||||
DateTime? cancelDT = null,
|
||||
string? cancelledBy = null,
|
||||
byte[]? rowVersion = null)
|
||||
{
|
||||
return new ManualSyncRequest
|
||||
{
|
||||
Id = id,
|
||||
PipelineName = pipelineName,
|
||||
SyncType = syncType,
|
||||
RequestedBy = requestedBy,
|
||||
RequestDT = requestDT,
|
||||
CompletedDT = completedDT,
|
||||
CancelDT = cancelDT,
|
||||
CancelledBy = cancelledBy,
|
||||
RowVersion = rowVersion ?? new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 }
|
||||
};
|
||||
}
|
||||
|
||||
private static EtlPipelineConfig CreatePipeline(
|
||||
string name,
|
||||
int? massSyncInterval = null,
|
||||
int? dailySyncInterval = null,
|
||||
int? hourlySyncInterval = null,
|
||||
bool isEnabled = true)
|
||||
{
|
||||
return new EtlPipelineConfig
|
||||
{
|
||||
Name = name,
|
||||
IsEnabled = isEnabled,
|
||||
MassSyncIntervalMinutes = massSyncInterval,
|
||||
DailySyncIntervalMinutes = dailySyncInterval,
|
||||
HourlySyncIntervalMinutes = hourlySyncInterval
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Security.Claims;
|
||||
using JdeScoping.Api.Controllers;
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
using JdeScoping.DataSync.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace JdeScoping.Api.Tests.Controllers;
|
||||
|
||||
public class PipelineControllerTests
|
||||
{
|
||||
private readonly IPipelineRegistry _pipelineRegistry;
|
||||
private readonly ILogger<PipelineController> _logger;
|
||||
private readonly PipelineController _controller;
|
||||
|
||||
public PipelineControllerTests()
|
||||
{
|
||||
_pipelineRegistry = Substitute.For<IPipelineRegistry>();
|
||||
_logger = Substitute.For<ILogger<PipelineController>>();
|
||||
_controller = new PipelineController(_pipelineRegistry, _logger);
|
||||
SetupAuthenticatedUser("testuser", isAdmin: false);
|
||||
}
|
||||
|
||||
#region GetPipelines Tests
|
||||
|
||||
[Fact]
|
||||
public void GetPipelines_ReturnsEnabledPipelines()
|
||||
{
|
||||
// Arrange
|
||||
var pipelines = new List<EtlPipelineConfig>
|
||||
{
|
||||
CreatePipeline("WorkOrders", massSyncInterval: 1440, dailySyncInterval: 60, hourlySyncInterval: 15),
|
||||
CreatePipeline("Items", massSyncInterval: 1440, dailySyncInterval: 60)
|
||||
};
|
||||
_pipelineRegistry.GetEnabledPipelines().Returns(pipelines);
|
||||
|
||||
// Act
|
||||
var result = _controller.GetPipelines();
|
||||
|
||||
// Assert
|
||||
result.Result.ShouldBeOfType<OkObjectResult>();
|
||||
var okResult = (OkObjectResult)result.Result!;
|
||||
var viewModels = okResult.Value.ShouldBeAssignableTo<List<Api.Contracts.ManualSync.PipelineInfoViewModel>>()!;
|
||||
|
||||
viewModels.Count.ShouldBe(2);
|
||||
viewModels[0].Name.ShouldBe("WorkOrders");
|
||||
viewModels[0].SupportedSyncTypes.ShouldContain("mass");
|
||||
viewModels[0].SupportedSyncTypes.ShouldContain("daily");
|
||||
viewModels[0].SupportedSyncTypes.ShouldContain("hourly");
|
||||
viewModels[1].Name.ShouldBe("Items");
|
||||
viewModels[1].SupportedSyncTypes.ShouldNotContain("hourly");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPipelines_WhenEmpty_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
_pipelineRegistry.GetEnabledPipelines().Returns(new List<EtlPipelineConfig>());
|
||||
|
||||
// Act
|
||||
var result = _controller.GetPipelines();
|
||||
|
||||
// Assert
|
||||
var okResult = (OkObjectResult)result.Result!;
|
||||
var viewModels = okResult.Value.ShouldBeAssignableTo<List<Api.Contracts.ManualSync.PipelineInfoViewModel>>()!;
|
||||
viewModels.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPipelines_MapsOnlySupportedSyncTypes()
|
||||
{
|
||||
// Arrange - pipeline with only mass sync
|
||||
var pipelines = new List<EtlPipelineConfig>
|
||||
{
|
||||
CreatePipeline("MassOnly", massSyncInterval: 1440)
|
||||
};
|
||||
_pipelineRegistry.GetEnabledPipelines().Returns(pipelines);
|
||||
|
||||
// Act
|
||||
var result = _controller.GetPipelines();
|
||||
|
||||
// Assert
|
||||
var okResult = (OkObjectResult)result.Result!;
|
||||
var viewModels = okResult.Value.ShouldBeAssignableTo<List<Api.Contracts.ManualSync.PipelineInfoViewModel>>()!;
|
||||
|
||||
viewModels[0].SupportedSyncTypes.Count.ShouldBe(1);
|
||||
viewModels[0].SupportedSyncTypes.ShouldContain("mass");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStatus Tests
|
||||
|
||||
[Fact]
|
||||
public void GetStatus_ReturnsRegistryMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var allPipelines = new List<EtlPipelineConfig>
|
||||
{
|
||||
CreatePipeline("Pipeline1", isEnabled: true),
|
||||
CreatePipeline("Pipeline2", isEnabled: true),
|
||||
CreatePipeline("Pipeline3", isEnabled: false)
|
||||
};
|
||||
var enabledPipelines = allPipelines.Where(p => p.IsEnabled).ToList();
|
||||
|
||||
_pipelineRegistry.GetAllPipelines().Returns(allPipelines);
|
||||
_pipelineRegistry.GetEnabledPipelines().Returns(enabledPipelines);
|
||||
_pipelineRegistry.Version.Returns(5);
|
||||
_pipelineRegistry.LastLoadedAt.Returns(new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc));
|
||||
|
||||
// Act
|
||||
var result = _controller.GetStatus();
|
||||
|
||||
// Assert
|
||||
result.Result.ShouldBeOfType<OkObjectResult>();
|
||||
var okResult = (OkObjectResult)result.Result!;
|
||||
var status = okResult.Value.ShouldBeOfType<PipelineRegistryStatusViewModel>();
|
||||
|
||||
status.Version.ShouldBe(5);
|
||||
status.LastLoadedAt.ShouldBe(new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc));
|
||||
status.TotalPipelines.ShouldBe(3);
|
||||
status.EnabledPipelines.ShouldBe(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReloadPipelines Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadPipelines_CallsRegistry()
|
||||
{
|
||||
// Arrange
|
||||
SetupAuthenticatedUser("admin", isAdmin: true);
|
||||
_pipelineRegistry.ReloadAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(CreateSuccessfulReloadResult(5, 0));
|
||||
|
||||
// Act
|
||||
await _controller.ReloadPipelines(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
await _pipelineRegistry.Received(1).ReloadAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadPipelines_ReturnsResult()
|
||||
{
|
||||
// Arrange
|
||||
SetupAuthenticatedUser("admin", isAdmin: true);
|
||||
_pipelineRegistry.ReloadAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(CreateSuccessfulReloadResult(pipelinesLoaded: 10, pipelinesSkipped: 2, previousVersion: 3, newVersion: 4));
|
||||
|
||||
// Act
|
||||
var result = await _controller.ReloadPipelines(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Result.ShouldBeOfType<OkObjectResult>();
|
||||
var okResult = (OkObjectResult)result.Result!;
|
||||
var reloadResult = okResult.Value.ShouldBeOfType<PipelineReloadResultViewModel>();
|
||||
|
||||
reloadResult.Success.ShouldBeTrue();
|
||||
reloadResult.PipelinesLoaded.ShouldBe(10);
|
||||
reloadResult.PipelinesSkipped.ShouldBe(2);
|
||||
reloadResult.PreviousVersion.ShouldBe(3);
|
||||
reloadResult.NewVersion.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadPipelines_WithErrors_ReturnsErrorDetails()
|
||||
{
|
||||
// Arrange
|
||||
SetupAuthenticatedUser("admin", isAdmin: true);
|
||||
var errors = new List<PipelineLoadError>
|
||||
{
|
||||
new PipelineLoadError
|
||||
{
|
||||
FileName = "pipeline.Bad.json",
|
||||
PipelineName = "Bad",
|
||||
ErrorType = "Validation",
|
||||
Messages = new List<string> { "Missing source", "Missing destination" }
|
||||
}
|
||||
};
|
||||
|
||||
_pipelineRegistry.ReloadAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new PipelineReloadResult
|
||||
{
|
||||
Success = false,
|
||||
PipelinesLoaded = 5,
|
||||
PipelinesSkipped = 1,
|
||||
PreviousVersion = 1,
|
||||
NewVersion = 1,
|
||||
Errors = errors
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _controller.ReloadPipelines(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Result.ShouldBeOfType<OkObjectResult>();
|
||||
var okResult = (OkObjectResult)result.Result!;
|
||||
var reloadResult = okResult.Value.ShouldBeOfType<PipelineReloadResultViewModel>();
|
||||
|
||||
reloadResult.Success.ShouldBeFalse();
|
||||
reloadResult.Errors.Count.ShouldBe(1);
|
||||
reloadResult.Errors[0].FileName.ShouldBe("pipeline.Bad.json");
|
||||
reloadResult.Errors[0].ErrorType.ShouldBe("Validation");
|
||||
reloadResult.Errors[0].Messages.ShouldContain("Missing source");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadPipelines_WhenExceptionThrown_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
SetupAuthenticatedUser("admin", isAdmin: true);
|
||||
_pipelineRegistry.ReloadAsync(Arg.Any<CancellationToken>())
|
||||
.Throws(new Exception("Unexpected error"));
|
||||
|
||||
// Act
|
||||
var result = await _controller.ReloadPipelines(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Result.ShouldBeOfType<ObjectResult>();
|
||||
var objectResult = (ObjectResult)result.Result!;
|
||||
objectResult.StatusCode.ShouldBe(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupAuthenticatedUser(string username, bool isAdmin = false)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, username)
|
||||
};
|
||||
|
||||
if (isAdmin)
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, "Admin"));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var httpContext = new DefaultHttpContext { User = principal };
|
||||
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
||||
}
|
||||
|
||||
private static EtlPipelineConfig CreatePipeline(
|
||||
string name,
|
||||
bool isEnabled = true,
|
||||
int? massSyncInterval = null,
|
||||
int? dailySyncInterval = null,
|
||||
int? hourlySyncInterval = null)
|
||||
{
|
||||
return new EtlPipelineConfig
|
||||
{
|
||||
Name = name,
|
||||
IsEnabled = isEnabled,
|
||||
MassSyncIntervalMinutes = massSyncInterval,
|
||||
DailySyncIntervalMinutes = dailySyncInterval,
|
||||
HourlySyncIntervalMinutes = hourlySyncInterval
|
||||
};
|
||||
}
|
||||
|
||||
private static PipelineReloadResult CreateSuccessfulReloadResult(
|
||||
int pipelinesLoaded,
|
||||
int pipelinesSkipped,
|
||||
int previousVersion = 1,
|
||||
int newVersion = 2)
|
||||
{
|
||||
return new PipelineReloadResult
|
||||
{
|
||||
Success = true,
|
||||
PipelinesLoaded = pipelinesLoaded,
|
||||
PipelinesSkipped = pipelinesSkipped,
|
||||
PreviousVersion = previousVersion,
|
||||
NewVersion = newVersion,
|
||||
Errors = new List<PipelineLoadError>()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
using System.Text.Json;
|
||||
using JdeScoping.ConfigManager.Models;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace JdeScoping.ConfigManager.Tests.Models;
|
||||
|
||||
public class ConnectionStringsSectionConverterTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_StandardDictionaryFormat_ParsesAllConnections()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"LotFinder": "Server=localhost,1434;Database=ScopingTool;User Id=sa;Password=test",
|
||||
"JDE": "Data Source=jde-server:1521/JDEPROD;User Id=jdeuser;Password=jdepass",
|
||||
"CMS": "Data Source=cms-server:1521/CMSPROD;User Id=cmsuser;Password=cmspass"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var section = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
|
||||
|
||||
// Assert
|
||||
section.ShouldNotBeNull();
|
||||
section.Entries.Count.ShouldBe(3);
|
||||
|
||||
var lotFinder = section.Entries.First(e => e.Name == "LotFinder");
|
||||
lotFinder.Provider.ShouldBe(ConnectionProvider.SqlServer);
|
||||
lotFinder.Server.ShouldBe("localhost,1434");
|
||||
lotFinder.Database.ShouldBe("ScopingTool");
|
||||
lotFinder.UserId.ShouldBe("sa");
|
||||
|
||||
var jde = section.Entries.First(e => e.Name == "JDE");
|
||||
jde.Provider.ShouldBe(ConnectionProvider.Oracle);
|
||||
jde.Host.ShouldBe("jde-server");
|
||||
jde.Port.ShouldBe(1521);
|
||||
jde.ServiceName.ShouldBe("JDEPROD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_SqlServerConnection_ParsesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"TestDb": "Server=myserver;Database=TestDB;User Id=testuser;Password=testpass;Encrypt=True;TrustServerCertificate=True;Connection Timeout=60;Application Name=TestApp"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var section = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
|
||||
|
||||
// Assert
|
||||
section.ShouldNotBeNull();
|
||||
section.Entries.Count.ShouldBe(1);
|
||||
|
||||
var entry = section.Entries[0];
|
||||
entry.Name.ShouldBe("TestDb");
|
||||
entry.Provider.ShouldBe(ConnectionProvider.SqlServer);
|
||||
entry.Server.ShouldBe("myserver");
|
||||
entry.Database.ShouldBe("TestDB");
|
||||
entry.UserId.ShouldBe("testuser");
|
||||
entry.Password.ShouldBe("testpass");
|
||||
entry.Encrypt.ShouldBe("True");
|
||||
entry.TrustServerCertificate.ShouldBeTrue();
|
||||
entry.ConnectionTimeout.ShouldBe(60);
|
||||
entry.ApplicationName.ShouldBe("TestApp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_OracleConnection_ParsesHostPortService()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"Oracle": "Data Source=//db-host:1523/PRODDB;User Id=orauser;Password=orapass"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var section = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
|
||||
|
||||
// Assert
|
||||
section.ShouldNotBeNull();
|
||||
section.Entries.Count.ShouldBe(1);
|
||||
|
||||
var entry = section.Entries[0];
|
||||
entry.Name.ShouldBe("Oracle");
|
||||
entry.Provider.ShouldBe(ConnectionProvider.Oracle);
|
||||
entry.Host.ShouldBe("db-host");
|
||||
entry.Port.ShouldBe(1523);
|
||||
entry.ServiceName.ShouldBe("PRODDB");
|
||||
entry.UserId.ShouldBe("orauser");
|
||||
entry.Password.ShouldBe("orapass");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_EmptyObject_ReturnsEmptyEntries()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{}";
|
||||
|
||||
// Act
|
||||
var section = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
|
||||
|
||||
// Assert
|
||||
section.ShouldNotBeNull();
|
||||
section.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ToStandardDictionaryFormat()
|
||||
{
|
||||
// Arrange
|
||||
var section = new ConnectionStringsSection
|
||||
{
|
||||
Entries = new List<ConnectionStringEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "TestDb",
|
||||
Provider = ConnectionProvider.SqlServer,
|
||||
Server = "myserver",
|
||||
Database = "TestDB",
|
||||
UserId = "testuser",
|
||||
Password = "testpass"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(section, JsonOptions);
|
||||
|
||||
// Assert
|
||||
json.ShouldNotBeNull();
|
||||
json.ShouldContain("\"TestDb\"");
|
||||
json.ShouldContain("Server=myserver");
|
||||
json.ShouldContain("Database=TestDB");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesConnections()
|
||||
{
|
||||
// Arrange
|
||||
var original = new ConnectionStringsSection
|
||||
{
|
||||
Entries = new List<ConnectionStringEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "Primary",
|
||||
Provider = ConnectionProvider.SqlServer,
|
||||
Server = "server1",
|
||||
Database = "DB1",
|
||||
UserId = "user1",
|
||||
Password = "pass1"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "Secondary",
|
||||
Provider = ConnectionProvider.Oracle,
|
||||
Host = "oracle-host",
|
||||
Port = 1521,
|
||||
ServiceName = "ORCL",
|
||||
UserId = "user2",
|
||||
Password = "pass2"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(original, JsonOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<ConnectionStringsSection>(json, JsonOptions);
|
||||
|
||||
// Assert
|
||||
deserialized.ShouldNotBeNull();
|
||||
deserialized.Entries.Count.ShouldBe(2);
|
||||
|
||||
var primary = deserialized.Entries.First(e => e.Name == "Primary");
|
||||
primary.Provider.ShouldBe(ConnectionProvider.SqlServer);
|
||||
primary.Server.ShouldBe("server1");
|
||||
primary.Database.ShouldBe("DB1");
|
||||
|
||||
var secondary = deserialized.Entries.First(e => e.Name == "Secondary");
|
||||
secondary.Provider.ShouldBe(ConnectionProvider.Oracle);
|
||||
secondary.Host.ShouldBe("oracle-host");
|
||||
secondary.Port.ShouldBe(1521);
|
||||
secondary.ServiceName.ShouldBe("ORCL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
using JdeScoping.DataAccess.Interfaces;
|
||||
using JdeScoping.DataAccess.Services;
|
||||
using JdeScoping.Domain.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace JdeScoping.DataAccess.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ManualSyncRequestService.
|
||||
/// Tests constructor validation, interface contract compliance, and static helper methods.
|
||||
/// Note: Since this service uses Dapper with raw SQL, full integration tests with
|
||||
/// an actual database are required for complete coverage of the SQL operations.
|
||||
/// </summary>
|
||||
public class ManualSyncRequestServiceTests
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<ManualSyncRequestService> _logger;
|
||||
|
||||
public ManualSyncRequestServiceTests()
|
||||
{
|
||||
_connectionFactory = Substitute.For<IDbConnectionFactory>();
|
||||
_logger = NullLogger<ManualSyncRequestService>.Instance;
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullConnectionFactory_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Should.Throw<ArgumentNullException>(() =>
|
||||
new ManualSyncRequestService(null!, _logger));
|
||||
|
||||
exception.ParamName.ShouldBe("connectionFactory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullLogger_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Should.Throw<ArgumentNullException>(() =>
|
||||
new ManualSyncRequestService(_connectionFactory, null!));
|
||||
|
||||
exception.ParamName.ShouldBe("logger");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithValidDependencies_CreatesInstance()
|
||||
{
|
||||
// Act
|
||||
var service = new ManualSyncRequestService(_connectionFactory, _logger);
|
||||
|
||||
// Assert
|
||||
service.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Interface Contract Tests
|
||||
|
||||
[Fact]
|
||||
public void ManualSyncRequestService_ImplementsIManualSyncRequestService()
|
||||
{
|
||||
// Arrange & Act
|
||||
var service = new ManualSyncRequestService(_connectionFactory, _logger);
|
||||
|
||||
// Assert
|
||||
service.ShouldBeAssignableTo<IManualSyncRequestService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRequestsAsync_HasCorrectSignature()
|
||||
{
|
||||
// Arrange
|
||||
var service = new ManualSyncRequestService(_connectionFactory, _logger);
|
||||
|
||||
// Act - Verify method exists with correct return type
|
||||
var methodInfo = typeof(ManualSyncRequestService).GetMethod(nameof(ManualSyncRequestService.GetRequestsAsync));
|
||||
|
||||
// Assert
|
||||
methodInfo.ShouldNotBeNull();
|
||||
methodInfo.ReturnType.ShouldBe(typeof(Task<IReadOnlyList<ManualSyncRequest>>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextPendingRequestAsync_HasCorrectSignature()
|
||||
{
|
||||
// Arrange
|
||||
var service = new ManualSyncRequestService(_connectionFactory, _logger);
|
||||
|
||||
// Act - Verify method exists with correct return type
|
||||
var methodInfo = typeof(ManualSyncRequestService).GetMethod(nameof(ManualSyncRequestService.GetNextPendingRequestAsync));
|
||||
|
||||
// Assert
|
||||
methodInfo.ShouldNotBeNull();
|
||||
methodInfo.ReturnType.ShouldBe(typeof(Task<ManualSyncRequest?>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateRequestAsync_HasCorrectSignature()
|
||||
{
|
||||
// Arrange
|
||||
var service = new ManualSyncRequestService(_connectionFactory, _logger);
|
||||
|
||||
// Act - Verify method exists with correct return type
|
||||
var methodInfo = typeof(ManualSyncRequestService).GetMethod(nameof(ManualSyncRequestService.CreateRequestAsync));
|
||||
|
||||
// Assert
|
||||
methodInfo.ShouldNotBeNull();
|
||||
methodInfo.ReturnType.ShouldBe(typeof(Task<ManualSyncRequest>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelRequestAsync_HasCorrectSignature()
|
||||
{
|
||||
// Arrange
|
||||
var service = new ManualSyncRequestService(_connectionFactory, _logger);
|
||||
|
||||
// Act - Verify method exists with correct return type
|
||||
var methodInfo = typeof(ManualSyncRequestService).GetMethod(nameof(ManualSyncRequestService.CancelRequestAsync));
|
||||
|
||||
// Assert
|
||||
methodInfo.ShouldNotBeNull();
|
||||
methodInfo.ReturnType.ShouldBe(typeof(Task<bool>));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteRequestAsync_HasCorrectSignature()
|
||||
{
|
||||
// Arrange
|
||||
var service = new ManualSyncRequestService(_connectionFactory, _logger);
|
||||
|
||||
// Act - Verify method exists with correct return type
|
||||
var methodInfo = typeof(ManualSyncRequestService).GetMethod(nameof(ManualSyncRequestService.CompleteRequestAsync));
|
||||
|
||||
// Assert
|
||||
methodInfo.ShouldNotBeNull();
|
||||
methodInfo.ReturnType.ShouldBe(typeof(Task<bool>));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Domain Model Tests
|
||||
|
||||
[Fact]
|
||||
public void ManualSyncRequest_Status_WhenPending_ReturnsPending()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ManualSyncRequest
|
||||
{
|
||||
Id = 1,
|
||||
PipelineName = "TestPipeline",
|
||||
SyncType = "mass",
|
||||
RequestedBy = "testuser",
|
||||
RequestDT = DateTime.UtcNow,
|
||||
CompletedDT = null,
|
||||
CancelDT = null
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
request.Status.ShouldBe("Pending");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualSyncRequest_Status_WhenCompleted_ReturnsCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ManualSyncRequest
|
||||
{
|
||||
Id = 1,
|
||||
PipelineName = "TestPipeline",
|
||||
SyncType = "mass",
|
||||
RequestedBy = "testuser",
|
||||
RequestDT = DateTime.UtcNow.AddHours(-1),
|
||||
CompletedDT = DateTime.UtcNow,
|
||||
CancelDT = null
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
request.Status.ShouldBe("Completed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualSyncRequest_Status_WhenCancelled_ReturnsCancelled()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ManualSyncRequest
|
||||
{
|
||||
Id = 1,
|
||||
PipelineName = "TestPipeline",
|
||||
SyncType = "mass",
|
||||
RequestedBy = "testuser",
|
||||
RequestDT = DateTime.UtcNow.AddHours(-1),
|
||||
CompletedDT = null,
|
||||
CancelDT = DateTime.UtcNow,
|
||||
CancelledBy = "admin"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
request.Status.ShouldBe("Cancelled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualSyncRequest_Status_WhenCancelledAndCompleted_ReturnsCancelled()
|
||||
{
|
||||
// Arrange - Edge case: both CancelDT and CompletedDT are set
|
||||
// Based on the implementation, CancelDT takes precedence
|
||||
var request = new ManualSyncRequest
|
||||
{
|
||||
Id = 1,
|
||||
PipelineName = "TestPipeline",
|
||||
SyncType = "mass",
|
||||
RequestedBy = "testuser",
|
||||
RequestDT = DateTime.UtcNow.AddHours(-2),
|
||||
CompletedDT = DateTime.UtcNow.AddHours(-1),
|
||||
CancelDT = DateTime.UtcNow,
|
||||
CancelledBy = "admin"
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
// CancelDT is checked first in the Status property, so it should return "Cancelled"
|
||||
request.Status.ShouldBe("Cancelled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualSyncRequest_DefaultRowVersion_IsEmptyArray()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ManualSyncRequest();
|
||||
|
||||
// Act & Assert
|
||||
request.RowVersion.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualSyncRequest_DefaultPipelineName_IsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ManualSyncRequest();
|
||||
|
||||
// Act & Assert
|
||||
request.PipelineName.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualSyncRequest_DefaultSyncType_IsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ManualSyncRequest();
|
||||
|
||||
// Act & Assert
|
||||
request.SyncType.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualSyncRequest_DefaultRequestedBy_IsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ManualSyncRequest();
|
||||
|
||||
// Act & Assert
|
||||
request.RequestedBy.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManualSyncRequest_CancelledBy_IsNullableAndDefaultsToNull()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ManualSyncRequest();
|
||||
|
||||
// Act & Assert
|
||||
request.CancelledBy.ShouldBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Method Parameter Tests
|
||||
|
||||
[Fact]
|
||||
public void GetRequestsAsync_PendingOnlyParameter_DefaultsToFalse()
|
||||
{
|
||||
// Verify the interface defines correct default parameter
|
||||
var methodInfo = typeof(IManualSyncRequestService).GetMethod(nameof(IManualSyncRequestService.GetRequestsAsync));
|
||||
var parameters = methodInfo!.GetParameters();
|
||||
|
||||
// The pendingOnly parameter should have a default value of false
|
||||
var pendingOnlyParam = parameters.FirstOrDefault(p => p.Name == "pendingOnly");
|
||||
pendingOnlyParam.ShouldNotBeNull();
|
||||
pendingOnlyParam.HasDefaultValue.ShouldBeTrue();
|
||||
pendingOnlyParam.DefaultValue.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateRequestAsync_RequiresPipelineName()
|
||||
{
|
||||
// Verify the method has a pipelineName parameter
|
||||
var methodInfo = typeof(IManualSyncRequestService).GetMethod(nameof(IManualSyncRequestService.CreateRequestAsync));
|
||||
var parameters = methodInfo!.GetParameters();
|
||||
|
||||
var param = parameters.FirstOrDefault(p => p.Name == "pipelineName");
|
||||
param.ShouldNotBeNull();
|
||||
param.ParameterType.ShouldBe(typeof(string));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateRequestAsync_RequiresSyncType()
|
||||
{
|
||||
// Verify the method has a syncType parameter
|
||||
var methodInfo = typeof(IManualSyncRequestService).GetMethod(nameof(IManualSyncRequestService.CreateRequestAsync));
|
||||
var parameters = methodInfo!.GetParameters();
|
||||
|
||||
var param = parameters.FirstOrDefault(p => p.Name == "syncType");
|
||||
param.ShouldNotBeNull();
|
||||
param.ParameterType.ShouldBe(typeof(string));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateRequestAsync_RequiresRequestedBy()
|
||||
{
|
||||
// Verify the method has a requestedBy parameter
|
||||
var methodInfo = typeof(IManualSyncRequestService).GetMethod(nameof(IManualSyncRequestService.CreateRequestAsync));
|
||||
var parameters = methodInfo!.GetParameters();
|
||||
|
||||
var param = parameters.FirstOrDefault(p => p.Name == "requestedBy");
|
||||
param.ShouldNotBeNull();
|
||||
param.ParameterType.ShouldBe(typeof(string));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelRequestAsync_RequiresId()
|
||||
{
|
||||
// Verify the method has an id parameter
|
||||
var methodInfo = typeof(IManualSyncRequestService).GetMethod(nameof(IManualSyncRequestService.CancelRequestAsync));
|
||||
var parameters = methodInfo!.GetParameters();
|
||||
|
||||
var param = parameters.FirstOrDefault(p => p.Name == "id");
|
||||
param.ShouldNotBeNull();
|
||||
param.ParameterType.ShouldBe(typeof(int));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelRequestAsync_RequiresRowVersion()
|
||||
{
|
||||
// Verify the method has a rowVersion parameter for optimistic concurrency
|
||||
var methodInfo = typeof(IManualSyncRequestService).GetMethod(nameof(IManualSyncRequestService.CancelRequestAsync));
|
||||
var parameters = methodInfo!.GetParameters();
|
||||
|
||||
var param = parameters.FirstOrDefault(p => p.Name == "rowVersion");
|
||||
param.ShouldNotBeNull();
|
||||
param.ParameterType.ShouldBe(typeof(byte[]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteRequestAsync_RequiresId()
|
||||
{
|
||||
// Verify the method has an id parameter
|
||||
var methodInfo = typeof(IManualSyncRequestService).GetMethod(nameof(IManualSyncRequestService.CompleteRequestAsync));
|
||||
var parameters = methodInfo!.GetParameters();
|
||||
|
||||
var param = parameters.FirstOrDefault(p => p.Name == "id");
|
||||
param.ShouldNotBeNull();
|
||||
param.ParameterType.ShouldBe(typeof(int));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteRequestAsync_RequiresRowVersion()
|
||||
{
|
||||
// Verify the method has a rowVersion parameter for optimistic concurrency
|
||||
var methodInfo = typeof(IManualSyncRequestService).GetMethod(nameof(IManualSyncRequestService.CompleteRequestAsync));
|
||||
var parameters = methodInfo!.GetParameters();
|
||||
|
||||
var param = parameters.FirstOrDefault(p => p.Name == "rowVersion");
|
||||
param.ShouldNotBeNull();
|
||||
param.ParameterType.ShouldBe(typeof(byte[]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using JdeScoping.Core.Models;
|
||||
using JdeScoping.Core.Models.Enums;
|
||||
using JdeScoping.Core.Models.Infrastructure;
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
using JdeScoping.DataSync.Options;
|
||||
using JdeScoping.DataSync.Contracts;
|
||||
using JdeScoping.DataSync.Services;
|
||||
@@ -17,19 +18,28 @@ namespace JdeScoping.DataSync.Tests;
|
||||
public class ScheduleCheckerTests
|
||||
{
|
||||
private readonly IDataUpdateRepository _repository;
|
||||
private readonly IPipelineRegistry _pipelineRegistry;
|
||||
private readonly IOptions<DataSyncOptions> _options;
|
||||
private readonly List<EtlPipelineConfig> _pipelines;
|
||||
private readonly ScheduleChecker _sut;
|
||||
|
||||
public ScheduleCheckerTests()
|
||||
{
|
||||
_repository = Substitute.For<IDataUpdateRepository>();
|
||||
_pipelineRegistry = Substitute.For<IPipelineRegistry>();
|
||||
_pipelines = [];
|
||||
_options = Microsoft.Extensions.Options.Options.Create(new DataSyncOptions
|
||||
{
|
||||
LookbackMultiplier = 3,
|
||||
DataSources = []
|
||||
});
|
||||
|
||||
// Setup pipeline registry to return our pipeline list
|
||||
_pipelineRegistry.GetEnabledPipelines().Returns(_ => _pipelines);
|
||||
|
||||
_sut = new ScheduleChecker(
|
||||
_repository,
|
||||
_pipelineRegistry,
|
||||
_options,
|
||||
NullLogger<ScheduleChecker>.Instance);
|
||||
}
|
||||
@@ -40,8 +50,8 @@ public class ScheduleCheckerTests
|
||||
public async Task GetPendingTasksAsync_WhenMassNeverRun_ReturnsMassTask()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateDataSourceConfig("WorkOrder", massEnabled: true, dailyEnabled: true, hourlyEnabled: true);
|
||||
_options.Value.DataSources.Add(config);
|
||||
var pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440, hourlyInterval: 60);
|
||||
_pipelines.Add(pipeline);
|
||||
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<string, DataUpdate>());
|
||||
|
||||
@@ -59,10 +69,8 @@ public class ScheduleCheckerTests
|
||||
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 pipeline = CreatePipeline("WorkOrder", massInterval: 60, dailyInterval: 1440);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddMinutes(-120), success: true);
|
||||
@@ -85,11 +93,8 @@ public class ScheduleCheckerTests
|
||||
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 pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440, hourlyInterval: 60);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddHours(-1), success: true);
|
||||
@@ -114,11 +119,8 @@ public class ScheduleCheckerTests
|
||||
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 pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440, hourlyInterval: 60);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
|
||||
@@ -145,11 +147,8 @@ public class ScheduleCheckerTests
|
||||
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 pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440, hourlyInterval: 60);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
|
||||
@@ -181,10 +180,8 @@ public class ScheduleCheckerTests
|
||||
{
|
||||
// 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 pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-2), success: true);
|
||||
@@ -214,11 +211,8 @@ public class ScheduleCheckerTests
|
||||
public async Task GetPendingTasksAsync_HourlySync_UsesHourlyTimestampForMinimumDT()
|
||||
{
|
||||
// Arrange: Hourly uses its own timestamp and interval 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 pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440, hourlyInterval: 60);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
|
||||
@@ -251,10 +245,8 @@ public class ScheduleCheckerTests
|
||||
{
|
||||
// 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 pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-2), success: true);
|
||||
@@ -277,92 +269,15 @@ public class ScheduleCheckerTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Disabled Table Handling
|
||||
#region Manual-Only Pipelines
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingTasksAsync_DisabledDataSource_ReturnsNoTasks()
|
||||
public async Task GetPendingTasksAsync_ManualOnlyPipeline_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);
|
||||
var pipeline = CreatePipeline("WorkOrder", massInterval: 10080);
|
||||
pipeline.IsManualOnly = true;
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<string, DataUpdate>());
|
||||
@@ -382,11 +297,8 @@ public class ScheduleCheckerTests
|
||||
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);
|
||||
var pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440, hourlyInterval: 60);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<string, DataUpdate>());
|
||||
@@ -404,10 +316,8 @@ public class ScheduleCheckerTests
|
||||
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 pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddHours(-1), success: true);
|
||||
@@ -431,11 +341,8 @@ public class ScheduleCheckerTests
|
||||
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);
|
||||
var pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440, hourlyInterval: 60);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<string, DataUpdate>());
|
||||
@@ -456,9 +363,8 @@ public class ScheduleCheckerTests
|
||||
public async Task GetPendingTasksAsync_FailedMass_ReturnsMassAgain()
|
||||
{
|
||||
// Arrange
|
||||
var config = CreateDataSourceConfig("WorkOrder",
|
||||
massEnabled: true, massInterval: 10080);
|
||||
_options.Value.DataSources.Add(config);
|
||||
var pipeline = CreatePipeline("WorkOrder", massInterval: 10080);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddMinutes(-5), success: false);
|
||||
@@ -481,10 +387,8 @@ public class ScheduleCheckerTests
|
||||
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 pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
|
||||
@@ -509,11 +413,8 @@ public class ScheduleCheckerTests
|
||||
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 pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440, hourlyInterval: 60);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMass = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
|
||||
@@ -544,10 +445,10 @@ public class ScheduleCheckerTests
|
||||
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);
|
||||
var pipeline1 = CreatePipeline("WorkOrder", massInterval: 60);
|
||||
var pipeline2 = CreatePipeline("LotUsage", massInterval: 60);
|
||||
_pipelines.Add(pipeline1);
|
||||
_pipelines.Add(pipeline2);
|
||||
|
||||
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<string, DataUpdate>());
|
||||
@@ -565,13 +466,10 @@ public class ScheduleCheckerTests
|
||||
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 pipeline1 = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440);
|
||||
var pipeline2 = CreatePipeline("LotUsage", massInterval: 60);
|
||||
_pipelines.Add(pipeline1);
|
||||
_pipelines.Add(pipeline2);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var lastMassWorkOrder = CreateDataUpdate("WorkOrder", UpdateTypes.Mass, now.AddDays(-1), success: true);
|
||||
@@ -603,11 +501,8 @@ public class ScheduleCheckerTests
|
||||
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 pipeline = CreatePipeline("WorkOrder", massInterval: 10080, dailyInterval: 1440, hourlyInterval: 60);
|
||||
_pipelines.Add(pipeline);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// All syncs completed recently
|
||||
@@ -631,9 +526,9 @@ public class ScheduleCheckerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingTasksAsync_NoDataSources_ReturnsEmptyList()
|
||||
public async Task GetPendingTasksAsync_NoPipelines_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange: No data sources configured
|
||||
// Arrange: No pipelines configured
|
||||
_repository.GetLastDataUpdatesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<string, DataUpdate>());
|
||||
|
||||
@@ -648,35 +543,29 @@ public class ScheduleCheckerTests
|
||||
|
||||
#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)
|
||||
private static EtlPipelineConfig CreatePipeline(
|
||||
string name,
|
||||
int? massInterval = null,
|
||||
int? dailyInterval = null,
|
||||
int? hourlyInterval = null)
|
||||
{
|
||||
return new DataSourceConfig
|
||||
return new EtlPipelineConfig
|
||||
{
|
||||
TableName = tableName,
|
||||
SourceSystem = "JDE",
|
||||
SourceData = tableName.ToUpper(),
|
||||
Name = name,
|
||||
IsEnabled = true,
|
||||
MassConfig = new ScheduleConfig
|
||||
IsManualOnly = false,
|
||||
MassSyncIntervalMinutes = massInterval,
|
||||
DailySyncIntervalMinutes = dailyInterval,
|
||||
HourlySyncIntervalMinutes = hourlyInterval,
|
||||
Source = new SourceElement
|
||||
{
|
||||
Enabled = massEnabled,
|
||||
IntervalMinutes = massInterval
|
||||
Connection = "jde",
|
||||
Query = "SELECT * FROM Test"
|
||||
},
|
||||
DailyConfig = new ScheduleConfig
|
||||
Destination = new DestinationElement
|
||||
{
|
||||
Enabled = dailyEnabled,
|
||||
IntervalMinutes = dailyInterval
|
||||
},
|
||||
HourlyConfig = new ScheduleConfig
|
||||
{
|
||||
Enabled = hourlyEnabled,
|
||||
IntervalMinutes = hourlyInterval
|
||||
Table = name,
|
||||
MatchColumns = ["Id"]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
using JdeScoping.DataSync.Options;
|
||||
using JdeScoping.DataSync.Services;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
using MsOptions = Microsoft.Extensions.Options.Options;
|
||||
|
||||
namespace JdeScoping.DataSync.Tests.Services;
|
||||
|
||||
public class PipelineRegistryTests : IDisposable
|
||||
{
|
||||
private readonly string _testDirectory;
|
||||
private readonly IPipelineValidator _validator;
|
||||
private readonly IOptions<DataSyncOptions> _options;
|
||||
private readonly ILogger<PipelineRegistry> _logger;
|
||||
private readonly IHostEnvironment _environment;
|
||||
|
||||
public PipelineRegistryTests()
|
||||
{
|
||||
_testDirectory = Path.Combine(Path.GetTempPath(), $"PipelineRegistryTests_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDirectory);
|
||||
|
||||
_validator = new PipelineValidator();
|
||||
_logger = Substitute.For<ILogger<PipelineRegistry>>();
|
||||
_environment = Substitute.For<IHostEnvironment>();
|
||||
_environment.ContentRootPath.Returns(_testDirectory);
|
||||
|
||||
_options = MsOptions.Create(new DataSyncOptions
|
||||
{
|
||||
PipelinesDirectory = "Pipelines",
|
||||
StrictPipelineValidation = false
|
||||
});
|
||||
|
||||
// Create the Pipelines subdirectory
|
||||
Directory.CreateDirectory(Path.Combine(_testDirectory, "Pipelines"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testDirectory))
|
||||
{
|
||||
Directory.Delete(_testDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private string PipelinesDir => Path.Combine(_testDirectory, "Pipelines");
|
||||
|
||||
#region Loading Tests
|
||||
|
||||
[Fact]
|
||||
public async Task LoadPipelines_ValidDirectory_LoadsAll()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("Pipeline1", true);
|
||||
CreatePipelineFile("Pipeline2", true);
|
||||
CreatePipelineFile("Pipeline3", true);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// Act
|
||||
var result = await registry.ReloadAsync();
|
||||
|
||||
// Assert
|
||||
result.Success.ShouldBeTrue();
|
||||
result.PipelinesLoaded.ShouldBe(3);
|
||||
registry.GetAllPipelines().Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadPipelines_EmptyDirectory_ReturnsEmpty()
|
||||
{
|
||||
// Arrange - empty directory (Pipelines subdirectory is already created but empty)
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// Act
|
||||
var result = await registry.ReloadAsync();
|
||||
|
||||
// Assert
|
||||
result.Success.ShouldBeTrue();
|
||||
result.PipelinesLoaded.ShouldBe(0);
|
||||
registry.GetAllPipelines().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadPipelines_OnlyLoadsJsonFiles()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("ValidPipeline", true);
|
||||
File.WriteAllText(Path.Combine(PipelinesDir, "readme.txt"), "Some text");
|
||||
File.WriteAllText(Path.Combine(PipelinesDir, "config.xml"), "<xml/>");
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// Act
|
||||
var result = await registry.ReloadAsync();
|
||||
|
||||
// Assert
|
||||
result.PipelinesLoaded.ShouldBe(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public async Task LoadPipelines_InvalidJson_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
File.WriteAllText(Path.Combine(PipelinesDir, "pipeline.Invalid.json"), "{ invalid json }");
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// Act
|
||||
var result = await registry.ReloadAsync();
|
||||
|
||||
// Assert
|
||||
result.Errors.ShouldContain(e => e.ErrorType.Contains("parse", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadPipelines_DuplicateNames_ReturnsError()
|
||||
{
|
||||
// Arrange - two files with same pipeline name
|
||||
CreatePipelineFile("DuplicateName", true, "pipeline.First.json");
|
||||
CreatePipelineFile("DuplicateName", true, "pipeline.Second.json");
|
||||
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// Act
|
||||
var result = await registry.ReloadAsync();
|
||||
|
||||
// Assert
|
||||
result.Errors.ShouldContain(e => e.ErrorType.Contains("validation", StringComparison.OrdinalIgnoreCase)
|
||||
&& e.Messages.Any(m => m.Contains("Duplicate", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Retrieval Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetPipeline_ByName_ReturnsCorrect()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("TestPipeline", true);
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Act
|
||||
var pipeline = registry.GetPipeline("TestPipeline");
|
||||
|
||||
// Assert
|
||||
pipeline.ShouldNotBeNull();
|
||||
pipeline.Name.ShouldBe("TestPipeline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPipeline_CaseInsensitive_ReturnsCorrect()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("MyPipeline", true);
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Act
|
||||
var pipeline = registry.GetPipeline("mypipeline");
|
||||
|
||||
// Assert
|
||||
pipeline.ShouldNotBeNull();
|
||||
pipeline.Name.ShouldBe("MyPipeline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPipeline_NotFound_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("ExistingPipeline", true);
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Act
|
||||
var pipeline = registry.GetPipeline("NonExistentPipeline");
|
||||
|
||||
// Assert
|
||||
pipeline.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEnabledPipelines_OnlyReturnsEnabled()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("EnabledPipeline1", true);
|
||||
CreatePipelineFile("EnabledPipeline2", true);
|
||||
CreatePipelineFile("DisabledPipeline", false);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Act
|
||||
var enabledPipelines = registry.GetEnabledPipelines();
|
||||
|
||||
// Assert
|
||||
enabledPipelines.Count.ShouldBe(2);
|
||||
enabledPipelines.ShouldAllBe(p => p.IsEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllPipelines_IncludesDisabled()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("EnabledPipeline", true);
|
||||
CreatePipelineFile("DisabledPipeline", false);
|
||||
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Act
|
||||
var allPipelines = registry.GetAllPipelines();
|
||||
|
||||
// Assert
|
||||
allPipelines.Count.ShouldBe(2);
|
||||
allPipelines.ShouldContain(p => !p.IsEnabled);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reload Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadAsync_UpdatesSnapshot()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("Pipeline1", true);
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Add another pipeline
|
||||
CreatePipelineFile("Pipeline2", true);
|
||||
|
||||
// Act
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Assert
|
||||
registry.GetAllPipelines().Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadAsync_IncrementsVersion()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("Pipeline1", true);
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// Act
|
||||
await registry.ReloadAsync();
|
||||
var version1 = registry.Version;
|
||||
|
||||
await registry.ReloadAsync();
|
||||
var version2 = registry.Version;
|
||||
|
||||
// Assert
|
||||
version2.ShouldBe(version1 + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReloadAsync_UpdatesLastLoadedAt()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("Pipeline1", true);
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// Act
|
||||
var before = DateTime.UtcNow;
|
||||
await registry.ReloadAsync();
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
// Assert
|
||||
registry.LastLoadedAt.ShouldNotBeNull();
|
||||
registry.LastLoadedAt.Value.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
|
||||
registry.LastLoadedAt.Value.ShouldBeLessThanOrEqualTo(after.AddSeconds(1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsValidPipelineAndSyncType Tests
|
||||
|
||||
[Fact]
|
||||
public async Task IsValidPipelineAndSyncType_ValidCombination_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("TestPipeline", true, massSyncInterval: 1440, dailySyncInterval: 60);
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Act & Assert
|
||||
registry.IsValidPipelineAndSyncType("TestPipeline", "mass").ShouldBeTrue();
|
||||
registry.IsValidPipelineAndSyncType("TestPipeline", "daily").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsValidPipelineAndSyncType_UnsupportedSyncType_ReturnsFalse()
|
||||
{
|
||||
// Arrange - only mass sync
|
||||
CreatePipelineFile("TestPipeline", true, massSyncInterval: 1440);
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Act & Assert
|
||||
registry.IsValidPipelineAndSyncType("TestPipeline", "hourly").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsValidPipelineAndSyncType_UnknownPipeline_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("TestPipeline", true);
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Act & Assert
|
||||
registry.IsValidPipelineAndSyncType("UnknownPipeline", "mass").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsValidPipelineAndSyncType_DisabledPipeline_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("DisabledPipeline", false, massSyncInterval: 1440);
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Act & Assert
|
||||
registry.IsValidPipelineAndSyncType("DisabledPipeline", "mass").ShouldBeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Thread Safety Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ReadOperations_ThreadSafe()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
CreatePipelineFile($"Pipeline{i}", true);
|
||||
}
|
||||
|
||||
var registry = CreateRegistry();
|
||||
await registry.ReloadAsync();
|
||||
|
||||
// Act - multiple concurrent reads
|
||||
var tasks = Enumerable.Range(0, 100).Select(_ => Task.Run(() =>
|
||||
{
|
||||
var all = registry.GetAllPipelines();
|
||||
var enabled = registry.GetEnabledPipelines();
|
||||
var specific = registry.GetPipeline("Pipeline5");
|
||||
return all.Count + enabled.Count + (specific != null ? 1 : 0);
|
||||
}));
|
||||
|
||||
// Assert - no exceptions
|
||||
var results = await Task.WhenAll(tasks);
|
||||
results.ShouldAllBe(r => r > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentReloads_Serialized()
|
||||
{
|
||||
// Arrange
|
||||
CreatePipelineFile("Pipeline1", true);
|
||||
var registry = CreateRegistry();
|
||||
|
||||
// Act - multiple concurrent reloads
|
||||
var tasks = Enumerable.Range(0, 10).Select(_ => registry.ReloadAsync());
|
||||
|
||||
// Assert - no exceptions and final state is valid
|
||||
await Task.WhenAll(tasks);
|
||||
registry.GetAllPipelines().Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private PipelineRegistry CreateRegistry() =>
|
||||
new PipelineRegistry(_options, _validator, _logger, _environment);
|
||||
|
||||
private void CreatePipelineFile(
|
||||
string name,
|
||||
bool isEnabled,
|
||||
string? fileName = null,
|
||||
int? massSyncInterval = 1440,
|
||||
int? dailySyncInterval = null,
|
||||
int? hourlySyncInterval = null)
|
||||
{
|
||||
var pipeline = new
|
||||
{
|
||||
name,
|
||||
isEnabled,
|
||||
isManualOnly = !massSyncInterval.HasValue && !dailySyncInterval.HasValue && !hourlySyncInterval.HasValue && !isEnabled,
|
||||
massSyncIntervalMinutes = massSyncInterval,
|
||||
dailySyncIntervalMinutes = dailySyncInterval,
|
||||
hourlySyncIntervalMinutes = hourlySyncInterval,
|
||||
source = new
|
||||
{
|
||||
connection = "jde",
|
||||
query = "SELECT * FROM TestTable"
|
||||
},
|
||||
destination = new
|
||||
{
|
||||
table = $"{name}_Table",
|
||||
matchColumns = new[] { "Id" }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(pipeline, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
|
||||
var actualFileName = fileName ?? $"pipeline.{name}.json";
|
||||
var filePath = Path.Combine(PipelinesDir, actualFileName);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
using JdeScoping.DataSync.Configuration;
|
||||
using JdeScoping.DataSync.Services;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace JdeScoping.DataSync.Tests.Services;
|
||||
|
||||
public class PipelineValidatorTests
|
||||
{
|
||||
private readonly IPipelineValidator _validator;
|
||||
|
||||
public PipelineValidatorTests()
|
||||
{
|
||||
_validator = new PipelineValidator();
|
||||
}
|
||||
|
||||
#region Name/Filename Matching
|
||||
|
||||
[Fact]
|
||||
public void Validate_NameMatchesFilename_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = CreateValidPipeline("TestPipeline");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NameMismatchFilename_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = CreateValidPipeline("WrongName");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.CorrectName.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("does not match filename", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NameCaseInsensitive_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = CreateValidPipeline("testpipeline");
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Source Validation
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingSource_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = true,
|
||||
MassSyncIntervalMinutes = 1440,
|
||||
Source = null!,
|
||||
Destination = CreateValidDestination()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("Source is required", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingSourceConnection_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = true,
|
||||
MassSyncIntervalMinutes = 1440,
|
||||
Source = new SourceElement
|
||||
{
|
||||
Connection = "",
|
||||
Query = "SELECT * FROM table"
|
||||
},
|
||||
Destination = CreateValidDestination()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("Connection is required", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidConnection_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = true,
|
||||
MassSyncIntervalMinutes = 1440,
|
||||
Source = new SourceElement
|
||||
{
|
||||
Connection = "invalid_db",
|
||||
Query = "SELECT * FROM table"
|
||||
},
|
||||
Destination = CreateValidDestination()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("not valid", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidConnections_Pass()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
foreach (var connection in new[] { "jde", "cms", "giw", "lotfinder" })
|
||||
{
|
||||
var pipeline = CreateValidPipeline("TestPipeline");
|
||||
pipeline.Source.Connection = connection;
|
||||
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
result.IsValid.ShouldBeTrue($"Connection '{connection}' should be valid");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingSourceQuery_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = true,
|
||||
MassSyncIntervalMinutes = 1440,
|
||||
Source = new SourceElement
|
||||
{
|
||||
Connection = "jde",
|
||||
Query = ""
|
||||
},
|
||||
Destination = CreateValidDestination()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("Query is required", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Destination Validation
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingDestination_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = true,
|
||||
MassSyncIntervalMinutes = 1440,
|
||||
Source = CreateValidSource(),
|
||||
Destination = null!
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("Destination is required", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingDestinationTable_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = true,
|
||||
MassSyncIntervalMinutes = 1440,
|
||||
Source = CreateValidSource(),
|
||||
Destination = new DestinationElement
|
||||
{
|
||||
Table = "",
|
||||
MatchColumns = ["Id"]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("Table is required", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyMatchColumns_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = true,
|
||||
MassSyncIntervalMinutes = 1440,
|
||||
Source = CreateValidSource(),
|
||||
Destination = new DestinationElement
|
||||
{
|
||||
Table = "TestTable",
|
||||
MatchColumns = []
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("MatchColumns", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Interval Validation
|
||||
|
||||
[Fact]
|
||||
public void Validate_EnabledWithoutAnyInterval_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = true,
|
||||
IsManualOnly = false,
|
||||
// No intervals set
|
||||
Source = CreateValidSource(),
|
||||
Destination = CreateValidDestination()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("At least one sync interval", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ManualOnlyWithoutInterval_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = true,
|
||||
IsManualOnly = true,
|
||||
// No intervals set - ok for manual-only
|
||||
Source = CreateValidSource(),
|
||||
Destination = CreateValidDestination()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DisabledWithoutInterval_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = false,
|
||||
// No intervals set - ok for disabled
|
||||
Source = CreateValidSource(),
|
||||
Destination = CreateValidDestination()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZeroMassInterval_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = CreateValidPipeline("TestPipeline");
|
||||
pipeline.MassSyncIntervalMinutes = 0;
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("MassSyncIntervalMinutes must be greater than 0", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NegativeInterval_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = CreateValidPipeline("TestPipeline");
|
||||
pipeline.DailySyncIntervalMinutes = -60;
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("DailySyncIntervalMinutes must be greater than 0", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Warning Cases
|
||||
|
||||
[Fact]
|
||||
public void Validate_HourlyWithoutDaily_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "TestPipeline",
|
||||
IsEnabled = true,
|
||||
HourlySyncIntervalMinutes = 15,
|
||||
// No daily interval
|
||||
Source = CreateValidSource(),
|
||||
Destination = CreateValidDestination()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue(); // Warnings don't fail validation
|
||||
result.Warnings.ShouldContain(w => w.Contains("daily", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Script Validation
|
||||
|
||||
[Fact]
|
||||
public void Validate_PreScriptWithEmptyScript_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = CreateValidPipeline("TestPipeline");
|
||||
pipeline.PreScripts = [new ScriptElement { Script = "" }];
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("PreScripts", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PostScriptWithEmptyScript_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = CreateValidPipeline("TestPipeline");
|
||||
pipeline.PostScripts = [new ScriptElement { Script = "" }];
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("PostScripts", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidScripts_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = CreateValidPipeline("TestPipeline");
|
||||
pipeline.PreScripts = [new ScriptElement { Script = "TRUNCATE TABLE Staging" }];
|
||||
pipeline.PostScripts = [new ScriptElement { Script = "EXEC ProcessData" }];
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.TestPipeline.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Complete Valid Pipeline
|
||||
|
||||
[Fact]
|
||||
public void Validate_CompleteValidPipeline_Passes()
|
||||
{
|
||||
// Arrange
|
||||
var pipeline = new EtlPipelineConfig
|
||||
{
|
||||
Name = "WorkOrder_Curr",
|
||||
IsEnabled = true,
|
||||
MassSyncIntervalMinutes = 1440,
|
||||
DailySyncIntervalMinutes = 60,
|
||||
HourlySyncIntervalMinutes = 15,
|
||||
Source = new SourceElement
|
||||
{
|
||||
Connection = "jde",
|
||||
Query = "SELECT * FROM WorkOrders WHERE ModDate > @lastSync",
|
||||
MassQuery = "SELECT * FROM WorkOrders"
|
||||
},
|
||||
Destination = new DestinationElement
|
||||
{
|
||||
Table = "WorkOrder_Curr",
|
||||
MatchColumns = ["OrderNumber"]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(pipeline, "pipeline.WorkOrder_Curr.json");
|
||||
|
||||
// Assert
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static EtlPipelineConfig CreateValidPipeline(string name) => new()
|
||||
{
|
||||
Name = name,
|
||||
IsEnabled = true,
|
||||
MassSyncIntervalMinutes = 1440,
|
||||
Source = CreateValidSource(),
|
||||
Destination = CreateValidDestination()
|
||||
};
|
||||
|
||||
private static SourceElement CreateValidSource() => new()
|
||||
{
|
||||
Connection = "jde",
|
||||
Query = "SELECT * FROM TestTable"
|
||||
};
|
||||
|
||||
private static DestinationElement CreateValidDestination() => new()
|
||||
{
|
||||
Table = "TestTable",
|
||||
MatchColumns = ["Id"]
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user