Compare commits
37 Commits
master
...
d61d14145a
| Author | SHA1 | Date | |
|---|---|---|---|
| d61d14145a | |||
| 8b0f97c330 | |||
| d50232b0bb | |||
| 59bef13f5d | |||
| 475679c6e2 | |||
| 3a1e16a7a0 | |||
| 7efc77ca51 | |||
| ad2ab741ff | |||
| eb7adc741a | |||
| 8255f1f6aa | |||
| eaf24e05ee | |||
| 3f924b75b0 | |||
| 52c0614975 | |||
| 5d2f128cc7 | |||
| 63a16410ea | |||
| 222a5e24bf | |||
| 26ea1db09f | |||
| 4767bcfbe1 | |||
| 2af17ec870 | |||
| 8a1a579501 | |||
| 7e267b427e | |||
| 30a2a4ce3b | |||
| 46ad02f624 | |||
| ee3af3e462 | |||
| bd6c1c1309 | |||
| 9bae0ab95c | |||
| 574e5ed209 | |||
| 5eca5f1d4b | |||
| 65a51482dd | |||
| eb9c5c323a | |||
| 7d53b599de | |||
| ff6faa1e5b | |||
| 3e007ccfeb | |||
| ce47653831 | |||
| be8e58df84 | |||
| 26dc58d82e | |||
| 19c415da01 |
@@ -3,11 +3,15 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.4.33103.184
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ECM.JobRunner.Web", "ECM.JobRunner.Web\ECM.JobRunner.Web.csproj", "{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Domain", "ECMJobRunner.Domain\ECMJobRunner.Domain.csproj", "{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}"
|
||||
EndProject
|
||||
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "ECM.JobRunner.Common", "ECM.JobRunner.Common\ECM.JobRunner.Common.vbproj", "{7EACF04E-525B-40C8-9207-B83FB847B090}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Infrastructure", "ECMJobRunner.Infrastructure\ECMJobRunner.Infrastructure.csproj", "{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}"
|
||||
EndProject
|
||||
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "ECM.JobRunner.Windows", "ECM.JobRunner.Windows\ECM.JobRunner.Windows.vbproj", "{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Application", "ECMJobRunner.Application\ECMJobRunner.Application.csproj", "{D97CD489-10D7-432C-9921-196F2E0505FF}"
|
||||
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
|
||||
@@ -15,18 +19,26 @@ Global
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7EACF04E-525B-40C8-9207-B83FB847B090}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7EACF04E-525B-40C8-9207-B83FB847B090}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7EACF04E-525B-40C8-9207-B83FB847B090}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7EACF04E-525B-40C8-9207-B83FB847B090}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D97CD489-10D7-432C-9921-196F2E0505FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D97CD489-10D7-432C-9921-196F2E0505FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D97CD489-10D7-432C-9921-196F2E0505FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D97CD489-10D7-432C-9921-196F2E0505FF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{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
|
||||
|
||||
186
ECMJobRunner.Application/AGENTS.md
Normal file
186
ECMJobRunner.Application/AGENTS.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# ECMJobRunner.Application
|
||||
|
||||
## Project Overview
|
||||
|
||||
ECMJobRunner.Application is the **application layer** for the ECM Job Runner system following **Clean Architecture** principles. This project contains:
|
||||
- **Application services** and business logic
|
||||
- **DTOs (Data Transfer Objects)** for external communication
|
||||
- **AutoMapper profiles** for entity-DTO mapping
|
||||
- **MediatR handlers** for CQRS pattern (Commands/Queries)
|
||||
- **Validators** for business rules
|
||||
|
||||
**Key Principle**: This layer orchestrates application workflows, coordinates domain logic, and implements use cases.
|
||||
|
||||
## Target Frameworks
|
||||
|
||||
- **.NET Framework 4.8** (`net480`)
|
||||
- AutoMapper 10.1.1
|
||||
- MediatR 9.0.0
|
||||
|
||||
- **.NET 8.0** (`net8.0`)
|
||||
- AutoMapper 13.0.1
|
||||
- MediatR 12.4.1
|
||||
|
||||
The project uses conditional NuGet package references to support both frameworks.
|
||||
|
||||
## Architecture
|
||||
|
||||
This project follows **Clean Architecture** principles:
|
||||
- **Depends on Domain layer** (uses entities and repository interfaces)
|
||||
- **Independent of Infrastructure** (uses dependency injection for repositories)
|
||||
- **CQRS pattern** via MediatR (Commands and Queries)
|
||||
- **AutoMapper** for DTO mapping
|
||||
- **Validation** for business rules
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ECMJobRunner.Application/
|
||||
├── DTOs/ # Data Transfer Objects (to be created)
|
||||
├── MappingProfiles/ # AutoMapper profiles (to be created)
|
||||
├── Commands/ # MediatR command handlers (to be created)
|
||||
├── Queries/ # MediatR query handlers (to be created)
|
||||
├── Validators/ # Business rule validators (to be created)
|
||||
├── Services/ # Application services (to be created)
|
||||
├── ECMJobRunner.Application.csproj
|
||||
└── AGENTS.md # This file
|
||||
```
|
||||
|
||||
## Planned Components
|
||||
|
||||
### DTOs (Data Transfer Objects)
|
||||
Will contain data transfer objects for external communication:
|
||||
- `ProfileDto` - Profile data transfer object
|
||||
- `ProfileSqlJobDto` - SQL job data transfer object
|
||||
- `ProfileHistoryDto` - History data transfer object
|
||||
|
||||
### AutoMapper Profiles
|
||||
Will contain mapping configurations:
|
||||
- `ProfileMappingProfile` - Maps between entities and DTOs
|
||||
|
||||
### MediatR Handlers
|
||||
|
||||
**Commands** (write operations):
|
||||
- `CreateProfileCommand` / `CreateProfileCommandHandler`
|
||||
- `UpdateProfileCommand` / `UpdateProfileCommandHandler`
|
||||
- `DeleteProfileCommand` / `DeleteProfileCommandHandler`
|
||||
|
||||
**Queries** (read operations):
|
||||
- `GetProfileByIdQuery` / `GetProfileByIdQueryHandler`
|
||||
- `GetAllProfilesQuery` / `GetAllProfilesQueryHandler`
|
||||
- `GetActiveProfilesQuery` / `GetActiveProfilesQueryHandler`
|
||||
|
||||
### Validators
|
||||
Will contain business rule validation:
|
||||
- `CreateProfileCommandValidator`
|
||||
- `UpdateProfileCommandValidator`
|
||||
|
||||
### Services
|
||||
Application services orchestrating business workflows:
|
||||
- `ProfileService` - Profile management service
|
||||
- `JobExecutionService` - Job execution orchestration
|
||||
|
||||
## Dependencies
|
||||
|
||||
### .NET Framework 4.8 (`net480`)
|
||||
- **ECMJobRunner.Domain** (project reference)
|
||||
- **AutoMapper 10.1.1** (NuGet package)
|
||||
- **MediatR 9.0.0** (NuGet package)
|
||||
|
||||
### .NET 8.0 (`net8.0`)
|
||||
- **ECMJobRunner.Domain** (project reference)
|
||||
- **AutoMapper 13.0.1** (NuGet package)
|
||||
- **MediatR 12.4.1** (NuGet package)
|
||||
|
||||
## CQRS Pattern with MediatR
|
||||
|
||||
The application uses the **CQRS (Command Query Responsibility Segregation)** pattern:
|
||||
|
||||
- **Commands**: Modify state (Create, Update, Delete)
|
||||
- **Queries**: Read state (Get, List, Find)
|
||||
|
||||
**Benefits:**
|
||||
- Clear separation of read/write operations
|
||||
- Easier to test and maintain
|
||||
- Better scalability
|
||||
- Loose coupling
|
||||
|
||||
## AutoMapper Configuration
|
||||
|
||||
AutoMapper is used to map between domain entities and DTOs:
|
||||
|
||||
**Example mapping:**
|
||||
```csharp
|
||||
public class ProfileMappingProfile : Profile
|
||||
{
|
||||
public ProfileMappingProfile()
|
||||
{
|
||||
CreateMap<Profile, ProfileDto>();
|
||||
CreateMap<CreateProfileCommand, Profile>();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Building the Project
|
||||
|
||||
```bash
|
||||
dotnet build ECMJobRunner.Application.csproj
|
||||
```
|
||||
|
||||
For specific framework:
|
||||
```bash
|
||||
dotnet build ECMJobRunner.Application.csproj -f net8.0
|
||||
dotnet build ECMJobRunner.Application.csproj -f net480
|
||||
```
|
||||
|
||||
## Known Warnings
|
||||
|
||||
AutoMapper versions have known security vulnerabilities (NU1903):
|
||||
- AutoMapper 10.1.1 (for .NET Framework 4.8)
|
||||
- AutoMapper 13.0.1 (for .NET 8.0)
|
||||
|
||||
**Note**: These are the highest compatible versions for the respective frameworks. The vulnerabilities are related to expression compilation and should be evaluated based on your security requirements.
|
||||
|
||||
## Usage Example
|
||||
|
||||
```csharp
|
||||
// Using MediatR to get a profile
|
||||
var query = new GetProfileByIdQuery { Id = 123 };
|
||||
var profileDto = await mediator.Send(query);
|
||||
|
||||
// Using MediatR to create a profile
|
||||
var command = new CreateProfileCommand
|
||||
{
|
||||
ProfileName = "New Profile",
|
||||
Active = true,
|
||||
TypeId = 2
|
||||
};
|
||||
var newProfileId = await mediator.Send(command);
|
||||
```
|
||||
|
||||
## Development Notes
|
||||
|
||||
- **Clean Architecture**: Application layer uses domain interfaces, not implementations
|
||||
- **Dependency Injection**: Infrastructure implementations injected at runtime
|
||||
- **CQRS**: Separate models for read and write operations
|
||||
- **Validation**: Business rules validated before command execution
|
||||
- **Mapping**: AutoMapper handles entity-DTO conversion
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. Add FluentValidation for complex validation rules
|
||||
2. Add caching layer for frequently accessed data
|
||||
3. Add logging with Serilog
|
||||
4. Add application events for cross-cutting concerns
|
||||
5. Add background job scheduling integration
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **ECMJobRunner.Domain**: Contains entities and repository interfaces
|
||||
- **ECMJobRunner.Infrastructure**: Provides repository implementations
|
||||
|
||||
## Company Information
|
||||
|
||||
**Author**: Digital Data GmbH
|
||||
**Copyright**: 2026
|
||||
**Repository**: http://git.dd:3000/AppStd/ECMJobRunner.git
|
||||
@@ -0,0 +1,116 @@
|
||||
using ECMJobRunner.Application.Common.Constants;
|
||||
using ECMJobRunner.Application.Common.Dtos;
|
||||
using ECMJobRunner.Application.Common.Exceptions;
|
||||
using ECMJobRunner.Application.Common.Interfaces;
|
||||
using ECMJobRunner.Application.Common.Options;
|
||||
using ECMJobRunner.Application.DEXJob.Commands;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ECMJobRunner.Application.Behaviors
|
||||
{
|
||||
/// <summary>
|
||||
/// Pipeline behavior that executes the check SQL query for DEX jobs
|
||||
/// Runs after MainQueryExecutionBehavior and validates query results
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The request type</typeparam>
|
||||
/// <typeparam name="TResponse">The response type</typeparam>
|
||||
public class CheckQueryExecutionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
{
|
||||
private readonly ISQLExecutor _executor;
|
||||
private readonly DexJobOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of CheckQueryExecutionBehavior
|
||||
/// </summary>
|
||||
/// <param name="executor">SQL executor for query execution</param>
|
||||
/// <param name="options">DEX job configuration options</param>
|
||||
public CheckQueryExecutionBehavior(ISQLExecutor executor, IOptions<DexJobOptions> options)
|
||||
{
|
||||
_executor = executor;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the pipeline behavior
|
||||
/// Executes check query if request is TriggeringDEXJobCommand
|
||||
/// </summary>
|
||||
#if NET48
|
||||
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
|
||||
#else
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||
#endif
|
||||
{
|
||||
if (request is TriggeringDEXJobCommand command)
|
||||
{
|
||||
await ExecuteCheckQueryAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
|
||||
private async Task ExecuteCheckQueryAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(command.Job.SqlCheckQuery))
|
||||
{
|
||||
var sqlCheckQuery = Regex.Replace(
|
||||
command.Job.SqlCheckQuery!,
|
||||
_options.Placeholders.BatchId.Pattern,
|
||||
command.BatchId,
|
||||
_options.Placeholders.BatchId.RegexOptions);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _executor.ExecuteQueryAsync<CheckQueryResult>(sqlCheckQuery, cancel);
|
||||
if (_options.Error.CheckQuery.OnUnexpectedResult == ErrorAction.Stop)
|
||||
{
|
||||
if (result is null)
|
||||
throw new JobSqlException(
|
||||
jobName: "Triggering DEX",
|
||||
processName: "Check Query",
|
||||
batchId: command.BatchId,
|
||||
reason: "Check Query returned nothing.",
|
||||
query: sqlCheckQuery,
|
||||
innerException: null);
|
||||
else if (result.ReturnValue <= 0)
|
||||
throw new JobSqlException(
|
||||
jobName: "Triggering DEX",
|
||||
processName: "Check Query",
|
||||
batchId: command.BatchId,
|
||||
reason: $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was any value greater than 0.",
|
||||
query: sqlCheckQuery,
|
||||
innerException: null);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_options.Error.CheckQuery.OnExecution == ErrorAction.Stop)
|
||||
throw new JobSqlException(
|
||||
jobName: "Triggering DEX",
|
||||
processName: "Check Query",
|
||||
batchId: command.BatchId,
|
||||
reason: null,
|
||||
query: sqlCheckQuery,
|
||||
innerException: ex
|
||||
);
|
||||
}
|
||||
}
|
||||
else if (_options.Error.CheckQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
|
||||
{
|
||||
throw new JobSqlException(
|
||||
jobName:"Triggering DEX",
|
||||
processName:"Check Query",
|
||||
batchId:command.BatchId,
|
||||
reason: "SQL Check Query is null or empty",
|
||||
query: null,
|
||||
innerException: null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
ECMJobRunner.Application/Behaviors/MainQueryExecutionBehavior.cs
Normal file
113
ECMJobRunner.Application/Behaviors/MainQueryExecutionBehavior.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using ECMJobRunner.Application.Common.Constants;
|
||||
using ECMJobRunner.Application.Common.Dtos;
|
||||
using ECMJobRunner.Application.Common.Exceptions;
|
||||
using ECMJobRunner.Application.Common.Interfaces;
|
||||
using ECMJobRunner.Application.Common.Options;
|
||||
using ECMJobRunner.Application.DEXJob.Commands;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ECMJobRunner.Application.Behaviors
|
||||
{
|
||||
/// <summary>
|
||||
/// Pipeline behavior that executes the main SQL query for DEX jobs
|
||||
/// Runs before the command handler and validates query results
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The request type</typeparam>
|
||||
/// <typeparam name="TResponse">The response type</typeparam>
|
||||
public class MainQueryExecutionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
{
|
||||
private readonly ISQLExecutor _executor;
|
||||
private readonly DexJobOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of MainQueryExecutionBehavior
|
||||
/// </summary>
|
||||
/// <param name="executor">SQL executor for query execution</param>
|
||||
/// <param name="options">DEX job configuration options</param>
|
||||
public MainQueryExecutionBehavior(ISQLExecutor executor, IOptions<DexJobOptions> options)
|
||||
{
|
||||
_executor = executor;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the pipeline behavior
|
||||
/// Executes main query if request is TriggeringDEXJobCommand
|
||||
/// </summary>
|
||||
#if NET48
|
||||
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
|
||||
#else
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||
#endif
|
||||
{
|
||||
if (request is TriggeringDEXJobCommand command)
|
||||
{
|
||||
await ExecuteMainQueryAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
|
||||
private async Task ExecuteMainQueryAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(command.Job.SqlMainQuery))
|
||||
{
|
||||
var sqlMainQuery = Regex.Replace(
|
||||
command.Job.SqlMainQuery!,
|
||||
_options.Placeholders.BatchId.Pattern,
|
||||
command.BatchId,
|
||||
_options.Placeholders.BatchId.RegexOptions);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _executor.ExecuteQueryAsync<MainQueryResult>(sqlMainQuery, cancel);
|
||||
if (_options.Error.MainQuery.OnUnexpectedResult == ErrorAction.Stop)
|
||||
{
|
||||
if (result is null)
|
||||
throw new JobSqlException(
|
||||
jobName: "Triggering DEX",
|
||||
processName: "Main Query",
|
||||
batchId: command.BatchId,
|
||||
reason: "Main Query returned nothing.",
|
||||
query: sqlMainQuery,
|
||||
innerException: null);
|
||||
else if (result.ReturnValue is not null)
|
||||
throw new JobSqlException(
|
||||
jobName: "Triggering DEX",
|
||||
processName: "Main Query",
|
||||
batchId: command.BatchId,
|
||||
reason: $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was null.",
|
||||
query: sqlMainQuery, innerException: null);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_options.Error.MainQuery.OnExecution == ErrorAction.Stop)
|
||||
throw new JobSqlException(
|
||||
jobName: "Triggering DEX",
|
||||
processName: "Main Query",
|
||||
batchId: command.BatchId,
|
||||
reason: null,
|
||||
query: sqlMainQuery,
|
||||
innerException: ex);
|
||||
}
|
||||
}
|
||||
else if (_options.Error.MainQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
|
||||
{
|
||||
throw new JobSqlException(
|
||||
jobName: "Triggering DEX",
|
||||
processName: "Main Query",
|
||||
batchId: command.BatchId,
|
||||
reason: "SQL Check Query is null or empty",
|
||||
query: null,
|
||||
innerException: null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using ECMJobRunner.Application.Common.Constants;
|
||||
using ECMJobRunner.Application.Common.Exceptions;
|
||||
using ECMJobRunner.Application.Common.Options;
|
||||
using ECMJobRunner.Application.DEXJob.Commands;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ReC.Client;
|
||||
using ReC.Client.Api;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ECMJobRunner.Application.Behaviors
|
||||
{
|
||||
/// <summary>
|
||||
/// Pipeline behavior that sends ReC HTTP request for DEX jobs
|
||||
/// Runs after CheckQueryExecutionBehavior and invokes ReC API
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">The request type</typeparam>
|
||||
/// <typeparam name="TResponse">The response type</typeparam>
|
||||
public class ReCRequestExecutionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
{
|
||||
private readonly ReCClient _reCClient;
|
||||
private readonly DexJobOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of ReCRequestExecutionBehavior
|
||||
/// </summary>
|
||||
/// <param name="reCClient">ReC client for HTTP requests</param>
|
||||
/// <param name="options">DEX job configuration options</param>
|
||||
public ReCRequestExecutionBehavior(ReCClient reCClient, IOptions<DexJobOptions> options)
|
||||
{
|
||||
_reCClient = reCClient;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the pipeline behavior
|
||||
/// Sends ReC request if request is TriggeringDEXJobCommand
|
||||
/// </summary>
|
||||
#if NET48
|
||||
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
|
||||
#else
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||
#endif
|
||||
{
|
||||
if (request is TriggeringDEXJobCommand command)
|
||||
{
|
||||
await SendReCRequestAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
|
||||
private async Task SendReCRequestAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _reCClient.RecActions.InvokeAsync(command.Job.ProfileId, new InvokeReferences()
|
||||
{
|
||||
BatchId = command.BatchId,
|
||||
}, cancel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_options.Error.ReCRequest.OnSending == ErrorAction.Stop)
|
||||
{
|
||||
throw new JobHttpException(
|
||||
jobName: "Triggering DEX",
|
||||
processName: "ReC Http Request",
|
||||
batchId: command.BatchId,
|
||||
reason: null,
|
||||
clientLibrary: "ReC.Client",
|
||||
clientMethod: "RecActions.InvokeAsync",
|
||||
innerException: ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
ECMJobRunner.Application/Common/Constants/ErrorAction.cs
Normal file
18
ECMJobRunner.Application/Common/Constants/ErrorAction.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace ECMJobRunner.Application.Common.Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines actions to take when an error occurs during job execution
|
||||
/// </summary>
|
||||
public enum ErrorAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Ignore the error and continue execution
|
||||
/// </summary>
|
||||
Ignore,
|
||||
|
||||
/// <summary>
|
||||
/// Stop execution and throw an exception
|
||||
/// </summary>
|
||||
Stop,
|
||||
}
|
||||
}
|
||||
67
ECMJobRunner.Application/Common/Dtos/CfgProfileDto.cs
Normal file
67
ECMJobRunner.Application/Common/Dtos/CfgProfileDto.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ECMJobRunner.Application.Common.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// Data Transfer Object for CfgProfile entity
|
||||
/// Used for querying and returning profile data
|
||||
/// </summary>
|
||||
public record CfgProfileDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile ID
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Active / Inactive switch
|
||||
/// </summary>
|
||||
public bool Active { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile name
|
||||
/// </summary>
|
||||
public string ProfileName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Profile type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
|
||||
/// </summary>
|
||||
public byte TypeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Schedule in Cron format
|
||||
/// </summary>
|
||||
public string Schedule { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Optional description
|
||||
/// </summary>
|
||||
public string? Comment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Created by
|
||||
/// </summary>
|
||||
public string AddedWho { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Created at
|
||||
/// </summary>
|
||||
public DateTime AddedWhen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Modified by
|
||||
/// </summary>
|
||||
public string? ChangedWho { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Modified at
|
||||
/// </summary>
|
||||
public DateTime? ChangedWhen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SQL Jobs associated with this profile
|
||||
/// </summary>
|
||||
public List<ProfileSqlJobDto>? SqlJobs { get; set; }
|
||||
}
|
||||
}
|
||||
16
ECMJobRunner.Application/Common/Dtos/CheckQueryResult.cs
Normal file
16
ECMJobRunner.Application/Common/Dtos/CheckQueryResult.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace ECMJobRunner.Application.Common.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of a check query execution
|
||||
/// </summary>
|
||||
public class CheckQueryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Return value from the check query (expected: > 0 for success)
|
||||
/// </summary>
|
||||
[Column("Return Value")]
|
||||
public int? ReturnValue { get; set; }
|
||||
}
|
||||
}
|
||||
16
ECMJobRunner.Application/Common/Dtos/MainQueryResult.cs
Normal file
16
ECMJobRunner.Application/Common/Dtos/MainQueryResult.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace ECMJobRunner.Application.Common.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of a main query execution
|
||||
/// </summary>
|
||||
public class MainQueryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Return value from the main query (expected: null for success)
|
||||
/// </summary>
|
||||
[Column("Return Value")]
|
||||
public int? ReturnValue { get; set; }
|
||||
}
|
||||
}
|
||||
53
ECMJobRunner.Application/Common/Dtos/ProfileSqlJobDto.cs
Normal file
53
ECMJobRunner.Application/Common/Dtos/ProfileSqlJobDto.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace ECMJobRunner.Application.Common.Dtos
|
||||
{
|
||||
/// <summary>
|
||||
/// Data Transfer Object for ProfileSqlJob entity
|
||||
/// </summary>
|
||||
public class ProfileSqlJobDto
|
||||
{
|
||||
/// <summary>
|
||||
/// SQL Job ID
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile ID (foreign key)
|
||||
/// </summary>
|
||||
public long ProfileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Active / Inactive switch
|
||||
/// </summary>
|
||||
public bool Active { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Execution sequence order
|
||||
/// </summary>
|
||||
public short Sequence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Job name
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SQL query for pre-check validation
|
||||
/// </summary>
|
||||
public string? SqlCheckQuery { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Main SQL query to execute
|
||||
/// </summary>
|
||||
public string? SqlMainQuery { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// API command to execute
|
||||
/// </summary>
|
||||
public string? ApiCommand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description
|
||||
/// </summary>
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
91
ECMJobRunner.Application/Common/Exceptions/JobException.cs
Normal file
91
ECMJobRunner.Application/Common/Exceptions/JobException.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ECMJobRunner.Application.Common.Exceptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base exception class for job execution failures
|
||||
/// Provides a flexible structure for capturing job context and detailed error information
|
||||
/// </summary>
|
||||
public class JobException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of JobException with detailed context information
|
||||
/// </summary>
|
||||
/// <param name="jobName">Name of the job that failed (e.g., "SQL Main Query", "ReC Request")</param>
|
||||
/// <param name="processName">Name of the process/stage being executed (e.g., "MainQueryExecution", "CheckQueryValidation")</param>
|
||||
/// <param name="batchId">Unique batch identifier for tracking the execution</param>
|
||||
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
|
||||
/// <param name="innerException">The underlying exception that caused the failure (nullable)</param>
|
||||
/// <param name="details">Additional contextual details as name-value pairs with optional null-handling</param>
|
||||
/// <remarks>
|
||||
/// The details parameter accepts tuples with:
|
||||
/// - Name: Display name of the detail
|
||||
/// - Value: String value of the detail (nullable)
|
||||
/// - IgnoreIfNull: If true, the detail is omitted from the message when value is null
|
||||
/// </remarks>
|
||||
public JobException(string jobName, string processName, string batchId, string? reason, Exception? innerException, params (string Name, string? Value, bool IgnoreIfNull)[] details)
|
||||
: base(
|
||||
Message(jobName,
|
||||
[
|
||||
("Process Name", processName, false),
|
||||
("Batch Id", batchId, false),
|
||||
("Reason", reason, true),
|
||||
..details
|
||||
]),
|
||||
innerException)
|
||||
{
|
||||
JobName = jobName;
|
||||
ProcessName = processName;
|
||||
BatchId = batchId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the job that failed
|
||||
/// </summary>
|
||||
public string JobName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the process/stage that was being executed when the failure occurred
|
||||
/// </summary>
|
||||
public string ProcessName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique batch identifier for tracking the execution
|
||||
/// </summary>
|
||||
public string BatchId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Generates a formatted error message with job context and details
|
||||
/// </summary>
|
||||
/// <param name="jobName">Name of the job that failed</param>
|
||||
/// <param name="details">Collection of name-value pairs with optional null-handling</param>
|
||||
/// <returns>Formatted multi-line error message with visual separators</returns>
|
||||
/// <remarks>
|
||||
/// Message format:
|
||||
/// <code>
|
||||
/// {jobName} could not be completed.
|
||||
/// ─────────────────────────────────────────
|
||||
/// Process Name: {processName}
|
||||
/// Batch Id: {batchId}
|
||||
/// {additional details...}
|
||||
/// ─────────────────────────────────────────
|
||||
/// </code>
|
||||
/// Details with IgnoreIfNull=true are omitted when their value is null.
|
||||
/// </remarks>
|
||||
internal static string Message(string jobName, IEnumerable<(string Name, string? Value, bool IgnoreIfNull)> details)
|
||||
{
|
||||
var message = new System.Text.StringBuilder();
|
||||
message.AppendLine($"{jobName} could not be completed.");
|
||||
message.AppendLine("─────────────────────────────────────────");
|
||||
foreach (var (name, value, ignoreNullValue) in details)
|
||||
{
|
||||
if (ignoreNullValue && value is null)
|
||||
continue;
|
||||
message.AppendLine($" {name}: {value}");
|
||||
}
|
||||
message.AppendLine("─────────────────────────────────────────");
|
||||
return message.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
|
||||
namespace ECMJobRunner.Application.Common.Exceptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Exception for HTTP client-related job failures (e.g., ReC API calls, REST requests)
|
||||
/// Extends JobException with client library and method context
|
||||
/// </summary>
|
||||
public class JobHttpException : JobException
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of JobHttpException with HTTP client context
|
||||
/// </summary>
|
||||
/// <param name="jobName">Name of the job that failed (e.g., "ReC Request", "API Call")</param>
|
||||
/// <param name="processName">Name of the process/stage being executed</param>
|
||||
/// <param name="batchId">Unique batch identifier for tracking</param>
|
||||
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
|
||||
/// <param name="clientLibrary">Name of the HTTP client library used (e.g., "ReC.Client", "HttpClient") (nullable)</param>
|
||||
/// <param name="clientMethod">Name of the client method that failed (e.g., "ExecuteAsync", "PostAsync") (nullable)</param>
|
||||
/// <param name="innerException">The underlying exception that caused the failure (nullable)</param>
|
||||
/// <remarks>
|
||||
/// Use this exception for HTTP-related failures such as:
|
||||
/// - ReC API request failures
|
||||
/// - REST API communication errors
|
||||
/// - HTTP client timeout/network issues
|
||||
/// - Authentication/authorization failures
|
||||
/// </remarks>
|
||||
public JobHttpException(string jobName, string processName, string batchId, string? reason, string? clientLibrary, string? clientMethod, Exception? innerException) : base(jobName, processName, batchId, reason, innerException,
|
||||
("Client Library", clientLibrary, true),
|
||||
("Client Method", clientMethod, true))
|
||||
{
|
||||
ClientLibrary = clientLibrary;
|
||||
ClientMethod = clientMethod;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the HTTP client library that was used (e.g., "ReC.Client", "HttpClient")
|
||||
/// </summary>
|
||||
public string? ClientLibrary { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the client method that failed (e.g., "ExecuteAsync", "PostAsync")
|
||||
/// </summary>
|
||||
public string? ClientMethod { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
|
||||
namespace ECMJobRunner.Application.Common.Exceptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Exception for SQL query execution failures
|
||||
/// Extends JobException with SQL query context for debugging
|
||||
/// </summary>
|
||||
public class JobSqlException : JobException
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of JobSqlException with SQL query context
|
||||
/// </summary>
|
||||
/// <param name="jobName">Name of the job that failed (e.g., "Main Query Execution", "Check Query")</param>
|
||||
/// <param name="processName">Name of the process/stage being executed</param>
|
||||
/// <param name="batchId">Unique batch identifier for tracking</param>
|
||||
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
|
||||
/// <param name="query">The SQL query that failed (nullable, for debugging purposes)</param>
|
||||
/// <param name="innerException">The underlying SQL exception (nullable)</param>
|
||||
/// <remarks>
|
||||
/// Use this exception for SQL-related failures such as:
|
||||
/// - Main query execution errors
|
||||
/// - Check query validation failures
|
||||
/// - Database connection issues
|
||||
/// - SQL syntax errors
|
||||
/// - Query timeout exceptions
|
||||
///
|
||||
/// The Query property can be logged for debugging but should be handled carefully
|
||||
/// to avoid exposing sensitive data in production logs.
|
||||
/// </remarks>
|
||||
public JobSqlException(string jobName, string processName, string batchId, string? reason, string? query, Exception? innerException) : base(jobName, processName, batchId, reason, innerException,
|
||||
("Query", query, true))
|
||||
{
|
||||
Query = query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SQL query that failed (nullable)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This property is marked as virtual to allow derived classes to customize query handling
|
||||
/// (e.g., sanitizing sensitive data, truncating long queries)
|
||||
/// </remarks>
|
||||
public virtual string? Query { get; }
|
||||
}
|
||||
}
|
||||
20
ECMJobRunner.Application/Common/Interfaces/ISQLExecutor.cs
Normal file
20
ECMJobRunner.Application/Common/Interfaces/ISQLExecutor.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ECMJobRunner.Application.Common.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for executing SQL queries and mapping results to DTOs
|
||||
/// </summary>
|
||||
public interface ISQLExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes a SQL query and maps the result to the specified type
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type to map the query result to</typeparam>
|
||||
/// <param name="sql">The SQL query to execute</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The mapped result or null if no result</returns>
|
||||
Task<TResult?> ExecuteQueryAsync<TResult>(string sql, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using AutoMapper;
|
||||
using ECMJobRunner.Application.Common.Dtos;
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
|
||||
namespace ECMJobRunner.Application.Common.Mapping
|
||||
{
|
||||
/// <summary>
|
||||
/// AutoMapper profile for CfgProfile and ProfileSqlJob mappings
|
||||
/// Maps domain entities to DTOs
|
||||
/// </summary>
|
||||
public class ProfileMappingProfile : Profile
|
||||
{
|
||||
public ProfileMappingProfile()
|
||||
{
|
||||
// CfgProfile -> CfgProfileDto
|
||||
CreateMap<CfgProfile, CfgProfileDto>()
|
||||
.ForMember(dest => dest.SqlJobs, opt => opt.MapFrom(src => src.SqlJobs));
|
||||
|
||||
// ProfileSqlJob -> ProfileSqlJobDto
|
||||
CreateMap<ProfileSqlJob, ProfileSqlJobDto>();
|
||||
}
|
||||
}
|
||||
}
|
||||
105
ECMJobRunner.Application/Common/Options/DexJobOptions.cs
Normal file
105
ECMJobRunner.Application/Common/Options/DexJobOptions.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using ECMJobRunner.Application.Common.Constants;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ECMJobRunner.Application.Common.Options
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration options for DEX job execution
|
||||
/// </summary>
|
||||
public class DexJobOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Error handling options for DEX job operations
|
||||
/// </summary>
|
||||
public record DexJobErrorHandlingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Error handling options for SQL query execution
|
||||
/// </summary>
|
||||
public record SqlQueryErrorHandlingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Action to take when query execution fails
|
||||
/// </summary>
|
||||
public ErrorAction OnExecution { get; set; } = ErrorAction.Stop;
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when query is null or whitespace
|
||||
/// </summary>
|
||||
public ErrorAction IfNullOrWhiteSpace { get; set; } = ErrorAction.Ignore;
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when query returns unexpected result
|
||||
/// </summary>
|
||||
public ErrorAction OnUnexpectedResult { get; set; } = ErrorAction.Stop;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error handling options for HTTP requests
|
||||
/// </summary>
|
||||
public record HttpRequestErrorHandlingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Action to take when HTTP request fails
|
||||
/// </summary>
|
||||
public ErrorAction OnSending { get; set; } = ErrorAction.Stop;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error handling for main SQL query
|
||||
/// </summary>
|
||||
public SqlQueryErrorHandlingOptions MainQuery { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Error handling for check SQL query
|
||||
/// </summary>
|
||||
public SqlQueryErrorHandlingOptions CheckQuery { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Error handling for ReC HTTP request
|
||||
/// </summary>
|
||||
public HttpRequestErrorHandlingOptions ReCRequest { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error handling configuration
|
||||
/// </summary>
|
||||
public DexJobErrorHandlingOptions Error { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder configuration for dynamic value replacement
|
||||
/// </summary>
|
||||
public record PlaceHolderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration for a single placeholder
|
||||
/// </summary>
|
||||
public record PlaceHolder
|
||||
{
|
||||
/// <summary>
|
||||
/// Regex pattern to match the placeholder
|
||||
/// </summary>
|
||||
public string Pattern { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Regex options for pattern matching
|
||||
/// </summary>
|
||||
public RegexOptions RegexOptions { get; set; } = RegexOptions.IgnoreCase;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BatchId placeholder configuration
|
||||
/// </summary>
|
||||
public PlaceHolder BatchId { get; set; } = new()
|
||||
{
|
||||
Pattern = "#INT#BATCH_ID",
|
||||
RegexOptions = RegexOptions.IgnoreCase
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder configuration
|
||||
/// </summary>
|
||||
public PlaceHolderOptions Placeholders { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command to trigger DEX job batch for a profile
|
||||
/// Executes all SQL jobs associated with a profile ID
|
||||
/// </summary>
|
||||
public class TriggeringDEXJobBatchCommand : IRequest<Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile ID to trigger all associated SQL jobs
|
||||
/// </summary>
|
||||
public long ProfileId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for TriggeringDEXJobBatchCommand
|
||||
/// Retrieves all SQL jobs for a profile and executes them sequentially
|
||||
/// Validates that the profile is active before execution
|
||||
/// </summary>
|
||||
public class TriggeringDEXJobBatchCommandHandler(ICfgProfileRepository profileRepo, IProfileSqlJobRepository jobRepo, ISender sender)
|
||||
: IRequestHandler<TriggeringDEXJobBatchCommand, Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the TriggeringDEXJobBatchCommand
|
||||
/// Creates a unique batch ID and triggers individual job commands
|
||||
/// </summary>
|
||||
/// <param name="request">The command containing the profile ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <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)
|
||||
{
|
||||
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
|
||||
{
|
||||
Job = job,
|
||||
BatchId = batchId
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a unique batch ID based on current timestamp
|
||||
/// </summary>
|
||||
/// <returns>20-character timestamp string (yyyyMMddHHmmssfffffff truncated to 20 chars)</returns>
|
||||
public static string CreateBatchId()
|
||||
{
|
||||
// Format: "yyyy" : Year (4 digits)
|
||||
// "MM" : Month (2 digits)
|
||||
// "dd" : Day (2 digits)
|
||||
// "HH" : Hour (24-hour format, 2 digits)
|
||||
// "mm" : Minute (2 digits)
|
||||
// "ss" : Second (2 digits)
|
||||
// "fffffff": Fractions of a second / 100-nanoseconds (7 digits)
|
||||
|
||||
string fullBatchId = DateTime.Now.ToString("yyyyMMddHHmmssfffffff");
|
||||
|
||||
// Limit to 20 characters as per specification
|
||||
return fullBatchId.Substring(0, 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
using MediatR;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ECMJobRunner.Application.DEXJob.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command to trigger a single DEX job execution
|
||||
/// Execution logic is handled by pipeline behaviors:
|
||||
/// 1. MainQueryExecutionBehavior - executes main SQL query
|
||||
/// 2. CheckQueryExecutionBehavior - validates with check SQL query
|
||||
/// 3. ReCRequestExecutionBehavior - invokes ReC HTTP request
|
||||
/// </summary>
|
||||
public class TriggeringDEXJobCommand : IRequest<Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// The SQL job to execute
|
||||
/// </summary>
|
||||
public ProfileSqlJob Job { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Unique batch identifier for this execution
|
||||
/// </summary>
|
||||
public string BatchId { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for TriggeringDEXJobCommand
|
||||
/// All execution logic is delegated to pipeline behaviors
|
||||
/// This handler simply returns completion after behaviors execute
|
||||
/// </summary>
|
||||
public class TriggeringDEXJobCommandHandler : IRequestHandler<TriggeringDEXJobCommand, Unit>
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the TriggeringDEXJobCommand
|
||||
/// Returns immediately as behaviors perform all work
|
||||
/// </summary>
|
||||
/// <param name="request">The command containing job and batch ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Unit value indicating completion</returns>
|
||||
public Task<Unit> Handle(TriggeringDEXJobCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// All execution logic is handled by pipeline behaviors:
|
||||
// - MainQueryExecutionBehavior
|
||||
// - CheckQueryExecutionBehavior
|
||||
// - ReCRequestExecutionBehavior
|
||||
return Task.FromResult(Unit.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
124
ECMJobRunner.Application/DEXJob/Queries/GetProfileQuery.cs
Normal file
124
ECMJobRunner.Application/DEXJob/Queries/GetProfileQuery.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using AutoMapper;
|
||||
using ECMJobRunner.Application.Common.Dtos;
|
||||
using ECMJobRunner.Domain.Interfaces;
|
||||
using MediatR;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ECMJobRunner.Application.DEXJob.Queries
|
||||
{
|
||||
/// <summary>
|
||||
/// Query to retrieve profiles with flexible filtering options
|
||||
/// All query options are nullable - when no filters are specified, returns all profiles
|
||||
/// </summary>
|
||||
public class GetProfileQuery : IRequest<List<CfgProfileDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile ID to retrieve (optional)
|
||||
/// When specified, returns only the profile with this ID
|
||||
/// </summary>
|
||||
public long? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by active status (optional)
|
||||
/// When null, returns both active and inactive profiles
|
||||
/// When true, returns only active profiles
|
||||
/// When false, returns only inactive profiles
|
||||
/// </summary>
|
||||
public bool? Active { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by profile type (optional)
|
||||
/// When specified, returns only profiles with this type
|
||||
/// Type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
|
||||
/// </summary>
|
||||
public byte? TypeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by profile name (optional)
|
||||
/// When specified, returns profiles with matching name (case-insensitive contains)
|
||||
/// </summary>
|
||||
public string? ProfileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Include associated SQL jobs in the result (optional)
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool IncludeSqlJobs { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for GetProfileQuery
|
||||
/// Retrieves profiles with optional filtering and uses AutoMapper for DTO mapping
|
||||
/// </summary>
|
||||
public class GetProfileQueryHandler : IRequestHandler<GetProfileQuery, List<CfgProfileDto>>
|
||||
{
|
||||
private readonly ICfgProfileRepository _profileRepository;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public GetProfileQueryHandler(ICfgProfileRepository profileRepository, IMapper mapper)
|
||||
{
|
||||
_profileRepository = profileRepository;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<List<CfgProfileDto>> Handle(GetProfileQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Domain.Entities.CfgProfile> profiles;
|
||||
|
||||
// If ID is specified, get single profile by ID
|
||||
if (request.Id.HasValue)
|
||||
{
|
||||
var profile = request.IncludeSqlJobs
|
||||
? await _profileRepository.GetByIdWithSqlJobsAsync(request.Id.Value, cancellationToken)
|
||||
: await _profileRepository.GetByIdAsync(request.Id.Value, cancellationToken);
|
||||
|
||||
profiles = profile != null ? new[] { profile } : [];
|
||||
}
|
||||
// Otherwise, get profiles with filters
|
||||
else
|
||||
{
|
||||
// Get all profiles with SQL jobs if requested
|
||||
if (request.IncludeSqlJobs)
|
||||
{
|
||||
// If Active filter is specified and true, use optimized method
|
||||
if (request.Active.HasValue && request.Active.Value)
|
||||
{
|
||||
profiles = await _profileRepository.GetAllActiveWithSqlJobsAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generic query with filters
|
||||
profiles = await _profileRepository.FindAsync(p => true, cancellationToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
profiles = await _profileRepository.GetAllAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (request.Active.HasValue)
|
||||
{
|
||||
profiles = profiles.Where(p => p.Active == request.Active.Value);
|
||||
}
|
||||
|
||||
if (request.TypeId.HasValue)
|
||||
{
|
||||
profiles = profiles.Where(p => p.TypeId == request.TypeId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ProfileName))
|
||||
{
|
||||
var searchName = request.ProfileName.ToLowerInvariant();
|
||||
profiles = profiles.Where(p => p.ProfileName.ToLowerInvariant().Contains(searchName));
|
||||
}
|
||||
}
|
||||
|
||||
// Use AutoMapper to map entities to DTOs
|
||||
return _mapper.Map<List<CfgProfileDto>>(profiles.ToList());
|
||||
}
|
||||
}
|
||||
}
|
||||
44
ECMJobRunner.Application/DependencyInjection.cs
Normal file
44
ECMJobRunner.Application/DependencyInjection.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using ECMJobRunner.Application.Behaviors;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ReC.Client;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ECMJobRunner.Application
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Application layer services
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Application layer services to the dependency injection container
|
||||
/// Registers MediatR, pipeline behaviors, and AutoMapper
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection</param>
|
||||
/// <returns>The service collection for chaining</returns>
|
||||
public static IServiceCollection AddJobRunnerServices(this IServiceCollection services, string recClientApiUrl)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
// Register MediatR with all handlers from this assembly
|
||||
#if NET48
|
||||
services.AddMediatR(assembly);
|
||||
#else
|
||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
||||
#endif
|
||||
services.AddRecClient(recClientApiUrl, opt =>
|
||||
{
|
||||
opt.LogSuccessfulRequests = true;
|
||||
});
|
||||
|
||||
// Register pipeline behaviors in execution order
|
||||
// Order matters: MainQuery -> CheckQuery -> ReCRequest
|
||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(MainQueryExecutionBehavior<,>));
|
||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CheckQueryExecutionBehavior<,>));
|
||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ReCRequestExecutionBehavior<,>));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
ECMJobRunner.Application/ECMJobRunner.Application.csproj
Normal file
44
ECMJobRunner.Application/ECMJobRunner.Application.csproj
Normal file
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net480;net8.0</TargetFrameworks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).xml</DocumentationFile>
|
||||
<PackageId>ECMJobRunner.Application</PackageId>
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>ECMJobRunner.Application</Product>
|
||||
<Copyright>Copyright 2026</Copyright>
|
||||
<RepositoryUrl>http://git.dd:3000/AppStd/ECMJobRunner.git</RepositoryUrl>
|
||||
<PackageTags>digital data ecm job runner application</PackageTags>
|
||||
<!-- Suppress AutoMapper vulnerability warning (known issue, acceptable for this project) -->
|
||||
<NoWarn>$(NoWarn);NU1903</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
|
||||
<!-- AutoMapper for .NET Framework 4.8 (matching Infrastructure) -->
|
||||
<PackageReference Include="AutoMapper" Version="10.1.1" />
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
|
||||
<!-- MediatR for .NET Framework 4.8 -->
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<!-- AutoMapper for .NET 8 (matching Infrastructure) -->
|
||||
<PackageReference Include="AutoMapper" Version="12.0.1" />
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||
<!-- MediatR for .NET 8 -->
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
373
ECMJobRunner.Application/README.md
Normal file
373
ECMJobRunner.Application/README.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# ECMJobRunner.Application - DEX Job Integration
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the **DEX Job triggering system** implemented in `ECMJobRunner.Application` using the **CQRS pattern with MediatR** and **Pipeline Behaviors**.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Commands
|
||||
|
||||
#### `TriggeringDEXJobBatchCommand`
|
||||
Orchestrates batch job execution for a profile.
|
||||
|
||||
**Properties:**
|
||||
- `ProfileId` (int): Profile ID to trigger all associated SQL jobs
|
||||
|
||||
**Handler:**
|
||||
- Creates a unique 20-character timestamp-based batch ID
|
||||
- Retrieves all SQL jobs for the profile
|
||||
- Executes each job sequentially using `TriggeringDEXJobCommand`
|
||||
|
||||
**Usage:**
|
||||
```csharp
|
||||
var command = new TriggeringDEXJobBatchCommand { ProfileId = 123 };
|
||||
await mediator.Send(command);
|
||||
```
|
||||
|
||||
#### `TriggeringDEXJobCommand`
|
||||
Executes a single DEX job with three-stage pipeline.
|
||||
|
||||
**Properties:**
|
||||
- `Job` (ProfileSqlJob): The SQL job to execute
|
||||
- `BatchId` (string): Unique batch identifier
|
||||
|
||||
**Execution Pipeline:**
|
||||
1. **MainQueryExecutionBehavior**: Executes main SQL query with batch ID placeholder replacement
|
||||
2. **CheckQueryExecutionBehavior**: Validates execution with return value check (> 0)
|
||||
3. **ReCRequestExecutionBehavior**: Invokes ReC API with batch ID reference
|
||||
|
||||
**Handler:**
|
||||
- Empty handler - all logic delegated to pipeline behaviors
|
||||
|
||||
**Usage:**
|
||||
```csharp
|
||||
var command = new TriggeringDEXJobCommand
|
||||
{
|
||||
Job = profileSqlJob,
|
||||
BatchId = "20260711143025123456"
|
||||
};
|
||||
await mediator.Send(command);
|
||||
```
|
||||
|
||||
### Pipeline Behaviors
|
||||
|
||||
#### Execution Order
|
||||
1. `MainQueryExecutionBehavior<,>` - SQL main query execution
|
||||
2. `CheckQueryExecutionBehavior<,>` - SQL check query validation
|
||||
3. `ReCRequestExecutionBehavior<,>` - ReC HTTP request
|
||||
|
||||
Each behavior:
|
||||
- Checks if request is `TriggeringDEXJobCommand`
|
||||
- Executes its stage logic
|
||||
- Calls `next()` to continue pipeline
|
||||
|
||||
## Configuration
|
||||
|
||||
### appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"DexJob": {
|
||||
"Error": {
|
||||
"MainQuery": {
|
||||
"OnExecution": "Stop",
|
||||
"IfNullOrWhiteSpace": "Ignore",
|
||||
"OnUnexpectedResult": "Stop"
|
||||
},
|
||||
"CheckQuery": {
|
||||
"OnExecution": "Stop",
|
||||
"IfNullOrWhiteSpace": "Ignore",
|
||||
"OnUnexpectedResult": "Stop"
|
||||
},
|
||||
"ReCRequest": {
|
||||
"OnSending": "Stop"
|
||||
}
|
||||
},
|
||||
"Placeholders": {
|
||||
"BatchId": {
|
||||
"Pattern": "#INT#BATCH_ID",
|
||||
"RegexOptions": "IgnoreCase"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### `DexJobOptions`
|
||||
Root configuration object for DEX job execution.
|
||||
|
||||
**Properties:**
|
||||
- `Error` (DexJobErrorHandlingOptions): Error handling configuration
|
||||
- `Placeholders` (PlaceHolderOptions): Placeholder replacement configuration
|
||||
|
||||
#### `DexJobErrorHandlingOptions`
|
||||
Hierarchical error handling per stage.
|
||||
|
||||
**Properties:**
|
||||
- `MainQuery` (SqlQueryErrorHandlingOptions): Main query error handling
|
||||
- `CheckQuery` (SqlQueryErrorHandlingOptions): Check query error handling
|
||||
- `ReCRequest` (HttpRequestErrorHandlingOptions): ReC request error handling
|
||||
|
||||
#### `SqlQueryErrorHandlingOptions`
|
||||
Error handling for SQL query execution.
|
||||
|
||||
**Properties:**
|
||||
- `OnExecution` (ErrorAction): Action when query execution fails (default: `Stop`)
|
||||
- `IfNullOrWhiteSpace` (ErrorAction): Action when query is null/empty (default: `Ignore`)
|
||||
- `OnUnexpectedResult` (ErrorAction): Action when result is unexpected (default: `Stop`)
|
||||
|
||||
**Error Actions:**
|
||||
- `Ignore`: Continue execution
|
||||
- `Stop`: Throw `DEXJobException`
|
||||
|
||||
#### `HttpRequestErrorHandlingOptions`
|
||||
Error handling for HTTP requests.
|
||||
|
||||
**Properties:**
|
||||
- `OnSending` (ErrorAction): Action when HTTP request fails (default: `Stop`)
|
||||
|
||||
#### `PlaceHolderOptions`
|
||||
Configuration for dynamic placeholder replacement.
|
||||
|
||||
**Properties:**
|
||||
- `BatchId` (PlaceHolder): Batch ID placeholder configuration
|
||||
- `Pattern` (string): Regex pattern (default: `#INT#BATCH_ID`)
|
||||
- `RegexOptions` (RegexOptions): Regex options (default: `IgnoreCase`)
|
||||
|
||||
### Expected Query Results
|
||||
|
||||
#### Main Query (`SqlMainQuery`)
|
||||
**Expected Result:**
|
||||
- `ReturnValue` should be **null**
|
||||
- If not null: throws `DEXJobException` (when `OnUnexpectedResult` = `Stop`)
|
||||
|
||||
**Example SQL:**
|
||||
```sql
|
||||
INSERT INTO TBJR_OUT_PROFILE_HISTORY (BatchId, ProfileId, CreatedAt)
|
||||
VALUES ('#INT#BATCH_ID', 123, GETDATE())
|
||||
```
|
||||
|
||||
#### Check Query (`SqlCheckQuery`)
|
||||
**Expected Result:**
|
||||
- `ReturnValue` should be **> 0**
|
||||
- If ≤ 0: throws `DEXJobException` (when `OnUnexpectedResult` = `Stop`)
|
||||
|
||||
**Example SQL:**
|
||||
```sql
|
||||
SELECT COUNT(*) AS [Return Value]
|
||||
FROM TBJR_OUT_PROFILE_HISTORY
|
||||
WHERE BatchId = '#INT#BATCH_ID'
|
||||
```
|
||||
|
||||
### Placeholder Replacement
|
||||
|
||||
The `#INT#BATCH_ID` placeholder in SQL queries is replaced with the actual batch ID using regex:
|
||||
|
||||
**Before:**
|
||||
```sql
|
||||
INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')
|
||||
```
|
||||
|
||||
**After:**
|
||||
```sql
|
||||
INSERT INTO Table (BatchId) VALUES ('20260711143025123456')
|
||||
```
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
### Setup in Startup.cs / Program.cs
|
||||
|
||||
```csharp
|
||||
using ECMJobRunner.Application;
|
||||
using ECMJobRunner.Application.Common.Options;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
// Add Application layer services
|
||||
services.AddApplication();
|
||||
|
||||
// Configure DexJobOptions from appsettings.json
|
||||
services.Configure<DexJobOptions>(configuration.GetSection("DexJob"));
|
||||
|
||||
// Register dependencies
|
||||
services.AddSingleton<ISQLExecutor, SqlExecutor>(); // Your implementation
|
||||
services.AddSingleton<ReCClient>(); // ReC client configuration
|
||||
```
|
||||
|
||||
### Required Dependencies
|
||||
|
||||
The following interfaces must be implemented in your Infrastructure layer:
|
||||
|
||||
1. **`ISQLExecutor`**: SQL query execution and DTO mapping
|
||||
```csharp
|
||||
public interface ISQLExecutor
|
||||
{
|
||||
Task<TResult?> ExecuteQueryAsync<TResult>(string sql, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
2. **`IProfileSqlJobRepository`**: Repository for ProfileSqlJob entities
|
||||
```csharp
|
||||
public interface IProfileSqlJobRepository : IRepository
|
||||
{
|
||||
Task<IEnumerable<ProfileSqlJob>> FindAsync(Expression<Func<ProfileSqlJob, bool>> predicate, CancellationToken cancellationToken);
|
||||
}
|
||||
```
|
||||
|
||||
3. **`ReCClient`**: ReC HTTP client (from `ReC.Client` NuGet package)
|
||||
|
||||
## Exception Handling
|
||||
|
||||
### `DEXJobException`
|
||||
Custom exception thrown when DEX job operations fail.
|
||||
|
||||
**Properties:**
|
||||
- `QueryName` (string): Name of the query that failed
|
||||
- `BatchId` (string): Batch ID associated with the operation
|
||||
- `SqlQuery` (string?): SQL query that was executed (nullable)
|
||||
- `Message` (string): Formatted error message with query details
|
||||
|
||||
**Constructors:**
|
||||
1. With inner exception:
|
||||
```csharp
|
||||
new DEXJobException("SQL Main Query", batchId, sqlQuery, innerException)
|
||||
```
|
||||
|
||||
2. With reason message:
|
||||
```csharp
|
||||
new DEXJobException("SQL Check Query", batchId, sqlQuery, "Check Query returned nothing.")
|
||||
```
|
||||
|
||||
**Example Error Message:**
|
||||
```
|
||||
[SQL Execution Failure] Query 'SQL Main Query' could not be completed for Batch '20260711143025123456'.
|
||||
─────────────────────────────────────────
|
||||
Query:
|
||||
INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')
|
||||
|
||||
Root Cause:
|
||||
Timeout expired. The timeout period elapsed prior to completion of the operation.
|
||||
─────────────────────────────────────────
|
||||
```
|
||||
|
||||
## Multi-Targeting Support
|
||||
|
||||
The project targets:
|
||||
- **.NET Framework 4.8** (`net480`) - MediatR 9.0.0
|
||||
- **.NET 8.0** (`net8.0`) - MediatR 12.4.1
|
||||
|
||||
Pipeline behaviors use conditional compilation for MediatR signature differences:
|
||||
|
||||
```csharp
|
||||
#if NET48
|
||||
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
|
||||
#else
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
|
||||
#endif
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests should mock:
|
||||
- `ISQLExecutor` - for SQL query execution
|
||||
- `IOptions<DexJobOptions>` - for configuration
|
||||
- `ReCClient` - for HTTP requests
|
||||
- `IProfileSqlJobRepository` - for data access
|
||||
|
||||
**Example test structure:**
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Handle_WithValidBatchCommand_ExecutesAllJobs()
|
||||
{
|
||||
// Arrange
|
||||
var mockRepo = new Mock<IProfileSqlJobRepository>();
|
||||
var mockSender = new Mock<ISender>();
|
||||
mockRepo.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<ProfileSqlJob> { job1, job2 });
|
||||
|
||||
var handler = new TriggeringDEXJobBatchCommandHandler(mockRepo.Object, mockSender.Object);
|
||||
var command = new TriggeringDEXJobBatchCommand { ProfileId = 123 };
|
||||
|
||||
// Act
|
||||
await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
mockSender.Verify(s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
```
|
||||
|
||||
## Batch ID Format
|
||||
|
||||
Batch IDs are 20-character timestamp strings:
|
||||
|
||||
**Format:** `yyyyMMddHHmmssfffffff` (truncated to 20 chars)
|
||||
|
||||
**Example:** `20260711143025123456`
|
||||
|
||||
**Components:**
|
||||
- `yyyy`: Year (4 digits)
|
||||
- `MM`: Month (2 digits)
|
||||
- `dd`: Day (2 digits)
|
||||
- `HH`: Hour - 24-hour format (2 digits)
|
||||
- `mm`: Minute (2 digits)
|
||||
- `ss`: Second (2 digits)
|
||||
- `fffffff`: Fractions of a second / 100-nanoseconds (7 digits) - truncated to 6 digits
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. "Main Query returned nothing"
|
||||
**Cause:** Main query execution did not return any result.
|
||||
**Solution:** Check if SQL query is valid and returns expected structure with `[Return Value]` column.
|
||||
|
||||
#### 2. "The query unexpectedly returned the value X. The expected value was null."
|
||||
**Cause:** Main query returned non-null value indicating an error.
|
||||
**Solution:** Check SQL query logic and ensure it returns NULL on success.
|
||||
|
||||
#### 3. "Check Query returned nothing"
|
||||
**Cause:** Check query did not return any result.
|
||||
**Solution:** Verify check query syntax and ensure it returns a `[Return Value]` column.
|
||||
|
||||
#### 4. "The query unexpectedly returned the value X. The expected value was any value greater than 0."
|
||||
**Cause:** Check query returned ≤ 0 indicating validation failure.
|
||||
**Solution:** Verify main query executed successfully and check query logic is correct.
|
||||
|
||||
#### 5. "SQL Main Query is null or empty"
|
||||
**Cause:** `SqlMainQuery` property is null or whitespace.
|
||||
**Solution:** Set `Error.MainQuery.IfNullOrWhiteSpace = Ignore` in configuration if optional.
|
||||
|
||||
### Debug Configuration
|
||||
|
||||
For detailed error information, set all error actions to `Stop`:
|
||||
|
||||
```json
|
||||
{
|
||||
"DexJob": {
|
||||
"Error": {
|
||||
"MainQuery": {
|
||||
"OnExecution": "Stop",
|
||||
"IfNullOrWhiteSpace": "Stop",
|
||||
"OnUnexpectedResult": "Stop"
|
||||
},
|
||||
"CheckQuery": {
|
||||
"OnExecution": "Stop",
|
||||
"IfNullOrWhiteSpace": "Stop",
|
||||
"OnUnexpectedResult": "Stop"
|
||||
},
|
||||
"ReCRequest": {
|
||||
"OnSending": "Stop"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Company Information
|
||||
|
||||
**Author:** Digital Data GmbH
|
||||
**Copyright:** 2026
|
||||
**Repository:** http://git.dd:3000/AppStd/ECMJobRunner.git
|
||||
169
ECMJobRunner.Domain/AGENTS.md
Normal file
169
ECMJobRunner.Domain/AGENTS.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# ECMJobRunner.Domain
|
||||
|
||||
## Project Overview
|
||||
|
||||
ECMJobRunner.Domain is the **domain layer** for the ECM Job Runner system following **Clean Architecture** principles. This project contains:
|
||||
- **Entity models** that represent the business domain
|
||||
- **Repository interfaces** for data access abstraction
|
||||
- **Domain logic** (currently none, pure data entities)
|
||||
|
||||
**Key Principle**: This layer has **NO** external dependencies - it's the core of the application.
|
||||
|
||||
## Target Frameworks
|
||||
|
||||
- **.NET Framework 4.8** (`net480`)
|
||||
- **.NET 8.0** (`net8.0`)
|
||||
|
||||
The project is multi-targeted to support both legacy .NET Framework applications and modern .NET 8 applications.
|
||||
|
||||
## Architecture
|
||||
|
||||
This project follows **Clean Architecture** principles:
|
||||
- **No infrastructure dependencies** (no Entity Framework, no database concerns)
|
||||
- **Pure domain entities** without ORM attributes
|
||||
- **Repository pattern interfaces** for data access abstraction
|
||||
- **Dependency inversion** - infrastructure depends on domain, not vice versa
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ECMJobRunner.Domain/
|
||||
├── Entities/
|
||||
│ ├── Profile.cs # Job configuration profile entity
|
||||
│ ├── ProfileSqlJob.cs # SQL job configuration entity
|
||||
│ └── ProfileHistory.cs # Job execution history entity
|
||||
├── Interfaces/
|
||||
│ ├── IRepository.cs # Generic repository interface
|
||||
│ ├── IProfileRepository.cs # Profile-specific repository
|
||||
│ ├── IProfileSqlJobRepository.cs
|
||||
│ ├── IProfileHistoryRepository.cs
|
||||
│ └── IUnitOfWork.cs # Unit of Work pattern interface
|
||||
├── ECMJobRunner.Domain.csproj
|
||||
└── AGENTS.md # This file
|
||||
```
|
||||
|
||||
## Entities
|
||||
|
||||
### Profile
|
||||
Represents a job runner profile configuration.
|
||||
|
||||
**Properties:**
|
||||
- `Id` (long): Primary key
|
||||
- `Active` (bool): Enable/disable switch
|
||||
- `ProfileName` (string, max 150): Name of the profile
|
||||
- `TypeId` (byte): Profile type (0=ADSync, 1=GraphQL, 2=SQL-Job, 3=SQL and REST-Job)
|
||||
- `Schedule` (string, max 150): Cron format schedule
|
||||
- `Comment` (string?, max 500): Optional description
|
||||
- `AddedWho`, `AddedWhen`, `ChangedWho`, `ChangedWhen`: Audit fields
|
||||
|
||||
**Navigation Properties:**
|
||||
- `SqlJobs` (IEnumerable<ProfileSqlJob>?): Associated SQL jobs
|
||||
- `ProfileHistories` (IEnumerable<ProfileHistory>?): Execution history
|
||||
|
||||
### ProfileSqlJob
|
||||
Represents individual SQL jobs within a profile.
|
||||
|
||||
**Properties:**
|
||||
- `Id` (long): Primary key
|
||||
- `ProfileId` (long): Foreign key to Profile
|
||||
- `Active` (bool): Enable/disable switch
|
||||
- `Sequence` (short): Execution order within the profile
|
||||
- `Name` (string?, max 150): Optional job name
|
||||
- `SqlCheckQuery` (string?): SQL query for pre-check
|
||||
- `SqlMainQuery` (string?): Main SQL query
|
||||
- `ApiCommand` (string?): API command to execute
|
||||
- `Comment` (string?, max 500): Optional description
|
||||
- Audit fields
|
||||
|
||||
**Navigation Properties:**
|
||||
- `Profile` (Profile?): Associated profile
|
||||
|
||||
### ProfileHistory
|
||||
Stores execution history and results of job profiles.
|
||||
|
||||
**Properties:**
|
||||
- `Id` (long): Primary key
|
||||
- `ProfileId` (long): Foreign key to Profile
|
||||
- `ResultId` (byte): Result status (0=OK, 1=ERROR, 2=WARNING)
|
||||
- `ResultText` (string): Result message/details
|
||||
- Audit fields
|
||||
|
||||
**Navigation Properties:**
|
||||
- `Profile` (Profile?): Associated profile
|
||||
|
||||
## Repository Interfaces
|
||||
|
||||
### IRepository<TEntity>
|
||||
Generic repository interface providing CRUD operations:
|
||||
- `GetById(id)`, `GetByIdAsync(id)`
|
||||
- `GetAll()`, `GetAllAsync()`
|
||||
- `Find(predicate)`, `FindAsync(predicate)`
|
||||
- `SingleOrDefault(predicate)`, `SingleOrDefaultAsync(predicate)`
|
||||
- `Add(entity)`, `AddRange(entities)`
|
||||
- `Update(entity)`, `Remove(entity)`, `RemoveRange(entities)`
|
||||
|
||||
### Entity-Specific Repositories
|
||||
- `IProfileRepository : IRepository<Profile>`
|
||||
- `IProfileSqlJobRepository : IRepository<ProfileSqlJob>`
|
||||
- `IProfileHistoryRepository : IRepository<ProfileHistory>`
|
||||
|
||||
### IUnitOfWork
|
||||
Manages transactions and provides access to all repositories:
|
||||
- `Profiles`: IProfileRepository
|
||||
- `ProfileSqlJobs`: IProfileSqlJobRepository
|
||||
- `ProfileHistories`: IProfileHistoryRepository
|
||||
- `SaveChanges()`, `SaveChangesAsync()`
|
||||
|
||||
## Database Schema Source
|
||||
|
||||
The entities map to SQL Server tables located at:
|
||||
```
|
||||
M:\Datenbank\[DD_ECM]-Database\JobRunner\
|
||||
```
|
||||
|
||||
**Table Mappings** (handled in Infrastructure layer):
|
||||
- `Profile` → `dbo.TBJR_CFG_PROFILE`
|
||||
- `ProfileSqlJob` → `dbo.TBJR_CFG_PROFILE_SQLJOB`
|
||||
- `ProfileHistory` → `dbo.TBJR_OUT_PROFILE_HISTORY`
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **Entity Classes**: Clean Pascal case (e.g., `Profile` instead of `TBJR_CFG_PROFILE`)
|
||||
- **Properties**: Pascal case (e.g., `ProfileName` instead of `PROFILE_NAME`)
|
||||
- **No ORM attributes** - entities are pure POCOs
|
||||
- **Navigation properties** are nullable `IEnumerable<T>?` (loaded only when explicitly included)
|
||||
|
||||
## Dependencies
|
||||
|
||||
**NONE** - This is a core domain layer with zero external dependencies.
|
||||
|
||||
## Building the Project
|
||||
|
||||
```bash
|
||||
dotnet build ECMJobRunner.Domain.csproj
|
||||
```
|
||||
|
||||
For specific framework:
|
||||
```bash
|
||||
dotnet build ECMJobRunner.Domain.csproj -f net8.0
|
||||
dotnet build ECMJobRunner.Domain.csproj -f net480
|
||||
```
|
||||
|
||||
## Development Notes
|
||||
|
||||
- **Clean Architecture**: Domain layer is independent of infrastructure concerns
|
||||
- **Navigation properties**: Nullable IEnumerable - populated only when explicitly loaded via `.Include()`
|
||||
- **No ORM attributes**: Pure POCOs - mapping is done in Infrastructure layer
|
||||
- **Repository pattern**: All data access through interfaces
|
||||
- **Unit of Work pattern**: Transaction management abstraction
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **ECMJobRunner.Infrastructure**: Implements repositories and DbContext
|
||||
- **ECMJobRunner.Application**: Application layer with business logic
|
||||
|
||||
## Company Information
|
||||
|
||||
**Author**: Digital Data GmbH
|
||||
**Copyright**: 2026
|
||||
**Repository**: http://git.dd:3000/AppStd/ECMJobRunner.git
|
||||
22
ECMJobRunner.Domain/ECMJobRunner.Domain.csproj
Normal file
22
ECMJobRunner.Domain/ECMJobRunner.Domain.csproj
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net480;net8.0</TargetFrameworks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).xml</DocumentationFile>
|
||||
<PackageId>ECMJobRunner.Domain</PackageId>
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>ECMJobRunner.Domain</Product>
|
||||
<Copyright>Copyright 2026</Copyright>
|
||||
<RepositoryUrl>http://git.dd:3000/AppStd/ECMJobRunner.git</RepositoryUrl>
|
||||
<PackageTags>digital data ecm job runner domain</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
|
||||
<!-- System.ComponentModel.DataAnnotations for .NET Framework 4.8 -->
|
||||
<Reference Include="System.ComponentModel.DataAnnotations" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
101
ECMJobRunner.Domain/Entities/CfgProfile.cs
Normal file
101
ECMJobRunner.Domain/Entities/CfgProfile.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace ECMJobRunner.Domain.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// Job Runner Configuration Profile Entity
|
||||
/// Represents a job runner profile configuration
|
||||
/// Type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
|
||||
/// </summary>
|
||||
[Table("TBJR_CFG_PROFILE", Schema = "dbo")]
|
||||
public class CfgProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary Key
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("PK_TBJR_CFG_PROFILE_ID")]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Active / Inactive switch
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("ACTIVE")]
|
||||
public bool Active { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Profile name (max 150 chars)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("PROFILE_NAME")]
|
||||
[MaxLength(150)]
|
||||
public string ProfileName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Profile type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("TYPE_ID")]
|
||||
public byte TypeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Schedule in Cron format (max 150 chars)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("SCHEDULE")]
|
||||
[MaxLength(150)]
|
||||
public string Schedule { get; set; } = "0 30 4 ? * MON-SAT";
|
||||
|
||||
/// <summary>
|
||||
/// Optional description (max 500 chars)
|
||||
/// </summary>
|
||||
[Column("COMMENT")]
|
||||
[MaxLength(500)]
|
||||
public string? Comment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Created by (max 50 chars)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("ADDED_WHO")]
|
||||
[MaxLength(50)]
|
||||
public string AddedWho { get; set; } = "DEFAULT";
|
||||
|
||||
/// <summary>
|
||||
/// Created at
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("ADDED_WHEN")]
|
||||
public DateTime AddedWhen { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// Modified by (max 50 chars)
|
||||
/// </summary>
|
||||
[Column("CHANGED_WHO")]
|
||||
[MaxLength(50)]
|
||||
public string? ChangedWho { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Modified at
|
||||
/// </summary>
|
||||
[Column("CHANGED_WHEN")]
|
||||
public DateTime? ChangedWhen { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
|
||||
/// <summary>
|
||||
/// SQL Jobs associated with this profile
|
||||
/// </summary>
|
||||
public virtual IEnumerable<ProfileSqlJob>? SqlJobs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile execution history
|
||||
/// </summary>
|
||||
public virtual IEnumerable<ProfileHistory>? ProfileHistories { get; set; }
|
||||
}
|
||||
}
|
||||
80
ECMJobRunner.Domain/Entities/ProfileHistory.cs
Normal file
80
ECMJobRunner.Domain/Entities/ProfileHistory.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace ECMJobRunner.Domain.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile Execution History
|
||||
/// Stores the execution history and results of job runner profiles
|
||||
/// Result ID: 0 = OK; 1 = ERROR; 2 = WARNING
|
||||
/// </summary>
|
||||
[Table("TBJR_OUT_PROFILE_HISTORY", Schema = "dbo")]
|
||||
public class ProfileHistory
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary Key
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("PK_TBJR_OUT_PROFILE_HISTORY_ID")]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign Key to Profile
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("FK_TBJR_CFG_PROFILE_ID")]
|
||||
[ForeignKey(nameof(CfgProfile))]
|
||||
public long ProfileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Result ID: 0 = OK; 1 = ERROR; 2 = WARNING
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("RESULT_ID")]
|
||||
public byte ResultId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Result text/message
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("RESULT_TEXT")]
|
||||
public string ResultText { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Created by (max 50 chars)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("ADDED_WHO")]
|
||||
[MaxLength(50)]
|
||||
public string AddedWho { get; set; } = "DEFAULT";
|
||||
|
||||
/// <summary>
|
||||
/// Created at
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("ADDED_WHEN")]
|
||||
public DateTime AddedWhen { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// Modified by (max 50 chars)
|
||||
/// </summary>
|
||||
[Column("CHANGED_WHO")]
|
||||
[MaxLength(50)]
|
||||
public string? ChangedWho { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Modified at
|
||||
/// </summary>
|
||||
[Column("CHANGED_WHEN")]
|
||||
public DateTime? ChangedWhen { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
|
||||
/// <summary>
|
||||
/// Associated Profile
|
||||
/// </summary>
|
||||
public virtual CfgProfile? CfgProfile { get; set; }
|
||||
}
|
||||
}
|
||||
111
ECMJobRunner.Domain/Entities/ProfileSqlJob.cs
Normal file
111
ECMJobRunner.Domain/Entities/ProfileSqlJob.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace ECMJobRunner.Domain.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// SQL Job Configuration for Profile
|
||||
/// Represents individual SQL jobs within a job runner profile
|
||||
/// </summary>
|
||||
[Table("TBJR_CFG_PROFILE_SQLJOB", Schema = "dbo")]
|
||||
public class ProfileSqlJob
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary Key
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("PK_TBJR_CFG_PROFILE_SQLJOB_ID")]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign Key to Profile
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("FK_TBJR_CFG_PROFILE_ID")]
|
||||
[ForeignKey(nameof(CfgProfile))]
|
||||
public long ProfileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Active / Inactive switch
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("ACTIVE")]
|
||||
public bool Active { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Sequence order within the profile
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("SEQUENCE")]
|
||||
public short Sequence { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Optional name (max 150 chars)
|
||||
/// </summary>
|
||||
[Column("NAME")]
|
||||
[MaxLength(150)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SQL Check Query
|
||||
/// </summary>
|
||||
[Column("SQL_CHECK_QUERY")]
|
||||
public string? SqlCheckQuery { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SQL Main Query
|
||||
/// </summary>
|
||||
[Column("SQL_MAIN_QUERY")]
|
||||
public string? SqlMainQuery { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// API Command
|
||||
/// </summary>
|
||||
[Column("API_COMMAND")]
|
||||
public string? ApiCommand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description (max 500 chars)
|
||||
/// </summary>
|
||||
[Column("COMMENT")]
|
||||
[MaxLength(500)]
|
||||
public string? Comment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Created by (max 50 chars)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("ADDED_WHO")]
|
||||
[MaxLength(50)]
|
||||
public string AddedWho { get; set; } = "DEFAULT";
|
||||
|
||||
/// <summary>
|
||||
/// Created at
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("ADDED_WHEN")]
|
||||
public DateTime AddedWhen { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// Modified by (max 50 chars)
|
||||
/// </summary>
|
||||
[Column("CHANGED_WHO")]
|
||||
[MaxLength(50)]
|
||||
public string? ChangedWho { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Modified at
|
||||
/// </summary>
|
||||
[Column("CHANGED_WHEN")]
|
||||
public DateTime? ChangedWhen { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
|
||||
/// <summary>
|
||||
/// Associated Profile
|
||||
/// </summary>
|
||||
public virtual CfgProfile? CfgProfile { get; set; }
|
||||
}
|
||||
}
|
||||
28
ECMJobRunner.Domain/Interfaces/ICfgProfileRepository.cs
Normal file
28
ECMJobRunner.Domain/Interfaces/ICfgProfileRepository.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ECMJobRunner.Domain.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Repository interface for CfgProfile entity
|
||||
/// </summary>
|
||||
public interface ICfgProfileRepository : IRepository<CfgProfile>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a profile by ID with associated SQL jobs eagerly loaded
|
||||
/// </summary>
|
||||
/// <param name="id">Profile ID</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Profile with SQL jobs or null if not found</returns>
|
||||
Task<CfgProfile?> GetByIdWithSqlJobsAsync(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active profiles with associated SQL jobs eagerly loaded
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of active profiles with SQL jobs</returns>
|
||||
Task<List<CfgProfile>> GetAllActiveWithSqlJobsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
12
ECMJobRunner.Domain/Interfaces/IProfileHistoryRepository.cs
Normal file
12
ECMJobRunner.Domain/Interfaces/IProfileHistoryRepository.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
|
||||
namespace ECMJobRunner.Domain.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Repository interface for ProfileHistory entity
|
||||
/// </summary>
|
||||
public interface IProfileHistoryRepository : IRepository<ProfileHistory>
|
||||
{
|
||||
// Add custom ProfileHistory-specific methods here if needed
|
||||
}
|
||||
}
|
||||
12
ECMJobRunner.Domain/Interfaces/IProfileSqlJobRepository.cs
Normal file
12
ECMJobRunner.Domain/Interfaces/IProfileSqlJobRepository.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
|
||||
namespace ECMJobRunner.Domain.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Repository interface for ProfileSqlJob entity
|
||||
/// </summary>
|
||||
public interface IProfileSqlJobRepository : IRepository<ProfileSqlJob>
|
||||
{
|
||||
// Add custom ProfileSqlJob-specific methods here if needed
|
||||
}
|
||||
}
|
||||
99
ECMJobRunner.Domain/Interfaces/IRepository.cs
Normal file
99
ECMJobRunner.Domain/Interfaces/IRepository.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ECMJobRunner.Domain.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Generic repository interface for data access operations
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">Entity type</typeparam>
|
||||
public interface IRepository<TEntity> where TEntity : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Get entity by ID asynchronously
|
||||
/// </summary>
|
||||
Task<TEntity?> GetByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all entities asynchronously
|
||||
/// </summary>
|
||||
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Find entities by predicate asynchronously
|
||||
/// </summary>
|
||||
Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get single entity by predicate asynchronously
|
||||
/// </summary>
|
||||
Task<TEntity?> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Add new entity from DTO asynchronously
|
||||
/// Maps DTO to entity and adds it
|
||||
/// </summary>
|
||||
/// <typeparam name="TDto">DTO type</typeparam>
|
||||
/// <param name="dto">DTO containing values for new entity</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Created entity</returns>
|
||||
Task<TEntity> AddAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) where TDto : class;
|
||||
|
||||
/// <summary>
|
||||
/// Add multiple entities from DTOs asynchronously
|
||||
/// Maps DTOs to entities and adds them
|
||||
/// </summary>
|
||||
/// <typeparam name="TDto">DTO type</typeparam>
|
||||
/// <param name="dtos">DTOs containing values for new entities</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Number of entities added</returns>
|
||||
Task<int> AddRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) where TDto : class;
|
||||
|
||||
/// <summary>
|
||||
/// Update entities matching predicate with DTO values asynchronously
|
||||
/// Maps DTO properties onto matching entities
|
||||
/// </summary>
|
||||
/// <typeparam name="TDto">DTO type</typeparam>
|
||||
/// <param name="predicate">Predicate to find entities</param>
|
||||
/// <param name="dto">DTO containing values to update</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Number of entities updated</returns>
|
||||
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class;
|
||||
|
||||
/// <summary>
|
||||
/// Update single entity matching predicate with DTO values asynchronously
|
||||
/// Throws exception if multiple entities match
|
||||
/// </summary>
|
||||
/// <typeparam name="TDto">DTO type</typeparam>
|
||||
/// <param name="predicate">Predicate to find entity</param>
|
||||
/// <param name="dto">DTO containing values to update</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if entity was found and updated, false otherwise</returns>
|
||||
Task<bool> UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class;
|
||||
|
||||
/// <summary>
|
||||
/// Delete entities matching predicate asynchronously (hard delete)
|
||||
/// </summary>
|
||||
/// <param name="predicate">Predicate to find entities to delete</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Number of entities deleted</returns>
|
||||
Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete single entity matching predicate asynchronously (hard delete)
|
||||
/// Throws exception if multiple entities match
|
||||
/// </summary>
|
||||
/// <param name="predicate">Predicate to find entity to delete</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>True if entity was found and deleted, false otherwise</returns>
|
||||
Task<bool> DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Save all changes asynchronously
|
||||
/// </summary>
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
205
ECMJobRunner.Infrastructure/AGENTS.md
Normal file
205
ECMJobRunner.Infrastructure/AGENTS.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# ECMJobRunner.Infrastructure
|
||||
|
||||
## Project Overview
|
||||
|
||||
ECMJobRunner.Infrastructure is the **infrastructure layer** for the ECM Job Runner system following **Clean Architecture** principles. This project contains:
|
||||
- **DbContext implementation** with Entity Framework
|
||||
- **Repository implementations** for data access
|
||||
- **Entity configurations** (EF mappings)
|
||||
- **Database connection management**
|
||||
|
||||
**Key Principle**: This layer implements the interfaces defined in the Domain layer and handles all database concerns.
|
||||
|
||||
## Target Frameworks
|
||||
|
||||
- **.NET Framework 4.8** (`net480`) - Uses **Entity Framework 6.5.1**
|
||||
- **.NET 8.0** (`net8.0`) - Uses **Entity Framework Core 8.0.11**
|
||||
|
||||
The project uses conditional compilation to support both EF6 and EF Core with the same codebase.
|
||||
|
||||
## Architecture
|
||||
|
||||
This project follows **Clean Architecture** principles:
|
||||
- **Depends on Domain layer** (implements domain interfaces)
|
||||
- **No dependencies from Domain** (dependency inversion)
|
||||
- **Conditional compilation** for EF6 vs EF Core differences
|
||||
- **Repository pattern** implementation
|
||||
- **Unit of Work pattern** implementation
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
ECMJobRunner.Infrastructure/
|
||||
├── Data/
|
||||
│ └── JobRunnerDbContext.cs # DbContext with conditional compilation
|
||||
├── Repositories/
|
||||
│ ├── Repository.cs # Generic repository implementation
|
||||
│ ├── ProfileRepository.cs # Profile-specific repository
|
||||
│ ├── ProfileSqlJobRepository.cs
|
||||
│ ├── ProfileHistoryRepository.cs
|
||||
│ └── UnitOfWork.cs # Unit of Work implementation
|
||||
├── ECMJobRunner.Infrastructure.csproj
|
||||
└── AGENTS.md # This file
|
||||
```
|
||||
|
||||
## Database Context
|
||||
|
||||
### JobRunnerDbContext
|
||||
|
||||
Multi-targeted DbContext supporting both EF6 and EF Core:
|
||||
|
||||
**DbSets:**
|
||||
- `Profiles`: DbSet<Profile>
|
||||
- `ProfileSqlJobs`: DbSet<ProfileSqlJob>
|
||||
- `ProfileHistories`: DbSet<ProfileHistory>
|
||||
|
||||
**Configuration:**
|
||||
- Connection string via constructor parameter
|
||||
- Conditional compilation directives (`#if NET48` / `#else`)
|
||||
- Fluent API configurations in `OnModelCreating`
|
||||
|
||||
**Table Mappings:**
|
||||
- `Profile` → `dbo.TBJR_CFG_PROFILE`
|
||||
- `ProfileSqlJob` → `dbo.TBJR_CFG_PROFILE_SQLJOB`
|
||||
- `ProfileHistory` → `dbo.TBJR_OUT_PROFILE_HISTORY`
|
||||
|
||||
**Column Mappings:** (Examples)
|
||||
- `Profile.ProfileName` → `PROFILE_NAME`
|
||||
- `ProfileSqlJob.SqlCheckQuery` → `SQL_CHECK_QUERY`
|
||||
- All navigation properties configured with relationships
|
||||
|
||||
## Repository Implementations
|
||||
|
||||
### Repository<TEntity>
|
||||
Generic repository implementing `IRepository<TEntity>` with full CRUD operations.
|
||||
|
||||
**Key Methods:**
|
||||
- Synchronous: `GetById`, `GetAll`, `Find`, `SingleOrDefault`, `Add`, `Update`, `Remove`
|
||||
- Asynchronous: `GetByIdAsync`, `GetAllAsync`, `FindAsync`, `SingleOrDefaultAsync`
|
||||
|
||||
### Entity-Specific Repositories
|
||||
- `ProfileRepository : Repository<Profile>, IProfileRepository`
|
||||
- `ProfileSqlJobRepository : Repository<ProfileSqlJob>, IProfileSqlJobRepository`
|
||||
- `ProfileHistoryRepository : Repository<ProfileHistory>, IProfileHistoryRepository`
|
||||
|
||||
These can be extended with entity-specific query methods as needed.
|
||||
|
||||
### UnitOfWork
|
||||
Implements `IUnitOfWork` interface:
|
||||
- Manages DbContext lifecycle
|
||||
- Provides repository instances
|
||||
- Handles transaction management via `SaveChanges`/`SaveChangesAsync`
|
||||
|
||||
## Connection String
|
||||
|
||||
Default connection string (configured in consuming applications):
|
||||
```
|
||||
Server=SDD-VMP04-SQL17\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;
|
||||
```
|
||||
|
||||
## Database Schema Source
|
||||
|
||||
SQL scripts and schema definitions are located at:
|
||||
```
|
||||
M:\Datenbank\[DD_ECM]-Database\JobRunner\
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### .NET Framework 4.8 (`net480`)
|
||||
- **ECMJobRunner.Domain** (project reference)
|
||||
- **EntityFramework 6.5.1** (NuGet package)
|
||||
|
||||
### .NET 8.0 (`net8.0`)
|
||||
- **ECMJobRunner.Domain** (project reference)
|
||||
- **Microsoft.EntityFrameworkCore 8.0.11** (NuGet package)
|
||||
- **Microsoft.EntityFrameworkCore.SqlServer 8.0.11** (NuGet package)
|
||||
|
||||
## Conditional Compilation
|
||||
|
||||
The project uses `#if NET48` / `#else` directives to handle differences between EF6 and EF Core:
|
||||
|
||||
**EF6 (.NET Framework 4.8):**
|
||||
```csharp
|
||||
#if NET48
|
||||
using System.Data.Entity;
|
||||
public class JobRunnerDbContext : DbContext
|
||||
#endif
|
||||
```
|
||||
|
||||
**EF Core (.NET 8.0):**
|
||||
```csharp
|
||||
#if !NET48
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
public class JobRunnerDbContext : DbContext
|
||||
#endif
|
||||
```
|
||||
|
||||
## Building the Project
|
||||
|
||||
```bash
|
||||
dotnet build ECMJobRunner.Infrastructure.csproj
|
||||
```
|
||||
|
||||
For specific framework:
|
||||
```bash
|
||||
dotnet build ECMJobRunner.Infrastructure.csproj -f net8.0
|
||||
dotnet build ECMJobRunner.Infrastructure.csproj -f net480
|
||||
```
|
||||
|
||||
## Entity Framework Differences
|
||||
|
||||
### DbContext Constructor
|
||||
- **EF6**: Accepts connection string directly
|
||||
- **EF Core**: Requires `DbContextOptions<T>`
|
||||
|
||||
### Configuration
|
||||
- **EF6**: `DbModelBuilder` in `OnModelCreating`
|
||||
- **EF Core**: `ModelBuilder` in `OnModelCreating`
|
||||
|
||||
### Querying
|
||||
- **EF6**: `DbSet<T>.AsNoTracking()` extension
|
||||
- **EF Core**: Same API, built-in support
|
||||
|
||||
### Async Operations
|
||||
- **EF6**: Limited async support
|
||||
- **EF Core**: Full async/await support
|
||||
|
||||
## Development Notes
|
||||
|
||||
- **Nullable navigation properties**: `IEnumerable<T>?` to support defensive programming
|
||||
- **No lazy loading**: Navigation properties loaded explicitly via `.Include()`
|
||||
- **Transaction management**: Handled by Unit of Work pattern
|
||||
- **Repository pattern**: Encapsulates EF-specific code
|
||||
- **Clean Architecture**: Infrastructure depends on Domain, not vice versa
|
||||
|
||||
## Usage Example
|
||||
|
||||
```csharp
|
||||
// Create DbContext (connection string from configuration)
|
||||
var connectionString = ConfigurationManager.ConnectionStrings["JobRunner"].ConnectionString;
|
||||
var context = new JobRunnerDbContext(connectionString);
|
||||
|
||||
// Use Unit of Work
|
||||
using var unitOfWork = new UnitOfWork(context);
|
||||
|
||||
// Query profiles
|
||||
var activeProfiles = await unitOfWork.Profiles
|
||||
.FindAsync(p => p.Active);
|
||||
|
||||
// Add new profile
|
||||
var profile = new Profile { ProfileName = "Test", Active = true };
|
||||
unitOfWork.Profiles.Add(profile);
|
||||
await unitOfWork.SaveChangesAsync();
|
||||
```
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **ECMJobRunner.Domain**: Contains entities and repository interfaces
|
||||
- **ECMJobRunner.Application**: Application layer consuming repositories
|
||||
|
||||
## Company Information
|
||||
|
||||
**Author**: Digital Data GmbH
|
||||
**Copyright**: 2026
|
||||
**Repository**: http://git.dd:3000/AppStd/ECMJobRunner.git
|
||||
61
ECMJobRunner.Infrastructure/Data/JobRunnerDbContext.cs
Normal file
61
ECMJobRunner.Infrastructure/Data/JobRunnerDbContext.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
#if NET48
|
||||
using System.Data.Entity;
|
||||
using System.Data.Common;
|
||||
#else
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
#endif
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
|
||||
namespace ECMJobRunner.Infrastructure.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Entity Framework DbContext for ECM Job Runner
|
||||
/// </summary>
|
||||
public class JobRunnerDbContext : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile entities
|
||||
/// </summary>
|
||||
public DbSet<CfgProfile> CfgProfiles { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// ProfileSqlJob entities
|
||||
/// </summary>
|
||||
public DbSet<ProfileSqlJob> ProfileSqlJobs { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// ProfileHistory entities
|
||||
/// </summary>
|
||||
public DbSet<ProfileHistory> ProfileHistories { get; set; } = null!;
|
||||
|
||||
#if NET48
|
||||
/// <summary>
|
||||
/// Default constructor for Entity Framework 6 (.NET Framework 4.8)
|
||||
/// </summary>
|
||||
public JobRunnerDbContext() : base("name=JobRunnerConnection")
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructor with connection string for Entity Framework 6
|
||||
/// </summary>
|
||||
public JobRunnerDbContext(string connectionString) : base(connectionString)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructor with DbConnection for Entity Framework 6 (used for InMemory testing with Effort)
|
||||
/// </summary>
|
||||
public JobRunnerDbContext(DbConnection connection) : base(connection, contextOwnsConnection: true)
|
||||
{
|
||||
}
|
||||
#else
|
||||
/// <summary>
|
||||
/// Constructor for Entity Framework Core (.NET 8)
|
||||
/// </summary>
|
||||
public JobRunnerDbContext(DbContextOptions<JobRunnerDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
114
ECMJobRunner.Infrastructure/DependencyExtension.cs
Normal file
114
ECMJobRunner.Infrastructure/DependencyExtension.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
#if NET48
|
||||
using System.Data.Entity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
#else
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
#endif
|
||||
using ECMJobRunner.Application.Common.Interfaces;
|
||||
using ECMJobRunner.Domain.Interfaces;
|
||||
using ECMJobRunner.Infrastructure.Data;
|
||||
using ECMJobRunner.Infrastructure.Repositories;
|
||||
using ECMJobRunner.Infrastructure.Services;
|
||||
|
||||
namespace ECMJobRunner.Infrastructure
|
||||
{
|
||||
/// <summary>
|
||||
/// Dependency injection extension methods for Infrastructure layer
|
||||
/// </summary>
|
||||
public static class DependencyExtension
|
||||
{
|
||||
/// <summary>
|
||||
/// Add Infrastructure services to dependency injection container
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection</param>
|
||||
/// <param name="connectionString">Database connection string</param>
|
||||
/// <returns>Service collection for chaining</returns>
|
||||
public static IServiceCollection AddJobRunnerInfrastructure(this IServiceCollection services, string connectionString)
|
||||
{
|
||||
#if NET48
|
||||
// Register DbContext as scoped for EF6 - same pattern as EF Core
|
||||
// Each scope (request) gets its own DbContext instance
|
||||
services.AddScoped(provider => new JobRunnerDbContext(connectionString));
|
||||
#else
|
||||
// Register DbContext as scoped for EF Core
|
||||
services.AddDbContext<JobRunnerDbContext>(options =>
|
||||
options.UseSqlServer(connectionString));
|
||||
#endif
|
||||
|
||||
// Register AutoMapper
|
||||
services.AddAutoMapper(typeof(DependencyExtension).Assembly);
|
||||
|
||||
// Register repositories as scoped (same lifetime as DbContext)
|
||||
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
services.AddScoped<ICfgProfileRepository, CfgProfileRepository>();
|
||||
services.AddScoped<IProfileSqlJobRepository, ProfileSqlJobRepository>();
|
||||
services.AddScoped<IProfileHistoryRepository, ProfileHistoryRepository>();
|
||||
|
||||
// Register SQL executor as scoped
|
||||
services.AddScoped<ISQLExecutor, SQLExecutor>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
#if NET48
|
||||
/// <summary>
|
||||
/// Add Infrastructure services with InMemory database for testing (.NET Framework 4.8 - uses Effort)
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection</param>
|
||||
/// <returns>Service collection for chaining</returns>
|
||||
public static IServiceCollection AddInfrastructureInMemory(this IServiceCollection services)
|
||||
{
|
||||
// Register DbContext as scoped with Effort InMemory connection
|
||||
services.AddScoped(provider =>
|
||||
{
|
||||
var connection = Effort.DbConnectionFactory.CreateTransient();
|
||||
return new JobRunnerDbContext(connection);
|
||||
});
|
||||
|
||||
// Register AutoMapper
|
||||
services.AddAutoMapper(typeof(DependencyExtension).Assembly);
|
||||
|
||||
// Register repositories as scoped (same lifetime as DbContext)
|
||||
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
services.AddScoped<ICfgProfileRepository, CfgProfileRepository>();
|
||||
services.AddScoped<IProfileSqlJobRepository, ProfileSqlJobRepository>();
|
||||
services.AddScoped<IProfileHistoryRepository, ProfileHistoryRepository>();
|
||||
|
||||
// Register SQL executor as scoped
|
||||
services.AddScoped<ISQLExecutor, SQLExecutor>();
|
||||
|
||||
return services;
|
||||
}
|
||||
#else
|
||||
/// <summary>
|
||||
/// Add Infrastructure services with InMemory database for testing (.NET 8 - uses EF Core InMemory)
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection</param>
|
||||
/// <param name="databaseName">Optional database name (default: random GUID)</param>
|
||||
/// <returns>Service collection for chaining</returns>
|
||||
public static IServiceCollection AddInfrastructureInMemory(this IServiceCollection services, string? databaseName = null)
|
||||
{
|
||||
var dbName = databaseName ?? $"TestDb_{System.Guid.NewGuid()}";
|
||||
|
||||
// Register DbContext as scoped with InMemory database
|
||||
services.AddDbContext<JobRunnerDbContext>(options =>
|
||||
options.UseInMemoryDatabase(dbName));
|
||||
|
||||
// Register AutoMapper
|
||||
services.AddAutoMapper(typeof(DependencyExtension).Assembly);
|
||||
|
||||
// Register repositories as scoped (same lifetime as DbContext)
|
||||
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
services.AddScoped<ICfgProfileRepository, CfgProfileRepository>();
|
||||
services.AddScoped<IProfileSqlJobRepository, ProfileSqlJobRepository>();
|
||||
services.AddScoped<IProfileHistoryRepository, ProfileHistoryRepository>();
|
||||
|
||||
// Register SQL executor as scoped
|
||||
services.AddScoped<ISQLExecutor, SQLExecutor>();
|
||||
|
||||
return services;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net480;net8.0</TargetFrameworks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).xml</DocumentationFile>
|
||||
<PackageId>ECMJobRunner.Infrastructure</PackageId>
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>ECMJobRunner.Infrastructure</Product>
|
||||
<Copyright>Copyright 2026</Copyright>
|
||||
<RepositoryUrl>http://git.dd:3000/AppStd/ECMJobRunner.git</RepositoryUrl>
|
||||
<PackageTags>digital data ecm job runner infrastructure</PackageTags>
|
||||
<!-- Suppress AutoMapper vulnerability warning (known issue, acceptable for this project) -->
|
||||
<NoWarn>$(NoWarn);NU1903</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
|
||||
<ProjectReference Include="..\ECMJobRunner.Application\ECMJobRunner.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
|
||||
<!-- Entity Framework 6 for .NET Framework 4.8 -->
|
||||
<PackageReference Include="EntityFramework" Version="6.5.1" />
|
||||
<!-- Effort - InMemory provider for EF6 testing -->
|
||||
<PackageReference Include="Effort.EF6" Version="2.2.16" />
|
||||
<!-- AutoMapper for .NET Framework 4.8 -->
|
||||
<PackageReference Include="AutoMapper" Version="10.1.1" />
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
|
||||
<!-- Dependency Injection for .NET Framework 4.8 -->
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<!-- Entity Framework Core 8 for .NET 8 -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" />
|
||||
<!-- InMemory provider for EF Core testing -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||
<!-- AutoMapper for .NET 8 - version 12.0.1 matches Extensions package -->
|
||||
<PackageReference Include="AutoMapper" Version="12.0.1" />
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
22
ECMJobRunner.Infrastructure/Mapping/EntityMappingProfile.cs
Normal file
22
ECMJobRunner.Infrastructure/Mapping/EntityMappingProfile.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using AutoMapper;
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
|
||||
namespace ECMJobRunner.Infrastructure.Mapping
|
||||
{
|
||||
/// <summary>
|
||||
/// AutoMapper profile for entity mappings
|
||||
/// </summary>
|
||||
public class EntityMappingProfile : Profile
|
||||
{
|
||||
/// <summary>
|
||||
/// Constructor configuring AutoMapper mappings for all entities
|
||||
/// </summary>
|
||||
public EntityMappingProfile()
|
||||
{
|
||||
// Note: object -> Entity mappings are intentionally removed
|
||||
// AutoMapper cannot map from object type due to reflection limitations
|
||||
// Callers should use explicit DTO types and create specific mappings
|
||||
// Example: CreateMap<YourDto, CfgProfile>() in your own AutoMapper profile
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using AutoMapper;
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
using ECMJobRunner.Domain.Interfaces;
|
||||
using ECMJobRunner.Infrastructure.Data;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
#if NET48
|
||||
using System.Data.Entity;
|
||||
#else
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
#endif
|
||||
|
||||
namespace ECMJobRunner.Infrastructure.Repositories
|
||||
{
|
||||
/// <summary>
|
||||
/// CfgProfile repository implementation
|
||||
/// </summary>
|
||||
public class CfgProfileRepository : Repository<CfgProfile>, ICfgProfileRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
public CfgProfileRepository(JobRunnerDbContext context, IMapper mapper) : base(context, mapper)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by ID with associated SQL jobs eagerly loaded
|
||||
/// </summary>
|
||||
public async Task<CfgProfile?> GetByIdWithSqlJobsAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.CfgProfiles
|
||||
.Include(p => p.SqlJobs)
|
||||
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active profiles with associated SQL jobs eagerly loaded
|
||||
/// </summary>
|
||||
public async Task<List<CfgProfile>> GetAllActiveWithSqlJobsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.CfgProfiles
|
||||
.Include(p => p.SqlJobs)
|
||||
.Where(p => p.Active)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using AutoMapper;
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
using ECMJobRunner.Domain.Interfaces;
|
||||
using ECMJobRunner.Infrastructure.Data;
|
||||
|
||||
namespace ECMJobRunner.Infrastructure.Repositories
|
||||
{
|
||||
/// <summary>
|
||||
/// ProfileHistory repository implementation
|
||||
/// </summary>
|
||||
public class ProfileHistoryRepository : Repository<ProfileHistory>, IProfileHistoryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
public ProfileHistoryRepository(JobRunnerDbContext context, IMapper mapper) : base(context, mapper)
|
||||
{
|
||||
}
|
||||
|
||||
// Entity-specific methods can be added here in the future
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using AutoMapper;
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
using ECMJobRunner.Domain.Interfaces;
|
||||
using ECMJobRunner.Infrastructure.Data;
|
||||
|
||||
namespace ECMJobRunner.Infrastructure.Repositories
|
||||
{
|
||||
/// <summary>
|
||||
/// ProfileSqlJob repository implementation
|
||||
/// </summary>
|
||||
public class ProfileSqlJobRepository : Repository<ProfileSqlJob>, IProfileSqlJobRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
public ProfileSqlJobRepository(JobRunnerDbContext context, IMapper mapper) : base(context, mapper)
|
||||
{
|
||||
}
|
||||
|
||||
// Entity-specific methods can be added here in the future
|
||||
}
|
||||
}
|
||||
188
ECMJobRunner.Infrastructure/Repositories/Repository.cs
Normal file
188
ECMJobRunner.Infrastructure/Repositories/Repository.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
#if NET48
|
||||
using System.Data.Entity;
|
||||
#else
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
#endif
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutoMapper;
|
||||
using ECMJobRunner.Domain.Interfaces;
|
||||
using ECMJobRunner.Infrastructure.Data;
|
||||
|
||||
namespace ECMJobRunner.Infrastructure.Repositories
|
||||
{
|
||||
/// <summary>
|
||||
/// Generic repository implementation for Entity Framework
|
||||
/// Uses AutoMapper for DTO mapping
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">Entity type</typeparam>
|
||||
public class Repository<TEntity> : IRepository<TEntity> where TEntity : class
|
||||
{
|
||||
/// <summary>
|
||||
/// Database context
|
||||
/// </summary>
|
||||
protected readonly JobRunnerDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// DbSet for the entity
|
||||
/// </summary>
|
||||
protected readonly DbSet<TEntity> _dbSet;
|
||||
|
||||
/// <summary>
|
||||
/// AutoMapper instance for DTO mapping
|
||||
/// </summary>
|
||||
protected readonly IMapper _mapper;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
public Repository(JobRunnerDbContext context, IMapper mapper)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
|
||||
_dbSet = context.Set<TEntity>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<TEntity?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET48
|
||||
return await _dbSet.FindAsync(cancellationToken, id);
|
||||
#else
|
||||
return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbSet.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<TEntity?> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<TEntity> AddAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) where TDto : class
|
||||
{
|
||||
if (dto == null) throw new ArgumentNullException(nameof(dto));
|
||||
|
||||
var entity = _mapper.Map<TEntity>(dto);
|
||||
|
||||
#if NET48
|
||||
_dbSet.Add(entity);
|
||||
await Task.CompletedTask;
|
||||
#else
|
||||
await _dbSet.AddAsync(entity, cancellationToken);
|
||||
#endif
|
||||
|
||||
await SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<int> AddRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) where TDto : class
|
||||
{
|
||||
if (dtos == null) throw new ArgumentNullException(nameof(dtos));
|
||||
|
||||
var dtoList = dtos.ToList();
|
||||
if (!dtoList.Any())
|
||||
return 0;
|
||||
|
||||
var entities = _mapper.Map<List<TEntity>>(dtoList);
|
||||
|
||||
#if NET48
|
||||
_dbSet.AddRange(entities);
|
||||
await Task.CompletedTask;
|
||||
#else
|
||||
await _dbSet.AddRangeAsync(entities, cancellationToken);
|
||||
#endif
|
||||
|
||||
return await SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
|
||||
{
|
||||
if (dto == null) throw new ArgumentNullException(nameof(dto));
|
||||
|
||||
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||
|
||||
if (!entities.Any())
|
||||
return 0;
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
_mapper.Map(dto, entity);
|
||||
}
|
||||
|
||||
return await SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<bool> UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
|
||||
{
|
||||
if (dto == null) throw new ArgumentNullException(nameof(dto));
|
||||
|
||||
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
|
||||
|
||||
if (entity == null)
|
||||
return false;
|
||||
|
||||
_mapper.Map(dto, entity);
|
||||
|
||||
await SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||
|
||||
if (!entities.Any())
|
||||
return 0;
|
||||
|
||||
_dbSet.RemoveRange(entities);
|
||||
|
||||
return await SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<bool> DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
|
||||
|
||||
if (entity == null)
|
||||
return false;
|
||||
|
||||
_dbSet.Remove(entity);
|
||||
|
||||
await SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET48
|
||||
return await _context.SaveChangesAsync();
|
||||
#else
|
||||
return await _context.SaveChangesAsync(cancellationToken);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
57
ECMJobRunner.Infrastructure/Services/SQLExecutor.cs
Normal file
57
ECMJobRunner.Infrastructure/Services/SQLExecutor.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
#if NET48
|
||||
using System.Data.Entity;
|
||||
using System.Linq;
|
||||
#else
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
#endif
|
||||
using ECMJobRunner.Application.Common.Interfaces;
|
||||
using ECMJobRunner.Infrastructure.Data;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ECMJobRunner.Infrastructure.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Entity Framework implementation of ISQLExecutor
|
||||
/// Executes raw SQL queries and maps results to DTOs
|
||||
/// </summary>
|
||||
public class SQLExecutor : ISQLExecutor
|
||||
{
|
||||
private readonly JobRunnerDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of SQLExecutor
|
||||
/// </summary>
|
||||
/// <param name="context">The database context</param>
|
||||
public SQLExecutor(JobRunnerDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a SQL query and maps the result to the specified type
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type to map the query result to</typeparam>
|
||||
/// <param name="sql">The SQL query to execute</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>The mapped result or null if no result</returns>
|
||||
public async Task<TResult?> ExecuteQueryAsync<TResult>(string sql, CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET48
|
||||
// Entity Framework 6 implementation
|
||||
var result = await _context.Database
|
||||
.SqlQuery<TResult>(sql)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return result;
|
||||
#else
|
||||
// Entity Framework Core implementation
|
||||
var result = await _context.Database
|
||||
.SqlQueryRaw<TResult>(sql)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.Application.Common.Interfaces;
|
||||
using ECMJobRunner.Application.Common.Options;
|
||||
using ECMJobRunner.Application.DEXJob.Commands;
|
||||
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<ISQLExecutor> _mockExecutor;
|
||||
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
|
||||
private readonly CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit> _behavior;
|
||||
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
|
||||
|
||||
public CheckQueryExecutionBehaviorTests()
|
||||
{
|
||||
_mockExecutor = new Mock<ISQLExecutor>();
|
||||
_mockOptions = new Mock<IOptions<DexJobOptions>>();
|
||||
_mockOptions.Setup(o => o.Value).Returns(new DexJobOptions());
|
||||
_behavior = new CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
||||
_mockNext = new Mock<RequestHandlerDelegate<Unit>>();
|
||||
_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<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.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<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CheckQueryResult { ReturnValue = returnValue });
|
||||
|
||||
// Act
|
||||
#if NET48
|
||||
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
|
||||
#else
|
||||
Func<Task> 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<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CheckQueryResult { ReturnValue = returnValue });
|
||||
|
||||
// Act
|
||||
#if NET48
|
||||
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
|
||||
#else
|
||||
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
|
||||
#endif
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<JobException>()
|
||||
.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<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
||||
var command = new TriggeringDEXJobCommand
|
||||
{
|
||||
Job = new ProfileSqlJob { SqlCheckQuery = null },
|
||||
BatchId = "20260711143025123456"
|
||||
};
|
||||
|
||||
// Act
|
||||
#if NET48
|
||||
Func<Task> act = async () => await behavior.Handle(command, CancellationToken.None, _mockNext.Object);
|
||||
#else
|
||||
Func<Task> act = async () => await behavior.Handle(command, _mockNext.Object, CancellationToken.None);
|
||||
#endif
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<JobException>()
|
||||
.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<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, CancellationToken>((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<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CheckQueryResult?)null);
|
||||
|
||||
// Act
|
||||
#if NET48
|
||||
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
|
||||
#else
|
||||
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
|
||||
#endif
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<JobException>()
|
||||
.WithMessage("*Check Query returned nothing*");
|
||||
}
|
||||
}
|
||||
}
|
||||
302
ECMJobRunner.Tests/Application/GetProfileQueryTests.cs
Normal file
302
ECMJobRunner.Tests/Application/GetProfileQueryTests.cs
Normal file
@@ -0,0 +1,302 @@
|
||||
using ECMJobRunner.Application.Common.Dtos;
|
||||
using ECMJobRunner.Application.DEXJob.Queries;
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
using ECMJobRunner.Domain.Interfaces;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace ECMJobRunner.Tests.Application
|
||||
{
|
||||
public class GetProfileByIdQueryTests
|
||||
{
|
||||
private readonly Mock<ICfgProfileRepository> _mockProfileRepo;
|
||||
private readonly GetProfileByIdQueryHandler _handler;
|
||||
|
||||
public GetProfileByIdQueryTests()
|
||||
{
|
||||
_mockProfileRepo = new Mock<ICfgProfileRepository>();
|
||||
_handler = new GetProfileByIdQueryHandler(_mockProfileRepo.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidProfileId_ReturnsProfile()
|
||||
{
|
||||
// Arrange
|
||||
var profileId = 123L;
|
||||
var profile = new CfgProfile
|
||||
{
|
||||
Id = profileId,
|
||||
Active = true,
|
||||
ProfileName = "Test Profile",
|
||||
TypeId = 2,
|
||||
Schedule = "0 30 4 ? * MON-SAT",
|
||||
Comment = "Test comment",
|
||||
AddedWho = "TestUser",
|
||||
AddedWhen = DateTime.Now,
|
||||
SqlJobs = new List<ProfileSqlJob>
|
||||
{
|
||||
new ProfileSqlJob
|
||||
{
|
||||
Id = 1,
|
||||
ProfileId = profileId,
|
||||
Active = true,
|
||||
Sequence = 1,
|
||||
Name = "Job 1",
|
||||
SqlMainQuery = "SELECT 1",
|
||||
SqlCheckQuery = "SELECT COUNT(*) FROM Table1"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_mockProfileRepo
|
||||
.Setup(r => r.GetByIdWithSqlJobsAsync(profileId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(profile);
|
||||
|
||||
var query = new GetProfileByIdQuery { ProfileId = profileId, IncludeSqlJobs = true };
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(profileId);
|
||||
result.ProfileName.Should().Be("Test Profile");
|
||||
result.Active.Should().BeTrue();
|
||||
result.TypeId.Should().Be(2);
|
||||
result.SqlJobs.Should().NotBeNull();
|
||||
result.SqlJobs!.Count.Should().Be(1);
|
||||
result.SqlJobs[0].Name.Should().Be("Job 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentProfileId_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var profileId = 999L;
|
||||
_mockProfileRepo
|
||||
.Setup(r => r.GetByIdWithSqlJobsAsync(profileId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CfgProfile?)null);
|
||||
|
||||
var query = new GetProfileByIdQuery { ProfileId = profileId };
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithIncludeSqlJobsFalse_DoesNotLoadSqlJobs()
|
||||
{
|
||||
// Arrange
|
||||
var profileId = 123L;
|
||||
var profile = new CfgProfile
|
||||
{
|
||||
Id = profileId,
|
||||
Active = true,
|
||||
ProfileName = "Test Profile",
|
||||
TypeId = 2,
|
||||
Schedule = "0 30 4 ? * MON-SAT",
|
||||
AddedWho = "TestUser",
|
||||
AddedWhen = DateTime.Now
|
||||
};
|
||||
|
||||
_mockProfileRepo
|
||||
.Setup(r => r.GetByIdAsync(profileId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(profile);
|
||||
|
||||
var query = new GetProfileByIdQuery { ProfileId = profileId, IncludeSqlJobs = false };
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.SqlJobs.Should().BeNull();
|
||||
_mockProfileRepo.Verify(
|
||||
r => r.GetByIdAsync(profileId, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_mockProfileRepo.Verify(
|
||||
r => r.GetByIdWithSqlJobsAsync(It.IsAny<long>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MapsAllProperties_Correctly()
|
||||
{
|
||||
// Arrange
|
||||
var profileId = 123L;
|
||||
var addedWhen = new DateTime(2026, 1, 1, 10, 0, 0);
|
||||
var changedWhen = new DateTime(2026, 1, 2, 14, 30, 0);
|
||||
|
||||
var profile = new CfgProfile
|
||||
{
|
||||
Id = profileId,
|
||||
Active = false,
|
||||
ProfileName = "Inactive Profile",
|
||||
TypeId = 3,
|
||||
Schedule = "0 0 12 * * ?",
|
||||
Comment = "Detailed comment",
|
||||
AddedWho = "Admin",
|
||||
AddedWhen = addedWhen,
|
||||
ChangedWho = "Editor",
|
||||
ChangedWhen = changedWhen
|
||||
};
|
||||
|
||||
_mockProfileRepo
|
||||
.Setup(r => r.GetByIdAsync(profileId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(profile);
|
||||
|
||||
var query = new GetProfileByIdQuery { ProfileId = profileId, IncludeSqlJobs = false };
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(profileId);
|
||||
result.Active.Should().BeFalse();
|
||||
result.ProfileName.Should().Be("Inactive Profile");
|
||||
result.TypeId.Should().Be(3);
|
||||
result.Schedule.Should().Be("0 0 12 * * ?");
|
||||
result.Comment.Should().Be("Detailed comment");
|
||||
result.AddedWho.Should().Be("Admin");
|
||||
result.AddedWhen.Should().Be(addedWhen);
|
||||
result.ChangedWho.Should().Be("Editor");
|
||||
result.ChangedWhen.Should().Be(changedWhen);
|
||||
}
|
||||
}
|
||||
|
||||
public class GetAllActiveProfilesQueryTests
|
||||
{
|
||||
private readonly Mock<ICfgProfileRepository> _mockProfileRepo;
|
||||
private readonly GetAllActiveProfilesQueryHandler _handler;
|
||||
|
||||
public GetAllActiveProfilesQueryTests()
|
||||
{
|
||||
_mockProfileRepo = new Mock<ICfgProfileRepository>();
|
||||
_handler = new GetAllActiveProfilesQueryHandler(_mockProfileRepo.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsAllActiveProfiles()
|
||||
{
|
||||
// Arrange
|
||||
var profiles = new List<CfgProfile>
|
||||
{
|
||||
new CfgProfile
|
||||
{
|
||||
Id = 1,
|
||||
Active = true,
|
||||
ProfileName = "Profile 1",
|
||||
TypeId = 2,
|
||||
Schedule = "0 30 4 ? * MON-SAT",
|
||||
AddedWho = "User1",
|
||||
AddedWhen = DateTime.Now,
|
||||
SqlJobs = new List<ProfileSqlJob>
|
||||
{
|
||||
new ProfileSqlJob { Id = 1, ProfileId = 1, Sequence = 1, Name = "Job 1" }
|
||||
}
|
||||
},
|
||||
new CfgProfile
|
||||
{
|
||||
Id = 2,
|
||||
Active = true,
|
||||
ProfileName = "Profile 2",
|
||||
TypeId = 3,
|
||||
Schedule = "0 0 12 * * ?",
|
||||
AddedWho = "User2",
|
||||
AddedWhen = DateTime.Now,
|
||||
SqlJobs = new List<ProfileSqlJob>
|
||||
{
|
||||
new ProfileSqlJob { Id = 2, ProfileId = 2, Sequence = 1, Name = "Job 2" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_mockProfileRepo
|
||||
.Setup(r => r.GetAllActiveWithSqlJobsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(profiles);
|
||||
|
||||
var query = new GetAllActiveProfilesQuery { IncludeSqlJobs = true };
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Count.Should().Be(2);
|
||||
result[0].ProfileName.Should().Be("Profile 1");
|
||||
result[1].ProfileName.Should().Be("Profile 2");
|
||||
result[0].SqlJobs.Should().NotBeNull();
|
||||
result[0].SqlJobs!.Count.Should().Be(1);
|
||||
result[1].SqlJobs.Should().NotBeNull();
|
||||
result[1].SqlJobs!.Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNoActiveProfiles_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
_mockProfileRepo
|
||||
.Setup(r => r.GetAllActiveWithSqlJobsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<CfgProfile>());
|
||||
|
||||
var query = new GetAllActiveProfilesQuery();
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithIncludeSqlJobsFalse_DoesNotLoadSqlJobs()
|
||||
{
|
||||
// Arrange
|
||||
var profiles = new List<CfgProfile>
|
||||
{
|
||||
new CfgProfile
|
||||
{
|
||||
Id = 1,
|
||||
Active = true,
|
||||
ProfileName = "Profile 1",
|
||||
TypeId = 2,
|
||||
Schedule = "0 30 4 ? * MON-SAT",
|
||||
AddedWho = "User1",
|
||||
AddedWhen = DateTime.Now
|
||||
}
|
||||
};
|
||||
|
||||
_mockProfileRepo
|
||||
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<CfgProfile, bool>>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(profiles);
|
||||
|
||||
var query = new GetAllActiveProfilesQuery { IncludeSqlJobs = false };
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Count.Should().Be(1);
|
||||
result[0].SqlJobs.Should().BeNull();
|
||||
_mockProfileRepo.Verify(
|
||||
r => r.FindAsync(It.IsAny<Expression<Func<CfgProfile, bool>>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_mockProfileRepo.Verify(
|
||||
r => r.GetAllActiveWithSqlJobsAsync(It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.Application.Common.Interfaces;
|
||||
using ECMJobRunner.Application.Common.Options;
|
||||
using ECMJobRunner.Application.DEXJob.Commands;
|
||||
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<ISQLExecutor> _mockExecutor;
|
||||
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
|
||||
private readonly MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit> _behavior;
|
||||
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
|
||||
|
||||
public MainQueryExecutionBehaviorTests()
|
||||
{
|
||||
_mockExecutor = new Mock<ISQLExecutor>();
|
||||
_mockOptions = new Mock<IOptions<DexJobOptions>>();
|
||||
_mockOptions.Setup(o => o.Value).Returns(new DexJobOptions());
|
||||
_behavior = new MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
||||
_mockNext = new Mock<RequestHandlerDelegate<Unit>>();
|
||||
_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<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.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<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MainQueryResult { ReturnValue = null });
|
||||
|
||||
// Act
|
||||
#if NET48
|
||||
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
|
||||
#else
|
||||
Func<Task> 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<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MainQueryResult { ReturnValue = 1 });
|
||||
|
||||
// Act
|
||||
#if NET48
|
||||
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
|
||||
#else
|
||||
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
|
||||
#endif
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<JobException>()
|
||||
.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<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
||||
var command = new TriggeringDEXJobCommand
|
||||
{
|
||||
Job = new ProfileSqlJob { SqlMainQuery = null },
|
||||
BatchId = "20260711143025123456"
|
||||
};
|
||||
|
||||
// Act
|
||||
#if NET48
|
||||
Func<Task> act = async () => await behavior.Handle(command, CancellationToken.None, _mockNext.Object);
|
||||
#else
|
||||
Func<Task> act = async () => await behavior.Handle(command, _mockNext.Object, CancellationToken.None);
|
||||
#endif
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<JobException>()
|
||||
.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<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, CancellationToken>((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<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("SQL error"));
|
||||
|
||||
// Act
|
||||
#if NET48
|
||||
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
|
||||
#else
|
||||
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
|
||||
#endif
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<JobException>()
|
||||
.WithMessage("*SQL Main Query*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonDEXJobCommand_SkipsExecution()
|
||||
{
|
||||
// Arrange
|
||||
var otherCommand = new OtherCommand();
|
||||
var behavior = new MainQueryExecutionBehavior<OtherCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
||||
var mockNext = new Mock<RequestHandlerDelegate<Unit>>();
|
||||
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<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
mockNext.Verify(n => n(), Times.Once);
|
||||
}
|
||||
|
||||
private class OtherCommand : IRequest<Unit> { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using ECMJobRunner.Application.Common.Dtos;
|
||||
using ECMJobRunner.Application.Common.Interfaces;
|
||||
using ECMJobRunner.Application.Common.Options;
|
||||
using ECMJobRunner.Application.DEXJob.Commands;
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
using ECMJobRunner.Domain.Interfaces;
|
||||
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<IProfileSqlJobRepository> _mockJobRepo;
|
||||
private readonly Mock<ISender> _mockSender;
|
||||
private readonly TriggeringDEXJobBatchCommandHandler _handler;
|
||||
|
||||
public TriggeringDEXJobBatchCommandTests()
|
||||
{
|
||||
_mockJobRepo = new Mock<IProfileSqlJobRepository>();
|
||||
_mockSender = new Mock<ISender>();
|
||||
_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<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
|
||||
.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<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()),
|
||||
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<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<ProfileSqlJob>());
|
||||
|
||||
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<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()),
|
||||
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<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { job });
|
||||
|
||||
TriggeringDEXJobCommand? capturedCommand = null;
|
||||
_mockSender
|
||||
.Setup(s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IRequest<Unit>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
ECMJobRunner.Tests/DTOs/CfgProfileDto.cs
Normal file
20
ECMJobRunner.Tests/DTOs/CfgProfileDto.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
|
||||
namespace ECMJobRunner.Tests.DTOs
|
||||
{
|
||||
/// <summary>
|
||||
/// DTO for CfgProfile entity used in tests
|
||||
/// </summary>
|
||||
public class CfgProfileDto
|
||||
{
|
||||
public bool Active { get; set; }
|
||||
public string ProfileName { get; set; } = string.Empty;
|
||||
public byte TypeId { get; set; }
|
||||
public string? Schedule { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
public string AddedWho { get; set; } = string.Empty;
|
||||
public DateTime AddedWhen { get; set; }
|
||||
public string ChangedWho { get; set; } = string.Empty;
|
||||
public DateTime ChangedWhen { get; set; }
|
||||
}
|
||||
}
|
||||
85
ECMJobRunner.Tests/ECMJobRunner.Tests.csproj
Normal file
85
ECMJobRunner.Tests/ECMJobRunner.Tests.csproj
Normal file
@@ -0,0 +1,85 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net480;net8.0</TargetFrameworks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
|
||||
<!-- Suppress AutoMapper vulnerability warning for .NET Framework 4.8 -->
|
||||
<NoWarn>$(NoWarn);NU1903</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Test Framework - Common for both frameworks -->
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
<!-- Fluent Assertions for Better Test Readability -->
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||
|
||||
<!-- Mocking Framework -->
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
|
||||
<!-- Fake Data Generation (common for both frameworks) -->
|
||||
<PackageReference Include="Bogus" Version="35.6.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- .NET Framework 4.8 specific packages -->
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
|
||||
<!-- Dependency Injection & Hosting for .NET Framework 4.8 -->
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
|
||||
<!-- AutoMapper for .NET Framework 4.8 (matching Infrastructure) -->
|
||||
<PackageReference Include="AutoMapper" Version="10.1.1" />
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
|
||||
|
||||
<!-- MediatR for .NET Framework 4.8 -->
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
|
||||
|
||||
<!-- ReC.Client for HTTP mocking -->
|
||||
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- .NET 8.0 specific packages -->
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<!-- Dependency Injection & Hosting for .NET 8 -->
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
|
||||
<!-- AutoMapper for .NET 8 (matching Infrastructure) -->
|
||||
<PackageReference Include="AutoMapper" Version="12.0.1" />
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||
|
||||
<!-- MediatR for .NET 8 -->
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
|
||||
<!-- ReC.Client for HTTP mocking -->
|
||||
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Project References -->
|
||||
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
|
||||
<ProjectReference Include="..\ECMJobRunner.Infrastructure\ECMJobRunner.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\ECMJobRunner.Application\ECMJobRunner.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
79
ECMJobRunner.Tests/Infrastructure/FakeDataGenerator.cs
Normal file
79
ECMJobRunner.Tests/Infrastructure/FakeDataGenerator.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using Bogus;
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
|
||||
namespace ECMJobRunner.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Fake data generator using Bogus library
|
||||
/// </summary>
|
||||
public static class FakeDataGenerator
|
||||
{
|
||||
private static int _profileIdCounter = 1;
|
||||
private static int _sqlJobIdCounter = 1;
|
||||
private static int _historyIdCounter = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Reset ID counters (call at the beginning of each test)
|
||||
/// </summary>
|
||||
public static void ResetIdCounters()
|
||||
{
|
||||
_profileIdCounter = 1;
|
||||
_sqlJobIdCounter = 1;
|
||||
_historyIdCounter = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate fake CfgProfile
|
||||
/// </summary>
|
||||
public static Faker<CfgProfile> CfgProfileFaker()
|
||||
{
|
||||
return new Faker<CfgProfile>()
|
||||
.RuleFor(p => p.Id, f => _profileIdCounter++)
|
||||
.RuleFor(p => p.Active, f => f.Random.Bool())
|
||||
.RuleFor(p => p.ProfileName, f => f.Commerce.ProductName())
|
||||
.RuleFor(p => p.TypeId, f => f.Random.Byte(0, 3))
|
||||
.RuleFor(p => p.Schedule, f => $"{f.Random.Int(0, 59)} {f.Random.Int(0, 23)} * * *")
|
||||
.RuleFor(p => p.Comment, f => f.Lorem.Sentence())
|
||||
.RuleFor(p => p.AddedWho, f => f.Internet.UserName())
|
||||
.RuleFor(p => p.AddedWhen, f => f.Date.Past())
|
||||
.RuleFor(p => p.ChangedWho, f => f.Internet.UserName())
|
||||
.RuleFor(p => p.ChangedWhen, f => f.Date.Recent());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate fake ProfileSqlJob
|
||||
/// </summary>
|
||||
public static Faker<ProfileSqlJob> ProfileSqlJobFaker(long? profileId = null)
|
||||
{
|
||||
return new Faker<ProfileSqlJob>()
|
||||
.RuleFor(j => j.Id, f => _sqlJobIdCounter++)
|
||||
.RuleFor(j => j.ProfileId, f => profileId ?? f.Random.Long(1, 100))
|
||||
.RuleFor(j => j.Active, f => f.Random.Bool())
|
||||
.RuleFor(j => j.Sequence, f => f.Random.Short(1, 100))
|
||||
.RuleFor(j => j.Name, f => f.Hacker.Verb())
|
||||
.RuleFor(j => j.SqlCheckQuery, f => $"SELECT COUNT(*) FROM {f.Database.Type()}")
|
||||
.RuleFor(j => j.SqlMainQuery, f => $"SELECT * FROM {f.Database.Type()}")
|
||||
.RuleFor(j => j.ApiCommand, f => f.Internet.Url())
|
||||
.RuleFor(j => j.Comment, f => f.Lorem.Sentence())
|
||||
.RuleFor(j => j.AddedWho, f => f.Internet.UserName())
|
||||
.RuleFor(j => j.AddedWhen, f => f.Date.Past())
|
||||
.RuleFor(j => j.ChangedWho, f => f.Internet.UserName())
|
||||
.RuleFor(j => j.ChangedWhen, f => f.Date.Recent());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate fake ProfileHistory
|
||||
/// </summary>
|
||||
public static Faker<ProfileHistory> ProfileHistoryFaker(long? profileId = null)
|
||||
{
|
||||
return new Faker<ProfileHistory>()
|
||||
.RuleFor(h => h.Id, f => _historyIdCounter++)
|
||||
.RuleFor(h => h.ProfileId, f => profileId ?? f.Random.Long(1, 100))
|
||||
.RuleFor(h => h.ResultId, f => f.Random.Byte(0, 2))
|
||||
.RuleFor(h => h.ResultText, f => f.Lorem.Paragraph())
|
||||
.RuleFor(h => h.AddedWho, f => f.Internet.UserName())
|
||||
.RuleFor(h => h.AddedWhen, f => f.Date.Past())
|
||||
.RuleFor(h => h.ChangedWho, f => f.Internet.UserName())
|
||||
.RuleFor(h => h.ChangedWhen, f => f.Date.Recent());
|
||||
}
|
||||
}
|
||||
81
ECMJobRunner.Tests/Infrastructure/TestFixture.cs
Normal file
81
ECMJobRunner.Tests/Infrastructure/TestFixture.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using AutoMapper;
|
||||
using ECMJobRunner.Domain.Interfaces;
|
||||
using ECMJobRunner.Infrastructure;
|
||||
using ECMJobRunner.Infrastructure.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace ECMJobRunner.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Base test fixture with Dependency Injection and In-Memory Database
|
||||
/// Supports both .NET Framework 4.8 (EF6 with Effort) and .NET 8 (EF Core InMemory)
|
||||
/// </summary>
|
||||
public class TestFixture : IDisposable
|
||||
{
|
||||
private readonly IHost _host;
|
||||
private IServiceScope? _scope;
|
||||
|
||||
public IServiceProvider Services => _scope?.ServiceProvider ?? _host.Services;
|
||||
public JobRunnerDbContext DbContext => Services.GetRequiredService<JobRunnerDbContext>();
|
||||
public IMapper Mapper => Services.GetRequiredService<IMapper>();
|
||||
public ICfgProfileRepository ProfileRepository => Services.GetRequiredService<ICfgProfileRepository>();
|
||||
public IProfileSqlJobRepository SqlJobRepository => Services.GetRequiredService<IProfileSqlJobRepository>();
|
||||
public IProfileHistoryRepository HistoryRepository => Services.GetRequiredService<IProfileHistoryRepository>();
|
||||
|
||||
public TestFixture()
|
||||
{
|
||||
_host = Host.CreateDefaultBuilder()
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
// Use AddInfrastructureInMemory extension for both frameworks
|
||||
services.AddInfrastructureInMemory();
|
||||
|
||||
// Register test-specific AutoMapper profiles
|
||||
services.AddAutoMapper(typeof(TestFixture).Assembly);
|
||||
})
|
||||
.Build();
|
||||
|
||||
// Create a scope for scoped services
|
||||
_scope = _host.Services.CreateScope();
|
||||
|
||||
#if !NET48
|
||||
// Ensure database is created (EF Core only)
|
||||
DbContext.Database.EnsureCreated();
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new service scope (useful for testing scoped lifetime)
|
||||
/// </summary>
|
||||
public IServiceScope CreateScope()
|
||||
{
|
||||
return _host.Services.CreateScope();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset the current scope (creates a new DbContext)
|
||||
/// </summary>
|
||||
public void ResetScope()
|
||||
{
|
||||
_scope?.Dispose();
|
||||
_scope = _host.Services.CreateScope();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all data from database
|
||||
/// </summary>
|
||||
public async Task ClearDatabaseAsync()
|
||||
{
|
||||
DbContext.ProfileHistories.RemoveRange(DbContext.ProfileHistories);
|
||||
DbContext.ProfileSqlJobs.RemoveRange(DbContext.ProfileSqlJobs);
|
||||
DbContext.CfgProfiles.RemoveRange(DbContext.CfgProfiles);
|
||||
await DbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_scope?.Dispose();
|
||||
_host?.Dispose();
|
||||
}
|
||||
}
|
||||
24
ECMJobRunner.Tests/Mapping/TestMappingProfile.cs
Normal file
24
ECMJobRunner.Tests/Mapping/TestMappingProfile.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using AutoMapper;
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
using ECMJobRunner.Tests.DTOs;
|
||||
|
||||
namespace ECMJobRunner.Tests.Mapping
|
||||
{
|
||||
/// <summary>
|
||||
/// AutoMapper profile for test DTOs
|
||||
/// </summary>
|
||||
public class TestMappingProfile : Profile
|
||||
{
|
||||
/// <summary>
|
||||
/// Constructor configuring test DTO mappings
|
||||
/// </summary>
|
||||
public TestMappingProfile()
|
||||
{
|
||||
// CfgProfileDto -> CfgProfile
|
||||
CreateMap<CfgProfileDto, CfgProfile>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.SqlJobs, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.ProfileHistories, opt => opt.Ignore());
|
||||
}
|
||||
}
|
||||
}
|
||||
153
ECMJobRunner.Tests/Repositories/CfgProfileRepositoryTests.cs
Normal file
153
ECMJobRunner.Tests/Repositories/CfgProfileRepositoryTests.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using ECMJobRunner.Domain.Entities;
|
||||
using ECMJobRunner.Tests.DTOs;
|
||||
using ECMJobRunner.Tests.Infrastructure;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace ECMJobRunner.Tests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CfgProfileRepository
|
||||
/// </summary>
|
||||
public class CfgProfileRepositoryTests : IClassFixture<TestFixture>
|
||||
{
|
||||
private readonly TestFixture _fixture;
|
||||
|
||||
public CfgProfileRepositoryTests(TestFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
FakeDataGenerator.ResetIdCounters();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ExistingProfile_ReturnsProfile()
|
||||
{
|
||||
// Arrange
|
||||
await _fixture.ClearDatabaseAsync();
|
||||
var dto = new CfgProfileDto
|
||||
{
|
||||
Active = true,
|
||||
ProfileName = "Test Profile for GetById",
|
||||
TypeId = (byte)1,
|
||||
Schedule = "0 0 * * *",
|
||||
Comment = "Test",
|
||||
AddedWho = "TestUser",
|
||||
AddedWhen = DateTime.Now,
|
||||
ChangedWho = "TestUser",
|
||||
ChangedWhen = DateTime.Now
|
||||
};
|
||||
var addedProfile = await _fixture.ProfileRepository.AddAsync(dto);
|
||||
|
||||
// Act
|
||||
var result = await _fixture.ProfileRepository.GetByIdAsync(addedProfile.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(addedProfile.Id);
|
||||
result.ProfileName.Should().Be("Test Profile for GetById");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_ReturnsAllProfiles()
|
||||
{
|
||||
// Arrange
|
||||
await _fixture.ClearDatabaseAsync();
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var dto = new CfgProfileDto
|
||||
{
|
||||
Active = true,
|
||||
ProfileName = $"Profile {i}",
|
||||
TypeId = (byte)1,
|
||||
Schedule = "0 0 * * *",
|
||||
AddedWho = "TestUser",
|
||||
AddedWhen = DateTime.Now,
|
||||
ChangedWho = "TestUser",
|
||||
ChangedWhen = DateTime.Now
|
||||
};
|
||||
await _fixture.ProfileRepository.AddAsync(dto);
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _fixture.ProfileRepository.GetAllAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAsync_NewProfile_AddsToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
await _fixture.ClearDatabaseAsync();
|
||||
var dto = new CfgProfileDto
|
||||
{
|
||||
Active = true,
|
||||
ProfileName = "Test Profile",
|
||||
TypeId = (byte)1,
|
||||
Schedule = "0 0 * * *",
|
||||
Comment = "Test comment",
|
||||
AddedWho = "TestUser",
|
||||
AddedWhen = DateTime.Now,
|
||||
ChangedWho = "TestUser",
|
||||
ChangedWhen = DateTime.Now
|
||||
};
|
||||
|
||||
// Act
|
||||
var addedProfile = await _fixture.ProfileRepository.AddAsync(dto);
|
||||
|
||||
// Assert
|
||||
var result = await _fixture.ProfileRepository.GetByIdAsync(addedProfile.Id);
|
||||
result.Should().NotBeNull();
|
||||
result!.ProfileName.Should().Be("Test Profile");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindAsync_WithPredicate_ReturnsMatchingProfiles()
|
||||
{
|
||||
// Arrange
|
||||
await _fixture.ClearDatabaseAsync();
|
||||
|
||||
// Add active profiles
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var dto = new CfgProfileDto
|
||||
{
|
||||
Active = true,
|
||||
ProfileName = $"Active Profile {i}",
|
||||
TypeId = (byte)1,
|
||||
Schedule = "0 0 * * *",
|
||||
AddedWho = "TestUser",
|
||||
AddedWhen = DateTime.Now,
|
||||
ChangedWho = "TestUser",
|
||||
ChangedWhen = DateTime.Now
|
||||
};
|
||||
await _fixture.ProfileRepository.AddAsync(dto);
|
||||
}
|
||||
|
||||
// Add inactive profiles
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
var dto = new CfgProfileDto
|
||||
{
|
||||
Active = false,
|
||||
ProfileName = $"Inactive Profile {i}",
|
||||
TypeId = (byte)1,
|
||||
Schedule = "0 0 * * *",
|
||||
AddedWho = "TestUser",
|
||||
AddedWhen = DateTime.Now,
|
||||
ChangedWho = "TestUser",
|
||||
ChangedWhen = DateTime.Now
|
||||
};
|
||||
await _fixture.ProfileRepository.AddAsync(dto);
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _fixture.ProfileRepository.FindAsync(p => p.Active);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
result.Should().OnlyContain(p => p.Active);
|
||||
}
|
||||
}
|
||||
|
||||
17
ECMJobRunner.WebCron/AllowAllDashboardAuthorizationFilter.cs
Normal file
17
ECMJobRunner.WebCron/AllowAllDashboardAuthorizationFilter.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Hangfire.Dashboard;
|
||||
|
||||
namespace ECMJobRunner.WebCron
|
||||
{
|
||||
/// <summary>
|
||||
/// Authorization filter that allows all users to access Hangfire Dashboard
|
||||
/// WARNING: This is for development only! Use proper authentication in production.
|
||||
/// </summary>
|
||||
public class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter
|
||||
{
|
||||
public bool Authorize(DashboardContext context)
|
||||
{
|
||||
// Allow all users - FOR DEVELOPMENT ONLY
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
ECMJobRunner.WebCron/ECMJobRunner.WebCron.csproj
Normal file
32
ECMJobRunner.WebCron/ECMJobRunner.WebCron.csproj
Normal file
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.23" />
|
||||
<PackageReference Include="Hangfire.Core" Version="1.8.23" />
|
||||
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
|
||||
<PackageReference Include="Hangfire.SqlServer" Version="1.8.23" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.9" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.SQLite" Version="7.0.0" />
|
||||
<PackageReference Include="Serilog.UI" Version="3.2.0" />
|
||||
<PackageReference Include="Serilog.UI.SqliteProvider" Version="1.1.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ECMJobRunner.Application\ECMJobRunner.Application.csproj" />
|
||||
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
|
||||
<ProjectReference Include="..\ECMJobRunner.Infrastructure\ECMJobRunner.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
21
ECMJobRunner.WebCron/Extensions/DtoExtensions.cs
Normal file
21
ECMJobRunner.WebCron/Extensions/DtoExtensions.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using ECMJobRunner.Application.Common.Dtos;
|
||||
using ECMJobRunner.Application.DEXJob.Commands;
|
||||
using MediatR;
|
||||
|
||||
namespace ECMJobRunner.WebCron.Extensions;
|
||||
|
||||
public static class DtoExtensions
|
||||
{
|
||||
public static string JobId(this CfgProfileDto profile)
|
||||
{
|
||||
return $"profile-{profile.Id}-{profile.ProfileName.Replace(' ', '_').ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public static TriggeringDEXJobBatchCommand ToJob(this CfgProfileDto profile)
|
||||
{
|
||||
return new TriggeringDEXJobBatchCommand
|
||||
{
|
||||
ProfileId = profile.Id,
|
||||
};
|
||||
}
|
||||
}
|
||||
71
ECMJobRunner.WebCron/ProfileManager.cs
Normal file
71
ECMJobRunner.WebCron/ProfileManager.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using ECMJobRunner.Application.Common.Dtos;
|
||||
using ECMJobRunner.Application.DEXJob.Commands;
|
||||
using ECMJobRunner.Application.DEXJob.Queries;
|
||||
using ECMJobRunner.WebCron.Extensions;
|
||||
using Hangfire;
|
||||
using MediatR;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ECMJobRunner.WebCron
|
||||
{
|
||||
public class ProfileManager(ILogger<ProfileManager> Logger, IServiceScopeFactory ScopeFactory, IRecurringJobManager JobManager) : BackgroundService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CfgProfileDto> Profiles = new();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (Logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
Logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
|
||||
}
|
||||
|
||||
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
var profiles = await mediator.Send(new GetProfileQuery()
|
||||
{
|
||||
Active = true,
|
||||
IncludeSqlJobs = true
|
||||
}, stoppingToken);
|
||||
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
if (Profiles.TryGetValue(profile.JobId(), out var currentProfile)
|
||||
&& currentProfile.Schedule == profile.Schedule)
|
||||
continue;
|
||||
|
||||
// Add or update recurring job using MediatR command
|
||||
JobManager.AddOrUpdate<IMediator>(
|
||||
profile.JobId(),
|
||||
mediator => mediator.Send(profile.ToJob(), CancellationToken.None),
|
||||
profile.Schedule,
|
||||
new RecurringJobOptions
|
||||
{
|
||||
TimeZone = TimeZoneInfo.Local
|
||||
}
|
||||
);
|
||||
|
||||
// Store/update in local cache
|
||||
Profiles[profile.JobId()] = profile;
|
||||
|
||||
Logger.LogInformation("Job {JobId} registered with schedule: {Schedule}",
|
||||
profile.JobId(), profile.Schedule);
|
||||
}
|
||||
|
||||
await Task.Delay(1000, stoppingToken);
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "An error occurred in ProfileManager.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
150
ECMJobRunner.WebCron/Program.cs
Normal file
150
ECMJobRunner.WebCron/Program.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using ECMJobRunner.Application;
|
||||
using ECMJobRunner.Infrastructure;
|
||||
using ECMJobRunner.WebCron;
|
||||
using Hangfire;
|
||||
using Hangfire.SqlServer;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Serilog;
|
||||
using Serilog.Ui.Core.Extensions;
|
||||
using Serilog.Ui.SqliteDataProvider.Extensions;
|
||||
using Serilog.Ui.Web.Extensions;
|
||||
|
||||
// Enable Serilog self-diagnostics
|
||||
Serilog.Debugging.SelfLog.Enable(msg => Console.WriteLine($"[SERILOG] {msg}"));
|
||||
|
||||
// Build temporary configuration to read log directory
|
||||
var tempConfig = new ConfigurationBuilder()
|
||||
.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
|
||||
.Build();
|
||||
|
||||
// Get log directory from configuration
|
||||
var logDirectory = tempConfig.GetValue<string>("Logging:LogDirectory")
|
||||
?? throw new InvalidOperationException("Logging:LogDirectory not found in configuration.");
|
||||
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
|
||||
|
||||
Console.WriteLine($"[INFO] SQLite Log Database Path: {sqliteDbPath}");
|
||||
|
||||
// Configure Serilog with SQLite sink
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Hangfire", Serilog.Events.LogEventLevel.Information)
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.WriteTo.SQLite(sqliteDbPath, storeTimestampInUtc: true)
|
||||
.Enrich.FromLogContext()
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting ECMJobRunner.WebCron application");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Use Serilog for logging
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
// Configure Windows Service hosting if enabled
|
||||
if (builder.Configuration.GetValue<bool>("HostingOptions:UseWindowsService"))
|
||||
{
|
||||
builder.Host.UseWindowsService();
|
||||
}
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddHostedService<ProfileManager>();
|
||||
|
||||
// Register services
|
||||
var cnnStr = builder.Configuration.GetConnectionString("SDD-VMP04-SQL17")
|
||||
?? throw new InvalidOperationException("Connection string 'SDD-VMP04-SQL17' not found.");
|
||||
builder.Services.AddJobRunnerInfrastructure(cnnStr);
|
||||
|
||||
var recClientApiUrl = builder.Configuration.GetValue<string>("ReC:ApiUrl")
|
||||
?? throw new InvalidOperationException("ReC:ApiUrl not found.");
|
||||
builder.Services.AddJobRunnerServices(recClientApiUrl);
|
||||
|
||||
// Get Hangfire storage configuration
|
||||
var useInMemory = builder.Configuration.GetValue<bool>("Hangfire:InMemory");
|
||||
|
||||
// Add Hangfire services with configurable storage
|
||||
builder.Services.AddHangfire(configuration =>
|
||||
{
|
||||
configuration
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings();
|
||||
|
||||
// Configure storage based on appsettings
|
||||
if (useInMemory)
|
||||
{
|
||||
configuration.UseInMemoryStorage();
|
||||
}
|
||||
else // Use SQL Server storage
|
||||
{
|
||||
configuration.UseSqlServerStorage(cnnStr, new SqlServerStorageOptions
|
||||
{
|
||||
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
|
||||
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
|
||||
QueuePollInterval = TimeSpan.Zero,
|
||||
UseRecommendedIsolationLevel = true,
|
||||
DisableGlobalLocks = true,
|
||||
SqlClientFactory = SqlClientFactory.Instance
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add Hangfire server
|
||||
builder.Services.AddHangfireServer();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// Add Serilog.UI with SQLite provider - use same path from configuration
|
||||
var serilogUiLogDirectory = builder.Configuration.GetValue<string>("Logging:LogDirectory")
|
||||
?? throw new InvalidOperationException("Logging:LogDirectory not found in configuration.");
|
||||
var serilogUiDbPath = Path.Combine(serilogUiLogDirectory, "logs.db");
|
||||
|
||||
builder.Services.AddSerilogUi(logUIOpt =>
|
||||
{
|
||||
logUIOpt.UseSqliteServer(dbOpt =>
|
||||
{
|
||||
dbOpt.WithConnectionString($"Data Source={serilogUiDbPath}");
|
||||
dbOpt.WithTable("Logs");
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
// Add Hangfire Dashboard with no authentication (for development)
|
||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||
{
|
||||
Authorization = new[] { new AllowAllDashboardAuthorizationFilter() }
|
||||
});
|
||||
|
||||
// Add Serilog.UI Dashboard
|
||||
app.UseSerilogUi();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
41
ECMJobRunner.WebCron/Properties/launchSettings.json
Normal file
41
ECMJobRunner.WebCron/Properties/launchSettings.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:17567",
|
||||
"sslPort": 44300
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "hangfire",
|
||||
"applicationUrl": "http://localhost:5271",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "hangfire",
|
||||
"applicationUrl": "https://localhost:7027;http://localhost:5271",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "hangfire",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
ECMJobRunner.WebCron/appsettings.Development.json
Normal file
10
ECMJobRunner.WebCron/appsettings.Development.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Hangfire": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
ECMJobRunner.WebCron/appsettings.json
Normal file
22
ECMJobRunner.WebCron/appsettings.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
},
|
||||
"LogDirectory": "E:\\LogFiles\\Digital Data\\ECMJobRunner.WebCron"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"SDD-VMP04-SQL17": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"HostingOptions": {
|
||||
"UseWindowsService": false
|
||||
},
|
||||
"Hangfire": {
|
||||
"InMemory": true
|
||||
},
|
||||
"ReC": {
|
||||
"ApiUrl": "http://172.24.12.39:90"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user