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
This commit is contained in:
2026-07-11 19:16:35 +02:00
parent eaf24e05ee
commit 8255f1f6aa
5 changed files with 239 additions and 8 deletions

View File

@@ -0,0 +1,47 @@
using System;
namespace ECMJobRunner.Application.Common.Exceptions
{
/// <summary>
/// Exception thrown when attempting to execute a job for an inactive profile
/// Extends JobException with profile-specific context
/// </summary>
public class InactiveProfileException : JobException
{
/// <summary>
/// Initializes a new instance of InactiveProfileException
/// </summary>
/// <param name="profileId">ID of the inactive profile</param>
/// <param name="profileName">Name of the inactive profile (nullable)</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
/// <remarks>
/// 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
/// </remarks>
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;
}
/// <summary>
/// Gets the ID of the inactive profile
/// </summary>
public long ProfileId { get; }
/// <summary>
/// Gets the name of the inactive profile (nullable)
/// </summary>
public string? ProfileName { get; }
}
}

View File

@@ -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 MediatR;
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob namespace ECMJobRunner.Application.DEXJob.Commands
{ {
/// <summary> /// <summary>
/// Command to trigger DEX job batch for a profile /// Command to trigger DEX job batch for a profile
@@ -21,8 +23,9 @@ namespace ECMJobRunner.Application.DEXJob
/// <summary> /// <summary>
/// Handler for TriggeringDEXJobBatchCommand /// Handler for TriggeringDEXJobBatchCommand
/// Retrieves all SQL jobs for a profile and executes them sequentially /// Retrieves all SQL jobs for a profile and executes them sequentially
/// Validates that the profile is active before execution
/// </summary> /// </summary>
public class TriggeringDEXJobBatchCommandHandler(IProfileSqlJobRepository jobRepo, ISender sender) public class TriggeringDEXJobBatchCommandHandler(ICfgProfileRepository profileRepo, IProfileSqlJobRepository jobRepo, ISender sender)
: IRequestHandler<TriggeringDEXJobBatchCommand, Unit> : IRequestHandler<TriggeringDEXJobBatchCommand, Unit>
{ {
/// <summary> /// <summary>
@@ -32,12 +35,28 @@ namespace ECMJobRunner.Application.DEXJob
/// <param name="request">The command containing the profile ID</param> /// <param name="request">The command containing the profile ID</param>
/// <param name="cancellationToken">Cancellation token</param> /// <param name="cancellationToken">Cancellation token</param>
/// <returns>Unit value indicating completion</returns> /// <returns>Unit value indicating completion</returns>
/// <exception cref="InactiveProfileException">Thrown when the profile is not active</exception>
public async Task<Unit> Handle(TriggeringDEXJobBatchCommand request, CancellationToken cancellationToken) public async Task<Unit> Handle(TriggeringDEXJobBatchCommand request, CancellationToken cancellationToken)
{ {
var jobs = await jobRepo.FindAsync(j => j.ProfileId == request.ProfileId, cancellationToken);
var batchId = CreateBatchId(); 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) foreach (var job in jobs)
{ {
await sender.Send(new TriggeringDEXJobCommand await sender.Send(new TriggeringDEXJobCommand

View File

@@ -3,7 +3,7 @@ using MediatR;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob namespace ECMJobRunner.Application.DEXJob.Commands
{ {
/// <summary> /// <summary>
/// Command to trigger a single DEX job execution /// Command to trigger a single DEX job execution

View File

@@ -1,8 +1,9 @@
using ECMJobRunner.Application.Common.Dtos; using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Domain.Interfaces; using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options; using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob; using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Domain.Entities; using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using FluentAssertions; using FluentAssertions;
using MediatR; using MediatR;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;

View File

@@ -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");
}
}
}