From 8255f1f6aa7e02134feadb9f1fc4e96e3be3732d Mon Sep 17 00:00:00 2001 From: TekH Date: Sat, 11 Jul 2026 19:16:35 +0200 Subject: [PATCH] feat(application): add inactive profile validation with custom exception - Create InactiveProfileException for profile active status validation - Update TriggeringDEXJobBatchCommand to check profile active status before execution - Add ICfgProfileRepository dependency to batch command handler - Throw InactiveProfileException when attempting to execute inactive profiles - Add profile not found validation with descriptive error message - Reorganize commands under DEXJob/Commands directory structure --- .../Exceptions/InactiveProfileException.cs | 47 +++++ .../TriggeringDEXJobBatchCommand.cs | 29 +++- .../{ => Commands}/TriggeringDEXJobCommand.cs | 2 +- .../TriggeringDEXJobBatchCommandTests.cs | 5 +- .../TriggeringDEXJobCommandExceptionTests.cs | 164 ++++++++++++++++++ 5 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 ECMJobRunner.Application/Common/Exceptions/InactiveProfileException.cs rename ECMJobRunner.Application/DEXJob/{ => Commands}/TriggeringDEXJobBatchCommand.cs (70%) rename ECMJobRunner.Application/DEXJob/{ => Commands}/TriggeringDEXJobCommand.cs (97%) create mode 100644 ECMJobRunner.Tests/Application/TriggeringDEXJobCommandExceptionTests.cs diff --git a/ECMJobRunner.Application/Common/Exceptions/InactiveProfileException.cs b/ECMJobRunner.Application/Common/Exceptions/InactiveProfileException.cs new file mode 100644 index 0000000..a18ed13 --- /dev/null +++ b/ECMJobRunner.Application/Common/Exceptions/InactiveProfileException.cs @@ -0,0 +1,47 @@ +using System; + +namespace ECMJobRunner.Application.Common.Exceptions +{ + /// + /// Exception thrown when attempting to execute a job for an inactive profile + /// Extends JobException with profile-specific context + /// + public class InactiveProfileException : JobException + { + /// + /// Initializes a new instance of InactiveProfileException + /// + /// ID of the inactive profile + /// Name of the inactive profile (nullable) + /// Unique batch identifier for tracking + /// + /// Use this exception when: + /// - Attempting to trigger DEX job batch for an inactive profile + /// - Attempting to execute individual jobs from an inactive profile + /// - Profile is deactivated during execution + /// + public InactiveProfileException(long profileId, string? profileName, string batchId) + : base( + jobName: "Profile Execution", + processName: "Profile Active Status Validation", + batchId: batchId, + reason: "The profile is marked as inactive and cannot be executed", + innerException: null, + ("Profile ID", profileId.ToString(), false), + ("Profile Name", profileName, true)) + { + ProfileId = profileId; + ProfileName = profileName; + } + + /// + /// Gets the ID of the inactive profile + /// + public long ProfileId { get; } + + /// + /// Gets the name of the inactive profile (nullable) + /// + public string? ProfileName { get; } + } +} diff --git a/ECMJobRunner.Application/DEXJob/TriggeringDEXJobBatchCommand.cs b/ECMJobRunner.Application/DEXJob/Commands/TriggeringDEXJobBatchCommand.cs similarity index 70% rename from ECMJobRunner.Application/DEXJob/TriggeringDEXJobBatchCommand.cs rename to ECMJobRunner.Application/DEXJob/Commands/TriggeringDEXJobBatchCommand.cs index 35c82c3..cf4c28f 100644 --- a/ECMJobRunner.Application/DEXJob/TriggeringDEXJobBatchCommand.cs +++ b/ECMJobRunner.Application/DEXJob/Commands/TriggeringDEXJobBatchCommand.cs @@ -1,10 +1,12 @@ -using ECMJobRunner.Domain.Interfaces; +using ECMJobRunner.Application.Common.Exceptions; +using ECMJobRunner.Application.Common.Interfaces; +using ECMJobRunner.Domain.Interfaces; using MediatR; using System; using System.Threading; using System.Threading.Tasks; -namespace ECMJobRunner.Application.DEXJob +namespace ECMJobRunner.Application.DEXJob.Commands { /// /// Command to trigger DEX job batch for a profile @@ -21,8 +23,9 @@ namespace ECMJobRunner.Application.DEXJob /// /// Handler for TriggeringDEXJobBatchCommand /// Retrieves all SQL jobs for a profile and executes them sequentially + /// Validates that the profile is active before execution /// - public class TriggeringDEXJobBatchCommandHandler(IProfileSqlJobRepository jobRepo, ISender sender) + public class TriggeringDEXJobBatchCommandHandler(ICfgProfileRepository profileRepo, IProfileSqlJobRepository jobRepo, ISender sender) : IRequestHandler { /// @@ -32,12 +35,28 @@ namespace ECMJobRunner.Application.DEXJob /// The command containing the profile ID /// Cancellation token /// Unit value indicating completion + /// Thrown when the profile is not active public async Task Handle(TriggeringDEXJobBatchCommand request, CancellationToken cancellationToken) { - var jobs = await jobRepo.FindAsync(j => j.ProfileId == request.ProfileId, cancellationToken); - var batchId = CreateBatchId(); + // Retrieve the profile to check if it's active + var profile = await profileRepo.GetByIdAsync(request.ProfileId, cancellationToken); + + // Check if profile exists and is active + if (profile == null) + { + throw new InvalidOperationException($"Profile with ID {request.ProfileId} not found."); + } + + if (!profile.Active) + { + throw new InactiveProfileException(profile.Id, profile.ProfileName, batchId); + } + + // Retrieve all jobs for the profile + var jobs = await jobRepo.FindAsync(j => j.ProfileId == request.ProfileId, cancellationToken); + foreach (var job in jobs) { await sender.Send(new TriggeringDEXJobCommand diff --git a/ECMJobRunner.Application/DEXJob/TriggeringDEXJobCommand.cs b/ECMJobRunner.Application/DEXJob/Commands/TriggeringDEXJobCommand.cs similarity index 97% rename from ECMJobRunner.Application/DEXJob/TriggeringDEXJobCommand.cs rename to ECMJobRunner.Application/DEXJob/Commands/TriggeringDEXJobCommand.cs index f28053c..218548f 100644 --- a/ECMJobRunner.Application/DEXJob/TriggeringDEXJobCommand.cs +++ b/ECMJobRunner.Application/DEXJob/Commands/TriggeringDEXJobCommand.cs @@ -3,7 +3,7 @@ using MediatR; using System.Threading; using System.Threading.Tasks; -namespace ECMJobRunner.Application.DEXJob +namespace ECMJobRunner.Application.DEXJob.Commands { /// /// Command to trigger a single DEX job execution diff --git a/ECMJobRunner.Tests/Application/TriggeringDEXJobBatchCommandTests.cs b/ECMJobRunner.Tests/Application/TriggeringDEXJobBatchCommandTests.cs index 90f1775..937ec4b 100644 --- a/ECMJobRunner.Tests/Application/TriggeringDEXJobBatchCommandTests.cs +++ b/ECMJobRunner.Tests/Application/TriggeringDEXJobBatchCommandTests.cs @@ -1,8 +1,9 @@ using ECMJobRunner.Application.Common.Dtos; -using ECMJobRunner.Domain.Interfaces; +using ECMJobRunner.Application.Common.Interfaces; using ECMJobRunner.Application.Common.Options; -using ECMJobRunner.Application.DEXJob; +using ECMJobRunner.Application.DEXJob.Commands; using ECMJobRunner.Domain.Entities; +using ECMJobRunner.Domain.Interfaces; using FluentAssertions; using MediatR; using Microsoft.Extensions.Options; diff --git a/ECMJobRunner.Tests/Application/TriggeringDEXJobCommandExceptionTests.cs b/ECMJobRunner.Tests/Application/TriggeringDEXJobCommandExceptionTests.cs new file mode 100644 index 0000000..8e88f16 --- /dev/null +++ b/ECMJobRunner.Tests/Application/TriggeringDEXJobCommandExceptionTests.cs @@ -0,0 +1,164 @@ +using ECMJobRunner.Application.Common.Exceptions; +using FluentAssertions; +using System; +using Xunit; + +namespace ECMJobRunner.Tests.Application +{ + public class TriggeringDEXJobCommandExceptionTests + { + [Fact] + public void JobSqlException_HasCorrectMessage() + { + // Arrange + var jobName = "Main Query Execution"; + var processName = "MainQueryExecution"; + var batchId = "12345678901234567890"; + var reason = "SQL syntax error"; + var query = "SELECT * FROM NonExistentTable"; + + // Act + var exception = new JobSqlException( + jobName, + processName, + batchId, + reason, + query, + null); + + // Assert + exception.JobName.Should().Be(jobName); + exception.ProcessName.Should().Be(processName); + exception.BatchId.Should().Be(batchId); + exception.Query.Should().Be(query); + exception.Message.Should().Contain(jobName); + exception.Message.Should().Contain(processName); + exception.Message.Should().Contain(batchId); + exception.Message.Should().Contain(reason); + exception.Message.Should().Contain(query); + } + + [Fact] + public void JobSqlException_WithInnerException_PreservesInnerException() + { + // Arrange + var innerException = new InvalidOperationException("Database connection failed"); + var exception = new JobSqlException( + "Main Query", + "Execution", + "12345678901234567890", + "Connection error", + "SELECT 1", + innerException); + + // Assert + exception.InnerException.Should().Be(innerException); + exception.InnerException!.Message.Should().Be("Database connection failed"); + } + + [Fact] + public void JobHttpException_HasCorrectMessage() + { + // Arrange + var jobName = "ReC Request Execution"; + var processName = "ReCRequestExecution"; + var batchId = "12345678901234567890"; + var reason = "HTTP 500 Internal Server Error"; + var clientLibrary = "ReC.Client"; + var clientMethod = "ExecuteAsync"; + + // Act + var exception = new JobHttpException( + jobName, + processName, + batchId, + reason, + clientLibrary, + clientMethod, + null); + + // Assert + exception.JobName.Should().Be(jobName); + exception.ProcessName.Should().Be(processName); + exception.BatchId.Should().Be(batchId); + exception.ClientLibrary.Should().Be(clientLibrary); + exception.ClientMethod.Should().Be(clientMethod); + exception.Message.Should().Contain(jobName); + exception.Message.Should().Contain(processName); + exception.Message.Should().Contain(batchId); + exception.Message.Should().Contain(reason); + exception.Message.Should().Contain(clientLibrary); + exception.Message.Should().Contain(clientMethod); + } + + [Fact] + public void JobHttpException_WithInnerException_PreservesInnerException() + { + // Arrange + var innerException = new TimeoutException("Request timed out"); + var exception = new JobHttpException( + "ReC Request", + "Execution", + "12345678901234567890", + "Timeout error", + "ReC.Client", + "ExecuteAsync", + innerException); + + // Assert + exception.InnerException.Should().Be(innerException); + exception.InnerException!.Message.Should().Be("Request timed out"); + } + + [Fact] + public void JobException_OmitsNullValues_WhenIgnoreIfNullIsTrue() + { + // Arrange & Act + var exception = new JobException( + "Test Job", + "Test Process", + "12345678901234567890", + null, // reason is null + null, + ("Custom Detail", "Value", false)); + + // Assert + exception.Message.Should().NotContain("Reason:"); // Should be omitted because it's null and IgnoreIfNull=true + exception.Message.Should().Contain("Custom Detail: Value"); + } + + [Fact] + public void JobException_IncludesNullValues_WhenIgnoreIfNullIsFalse() + { + // Arrange & Act + var exception = new JobException( + "Test Job", + "Test Process", + "12345678901234567890", + null, + null, + ("Custom Detail", null, false)); // IgnoreIfNull=false + + // Assert + exception.Message.Should().Contain("Custom Detail:"); // Should be included even though value is null + } + + [Fact] + public void JobException_FormatsMessageWithSeparators() + { + // Arrange & Act + var exception = new JobException( + "Test Job", + "Test Process", + "12345678901234567890", + "Test reason", + null); + + // Assert + exception.Message.Should().Contain("─────────────────────────────────────────"); + exception.Message.Should().Contain("Test Job could not be completed."); + exception.Message.Should().Contain("Process Name: Test Process"); + exception.Message.Should().Contain("Batch Id: 12345678901234567890"); + } + } +}