Compare commits
79 Commits
master
...
feat/dex-j
| Author | SHA1 | Date | |
|---|---|---|---|
| 612022862a | |||
| 94c2495515 | |||
| d7c8607fbf | |||
| 8d4c08fbec | |||
| bc9f234810 | |||
| 545648fe87 | |||
| dd9e6a710b | |||
| 5b67035a07 | |||
| 5b865c0442 | |||
| 111281ac08 | |||
| 2067ffdf2e | |||
| f75524f85d | |||
| 73db8fbd27 | |||
| 90916f6d03 | |||
| eb060aa54e | |||
| 45a7086c9a | |||
| 1110728741 | |||
| a698f8daae | |||
| afdeb8839c | |||
| dc2aaa245f | |||
| 2d7c54ceae | |||
| af3c8be133 | |||
| 8327c0fd0b | |||
| 98f8dc8710 | |||
| 5f251c1209 | |||
| 9537954626 | |||
| 275b06fedb | |||
| bc881bf70f | |||
| 84b95c53e4 | |||
| 511fb159c4 | |||
| 4af0ed7d14 | |||
| 0fd6a97968 | |||
| cec9cfe2ed | |||
| eb2514f1fc | |||
| 8a05d86285 | |||
| b48e3d1823 | |||
| 41b08e3454 | |||
| 2fd99694b6 | |||
| 66fad12e63 | |||
| f67c321380 | |||
| cba1aa85d0 | |||
| def4d7b7c7 | |||
| 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,19 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.4.33103.184
|
VisualStudioVersion = 17.4.33103.184
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
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
|
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
|
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
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{7704FD14-0546-4ABA-AA37-5EFA6BD7908D}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -15,22 +23,37 @@ Global
|
|||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Release|Any CPU.Build.0 = Release|Any CPU
|
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{7EACF04E-525B-40C8-9207-B83FB847B090}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{7EACF04E-525B-40C8-9207-B83FB847B090}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{7EACF04E-525B-40C8-9207-B83FB847B090}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{7EACF04E-525B-40C8-9207-B83FB847B090}.Release|Any CPU.Build.0 = Release|Any CPU
|
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{D97CD489-10D7-432C-9921-196F2E0505FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{D97CD489-10D7-432C-9921-196F2E0505FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{D97CD489-10D7-432C-9921-196F2E0505FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||||
|
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||||
|
{D97CD489-10D7-432C-9921-196F2E0505FF} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||||
|
{F64352B6-32BB-4BDE-90FD-FB77482D44E0} = {7704FD14-0546-4ABA-AA37-5EFA6BD7908D}
|
||||||
|
{C60BC965-D293-EA64-B153-1941F0648DF4} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||||
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {F4408662-65F4-4D6A-8E12-770DED292847}
|
SolutionGuid = {F4408662-65F4-4D6A-8E12-770DED292847}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
|
|||||||
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
|
||||||
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,36 @@
|
|||||||
|
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>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of InactiveProfileException
|
||||||
|
/// </remarks>
|
||||||
|
/// <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 class InactiveProfileException(long profileId, string? profileName, string batchId) : JobException(
|
||||||
|
profileId,
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the inactive profile (nullable)
|
||||||
|
/// </summary>
|
||||||
|
public string? ProfileName { get; } = profileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
ECMJobRunner.Application/Common/Exceptions/JobException.cs
Normal file
88
ECMJobRunner.Application/Common/Exceptions/JobException.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of JobException with detailed context information
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="profileId">Identifier of the profile associated with the job</param>
|
||||||
|
/// <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 class JobException(long profileId, string jobName, string processName, string batchId, string? reason, Exception? innerException, params (string Name, string? Value, bool IgnoreIfNull)[] details)
|
||||||
|
: Exception(
|
||||||
|
CreateMessage(jobName,
|
||||||
|
[
|
||||||
|
("Profile Id", profileId.ToString(), false),
|
||||||
|
("Job Name", jobName, false),
|
||||||
|
("Process Name", processName, false),
|
||||||
|
("Batch Id", batchId, false),
|
||||||
|
("Reason", reason, true),
|
||||||
|
..details
|
||||||
|
]),
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the profile identifier associated with the job execution
|
||||||
|
/// </summary>
|
||||||
|
public long ProfileId { get; } = profileId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the job that failed
|
||||||
|
/// </summary>
|
||||||
|
public string JobName { get; } = jobName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the process/stage that was being executed when the failure occurred
|
||||||
|
/// </summary>
|
||||||
|
public string ProcessName { get; } = processName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique batch identifier for tracking the execution
|
||||||
|
/// </summary>
|
||||||
|
public string BatchId { get; } = batchId;
|
||||||
|
|
||||||
|
/// <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 CreateMessage(string jobName, IEnumerable<(string Name, string? Value, bool IgnoreIfNull)> details)
|
||||||
|
{
|
||||||
|
var message = new System.Text.StringBuilder();
|
||||||
|
message.AppendLine($"{jobName} could not be completed.");
|
||||||
|
foreach (var (name, value, ignoreNullValue) in details)
|
||||||
|
{
|
||||||
|
if (ignoreNullValue && value is null)
|
||||||
|
continue;
|
||||||
|
message.AppendLine($" {name}: {value}");
|
||||||
|
}
|
||||||
|
return message.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of JobHttpException with HTTP client context
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="profileId">Identifier of the profile associated with the job</param>
|
||||||
|
/// <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 class JobHttpException(long profileId, string jobName, string processName, string batchId, string? reason, string? clientLibrary, string? clientMethod, Exception? innerException)
|
||||||
|
: JobException(profileId, jobName, processName, batchId, reason, innerException, ("Client Library", clientLibrary, true), ("Client Method", clientMethod, true))
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the HTTP client library that was used (e.g., "ReC.Client", "HttpClient")
|
||||||
|
/// </summary>
|
||||||
|
public string? ClientLibrary { get; } = clientLibrary;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the client method that failed (e.g., "ExecuteAsync", "PostAsync")
|
||||||
|
/// </summary>
|
||||||
|
public string? ClientMethod { get; } = clientMethod;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.Application.Common.Exceptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exception for SQL query execution failures
|
||||||
|
/// Extends JobException with SQL query context for debugging
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of JobSqlException with SQL query context
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="profileId">Identifier of the profile associated with the job</param>
|
||||||
|
/// <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 class JobSqlException(long profileId, string jobName, string processName, string batchId, string? reason, string? query, Exception? innerException)
|
||||||
|
: JobException(profileId, jobName, processName, batchId, reason, innerException, ("Query", query, true))
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <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; } = query;
|
||||||
|
}
|
||||||
|
}
|
||||||
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,26 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures AutoMapper mappings for <see cref="ECMJobRunner.Domain.Entities.CfgProfile"/> and <see cref="ECMJobRunner.Domain.Entities.ProfileSqlJob"/> entities
|
||||||
|
/// </summary>
|
||||||
|
public ProfileMappingProfile()
|
||||||
|
{
|
||||||
|
// CfgProfile -> CfgProfileDto
|
||||||
|
CreateMap<CfgProfile, CfgProfileDto>()
|
||||||
|
.ForMember(dest => dest.SqlJobs, opt => opt.MapFrom(src => src.SqlJobs));
|
||||||
|
|
||||||
|
// ProfileSqlJob -> ProfileSqlJobDto
|
||||||
|
CreateMap<ProfileSqlJob, ProfileSqlJobDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
ECMJobRunner.Application/Common/Options/DexJobOptions.cs
Normal file
109
ECMJobRunner.Application/Common/Options/DexJobOptions.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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>
|
||||||
|
/// The configuration section name used to bind this options class from application settings
|
||||||
|
/// </summary>
|
||||||
|
public const string SectionName = "DexJob";
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
}
|
||||||
55
ECMJobRunner.Application/DependencyInjection.cs
Normal file
55
ECMJobRunner.Application/DependencyInjection.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using ECMJobRunner.Application.Common.Options;
|
||||||
|
using ECMJobRunner.Application.Profiles.Commands.Behaviors;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
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>
|
||||||
|
/// <param name="recClientApiUrl">The base URL for the ReC client API</param>
|
||||||
|
/// <param name="configuration">The application configuration</param>
|
||||||
|
/// <returns>The service collection for chaining</returns>
|
||||||
|
public static IServiceCollection AddJobRunnerServices(this IServiceCollection services, string recClientApiUrl, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
|
||||||
|
// Register AutoMapper with all profiles from this assembly
|
||||||
|
services.AddAutoMapper(assembly);
|
||||||
|
services.AddRecClient(recClientApiUrl, opt =>
|
||||||
|
{
|
||||||
|
opt.LogSuccessfulRequests = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure DexJobOptions from appsettings.json
|
||||||
|
services.Configure<DexJobOptions>(configuration.GetSection(DexJobOptions.SectionName));
|
||||||
|
|
||||||
|
// Register pipeline behaviors in execution order
|
||||||
|
// Order matters: MainQuery -> CheckQuery -> ReCRequest
|
||||||
|
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(JobExceptionHandlingBehavior<,>));
|
||||||
|
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(MainQueryExecutionBehavior<,>));
|
||||||
|
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CheckQueryExecutionBehavior<,>));
|
||||||
|
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ReCRequestExecutionBehavior<,>));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
ECMJobRunner.Application/ECMJobRunner.Application.csproj
Normal file
46
ECMJobRunner.Application/ECMJobRunner.Application.csproj
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<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="1.0.0" />
|
||||||
|
</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" />
|
||||||
|
<!-- Options configuration binding for .NET Framework 4.8 -->
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.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>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using ECMJobRunner.Domain.Entities;
|
||||||
|
using ECMJobRunner.Domain.Interfaces;
|
||||||
|
using ECMJobRunner.Domain.ValueObjects;
|
||||||
|
using MediatR;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.Application.ProfileHistories.Commands
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Command to create a new profile execution history record
|
||||||
|
/// </summary>
|
||||||
|
public class CreateProfileHistoryCommand : IRequest<Unit>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key to the related profile
|
||||||
|
/// </summary>
|
||||||
|
public long ProfileId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execution result type
|
||||||
|
/// </summary>
|
||||||
|
public ResultType Result { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result text/message
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public required string ResultText { get; set; }
|
||||||
|
#else
|
||||||
|
|
||||||
|
public string ResultText { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Created by (max 50 chars)
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string AddedWho { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for <see cref="CreateProfileHistoryCommand"/>
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Constructor
|
||||||
|
/// </remarks>
|
||||||
|
public class CreateProfileHistoryCommandHandler(IProfileHistoryRepository Repository) : IRequestHandler<CreateProfileHistoryCommand, Unit>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Handles the command by persisting a new <see cref="ProfileHistory"/> record
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Unit> Handle(CreateProfileHistoryCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Repository.AddAsync(request, cancellationToken);
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
ECMJobRunner.Application/ProfileHistories/MappingProfiles.cs
Normal file
28
ECMJobRunner.Application/ProfileHistories/MappingProfiles.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using ECMJobRunner.Application.ProfileHistories.Commands;
|
||||||
|
using ECMJobRunner.Domain.Entities;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.Application.ProfileHistories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public class MappingProfiles : AutoMapper.Profile
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public MappingProfiles()
|
||||||
|
{
|
||||||
|
CreateMap<CreateProfileHistoryCommand, ProfileHistory>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.AddedWhen, opt => opt.MapFrom(_ => DateTime.Now))
|
||||||
|
.ForMember(dest => dest.ChangedWho, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.ChangedWhen, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.CfgProfile, opt => opt.Ignore());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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.Profiles.Commands;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.Application.Profiles.Commands.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 TriggeringProfileJobCommand command)
|
||||||
|
{
|
||||||
|
await ExecuteCheckQueryAsync(command, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteCheckQueryAsync(TriggeringProfileJobCommand 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(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
|
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(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
|
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(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
|
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(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
|
jobName:"Triggering DEX",
|
||||||
|
processName:"Check Query",
|
||||||
|
batchId:command.BatchId,
|
||||||
|
reason: "SQL Check Query is null or empty",
|
||||||
|
query: null,
|
||||||
|
innerException: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using ECMJobRunner.Application.Common.Exceptions;
|
||||||
|
using ECMJobRunner.Application.ProfileHistories.Commands;
|
||||||
|
using ECMJobRunner.Domain.ValueObjects;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.Application.Profiles.Commands.Behaviors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pipeline behavior that catches <see cref="ECMJobRunner.Application.Common.Exceptions.JobException"/> exceptions,
|
||||||
|
/// persists a profile history error record and re-throws the exception
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TRequest">The type of the MediatR request</typeparam>
|
||||||
|
/// <typeparam name="TResponse">The type of the MediatR response</typeparam>
|
||||||
|
/// <param name="Sender">MediatR sender used to dispatch the <see cref="ECMJobRunner.Application.ProfileHistories.Commands.CreateProfileHistoryCommand"/></param>
|
||||||
|
/// <param name="Logger">Logger for diagnostic output.</param>
|
||||||
|
public class JobExceptionHandlingBehavior<TRequest, TResponse>(ISender Sender, ILogger<JobExceptionHandlingBehavior<TRequest, TResponse>> Logger) : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
|
||||||
|
{
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
catch (JobException ex)
|
||||||
|
{
|
||||||
|
var cmd = new CreateProfileHistoryCommand()
|
||||||
|
{
|
||||||
|
ProfileId = ex.ProfileId,
|
||||||
|
Result = ResultType.Ok,
|
||||||
|
ResultText = ex.ToString(),
|
||||||
|
AddedWho = "ECMJobRunner"
|
||||||
|
};
|
||||||
|
await Sender.Send(cmd, cancellationToken);
|
||||||
|
|
||||||
|
Logger.LogWarning(ex, "JobException caught in JobExceptionHandlingBehavior for ProfileId {ProfileId}, JobName {JobName}, ProcessName {ProcessName}, BatchId {BatchId}", ex.ProfileId, ex.JobName, ex.ProcessName, ex.BatchId);
|
||||||
|
|
||||||
|
return default!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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.Profiles.Commands;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.Application.Profiles.Commands.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 TriggeringProfileJobCommand command)
|
||||||
|
{
|
||||||
|
await ExecuteMainQueryAsync(command, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteMainQueryAsync(TriggeringProfileJobCommand 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(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
|
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 && result.ReturnValue != 0)
|
||||||
|
throw new JobSqlException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
|
jobName: "Triggering DEX",
|
||||||
|
processName: "Main Query",
|
||||||
|
batchId: command.BatchId,
|
||||||
|
reason: $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was null or 0.",
|
||||||
|
query: sqlMainQuery, innerException: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (_options.Error.MainQuery.OnExecution == ErrorAction.Stop)
|
||||||
|
throw new JobSqlException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
|
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(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
|
jobName: "Triggering DEX",
|
||||||
|
processName: "Main Query",
|
||||||
|
batchId: command.BatchId,
|
||||||
|
reason: "SQL Check Query is null or empty",
|
||||||
|
query: null,
|
||||||
|
innerException: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using ECMJobRunner.Application.Common.Constants;
|
||||||
|
using ECMJobRunner.Application.Common.Exceptions;
|
||||||
|
using ECMJobRunner.Application.Common.Options;
|
||||||
|
using ECMJobRunner.Application.Profiles.Commands;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ReC.Client;
|
||||||
|
using ReC.Client.Api;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.Application.Profiles.Commands.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>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of ReCRequestExecutionBehavior
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="ReCClient">ReC client for HTTP requests</param>
|
||||||
|
/// <param name="options">DEX job configuration options</param>
|
||||||
|
public class ReCRequestExecutionBehavior<TRequest, TResponse>(ReCClient ReCClient, IOptions<DexJobOptions> options, ILogger<ReCRequestExecutionBehavior<TRequest, TResponse>> Logger) : IPipelineBehavior<TRequest, TResponse>
|
||||||
|
where TRequest : notnull
|
||||||
|
{
|
||||||
|
private readonly DexJobOptions 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 TriggeringProfileJobCommand command)
|
||||||
|
{
|
||||||
|
await SendReCRequestAsync(command, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendReCRequestAsync(TriggeringProfileJobCommand command, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
command.RecActionResult = await ReCClient.RecActions.InvokeAsync(command.Job.ProfileId, new InvokeReferences()
|
||||||
|
{
|
||||||
|
BatchId = command.BatchId,
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"ReC request completed successfully. Profile ID: {ProfileId} | Job name: {JobName} | Batch ID: {BatchId} | Total action count: {TotalActionCount} | Action exception count: {ActionExceptionCount}",
|
||||||
|
command.Job.ProfileId,
|
||||||
|
command.Job.Name,
|
||||||
|
command.BatchId,
|
||||||
|
command.RecActionResult?.TotalActionCount ?? 0,
|
||||||
|
command.RecActionResult?.ActionExceptionCount ?? 0);
|
||||||
|
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (Options.Error.ReCRequest.OnSending == ErrorAction.Stop)
|
||||||
|
{
|
||||||
|
throw new JobHttpException(
|
||||||
|
profileId: command.Job.ProfileId,
|
||||||
|
jobName: "Triggering DEX",
|
||||||
|
processName: "ReC Http Request",
|
||||||
|
batchId: command.BatchId,
|
||||||
|
reason: null,
|
||||||
|
clientLibrary: "ReC.Client",
|
||||||
|
clientMethod: "RecActions.InvokeAsync",
|
||||||
|
innerException: ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Profiles.Commands
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Command to trigger DEX job batch for a profile
|
||||||
|
/// Executes all SQL jobs associated with a profile ID
|
||||||
|
/// </summary>
|
||||||
|
public class TriggeringProfileJobBatchCommand : 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<TriggeringProfileJobBatchCommand, 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(TriggeringProfileJobBatchCommand 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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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,68 @@
|
|||||||
|
using ECMJobRunner.Application.ProfileHistories.Commands;
|
||||||
|
using ECMJobRunner.Domain.Entities;
|
||||||
|
using ECMJobRunner.Domain.Interfaces;
|
||||||
|
using ECMJobRunner.Domain.ValueObjects;
|
||||||
|
using MediatR;
|
||||||
|
using ReC.Client.Api;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.Application.Profiles.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 TriggeringProfileJobCommand : 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!;
|
||||||
|
|
||||||
|
internal BatchRecActionViewResponse? RecActionResult { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for TriggeringDEXJobCommand
|
||||||
|
/// All execution logic is delegated to pipeline behaviors
|
||||||
|
/// This handler simply returns completion after behaviors execute
|
||||||
|
/// </summary>
|
||||||
|
public class TriggeringDEXJobCommandHandler(ISender Sender) : IRequestHandler<TriggeringProfileJobCommand, 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 async Task<Unit> Handle(TriggeringProfileJobCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// All execution logic is handled by pipeline behaviors:
|
||||||
|
// - MainQueryExecutionBehavior
|
||||||
|
// - CheckQueryExecutionBehavior
|
||||||
|
// - ReCRequestExecutionBehavior
|
||||||
|
|
||||||
|
var cmd = new CreateProfileHistoryCommand
|
||||||
|
{
|
||||||
|
Result = ResultType.Ok,
|
||||||
|
ResultText = $"Job '{request.Job.Name}' erfolgreich abgeschlossen. | Batch-ID: {request.BatchId} | Verarbeitete Aktionen: {request.RecActionResult?.TotalActionCount ?? 0} | Fehlgeschlagene Aktionen: {request.RecActionResult?.ActionExceptionCount ?? 0}",
|
||||||
|
ProfileId = request.Job.ProfileId,
|
||||||
|
AddedWho = "ECMJobRunner"
|
||||||
|
};
|
||||||
|
|
||||||
|
await Sender.Send(cmd, cancellationToken);
|
||||||
|
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
ECMJobRunner.Application/Profiles/Queries/GetProfileQuery.cs
Normal file
139
ECMJobRunner.Application/Profiles/Queries/GetProfileQuery.cs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using ECMJobRunner.Application.Common.Dtos;
|
||||||
|
using ECMJobRunner.Domain.Interfaces;
|
||||||
|
using MediatR;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.Application.Profiles.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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructor
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="profileRepository">Repository for profile data access</param>
|
||||||
|
/// <param name="mapper">AutoMapper instance for entity-to-DTO mapping</param>
|
||||||
|
public GetProfileQueryHandler(ICfgProfileRepository profileRepository, IMapper mapper)
|
||||||
|
{
|
||||||
|
_profileRepository = profileRepository;
|
||||||
|
_mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles the <see cref="GetProfileQuery"/> by retrieving and mapping profiles
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The query containing optional filter parameters</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>List of matched profiles mapped to <see cref="CfgProfileDto"/></returns>
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
#if NET
|
||||||
|
profiles = profiles.Where(p => p.ProfileName.Contains(request.ProfileName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
#else
|
||||||
|
profiles = profiles.Where(p => p.ProfileName.IndexOf(request.ProfileName!, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use AutoMapper to map entities to DTOs
|
||||||
|
return _mapper.Map<List<CfgProfileDto>>(profiles.ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
93
ECMJobRunner.Domain/Entities/ProfileHistory.cs
Normal file
93
ECMJobRunner.Domain/Entities/ProfileHistory.cs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
using ECMJobRunner.Domain.ValueObjects;
|
||||||
|
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>
|
||||||
|
/// Gets or sets the result type of the job execution.
|
||||||
|
/// This property is not mapped to a database column; it wraps <see cref="ResultId"/>.
|
||||||
|
/// If <see cref="ResultId"/> does not correspond to a defined <see cref="ResultType"/> value, returns <see cref="ResultType.Unknown"/>.
|
||||||
|
/// </summary>
|
||||||
|
[NotMapped]
|
||||||
|
public ResultType Result
|
||||||
|
{
|
||||||
|
get => Enum.IsDefined(typeof(ResultType), ResultId) ? (ResultType)ResultId : ResultType.Unknown;
|
||||||
|
set => ResultId = (byte)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
}
|
||||||
|
}
|
||||||
94
ECMJobRunner.Domain/Interfaces/IRepository.cs
Normal file
94
ECMJobRunner.Domain/Interfaces/IRepository.cs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
ECMJobRunner.Domain/ValueObjects/ResultType.cs
Normal file
27
ECMJobRunner.Domain/ValueObjects/ResultType.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ECMJobRunner.Domain.ValueObjects;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result type of a job execution.
|
||||||
|
/// </summary>
|
||||||
|
public enum ResultType : byte
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The operation completed successfully.
|
||||||
|
/// </summary>
|
||||||
|
Ok = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The operation encountered an error.
|
||||||
|
/// </summary>
|
||||||
|
Error = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The operation completed with warnings.
|
||||||
|
/// </summary>
|
||||||
|
Warning = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The operation result is unknown.
|
||||||
|
/// </summary>
|
||||||
|
Unknown = 255
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
177
ECMJobRunner.Infrastructure/Repositories/Repository.cs
Normal file
177
ECMJobRunner.Infrastructure/Repositories/Repository.cs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
#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>
|
||||||
|
/// <remarks>
|
||||||
|
/// Constructor
|
||||||
|
/// </remarks>
|
||||||
|
public class Repository<TEntity>(JobRunnerDbContext context, IMapper mapper) : IRepository<TEntity> where TEntity : class
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Database context
|
||||||
|
/// </summary>
|
||||||
|
protected readonly JobRunnerDbContext Context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DbSet for the entity
|
||||||
|
/// </summary>
|
||||||
|
protected readonly DbSet<TEntity> DbSet = context.Set<TEntity>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AutoMapper instance for DTO mapping
|
||||||
|
/// </summary>
|
||||||
|
protected readonly IMapper Mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
|
||||||
|
|
||||||
|
/// <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([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 NETFRAMEWORK
|
||||||
|
if (dto == null) throw new ArgumentNullException(nameof(dto));
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var entity = Mapper.Map<TEntity>(dto);
|
||||||
|
|
||||||
|
#if NET48
|
||||||
|
DbSet.Add(entity);
|
||||||
|
#else
|
||||||
|
await DbSet.AddAsync(entity, cancellationToken);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async Task<int> AddRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) where TDto : class
|
||||||
|
{
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
if (dtos == null) throw new ArgumentNullException(nameof(dtos));
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var dtoList = dtos.ToList();
|
||||||
|
if (dtoList.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var entities = Mapper.Map<List<TEntity>>(dtoList);
|
||||||
|
|
||||||
|
#if NET48
|
||||||
|
DbSet.AddRange(entities);
|
||||||
|
#else
|
||||||
|
await DbSet.AddRangeAsync(entities, cancellationToken);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
|
||||||
|
{
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
if (dto == null) throw new ArgumentNullException(nameof(dto));
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (entities.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
Mapper.Map(dto, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async Task<bool> UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
|
||||||
|
{
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
if (dto == null) throw new ArgumentNullException(nameof(dto));
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
|
||||||
|
|
||||||
|
if (entity == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Mapper.Map(dto, entity);
|
||||||
|
|
||||||
|
await Context.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.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
DbSet.RemoveRange(entities);
|
||||||
|
|
||||||
|
return await Context.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 Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
#else
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
#endif
|
||||||
|
using ECMJobRunner.Application.Common.Interfaces;
|
||||||
|
using ECMJobRunner.Infrastructure.Data;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
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)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return result.FirstOrDefault();
|
||||||
|
#else
|
||||||
|
// Entity Framework Core implementation
|
||||||
|
var result = await _context.Database
|
||||||
|
.SqlQueryRaw<TResult>(sql)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return result.FirstOrDefault();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
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.Application.DEXJob.Commands.Behaviors;
|
||||||
|
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<TriggeringProfileJobCommand, 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<TriggeringProfileJobCommand, 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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
||||||
|
var command = new TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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.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.Application.DEXJob.Commands.Behaviors;
|
||||||
|
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<TriggeringProfileJobCommand, 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<TriggeringProfileJobCommand, 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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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<TriggeringProfileJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
|
||||||
|
var command = new TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobCommand
|
||||||
|
{
|
||||||
|
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 TriggeringProfileJobBatchCommand { ProfileId = profileId };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().Be(Unit.Value);
|
||||||
|
_mockSender.Verify(
|
||||||
|
s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), 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 TriggeringProfileJobBatchCommand { ProfileId = profileId };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().Be(Unit.Value);
|
||||||
|
_mockSender.Verify(
|
||||||
|
s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), 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 });
|
||||||
|
|
||||||
|
TriggeringProfileJobCommand? capturedCommand = null;
|
||||||
|
_mockSender
|
||||||
|
.Setup(s => s.Send(It.IsAny<TriggeringProfileJobCommand>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<IRequest<Unit>, CancellationToken>((cmd, _) => capturedCommand = cmd as TriggeringProfileJobCommand)
|
||||||
|
.ReturnsAsync(Unit.Value);
|
||||||
|
|
||||||
|
var command = new TriggeringProfileJobBatchCommand { 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="1.0.0" />
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
22
ECMJobRunner.WebCron/AllowAllDashboardAuthorizationFilter.cs
Normal file
22
ECMJobRunner.WebCron/AllowAllDashboardAuthorizationFilter.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the current user is authorized to access the Hangfire Dashboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The Hangfire dashboard context containing request information.</param>
|
||||||
|
/// <returns>Always returns <c>true</c> to allow all users (development only).</returns>
|
||||||
|
public bool Authorize(DashboardContext context)
|
||||||
|
{
|
||||||
|
// Allow all users - FOR DEVELOPMENT ONLY
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
ECMJobRunner.WebCron/ECMJobRunner.WebCron.csproj
Normal file
44
ECMJobRunner.WebCron/ECMJobRunner.WebCron.csproj
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net8.0</TargetFrameworks>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<PackageId>ECMJobRunner.WebCron</PackageId>
|
||||||
|
<Authors>Digital Data GmbH</Authors>
|
||||||
|
<Company>Digital Data GmbH</Company>
|
||||||
|
<Product>ECMJobRunner.WebCron</Product>
|
||||||
|
<Version>1.1.0</Version>
|
||||||
|
<FileVersion>1.1.0.1</FileVersion>
|
||||||
|
<AssemblyVersion>1.1.0.1</AssemblyVersion>
|
||||||
|
<InformationalVersion>1.1.0</InformationalVersion>
|
||||||
|
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
||||||
|
<PackageTags>digital data job runner</PackageTags>
|
||||||
|
<UserSecretsId>cf893b96-c71a-4a96-a6a7-40004249e1a3</UserSecretsId>
|
||||||
|
</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>
|
||||||
38
ECMJobRunner.WebCron/Extensions/DtoExtensions.cs
Normal file
38
ECMJobRunner.WebCron/Extensions/DtoExtensions.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using ECMJobRunner.Application.Common.Dtos;
|
||||||
|
using ECMJobRunner.Application.Profiles.Commands;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.WebCron.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for profile DTOs to support Hangfire job operations.
|
||||||
|
/// </summary>
|
||||||
|
public static class DtoExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a unique Hangfire job identifier for a profile.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="profile">The profile configuration DTO.</param>
|
||||||
|
/// <returns>A unique job identifier in the format "profile-{Id}-{normalized-name}".</returns>
|
||||||
|
/// <example>
|
||||||
|
/// Example: profile with Id=123 and ProfileName="Import Data"
|
||||||
|
/// returns "profile-123-import_data"
|
||||||
|
/// </example>
|
||||||
|
public static string JobId(this CfgProfileDto profile)
|
||||||
|
{
|
||||||
|
return $"profile-{profile.Id}-{profile.ProfileName.Replace(' ', '_').ToLowerInvariant()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a profile DTO to a DEX job batch command.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="profile">The profile configuration DTO.</param>
|
||||||
|
/// <returns>A <see cref="TriggeringProfileJobBatchCommand"/> ready to execute the profile job.</returns>
|
||||||
|
public static TriggeringProfileJobBatchCommand ToJob(this CfgProfileDto profile)
|
||||||
|
{
|
||||||
|
return new TriggeringProfileJobBatchCommand
|
||||||
|
{
|
||||||
|
ProfileId = profile.Id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
337
ECMJobRunner.WebCron/HealthCheck/HealthCheckHtmlGenerator.cs
Normal file
337
ECMJobRunner.WebCron/HealthCheck/HealthCheckHtmlGenerator.cs
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.WebCron.HealthCheck;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates HTML representation of ASP.NET Core health check reports.
|
||||||
|
/// Produces a Bootstrap 5-based UI with auto-refresh, status indicators, and detailed metrics.
|
||||||
|
/// </summary>
|
||||||
|
public static class HealthCheckHtmlGenerator
|
||||||
|
{
|
||||||
|
private static readonly string CacheId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a complete HTML page for displaying health check results.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="report">The health check report containing service status information.</param>
|
||||||
|
/// <returns>A complete HTML document string ready to be sent as HTTP response.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The generated UI includes:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Navigation links to Hangfire, Serilog.UI, and health endpoints</description></item>
|
||||||
|
/// <item><description>Overall health status with color-coded badge</description></item>
|
||||||
|
/// <item><description>Summary card with total duration and check count</description></item>
|
||||||
|
/// <item><description>Individual check cards with detailed metrics and exception info</description></item>
|
||||||
|
/// <item><description>Auto-refresh countdown (10 seconds)</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// External dependencies:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Bootstrap 5.3.0 (CDN)</description></item>
|
||||||
|
/// <item><description>/css/health-ui.css (custom styles)</description></item>
|
||||||
|
/// <item><description>/js/health-ui.js (auto-refresh logic)</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public static string Generate(HealthReport report)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
sb.AppendLine(@$"
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset=""UTF-8"">
|
||||||
|
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
||||||
|
<title>Health Check - ECMJobRunner</title>
|
||||||
|
<link rel=""stylesheet"" href=""https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"" />
|
||||||
|
<link rel=""stylesheet"" href=""/css/health-ui.css?cache={CacheId}"" />
|
||||||
|
<script src=""/js/health-ui.js?cache={CacheId}""></script>
|
||||||
|
</head>
|
||||||
|
<body>");
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
AppendNavigation(sb);
|
||||||
|
|
||||||
|
// Overall Status Header
|
||||||
|
AppendOverallStatus(sb, report);
|
||||||
|
|
||||||
|
// Summary Card
|
||||||
|
AppendSummaryCard(sb, report);
|
||||||
|
|
||||||
|
// Individual Checks
|
||||||
|
AppendIndividualChecks(sb, report);
|
||||||
|
|
||||||
|
// Refresh Info
|
||||||
|
AppendRefreshInfo(sb);
|
||||||
|
|
||||||
|
sb.AppendLine(@"
|
||||||
|
</body>
|
||||||
|
</html>");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the navigation menu with links to application dashboards.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
||||||
|
private static void AppendNavigation(StringBuilder sb)
|
||||||
|
{
|
||||||
|
sb.Append(@"
|
||||||
|
<div class=""container-fluid"">
|
||||||
|
<div class=""nav-links"">");
|
||||||
|
|
||||||
|
// Append emojis separately (not in verbatim string)
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(" <a href=\"/\">🏠 Home</a>");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(" <a href=\"/hangfire\">🔧 Hangfire Dashboard</a>");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(" <a href=\"/serilog-ui\">📋 Logs (Serilog.UI)</a>");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(" <a href=\"/health\">📊 Health API</a>");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(" </div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the overall status header showing aggregate health and timestamp.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
||||||
|
/// <param name="report">The health check report.</param>
|
||||||
|
private static void AppendOverallStatus(StringBuilder sb, HealthReport report)
|
||||||
|
{
|
||||||
|
var statusClass = GetStatusClass(report.Status);
|
||||||
|
var badgeClass = GetBadgeClass(report.Status);
|
||||||
|
var icon = GetStatusIcon(report.Status);
|
||||||
|
|
||||||
|
sb.Append($@"
|
||||||
|
<div class=""header-card"">
|
||||||
|
<div class=""d-flex justify-content-between align-items-center"">
|
||||||
|
<div>
|
||||||
|
<h1 class=""mb-2"">");
|
||||||
|
sb.Append(icon);
|
||||||
|
sb.Append($@" Health Check Status</h1>
|
||||||
|
<p class=""text-muted mb-0"">Last checked: {DateTime.Now:yyyy-MM-dd HH:mm:ss} UTC</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2><span class=""badge {badgeClass} fs-3"">{report.Status}</span></h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=""row"">
|
||||||
|
<div class=""col-md-4"">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the summary card displaying aggregate metrics (status, duration, check count).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
||||||
|
/// <param name="report">The health check report.</param>
|
||||||
|
private static void AppendSummaryCard(StringBuilder sb, HealthReport report)
|
||||||
|
{
|
||||||
|
var statusClass = GetStatusClass(report.Status);
|
||||||
|
|
||||||
|
sb.Append(@"
|
||||||
|
<div class=""card"">
|
||||||
|
<div class=""card-header bg-primary text-white"">");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(" 📊 Summary");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append($@" </div>
|
||||||
|
<div class=""card-body"">
|
||||||
|
<div class=""metric"">
|
||||||
|
<span class=""metric-label"">Overall Status:</span>
|
||||||
|
<strong class=""{statusClass}"">{report.Status}</strong>
|
||||||
|
</div>
|
||||||
|
<div class=""metric"">
|
||||||
|
<span class=""metric-label"">Total Duration:</span>
|
||||||
|
<span class=""metric-value"">{report.TotalDuration.TotalMilliseconds:F0} ms</span>
|
||||||
|
</div>
|
||||||
|
<div class=""metric"">
|
||||||
|
<span class=""metric-label"">Checks:</span>
|
||||||
|
<span class=""metric-value"">{report.Entries.Count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=""col-md-8"">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends individual health check result cards with detailed metrics.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
||||||
|
/// <param name="report">The health check report containing check entries.</param>
|
||||||
|
private static void AppendIndividualChecks(StringBuilder sb, HealthReport report)
|
||||||
|
{
|
||||||
|
foreach (var entry in report.Entries)
|
||||||
|
{
|
||||||
|
var checkStatusClass = GetStatusClass(entry.Value.Status);
|
||||||
|
var checkBadgeClass = GetBadgeClass(entry.Value.Status);
|
||||||
|
var checkIcon = GetStatusIcon(entry.Value.Status);
|
||||||
|
|
||||||
|
sb.Append($@"
|
||||||
|
<div class=""card"">
|
||||||
|
<div class=""card-header bg-light"">
|
||||||
|
<div class=""d-flex justify-content-between align-items-center"">
|
||||||
|
<span>");
|
||||||
|
sb.Append(checkIcon);
|
||||||
|
sb.Append($@" <strong>{entry.Key}</strong></span>
|
||||||
|
<span class=""badge {checkBadgeClass}"">{entry.Value.Status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class=""card-body"">
|
||||||
|
<div class=""metric"">
|
||||||
|
<span class=""metric-label"">Description:</span>
|
||||||
|
<span class=""metric-value"">{entry.Value.Description ?? "N/A"}</span>
|
||||||
|
</div>
|
||||||
|
<div class=""metric"">
|
||||||
|
<span class=""metric-label"">Duration:</span>
|
||||||
|
<span class=""metric-value"">{entry.Value.Duration.TotalMilliseconds:F0} ms</span>
|
||||||
|
</div>");
|
||||||
|
|
||||||
|
if (entry.Value.Exception != null)
|
||||||
|
{
|
||||||
|
sb.AppendLine($@"
|
||||||
|
<div class=""metric"">
|
||||||
|
<span class=""metric-label"">Exception:</span>
|
||||||
|
<span class=""metric-value text-danger"" style=""word-break: break-all;"">{System.Net.WebUtility.HtmlEncode(entry.Value.Exception.Message)}</span>
|
||||||
|
</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Value.Data.Any())
|
||||||
|
{
|
||||||
|
AppendCheckDetails(sb, entry.Value.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine(@"
|
||||||
|
</div>
|
||||||
|
</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine(@"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends a detail table for additional health check data (e.g., timestamps, counters).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
||||||
|
/// <param name="data">Dictionary of custom data provided by the health check.</param>
|
||||||
|
private static void AppendCheckDetails(StringBuilder sb, IReadOnlyDictionary<string, object> data)
|
||||||
|
{
|
||||||
|
sb.AppendLine(@"
|
||||||
|
<div class=""mt-3"">
|
||||||
|
<strong class=""metric-label"">Details:</strong>
|
||||||
|
<table class=""table table-sm table-borderless mt-2"">");
|
||||||
|
|
||||||
|
foreach (var item in data)
|
||||||
|
{
|
||||||
|
var value = FormatDataValue(item.Value);
|
||||||
|
|
||||||
|
sb.AppendLine($@"
|
||||||
|
<tr>
|
||||||
|
<td class=""text-muted"" style=""width: 40%;"">{item.Key}</td>
|
||||||
|
<td><strong>{value}</strong></td>
|
||||||
|
</tr>");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine(@"
|
||||||
|
</table>
|
||||||
|
</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the auto-refresh indicator widget with countdown timer.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sb">The StringBuilder to append HTML to.</param>
|
||||||
|
private static void AppendRefreshInfo(StringBuilder sb)
|
||||||
|
{
|
||||||
|
sb.AppendLine(@"
|
||||||
|
<div class=""refresh-info"">");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(" <span class=\"spinner\"></span> Auto-refresh in <strong><span id=\"countdown\">3</span>s</strong>");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(@" </div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a health status to the corresponding CSS class name for styling.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="status">The health status.</param>
|
||||||
|
/// <returns>CSS class name (e.g., "status-healthy", "status-degraded").</returns>
|
||||||
|
private static string GetStatusClass(HealthStatus status)
|
||||||
|
{
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
HealthStatus.Healthy => "status-healthy",
|
||||||
|
HealthStatus.Degraded => "status-degraded",
|
||||||
|
HealthStatus.Unhealthy => "status-unhealthy",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a health status to the corresponding Bootstrap badge CSS class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="status">The health status.</param>
|
||||||
|
/// <returns>Badge CSS class name (e.g., "badge-healthy", "badge-degraded").</returns>
|
||||||
|
private static string GetBadgeClass(HealthStatus status)
|
||||||
|
{
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
HealthStatus.Healthy => "badge-healthy",
|
||||||
|
HealthStatus.Degraded => "badge-degraded",
|
||||||
|
HealthStatus.Unhealthy => "badge-unhealthy",
|
||||||
|
_ => "badge-secondary"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a health status to the corresponding emoji icon.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="status">The health status.</param>
|
||||||
|
/// <returns>Unicode emoji character (✅ for healthy, ⚠️ for degraded, ❌ for unhealthy).</returns>
|
||||||
|
private static string GetStatusIcon(HealthStatus status)
|
||||||
|
{
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
HealthStatus.Healthy => "✅",
|
||||||
|
HealthStatus.Degraded => "⚠️",
|
||||||
|
HealthStatus.Unhealthy => "❌",
|
||||||
|
_ => "❓"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats health check data values for display.
|
||||||
|
/// Applies special formatting for common types (DateTime, TimeSpan).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The data value to format.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// Formatted string representation:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><see cref="DateTime"/> → "yyyy-MM-dd HH:mm:ss"</description></item>
|
||||||
|
/// <item><description><see cref="TimeSpan"/> → "{seconds}s"</description></item>
|
||||||
|
/// <item><description>Other types → ToString() or "N/A" if null</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </returns>
|
||||||
|
private static string FormatDataValue(object? value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
return "N/A";
|
||||||
|
|
||||||
|
if (value is DateTime dt)
|
||||||
|
return dt.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
if (value is TimeSpan ts)
|
||||||
|
return $"{ts.TotalSeconds:F1}s";
|
||||||
|
|
||||||
|
return value.ToString() ?? "N/A";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using ECMJobRunner.Application.Common.Exceptions;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.WebCron.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Middleware for handling exceptions globally in the application.
|
||||||
|
/// Captures exceptions thrown during the request pipeline execution,
|
||||||
|
/// logs them, and returns an appropriate HTTP response with a JSON error details.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="ExceptionHandlingMiddleware"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Next">The next middleware in the request pipeline.</param>
|
||||||
|
/// <param name="Logger">The logger instance for logging exceptions.</param>
|
||||||
|
public class ExceptionHandlingMiddleware(RequestDelegate Next, ILogger<ExceptionHandlingMiddleware> Logger)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes the middleware to handle the HTTP request.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The HTTP context of the current request.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Next(context); // Continue down the pipeline
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await HandleExceptionAsync(context, ex, Logger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles exceptions by logging them and writing an appropriate JSON response.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The HTTP context of the current request.</param>
|
||||||
|
/// <param name="exception">The exception that occurred.</param>
|
||||||
|
/// <param name="logger">The logger instance for logging the exception.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
private static async Task HandleExceptionAsync(HttpContext context, Exception exception, ILogger logger)
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
|
||||||
|
ValidationProblemDetails details;
|
||||||
|
|
||||||
|
switch (exception)
|
||||||
|
{
|
||||||
|
case JobException jobEx:
|
||||||
|
logger.LogWarning(jobEx, "Job exception occurred.");
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
|
||||||
|
details = new()
|
||||||
|
{
|
||||||
|
Title = "Job Exception",
|
||||||
|
Detail = jobEx.Message
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.LogError(exception, "Unhandled exception occurred.");
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||||
|
details = new()
|
||||||
|
{
|
||||||
|
Title = "Internal Server Error",
|
||||||
|
Detail = "An unexpected error occurred. Please try again later."
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details is not null)
|
||||||
|
await context.Response.WriteAsJsonAsync(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
ECMJobRunner.WebCron/ProfileWorker/DependencyInjection.cs
Normal file
81
ECMJobRunner.WebCron/ProfileWorker/DependencyInjection.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.WebCron.ProfileWorker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for configuring ProfileWorker services in the DI container.
|
||||||
|
/// </summary>
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers ProfileWorker background service and related dependencies.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection to configure.</param>
|
||||||
|
/// <param name="configuration">Application configuration containing ProfileWorker settings.</param>
|
||||||
|
/// <returns>The configured service collection for method chaining.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Registers the following services:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><see cref="ProfileWorkerOptions"/> - Configuration options from appsettings.json</description></item>
|
||||||
|
/// <item><description><see cref="ProfileWorker"/> - Singleton background service (also registered as IHostedService)</description></item>
|
||||||
|
/// <item><description><see cref="ProfileCache"/> - Singleton cache for profile state</description></item>
|
||||||
|
/// <item><description><see cref="ProfileWork"/> - Scoped service for profile synchronization logic</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public static IServiceCollection AddProfileWorker(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
// Configure ProfileWorker options from appsettings.json
|
||||||
|
services.Configure<ProfileWorkerOptions>(
|
||||||
|
configuration.GetSection(ProfileWorkerOptions.SectionName));
|
||||||
|
|
||||||
|
// Validate options at startup
|
||||||
|
services.AddSingleton<IValidateOptions<ProfileWorkerOptions>, ProfileWorkerOptionsValidator>();
|
||||||
|
|
||||||
|
// Register ProfileWorker as both HostedService and singleton (for health check access)
|
||||||
|
services.AddSingleton<ProfileWorker>();
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<ProfileWorker>());
|
||||||
|
|
||||||
|
services.AddSingleton<ProfileCache>();
|
||||||
|
services.AddScoped<ProfileWork>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates <see cref="ProfileWorkerOptions"/> configuration at application startup.
|
||||||
|
/// Ensures IntervalMS is within acceptable bounds to prevent misconfiguration.
|
||||||
|
/// </summary>
|
||||||
|
internal class ProfileWorkerOptionsValidator : IValidateOptions<ProfileWorkerOptions>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the ProfileWorker options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The name of the options instance (not used).</param>
|
||||||
|
/// <param name="options">The options to validate.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// <see cref="ValidateOptionsResult.Success"/> if valid,
|
||||||
|
/// or <see cref="ValidateOptionsResult.Fail(string)"/> with error message if invalid.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Validation rules:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>IntervalMS must be greater than 0</description></item>
|
||||||
|
/// <item><description>IntervalMS should be at least 100ms to avoid excessive CPU usage</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public ValidateOptionsResult Validate(string? name, ProfileWorkerOptions options)
|
||||||
|
{
|
||||||
|
if (options.IntervalMS <= 0)
|
||||||
|
{
|
||||||
|
return ValidateOptionsResult.Fail("ProfileWorker:IntervalMs must be greater than 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.IntervalMS < 100)
|
||||||
|
{
|
||||||
|
return ValidateOptionsResult.Fail("ProfileWorker:IntervalMs should be at least 100ms to avoid excessive CPU usage");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidateOptionsResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
ECMJobRunner.WebCron/ProfileWorker/ProfileCache.cs
Normal file
50
ECMJobRunner.WebCron/ProfileWorker/ProfileCache.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using ECMJobRunner.Application.Common.Dtos;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.WebCron.ProfileWorker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe cache for storing active profile configurations.
|
||||||
|
/// Uses <see cref="ConcurrentDictionary{TKey, TValue}"/> to track profile state
|
||||||
|
/// and detect changes in schedule or removal.
|
||||||
|
/// </summary>
|
||||||
|
public class ProfileCache
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, CfgProfileDto> _cache = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a profile from the cache by job identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobId">The unique job identifier.</param>
|
||||||
|
/// <returns>The cached profile, or <c>null</c> if not found.</returns>
|
||||||
|
public CfgProfileDto? Get(string jobId) => _cache.TryGetValue(jobId, out var profile) ? profile : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new profile or updates an existing profile in the cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobId">The unique job identifier.</param>
|
||||||
|
/// <param name="profile">The profile configuration to cache.</param>
|
||||||
|
public void AddOrUpdate(string jobId, CfgProfileDto profile) => _cache[jobId] = profile;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to remove a profile from the cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobId">The unique job identifier.</param>
|
||||||
|
/// <param name="profile">The removed profile, or <c>null</c> if not found.</param>
|
||||||
|
/// <returns><c>true</c> if the profile was removed; otherwise, <c>false</c>.</returns>
|
||||||
|
public bool TryRemove(string jobId, out CfgProfileDto? profile) => _cache.TryRemove(jobId, out profile);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all job identifiers currently stored in the cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A collection of job identifiers.</returns>
|
||||||
|
public IEnumerable<string> GetAllJobIds() => _cache.Keys;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to retrieve a profile from the cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobId">The unique job identifier.</param>
|
||||||
|
/// <param name="profile">The cached profile, or <c>null</c> if not found.</param>
|
||||||
|
/// <returns><c>true</c> if the profile was found; otherwise, <c>false</c>.</returns>
|
||||||
|
public bool TryGetValue(string jobId, out CfgProfileDto? profile) => _cache.TryGetValue(jobId, out profile);
|
||||||
|
}
|
||||||
82
ECMJobRunner.WebCron/ProfileWorker/ProfileWork.cs
Normal file
82
ECMJobRunner.WebCron/ProfileWorker/ProfileWork.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using ECMJobRunner.Application.Profiles.Queries;
|
||||||
|
using ECMJobRunner.WebCron.Extensions;
|
||||||
|
using Hangfire;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.WebCron.ProfileWorker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles the core business logic for synchronizing profiles between the database and Hangfire.
|
||||||
|
/// Responsible for adding, updating, and removing recurring jobs based on profile configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Logger">Logger for diagnostic output.</param>
|
||||||
|
/// <param name="JobManager">Hangfire recurring job manager.</param>
|
||||||
|
/// <param name="Mediator">MediatR mediator for executing commands and queries.</param>
|
||||||
|
/// <param name="ProfileCache">Thread-safe cache for tracking profile state.</param>
|
||||||
|
public class ProfileWork(ILogger<ProfileWork> Logger, IRecurringJobManager JobManager, IMediator Mediator, ProfileCache ProfileCache)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Synchronizes active profiles from the database with Hangfire recurring jobs.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stoppingToken">Cancellation token for graceful shutdown.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Execution flow:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description>Fetches active profiles from database via MediatR query</description></item>
|
||||||
|
/// <item><description>Removes jobs from Hangfire that no longer exist in the database</description></item>
|
||||||
|
/// <item><description>Adds or updates jobs with changed schedules</description></item>
|
||||||
|
/// <item><description>Updates local cache to reflect current state</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// Uses HashSet for O(1) job existence checks to optimize performance.
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns>A task representing the asynchronous synchronization operation.</returns>
|
||||||
|
public async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// Fetch active profiles from database
|
||||||
|
var profiles = await Mediator.Send(new GetProfileQuery()
|
||||||
|
{
|
||||||
|
Active = true,
|
||||||
|
IncludeSqlJobs = true
|
||||||
|
}, stoppingToken);
|
||||||
|
|
||||||
|
// Create HashSet for O(1) lookup performance
|
||||||
|
var profileJobIds = profiles.Select(p => p.JobId()).ToHashSet();
|
||||||
|
|
||||||
|
// Remove jobs from Hangfire that no longer exist in the database
|
||||||
|
foreach (var cachedJobId in ProfileCache.GetAllJobIds())
|
||||||
|
{
|
||||||
|
if (!profileJobIds.Contains(cachedJobId))
|
||||||
|
{
|
||||||
|
// Remove job from Hangfire if it no longer exists in the database
|
||||||
|
JobManager.RemoveIfExists(cachedJobId);
|
||||||
|
ProfileCache.TryRemove(cachedJobId, out _);
|
||||||
|
Logger.LogInformation("Job {JobId} removed", cachedJobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update jobs in Hangfire based on the database profiles
|
||||||
|
foreach (var profile in profiles)
|
||||||
|
{
|
||||||
|
if (ProfileCache.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(), stoppingToken),
|
||||||
|
profile.Schedule,
|
||||||
|
new RecurringJobOptions
|
||||||
|
{
|
||||||
|
TimeZone = TimeZoneInfo.Local
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store/update in local cache
|
||||||
|
ProfileCache.AddOrUpdate(profile.JobId(), profile);
|
||||||
|
|
||||||
|
Logger.LogInformation("Job {JobId} registered with schedule: {Schedule}",
|
||||||
|
profile.JobId(), profile.Schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
211
ECMJobRunner.WebCron/ProfileWorker/ProfileWorker.cs
Normal file
211
ECMJobRunner.WebCron/ProfileWorker/ProfileWorker.cs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ECMJobRunner.WebCron.ProfileWorker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that continuously synchronizes active profiles with Hangfire recurring jobs.
|
||||||
|
/// Implements health check monitoring to track service status and failure conditions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Logger">Logger for diagnostic output.</param>
|
||||||
|
/// <param name="ScopeFactory">Factory for creating service scopes (required for scoped service resolution).</param>
|
||||||
|
/// <param name="Options">Configuration options for interval timing.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// This service runs on a configurable interval (default 1 second) and performs the following:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Fetches active profiles from the database</description></item>
|
||||||
|
/// <item><description>Synchronizes profiles with Hangfire recurring jobs</description></item>
|
||||||
|
/// <item><description>Tracks health status based on success/failure patterns</description></item>
|
||||||
|
/// <item><description>Gracefully handles cancellation during application shutdown</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// Health states (with adaptive thresholds):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><b>Degraded (Initializing)</b>: Service starting up, waiting for first successful run</description></item>
|
||||||
|
/// <item><description><b>Healthy</b>: Recent successful run with no failures</description></item>
|
||||||
|
/// <item><description><b>Degraded</b>: 1+ consecutive failures but within time threshold</description></item>
|
||||||
|
/// <item><description><b>Unhealthy</b>: No success within adaptive threshold (3x interval for fast intervals <10s, 1.5x for slower intervals)</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// The adaptive multiplier ensures fast problem detection when using longer intervals (e.g., 60s interval = 90s timeout)
|
||||||
|
/// while maintaining tolerance for network jitter on short intervals (e.g., 1s interval = 3s timeout).
|
||||||
|
/// </remarks>
|
||||||
|
public class ProfileWorker(
|
||||||
|
ILogger<ProfileWorker> Logger,
|
||||||
|
IServiceScopeFactory ScopeFactory,
|
||||||
|
IOptions<ProfileWorkerOptions> Options) : BackgroundService, IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly ProfileWorkerOptions _options = Options.Value;
|
||||||
|
|
||||||
|
// Health check state
|
||||||
|
private DateTime? _lastSuccessfulRun = null; // null = not run yet
|
||||||
|
private int _consecutiveFailures = 0;
|
||||||
|
private Exception? _lastException = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service execution loop that synchronizes profiles on a configurable interval.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stoppingToken">Cancellation token for graceful shutdown.</param>
|
||||||
|
/// <returns>A task representing the background execution.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// The loop continues until:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Application shutdown is requested (via stoppingToken)</description></item>
|
||||||
|
/// <item><description>An unhandled exception causes service failure</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// Uses scoped services for each iteration to ensure proper lifetime management.
|
||||||
|
/// </remarks>
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
Logger.LogInformation("ProfileWorker started");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
|
||||||
|
using var scope = ScopeFactory.CreateScope();
|
||||||
|
var work = scope.ServiceProvider.GetRequiredService<ProfileWork>();
|
||||||
|
await work.ExecuteAsync(stoppingToken);
|
||||||
|
|
||||||
|
// Success - update health state
|
||||||
|
_lastSuccessfulRun = DateTime.Now;
|
||||||
|
_consecutiveFailures = 0;
|
||||||
|
_lastException = null;
|
||||||
|
|
||||||
|
if (Logger.IsEnabled(LogLevel.Debug))
|
||||||
|
{
|
||||||
|
Logger.LogDebug("ProfileWorker sync completed successfully at {time}", _lastSuccessfulRun);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Graceful shutdown - app is stopping
|
||||||
|
Logger.LogInformation("ProfileWorker stopping due to cancellation request");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Unexpected error - track for health check
|
||||||
|
_consecutiveFailures++;
|
||||||
|
_lastException = ex;
|
||||||
|
|
||||||
|
Logger.LogError(ex,
|
||||||
|
"An unexpected error occurred in ProfileWorker (consecutive failures: {failures})",
|
||||||
|
_consecutiveFailures);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(_options.IntervalMS, stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogInformation("ProfileWorker stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs a health check by evaluating recent success/failure patterns.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">Health check context (unused).</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="HealthCheckResult"/> indicating the current health status:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><see cref="HealthStatus.Healthy"/> - Service running normally</description></item>
|
||||||
|
/// <item><description><see cref="HealthStatus.Degraded"/> - Recent failures but still operational, or service initializing</description></item>
|
||||||
|
/// <item><description><see cref="HealthStatus.Unhealthy"/> - No successful run for extended period</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Health determination logic:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description><b>Degraded (Initializing)</b>: Service has not completed its first successful run yet</description></item>
|
||||||
|
/// <item><description><b>Unhealthy</b>: Time since last success exceeds adaptive threshold (3x interval for fast intervals <10s, 1.5x for slower intervals)</description></item>
|
||||||
|
/// <item><description><b>Degraded</b>: 1+ consecutive failures within time threshold</description></item>
|
||||||
|
/// <item><description><b>Healthy</b>: Recent successful run with no failures</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// Adaptive multiplier ensures fast problem detection for longer intervals while maintaining tolerance for short intervals.
|
||||||
|
/// </remarks>
|
||||||
|
public Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Service hasn't completed its first successful run yet
|
||||||
|
if (_lastSuccessfulRun == null)
|
||||||
|
{
|
||||||
|
var data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Status"] = "Initializing",
|
||||||
|
["ConsecutiveFailures"] = _consecutiveFailures,
|
||||||
|
["IntervalMS"] = _options.IntervalMS
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_lastException != null)
|
||||||
|
{
|
||||||
|
data["LastException"] = _lastException.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degraded during initialization (not unhealthy, service is starting up)
|
||||||
|
return Task.FromResult(HealthCheckResult.Degraded(
|
||||||
|
_consecutiveFailures > 0
|
||||||
|
? $"ProfileWorker initializing with {_consecutiveFailures} failure(s)"
|
||||||
|
: "ProfileWorker is initializing, waiting for first successful run",
|
||||||
|
_lastException,
|
||||||
|
data
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeSinceLastSuccess = DateTime.Now - _lastSuccessfulRun.Value;
|
||||||
|
|
||||||
|
// Adaptive multiplier: 3x for fast intervals (<10s), 1.5x for slower intervals
|
||||||
|
// This ensures faster problem detection when using longer intervals (e.g., 60s)
|
||||||
|
var multiplier = _options.IntervalMS < 10000 ? 3.0 : 1.5;
|
||||||
|
var maxAllowedDelay = TimeSpan.FromMilliseconds(_options.IntervalMS * multiplier);
|
||||||
|
|
||||||
|
// Unhealthy: No successful run within adaptive threshold
|
||||||
|
if (timeSinceLastSuccess > maxAllowedDelay)
|
||||||
|
{
|
||||||
|
var data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["LastSuccessfulRun"] = _lastSuccessfulRun.Value,
|
||||||
|
["TimeSinceLastSuccess"] = timeSinceLastSuccess,
|
||||||
|
["ConsecutiveFailures"] = _consecutiveFailures,
|
||||||
|
["IntervalMS"] = _options.IntervalMS,
|
||||||
|
["Multiplier"] = multiplier,
|
||||||
|
["MaxAllowedDelay"] = maxAllowedDelay
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_lastException != null)
|
||||||
|
{
|
||||||
|
data["LastException"] = _lastException.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(HealthCheckResult.Unhealthy(
|
||||||
|
$"ProfileWorker has not completed successfully for {timeSinceLastSuccess.TotalSeconds:F0} seconds ({_consecutiveFailures} consecutive failures)",
|
||||||
|
_lastException,
|
||||||
|
data
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degraded: 1+ consecutive failures but within time limit
|
||||||
|
if (_consecutiveFailures > 0)
|
||||||
|
{
|
||||||
|
var data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["LastSuccessfulRun"] = _lastSuccessfulRun.Value,
|
||||||
|
["ConsecutiveFailures"] = _consecutiveFailures
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult(HealthCheckResult.Degraded(
|
||||||
|
$"ProfileWorker has {_consecutiveFailures} consecutive failure(s) but still operational",
|
||||||
|
null,
|
||||||
|
data
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthy
|
||||||
|
return Task.FromResult(HealthCheckResult.Healthy(
|
||||||
|
"ProfileWorker is running normally",
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["LastSuccessfulRun"] = _lastSuccessfulRun.Value
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
23
ECMJobRunner.WebCron/ProfileWorker/ProfileWorkerOptions.cs
Normal file
23
ECMJobRunner.WebCron/ProfileWorker/ProfileWorkerOptions.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace ECMJobRunner.WebCron.ProfileWorker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for ProfileWorker background service.
|
||||||
|
/// Binds to "ProfileWorker" section in appsettings.json.
|
||||||
|
/// </summary>
|
||||||
|
public class ProfileWorkerOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration section name for binding options.
|
||||||
|
/// </summary>
|
||||||
|
public const string SectionName = "ProfileWorker";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interval in milliseconds between profile synchronization checks.
|
||||||
|
/// Default: 1000ms (1 second).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Lower values increase responsiveness but consume more resources.
|
||||||
|
/// Recommended range: 1000-5000ms.
|
||||||
|
/// </remarks>
|
||||||
|
public int IntervalMS { get; set; } = 1000;
|
||||||
|
}
|
||||||
209
ECMJobRunner.WebCron/Program.cs
Normal file
209
ECMJobRunner.WebCron/Program.cs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
using ECMJobRunner.Application;
|
||||||
|
using ECMJobRunner.Infrastructure;
|
||||||
|
using ECMJobRunner.WebCron;
|
||||||
|
using ECMJobRunner.WebCron.HealthCheck;
|
||||||
|
using ECMJobRunner.WebCron.Middleware;
|
||||||
|
using ECMJobRunner.WebCron.ProfileWorker;
|
||||||
|
using Hangfire;
|
||||||
|
using Hangfire.SqlServer;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
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>("Application:LogDirectory")
|
||||||
|
?? throw new InvalidOperationException("Application: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.AddProfileWorker(builder.Configuration);
|
||||||
|
|
||||||
|
// 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, builder.Configuration);
|
||||||
|
|
||||||
|
// 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 health checks
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddCheck<ProfileWorker>("profile-worker", tags: ["ready", "worker"]);
|
||||||
|
|
||||||
|
// Add Serilog.UI with SQLite provider - use same path from configuration
|
||||||
|
var serilogUiLogDirectory = builder.Configuration.GetValue<string>("Application:LogDirectory")
|
||||||
|
?? throw new InvalidOperationException("Application: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();
|
||||||
|
|
||||||
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// Enable static files for wwwroot
|
||||||
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// Add Hangfire Dashboard with no authentication (for development)
|
||||||
|
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||||
|
{
|
||||||
|
Authorization = [new AllowAllDashboardAuthorizationFilter()]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Health Check UI route (accessible outside Hangfire dashboard)
|
||||||
|
app.MapGet("/health-ui", async (HealthCheckService healthCheckService, HttpContext context) =>
|
||||||
|
{
|
||||||
|
var report = await healthCheckService.CheckHealthAsync(context.RequestAborted);
|
||||||
|
|
||||||
|
var html = HealthCheckHtmlGenerator.Generate(report);
|
||||||
|
|
||||||
|
context.Response.ContentType = "text/html";
|
||||||
|
await context.Response.WriteAsync(html);
|
||||||
|
|
||||||
|
return Results.Empty;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Serilog.UI Dashboard
|
||||||
|
app.UseSerilogUi();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
// Map health check endpoints
|
||||||
|
app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = _ => true,
|
||||||
|
ResponseWriter = async (context, report) =>
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
var result = System.Text.Json.JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
status = report.Status.ToString(),
|
||||||
|
timestamp = DateTime.Now,
|
||||||
|
checks = report.Entries.Select(e => new
|
||||||
|
{
|
||||||
|
name = e.Key,
|
||||||
|
status = e.Value.Status.ToString(),
|
||||||
|
description = e.Value.Description,
|
||||||
|
duration = e.Value.Duration.TotalMilliseconds,
|
||||||
|
exception = e.Value.Exception?.Message,
|
||||||
|
data = e.Value.Data
|
||||||
|
})
|
||||||
|
});
|
||||||
|
await context.Response.WriteAsync(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = check => check.Tags.Contains("ready")
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect root path to Health UI
|
||||||
|
app.MapGet("/", () => Results.Redirect("/health-ui"));
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<WebPublishMethod>Package</WebPublishMethod>
|
||||||
|
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||||
|
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||||
|
<SiteUrlToLaunchAfterPublish />
|
||||||
|
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||||
|
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||||
|
<ProjectGuid>c60bc965-d293-ea64-b153-1941f0648df4</ProjectGuid>
|
||||||
|
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\JobRunner\PreRelease\WebCron\net8\$(Version)\JobRunner.WebCron.zip</DesktopBuildPackageLocation>
|
||||||
|
<PackageAsSingleFile>true</PackageAsSingleFile>
|
||||||
|
<DeployIisAppPath>JobRunner.WebCron</DeployIisAppPath>
|
||||||
|
<_TargetId>IISWebDeployPackage</_TargetId>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
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": "",
|
||||||
|
"applicationUrl": "http://localhost:5271",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "",
|
||||||
|
"applicationUrl": "https://localhost:7027;http://localhost:5271",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"IIS Express": {
|
||||||
|
"commandName": "IISExpress",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
ECMJobRunner.WebCron/appsettings.json
Normal file
50
ECMJobRunner.WebCron/appsettings.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"SDD-VMP04-SQL17": "Server=SDD-VHP04-SQL19\\DD_TESTING01;Database=DD_ECM;User Id=sa;Password=123456789dD!;Encrypt=false;TrustServerCertificate=True;"
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"HostingOptions": {
|
||||||
|
"UseWindowsService": false
|
||||||
|
},
|
||||||
|
"Hangfire": {
|
||||||
|
"InMemory": true
|
||||||
|
},
|
||||||
|
"ReC": {
|
||||||
|
"ApiUrl": "http://172.24.12.39:90"
|
||||||
|
},
|
||||||
|
"Application": {
|
||||||
|
"LogDirectory": "E:\\LogFiles\\Digital Data\\ECMJobRunner.WebCron"
|
||||||
|
},
|
||||||
|
"ProfileWorker": {
|
||||||
|
"IntervalMS": 60000
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
ECMJobRunner.WebCron/wwwroot/css/health-ui.css
Normal file
96
ECMJobRunner.WebCron/wwwroot/css/health-ui.css
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
body {
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-fluid { max-width: 1400px; }
|
||||||
|
|
||||||
|
.status-healthy { color: #198754; }
|
||||||
|
.status-degraded { color: #ffc107; }
|
||||||
|
.status-unhealthy { color: #dc3545; }
|
||||||
|
|
||||||
|
.badge-healthy { background-color: #198754; }
|
||||||
|
.badge-degraded { background-color: #ffc107; color: #000; }
|
||||||
|
.badge-unhealthy { background-color: #dc3545; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 12px 12px 0 0 !important;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.metric-label { font-weight: 500; color: #666; }
|
||||||
|
.metric-value { color: #333; font-weight: 600; }
|
||||||
|
|
||||||
|
.refresh-info {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 50px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
margin-right: 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
15
ECMJobRunner.WebCron/wwwroot/js/health-ui.js
Normal file
15
ECMJobRunner.WebCron/wwwroot/js/health-ui.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
let countdown = 3;
|
||||||
|
|
||||||
|
function updateCountdown() {
|
||||||
|
document.getElementById('countdown').innerText = countdown;
|
||||||
|
countdown--;
|
||||||
|
if (countdown < 0) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoRefresh() {
|
||||||
|
setInterval(updateCountdown, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onload = autoRefresh;
|
||||||
Reference in New Issue
Block a user