diff --git a/ECM.JobRunner.sln b/ECM.JobRunner.sln index efc0e9b..e68d728 100644 --- a/ECM.JobRunner.sln +++ b/ECM.JobRunner.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Application", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Tests", "ECMJobRunner.Tests\ECMJobRunner.Tests.csproj", "{F64352B6-32BB-4BDE-90FD-FB77482D44E0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.WebCron", "ECMJobRunner.WebCron\ECMJobRunner.WebCron.csproj", "{C60BC965-D293-EA64-B153-1941F0648DF4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Debug|Any CPU.Build.0 = Debug|Any CPU {F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Release|Any CPU.ActiveCfg = Release|Any CPU {F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Release|Any CPU.Build.0 = Release|Any CPU + {C60BC965-D293-EA64-B153-1941F0648DF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C60BC965-D293-EA64-B153-1941F0648DF4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C60BC965-D293-EA64-B153-1941F0648DF4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C60BC965-D293-EA64-B153-1941F0648DF4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ECMJobRunner.Tests/Application/CheckQueryExecutionBehaviorTests.cs b/ECMJobRunner.Tests/Application/CheckQueryExecutionBehaviorTests.cs new file mode 100644 index 0000000..3099baf --- /dev/null +++ b/ECMJobRunner.Tests/Application/CheckQueryExecutionBehaviorTests.cs @@ -0,0 +1,234 @@ +using ECMJobRunner.Application.Behaviors; +using ECMJobRunner.Application.Common.Constants; +using ECMJobRunner.Application.Common.Dtos; +using ECMJobRunner.Application.Common.Exceptions; +using ECMJobRunner.Domain.Interfaces; +using ECMJobRunner.Application.Common.Options; +using ECMJobRunner.Application.DEXJob; +using ECMJobRunner.Domain.Entities; +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.Options; +using Moq; +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace ECMJobRunner.Tests.Application +{ + public class CheckQueryExecutionBehaviorTests + { + private readonly Mock _mockExecutor; + private readonly Mock> _mockOptions; + private readonly CheckQueryExecutionBehavior _behavior; + private readonly Mock> _mockNext; + + public CheckQueryExecutionBehaviorTests() + { + _mockExecutor = new Mock(); + _mockOptions = new Mock>(); + _mockOptions.Setup(o => o.Value).Returns(new DexJobOptions()); + _behavior = new CheckQueryExecutionBehavior(_mockExecutor.Object, _mockOptions.Object); + _mockNext = new Mock>(); + _mockNext.Setup(n => n()).ReturnsAsync(Unit.Value); + } + + [Fact] + public async Task Handle_WithValidCheckQuery_ExecutesSuccessfully() + { + // Arrange + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" }, + BatchId = "20260711143025123456" + }; + + _mockExecutor + .Setup(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new CheckQueryResult { ReturnValue = 5 }); + + // Act +#if NET48 + var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + result.Should().Be(Unit.Value); + _mockExecutor.Verify(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny()), Times.Once); + _mockNext.Verify(n => n(), Times.Once); + } + + [Theory] + [InlineData(1)] + [InlineData(10)] + [InlineData(100)] + public async Task Handle_WithPositiveReturnValue_DoesNotThrow(int returnValue) + { + // Arrange + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" }, + BatchId = "20260711143025123456" + }; + + _mockExecutor + .Setup(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new CheckQueryResult { ReturnValue = returnValue }); + + // Act +#if NET48 + Func act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + Func act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + await act.Should().NotThrowAsync(); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-10)] + public async Task Handle_WithZeroOrNegativeReturnValue_ThrowsDEXJobException(int returnValue) + { + // Arrange + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" }, + BatchId = "20260711143025123456" + }; + + _mockExecutor + .Setup(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new CheckQueryResult { ReturnValue = returnValue }); + + // Act +#if NET48 + Func act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + Func act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + await act.Should().ThrowAsync() + .WithMessage($"*unexpectedly returned the value {returnValue}*"); + } + + [Fact] + public async Task Handle_WithNullCheckQuery_IgnoresByDefault() + { + // Arrange + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlCheckQuery = null }, + BatchId = "20260711143025123456" + }; + + // Act +#if NET48 + var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + result.Should().Be(Unit.Value); + _mockExecutor.Verify(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockNext.Verify(n => n(), Times.Once); + } + + [Fact] + public async Task Handle_WithNullCheckQueryAndStopOption_ThrowsDEXJobException() + { + // Arrange + var options = new DexJobOptions + { + Error = new DexJobOptions.DexJobErrorHandlingOptions + { + CheckQuery = new DexJobOptions.DexJobErrorHandlingOptions.SqlQueryErrorHandlingOptions + { + IfNullOrWhiteSpace = ErrorAction.Stop + } + } + }; + _mockOptions.Setup(o => o.Value).Returns(options); + + var behavior = new CheckQueryExecutionBehavior(_mockExecutor.Object, _mockOptions.Object); + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlCheckQuery = null }, + BatchId = "20260711143025123456" + }; + + // Act +#if NET48 + Func act = async () => await behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + Func act = async () => await behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*SQL Check Query is null or empty*"); + } + + [Fact] + public async Task Handle_WithBatchIdPlaceholder_ReplacesCorrectly() + { + // Arrange + var batchId = "20260711143025123456"; + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table WHERE BatchId = '#INT#BATCH_ID'" }, + BatchId = batchId + }; + + string? capturedSql = null; + _mockExecutor + .Setup(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny())) + .Callback((sql, _) => capturedSql = sql) + .ReturnsAsync(new CheckQueryResult { ReturnValue = 1 }); + + // Act +#if NET48 + await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + capturedSql.Should().Contain(batchId); + capturedSql.Should().NotContain("#INT#BATCH_ID"); + } + + [Fact] + public async Task Handle_WithNullResult_ThrowsDEXJobException() + { + // Arrange + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" }, + BatchId = "20260711143025123456" + }; + + _mockExecutor + .Setup(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((CheckQueryResult?)null); + + // Act +#if NET48 + Func act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + Func act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*Check Query returned nothing*"); + } + } +} diff --git a/ECMJobRunner.Tests/Application/MainQueryExecutionBehaviorTests.cs b/ECMJobRunner.Tests/Application/MainQueryExecutionBehaviorTests.cs new file mode 100644 index 0000000..31b0232 --- /dev/null +++ b/ECMJobRunner.Tests/Application/MainQueryExecutionBehaviorTests.cs @@ -0,0 +1,252 @@ +using ECMJobRunner.Application.Behaviors; +using ECMJobRunner.Application.Common.Constants; +using ECMJobRunner.Application.Common.Dtos; +using ECMJobRunner.Application.Common.Exceptions; +using ECMJobRunner.Domain.Interfaces; +using ECMJobRunner.Application.Common.Options; +using ECMJobRunner.Application.DEXJob; +using ECMJobRunner.Domain.Entities; +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.Options; +using Moq; +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace ECMJobRunner.Tests.Application +{ + public class MainQueryExecutionBehaviorTests + { + private readonly Mock _mockExecutor; + private readonly Mock> _mockOptions; + private readonly MainQueryExecutionBehavior _behavior; + private readonly Mock> _mockNext; + + public MainQueryExecutionBehaviorTests() + { + _mockExecutor = new Mock(); + _mockOptions = new Mock>(); + _mockOptions.Setup(o => o.Value).Returns(new DexJobOptions()); + _behavior = new MainQueryExecutionBehavior(_mockExecutor.Object, _mockOptions.Object); + _mockNext = new Mock>(); + _mockNext.Setup(n => n()).ReturnsAsync(Unit.Value); + } + + [Fact] + public async Task Handle_WithValidMainQuery_ExecutesSuccessfully() + { + // Arrange + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" }, + BatchId = "20260711143025123456" + }; + + _mockExecutor + .Setup(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MainQueryResult { ReturnValue = null }); + + // Act +#if NET48 + var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + result.Should().Be(Unit.Value); + _mockExecutor.Verify(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny()), Times.Once); + _mockNext.Verify(n => n(), Times.Once); + } + + [Fact] + public async Task Handle_WithNullReturnValue_DoesNotThrow() + { + // Arrange + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" }, + BatchId = "20260711143025123456" + }; + + _mockExecutor + .Setup(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MainQueryResult { ReturnValue = null }); + + // Act +#if NET48 + Func act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + Func act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Handle_WithNonNullReturnValue_ThrowsDEXJobException() + { + // Arrange + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" }, + BatchId = "20260711143025123456" + }; + + _mockExecutor + .Setup(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MainQueryResult { ReturnValue = 1 }); + + // Act +#if NET48 + Func act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + Func act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*unexpectedly returned the value 1*"); + } + + [Fact] + public async Task Handle_WithNullMainQuery_IgnoresByDefault() + { + // Arrange + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlMainQuery = null }, + BatchId = "20260711143025123456" + }; + + // Act +#if NET48 + var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + result.Should().Be(Unit.Value); + _mockExecutor.Verify(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockNext.Verify(n => n(), Times.Once); + } + + [Fact] + public async Task Handle_WithNullMainQueryAndStopOption_ThrowsDEXJobException() + { + // Arrange + var options = new DexJobOptions + { + Error = new DexJobOptions.DexJobErrorHandlingOptions + { + MainQuery = new DexJobOptions.DexJobErrorHandlingOptions.SqlQueryErrorHandlingOptions + { + IfNullOrWhiteSpace = ErrorAction.Stop + } + } + }; + _mockOptions.Setup(o => o.Value).Returns(options); + + var behavior = new MainQueryExecutionBehavior(_mockExecutor.Object, _mockOptions.Object); + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlMainQuery = null }, + BatchId = "20260711143025123456" + }; + + // Act +#if NET48 + Func act = async () => await behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + Func act = async () => await behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*SQL Main Query is null or empty*"); + } + + [Fact] + public async Task Handle_WithBatchIdPlaceholder_ReplacesCorrectly() + { + // Arrange + var batchId = "20260711143025123456"; + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')" }, + BatchId = batchId + }; + + string? capturedSql = null; + _mockExecutor + .Setup(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny())) + .Callback((sql, _) => capturedSql = sql) + .ReturnsAsync(new MainQueryResult { ReturnValue = null }); + + // Act +#if NET48 + await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + capturedSql.Should().Contain(batchId); + capturedSql.Should().NotContain("#INT#BATCH_ID"); + } + + [Fact] + public async Task Handle_WithExecutionError_ThrowsDEXJobException() + { + // Arrange + var command = new TriggeringDEXJobCommand + { + Job = new ProfileSqlJob { SqlMainQuery = "INVALID SQL" }, + BatchId = "20260711143025123456" + }; + + _mockExecutor + .Setup(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("SQL error")); + + // Act +#if NET48 + Func act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object); +#else + Func act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None); +#endif + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*SQL Main Query*"); + } + + [Fact] + public async Task Handle_WithNonDEXJobCommand_SkipsExecution() + { + // Arrange + var otherCommand = new OtherCommand(); + var behavior = new MainQueryExecutionBehavior(_mockExecutor.Object, _mockOptions.Object); + var mockNext = new Mock>(); + mockNext.Setup(n => n()).ReturnsAsync(Unit.Value); + + // Act +#if NET48 + var result = await behavior.Handle(otherCommand, CancellationToken.None, mockNext.Object); +#else + var result = await behavior.Handle(otherCommand, mockNext.Object, CancellationToken.None); +#endif + + // Assert + result.Should().Be(Unit.Value); + _mockExecutor.Verify(e => e.ExecuteQueryAsync(It.IsAny(), It.IsAny()), Times.Never); + mockNext.Verify(n => n(), Times.Once); + } + + private class OtherCommand : IRequest { } + } +} diff --git a/ECMJobRunner.Tests/Application/TriggeringDEXJobBatchCommandTests.cs b/ECMJobRunner.Tests/Application/TriggeringDEXJobBatchCommandTests.cs new file mode 100644 index 0000000..90f1775 --- /dev/null +++ b/ECMJobRunner.Tests/Application/TriggeringDEXJobBatchCommandTests.cs @@ -0,0 +1,135 @@ +using ECMJobRunner.Application.Common.Dtos; +using ECMJobRunner.Domain.Interfaces; +using ECMJobRunner.Application.Common.Options; +using ECMJobRunner.Application.DEXJob; +using ECMJobRunner.Domain.Entities; +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.Options; +using Moq; +using ReC.Client; +using ReC.Client.Api; +using System; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace ECMJobRunner.Tests.Application +{ + public class TriggeringDEXJobBatchCommandTests + { + private readonly Mock _mockJobRepo; + private readonly Mock _mockSender; + private readonly TriggeringDEXJobBatchCommandHandler _handler; + + public TriggeringDEXJobBatchCommandTests() + { + _mockJobRepo = new Mock(); + _mockSender = new Mock(); + _handler = new TriggeringDEXJobBatchCommandHandler(_mockJobRepo.Object, _mockSender.Object); + } + + [Fact] + public async Task Handle_WithValidProfileId_ExecutesAllJobs() + { + // Arrange + var profileId = 123; + var jobs = new[] + { + new ProfileSqlJob { Id = 1, ProfileId = profileId, SqlMainQuery = "SELECT 1", SqlCheckQuery = "SELECT 2" }, + new ProfileSqlJob { Id = 2, ProfileId = profileId, SqlMainQuery = "SELECT 3", SqlCheckQuery = "SELECT 4" } + }; + + _mockJobRepo + .Setup(r => r.FindAsync(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(jobs); + + var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId }; + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().Be(Unit.Value); + _mockSender.Verify( + s => s.Send(It.IsAny(), It.IsAny()), + Times.Exactly(2), + "Should send command for each job"); + } + + [Fact] + public async Task Handle_WithNoJobs_CompletesWithoutError() + { + // Arrange + var profileId = 999; + _mockJobRepo + .Setup(r => r.FindAsync(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId }; + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().Be(Unit.Value); + _mockSender.Verify( + s => s.Send(It.IsAny(), It.IsAny()), + Times.Never, + "Should not send any commands when no jobs found"); + } + + [Fact] + public void CreateBatchId_ReturnsValidFormat() + { + // Act + var batchId = TriggeringDEXJobBatchCommandHandler.CreateBatchId(); + + // Assert + batchId.Should().NotBeNullOrEmpty(); + batchId.Should().HaveLength(20, "BatchId should be exactly 20 characters"); + batchId.Should().MatchRegex(@"^\d{20}$", "BatchId should contain only digits"); + } + + [Fact] + public void CreateBatchId_GeneratesUniqueBatchIds() + { + // Act + var batchId1 = TriggeringDEXJobBatchCommandHandler.CreateBatchId(); + var batchId2 = TriggeringDEXJobBatchCommandHandler.CreateBatchId(); + + // Assert + batchId1.Should().NotBe(batchId2, "Consecutive batch IDs should be different"); + } + + [Fact] + public async Task Handle_PassesBatchIdToCommands() + { + // Arrange + var profileId = 123; + var job = new ProfileSqlJob { Id = 1, ProfileId = profileId, SqlMainQuery = "SELECT 1" }; + + _mockJobRepo + .Setup(r => r.FindAsync(It.IsAny>>(), It.IsAny())) + .ReturnsAsync(new[] { job }); + + TriggeringDEXJobCommand? capturedCommand = null; + _mockSender + .Setup(s => s.Send(It.IsAny(), It.IsAny())) + .Callback, CancellationToken>((cmd, _) => capturedCommand = cmd as TriggeringDEXJobCommand) + .ReturnsAsync(Unit.Value); + + var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId }; + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + capturedCommand.Should().NotBeNull(); + capturedCommand!.BatchId.Should().NotBeNullOrEmpty(); + capturedCommand.BatchId.Should().HaveLength(20); + capturedCommand.Job.Should().Be(job); + } + } +} diff --git a/ECMJobRunner.Tests/ECMJobRunner.Tests.csproj b/ECMJobRunner.Tests/ECMJobRunner.Tests.csproj index 0c7d24e..3b9c73d 100644 --- a/ECMJobRunner.Tests/ECMJobRunner.Tests.csproj +++ b/ECMJobRunner.Tests/ECMJobRunner.Tests.csproj @@ -39,23 +39,36 @@ - - + + + + + + + + + - - + + + + + + + + @@ -66,6 +79,7 @@ +