Compare commits

...

37 Commits

Author SHA1 Message Date
d61d14145a feat: Add Serilog SQLite sink and Serilog.UI dashboard with centralized log directory configuration
- Add Serilog.Sinks.SQLite v7.0.0 for structured logging to SQLite database
- Add Serilog.UI v3.2.0 and Serilog.UI.SqliteProvider v1.1.0 for web-based log viewer
- Add Serilog.Settings.Configuration v10.0.1 for configuration support
- Configure SQLite log storage at E:\LogFiles\Digital Data\ECMJobRunner.WebCron\logs.db
- Add Serilog.UI dashboard at /serilog-ui endpoint
- Centralize log directory configuration in appsettings.json (Logging:LogDirectory)
- Configure Serilog programmatically with Console and SQLite sinks
- Remove deprecated Serilog configuration from appsettings files (now using code-based config)
- Enable Serilog self-diagnostics for troubleshooting
- Both Serilog sink and Serilog.UI use same SQLite database path from configuration
2026-07-12 17:33:19 +02:00
8b0f97c330 feat: Add Hangfire cronjob system with web dashboard, Windows Service support, and Serilog file/console logging
- Add Hangfire packages (AspNetCore, Core, InMemory, SqlServer) with configurable storage (InMemory vs SQL Server)
- Configure Hangfire dashboard at /hangfire with AllowAllDashboardAuthorizationFilter (no auth for development)
- Add Microsoft.Extensions.Hosting.WindowsServices package with conditional UseWindowsService() based on HostingOptions:UseWindowsService config
- Create ProfileManager BackgroundService with IServiceScopeFactory for scoped service resolution per iteration
- Create AllowAllDashboardAuthorizationFilter for Hangfire dashboard access
- Create DtoExtensions with JobId() and ToJob() helper methods
- Configure Serilog with file sink (Production: Logs/log-.txt, daily rolling, 30 day retention) and console sink (Development)
- Add Serilog enrichers: FromLogContext, WithMachineName, WithThreadId
- Update appsettings.json with Hangfire:InMemory flag, HostingOptions:UseWindowsService flag, and Serilog configuration
- Create appsettings.Development.json with console-specific Serilog configuration
2026-07-12 04:49:22 +02:00
d50232b0bb feat: Add Windows Service hosting support
- Add Microsoft.Extensions.Hosting.WindowsServices package
- Add HostingOptions:UseWindowsService configuration flag to appsettings.json
- Configure Program.cs to optionally run as Windows Service based on config
- IIS hosting continues to work by default (UseWindowsService=false)
- Delete obsolete Worker.cs that was conflicting with ProfileManager
2026-07-12 00:23:59 +02:00
59bef13f5d fix: Resolve scoped service dependency issue in ProfileManager
- Replace IMediator constructor injection with IServiceScopeFactory
- Create new scope in ExecuteAsync loop to resolve scoped services (ISQLExecutor)
- Fixes: Cannot resolve scoped service from root provider error
- Enables ProfileManager to properly execute MediatR queries with scoped dependencies
2026-07-12 00:22:33 +02:00
475679c6e2 feat: Update Infrastructure DependencyInjection
- Update Infrastructure layer service registrations for Hangfire compatibility
2026-07-12 00:04:51 +02:00
3a1e16a7a0 feat: Update Application layer for Hangfire integration
- Add CronSchedule property to CfgProfileDto for scheduling support
- Create ProfileSqlJobDto for SQL job execution data transfer
- Update TriggeringDEXJobBatchCommand to accept ProfileId and Jobs collection
- Make TriggeringDEXJobBatchCommand public for Hangfire job activation
- Update Application DependencyInjection with required service registrations
2026-07-12 00:04:40 +02:00
7efc77ca51 feat: Add AllowAllDashboardAuthorizationFilter for development
- Create authorization filter to bypass Hangfire dashboard authentication
- Allow unrestricted access to /hangfire dashboard for local development
- Note: Should be replaced with proper authentication in production
2026-07-12 00:04:25 +02:00
ad2ab741ff feat: Add ProfileManager for automatic Hangfire job scheduling
- Create ProfileManager background service that polls database every 1 second
- Query active profiles with cron schedules via MediatR GetProfileQuery
- Auto-create/update Hangfire recurring jobs using IRecurringJobManager
- Jobs execute TriggeringDEXJobBatchCommand via IMediator dependency injection
- Add profile caching with schedule change detection to avoid redundant updates
- Create DtoExtensions with JobId() and ToJob() helper methods for profile-to-command conversion
2026-07-12 00:04:13 +02:00
eb7adc741a feat: Add Hangfire infrastructure with dynamic storage configuration
- Install Hangfire packages (Core, AspNetCore, SqlServer, InMemory)
- Configure Hangfire in Program.cs with boolean InMemory flag from appsettings
- Add Hangfire dashboard at /hangfire route
- Update appsettings.json with Hangfire:InMemory configuration
- Update launchSettings.json with new launch URL
- Remove obsolete Worker.cs background service
2026-07-12 00:04:00 +02:00
8255f1f6aa feat(application): add inactive profile validation with custom exception
- Create InactiveProfileException for profile active status validation
- Update TriggeringDEXJobBatchCommand to check profile active status before execution
- Add ICfgProfileRepository dependency to batch command handler
- Throw InactiveProfileException when attempting to execute inactive profiles
- Add profile not found validation with descriptive error message
- Reorganize commands under DEXJob/Commands directory structure
2026-07-11 19:16:35 +02:00
eaf24e05ee refactor(application): consolidate query architecture with AutoMapper integration
- Add AutoMapper profile for CfgProfile and ProfileSqlJob entity-to-DTO mappings
- Consolidate GetProfileByIdQuery and GetAllActiveProfilesQuery into unified GetProfileQuery
- Implement flexible filtering with nullable query options (Id, Active, TypeId, ProfileName)
- Add IncludeSqlJobs option for optimized SQL job loading
- Move CfgProfileDto to Common/Dtos for better architecture alignment
- Add comprehensive unit tests for GetProfileQuery with multiple filter scenarios
- Move ISQLExecutor interface from Domain to Application layer
- Add GetByIdWithSqlJobsAsync and GetAllActiveWithSqlJobsAsync to repository
2026-07-11 19:14:51 +02:00
3f924b75b0 chore(application): remove old ISQLExecutor interface location
- Delete ECMJobRunner.Application/Common/Interfaces/ISQLExecutor.cs
- Interface moved to ECMJobRunner.Domain/Interfaces/ (committed in 26ea1db)
2026-07-11 16:45:15 +02:00
52c0614975 test(application): add comprehensive unit tests for DEX job pipeline
- Add TriggeringDEXJobBatchCommandTests
  - Test batch command execution with multiple profiles
  - Verify batch ID generation and propagation
  - Mock ISQLExecutor and IRecClient dependencies
- Add MainQueryExecutionBehaviorTests
  - Test main query execution success path
  - Test SQL exception handling (JobSqlException)
  - Verify MainQueryResults population
- Add CheckQueryExecutionBehaviorTests
  - Test check query validation (ErrorAction.SkipInsert)
  - Test skip behavior for zero results
  - Test SQL exception handling
- All tests pass on both net480 and net8.0 frameworks
- Update ECM.JobRunner.sln with test project reference
- Upgrade Microsoft.Extensions.DependencyInjection to 8.0.1 (ReC.Client requirement)
2026-07-11 16:44:53 +02:00
5d2f128cc7 feat(application): add dependency injection configuration & documentation
- Implement DependencyInjection.cs with AddApplication() extension
  - Register MediatR with assembly scanning
  - Register pipeline behaviors in execution order:
    1. CheckQueryExecutionBehavior
    2. MainQueryExecutionBehavior
    3. ReCRequestExecutionBehavior
  - Conditional package versions (MediatR 9.0.0/12.4.1)
- Add comprehensive README.md
  - Architecture overview & CQRS pipeline flow
  - Configuration guide (DexJobOptions, ErrorAction)
  - DI setup examples (AddApplication, AddInfrastructure)
  - Exception handling patterns
  - Multi-targeting notes & troubleshooting
- Update project file with MediatR.Extensions.DependencyInjection
2026-07-11 16:44:39 +02:00
63a16410ea feat(application): implement MediatR pipeline behaviors for DEX job stages
- Add MainQueryExecutionBehavior
  - Execute main SQL query via ISQLExecutor
  - Populate command.MainQueryResults
  - Throw JobSqlException on failure
- Add CheckQueryExecutionBehavior
  - Execute check SQL query via ISQLExecutor
  - Validate ErrorAction.SkipInsert for zero results
  - Throw JobSqlException on failure/validation error
- Add ReCRequestExecutionBehavior
  - Execute ReC API requests for each main query result
  - Batch ID tracking, error handling
  - Throw JobHttpException on API failures
- Conditional compilation for MediatR signature differences
  - net480: Handle(request, cancellationToken, next)
  - net8.0: Handle(request, next, cancellationToken)
- Refactor TriggeringDEXJobCommand handler: delegate all logic to behaviors
2026-07-11 16:44:25 +02:00
222a5e24bf refactor(application): redesign exception hierarchy with flexible base class
- Replace DEXJobException with JobException base class
  - Flexible params-based detail collection
  - Automatic message formatting with visual separators
  - Optional null-handling for contextual details
- Add JobHttpException for HTTP client failures
  - Properties: ClientLibrary, ClientMethod
  - Use case: ReC API, REST requests
- Add JobSqlException for SQL query failures
  - Property: Query (virtual for customization)
  - Use case: Main/Check query execution
- Comprehensive XML documentation for all exception classes
2026-07-11 16:44:10 +02:00
26ea1db09f feat(infrastructure): implement ISQLExecutor with EF6/EF Core support
- Move ISQLExecutor interface from Application to Domain layer
  - Follow Clean Architecture: Infrastructure references Domain, not Application
  - Namespace: ECMJobRunner.Domain.Interfaces
- Implement SQLExecutor service with conditional compilation
  - EF6 (net480): Database.SqlQuery<T>()
  - EF Core (net8.0): Database.SqlQueryRaw<T>()
- Register ISQLExecutor in DI (AddInfrastructure, AddInfrastructureInMemory)
  - Scoped lifetime (aligns with DbContext)
2026-07-11 16:43:57 +02:00
4767bcfbe1 Add .NET 8.0 web app with minimal API and background worker
Introduced a new .NET 8.0 web application project with a minimal API setup.
Configured Swagger for API documentation and added a `Worker` class as a
background service for periodic tasks.

Added `launchSettings.json` to define development profiles for HTTP, HTTPS,
and IIS Express. Configured logging levels in `appsettings.json` and
`appsettings.Development.json`.

Included `Swashbuckle.AspNetCore` package for Swagger integration and
configured the HTTP request pipeline to support development and production
environments.
2026-07-11 12:56:58 +02:00
2af17ec870 feat: implement DEX job triggering commands with CQRS pattern
TriggeringDEXJobBatchCommand:
- Orchestrates batch job execution for a profile
- Creates unique 20-character timestamp-based batch ID
- Executes all SQL jobs sequentially for given profile ID
- Uses MediatR ISender to trigger individual job commands

TriggeringDEXJobCommand:
- Executes single DEX job with three-stage pipeline:
  1. Main Query: executes primary SQL with batch ID placeholder replacement
  2. Check Query: validates execution with return value check (> 0)
  3. ReC Request: invokes ReC API with batch ID reference
- Configurable error handling per stage via DexJobOptions
- Regex-based placeholder replacement for dynamic batch ID injection
- Returns Unit for void-like MediatR command pattern

Both commands include:
- Comprehensive XML documentation
- Primary constructor injection (modern C# syntax)
- Proper async/await with CancellationToken support
- Integration with ISQLExecutor and ReCClient abstractions
2026-07-11 12:38:42 +02:00
8a1a579501 feat: add DEX job exception handling and configuration
Exceptions:
- DEXJobException: custom exception with formatted error messages
  * Includes query name, batch ID, SQL query, and detailed error info
  * Two constructors: with inner exception or reason message
  * Comprehensive XML documentation

Options:
- DexJobOptions: hierarchical configuration for DEX job execution
  * Error handling per stage (MainQuery, CheckQuery, ReCRequest)
  * Granular error actions (OnExecution, IfNullOrWhiteSpace, OnUnexpectedResult)
  * Placeholder configuration with regex pattern support
  * Default BatchId placeholder: #INT#BATCH_ID (case-insensitive)
  * Fully documented with XML comments
2026-07-11 12:38:31 +02:00
7e267b427e feat: add DEX job common infrastructure
Add foundational types for DEX job execution:

Constants:
- ErrorAction enum (Ignore, Stop) for error handling strategy

DTOs:
- CheckQueryResult: check query execution result with ReturnValue
- MainQueryResult: main query execution result with ReturnValue

Interfaces:
- ISQLExecutor: abstraction for SQL query execution and DTO mapping

All types include comprehensive XML documentation
2026-07-11 12:38:21 +02:00
30a2a4ce3b build: add ReC.Client package reference
- Add ReC.Client v2.0.0-beta for DEX job HTTP request integration
2026-07-11 12:38:13 +02:00
46ad02f624 refactor: remove legacy projects from solution
- Remove ECM.JobRunner.Web project
- Remove ECM.JobRunner.Common (VB.NET) project
- Remove ECM.JobRunner.Windows (VB.NET) project
- Clean Architecture implementation focus
2026-07-11 12:38:06 +02:00
ee3af3e462 Add test infrastructure and repository tests
Introduced `CfgProfileDto` for testing and added a `FakeDataGenerator` utility for generating test data. Updated `ECMJobRunner.Tests.csproj` to support both `.NET Framework 4.8` and `.NET 8.0`, enabling nullable reference types and adding dependencies for testing frameworks, DI, and AutoMapper.

Added `TestFixture` to set up dependency injection and in-memory databases for tests. Created `TestMappingProfile` for AutoMapper configurations. Implemented `CfgProfileRepositoryTests` to validate repository methods, including `GetByIdAsync`, `GetAllAsync`, `AddAsync`, and `FindAsync`.

Enhanced test maintainability with `FluentAssertions` and realistic data generation using `Bogus`.
2026-07-09 16:09:19 +02:00
bd6c1c1309 Update AutoMapper versions and suppress NU1903 warning
- Suppressed AutoMapper vulnerability warning (NU1903) as acceptable.
- Updated comments for AutoMapper references to clarify alignment with Infrastructure.
- Added `AutoMapper.Extensions.Microsoft.DependencyInjection`:
  - Version 8.1.1 for .NET Framework 4.8.
  - Version 12.0.1 for .NET 8.
- Downgraded AutoMapper for .NET 8 from 13.0.1 to 12.0.1.
2026-07-09 15:58:52 +02:00
9bae0ab95c Refactor Repository and update project dependencies
Added `SaveChangesAsync` to `IRepository` for async persistence.
Introduced AutoMapper and DbContext dependencies in `Repository`.
Simplified EF Core operations by removing `#if NET48` logic.
Replaced `Task.Run` with direct EF Core calls for .NET Framework.
Updated AutoMapper version for .NET 8 and added EF Core testing.
Suppressed AutoMapper vulnerability warning in project file.
Performed general cleanup and improved maintainability.
2026-07-09 15:58:33 +02:00
574e5ed209 Add in-memory DB support for .NET 4.8 and .NET 8 testing
Introduced a `DbConnection` constructor in `JobRunnerDbContext`
for Entity Framework 6 to enable in-memory testing with the
Effort library. Added conditional compilation to support both
.NET Framework 4.8 and .NET 8.

Implemented `AddInfrastructureInMemory` in `DependencyExtension`
to register services with in-memory databases:
- For .NET 4.8, uses Effort's transient connection.
- For .NET 8, uses EF Core's in-memory provider.

Registered AutoMapper in both methods for object mapping. Added
`EntityMappingProfile` to define AutoMapper configurations,
enforcing explicit DTO-to-entity mappings. These changes improve
testability and maintainability across .NET versions.
2026-07-09 15:45:18 +02:00
5eca5f1d4b Remove SaveChangesAsync from IRepository interface
The `SaveChangesAsync` method was removed from the `IRepository`
interface in the `ECMJobRunner.Domain.Interfaces` namespace.
This method previously allowed saving all changes asynchronously
with an optional `CancellationToken` parameter.
2026-07-09 15:44:40 +02:00
65a51482dd Add ECMJobRunner.Tests and enhance DI for multi-frameworks
Added a new `ECMJobRunner.Tests` project to the solution for unit
testing, including build configurations for Debug and Release.

Enhanced the `DependencyExtension` class to support dependency
injection for both .NET Framework 4.8 (EF6) and .NET 8 (EF Core)
using conditional compilation. Registered `JobRunnerDbContext`
differently based on the target framework and added AutoMapper
registration for both frameworks.

Updated `ECMJobRunner.Infrastructure.csproj` to include new
package references for DI and AutoMapper in .NET Framework 4.8.
Improved code structure and readability by removing redundant
directives and aligning DI patterns across frameworks.
2026-07-09 13:48:55 +02:00
eb9c5c323a Update AGENTS.md and csproj for ECMJobRunner.Application
Added detailed documentation to AGENTS.md, including project overview, architecture, components, and usage examples for the ECMJobRunner.Application layer. Documented target frameworks (.NET Framework 4.8 and .NET 8.0) and their dependencies (AutoMapper and MediatR).

Updated ECMJobRunner.Application.csproj:
- Changed `<Product>` tag to reflect the correct project name.
- Added a project reference to ECMJobRunner.Domain.
- Introduced conditional NuGet package references for framework-specific dependencies.
2026-07-09 13:35:24 +02:00
7d53b599de Add Infrastructure layer with EF6/EF Core support
Introduced a robust Infrastructure layer for the ECMJobRunner system:
- Added `AGENTS.md` with detailed project documentation.
- Implemented generic repository and unit-of-work patterns.
- Added `CfgProfileRepository`, `ProfileSqlJobRepository`, and `ProfileHistoryRepository`.
- Integrated AutoMapper for DTO-to-entity mapping.
- Added multi-framework support for .NET Framework 4.8 (EF6) and .NET 8.0 (EF Core) using conditional compilation.
- Updated `ECMJobRunner.Infrastructure.csproj` with metadata fixes and dependencies.
- Introduced dependency injection extension for .NET 8.0.
- Enhanced project structure and database context with entity mappings.
2026-07-09 13:35:02 +02:00
ff6faa1e5b Add JobRunnerDbContext with multi-framework support
Introduce `JobRunnerDbContext` to support both EF6 (.NET 4.8)
and EF Core (.NET 8) using conditional compilation. Add `DbSet`
properties for `CfgProfiles`, `ProfileSqlJobs`, and
`ProfileHistories`. Provide constructors for both frameworks
to handle connection strings or options. Ensure compatibility
with multiple runtime environments.
2026-07-09 13:34:03 +02:00
3e007ccfeb Refactor domain layer to follow Clean Architecture
Updated the domain layer to align with Clean Architecture principles:
- Removed Entity Framework dependencies from the project.
- Updated AGENTS.md to document the new architecture.
- Introduced repository interfaces for data access abstraction.
- Added a generic IRepository interface for CRUD operations.
- Implemented entity-specific repositories for Profile, ProfileSqlJob, and ProfileHistory.
- Ensured the domain layer is infrastructure-independent with pure POCOs.
- Updated project structure and documentation for clarity.
2026-07-09 13:33:39 +02:00
ce47653831 Refactor Profile entity and update property constraints
Renamed `Profile` to `CfgProfile` across the codebase for clarity and consistency. Updated property constraints in `CfgProfile.cs`, `ProfileHistory.cs`, and `ProfileSqlJob.cs` to include maximum length validations. Changed navigation properties in `CfgProfile.cs` to use `IEnumerable` and made them nullable. Updated `ForeignKey` attributes in `ProfileHistory.cs` and `ProfileSqlJob.cs` to reference `CfgProfile`. Improved property descriptions for better documentation.
2026-07-09 13:23:46 +02:00
be8e58df84 Add ECMJobRunner.Domain entities and documentation
Introduced the `ECMJobRunner.Domain` project to support both
.NET Framework 4.8 and .NET 8.0. Added `Profile`,
`ProfileSqlJob`, and `ProfileHistory` entities to map to the
database schema, including relationships and audit fields.

Updated `ECMJobRunner.Domain.csproj` to enable multi-targeting
and added dependencies for Entity Framework 6.5.1 and EF Core
8.0.11. Enabled nullable reference types and set the language
version to `latest`.

Added comprehensive documentation in `AGENTS.md` detailing the
project structure, entities, database schema, and build
instructions.
2026-07-09 12:45:09 +02:00
26dc58d82e Add Application and Infrastructure projects to solution
Two new projects, `ECMJobRunner.Application` and
`ECMJobRunner.Infrastructure`, were added to the solution file
(`ECM.JobRunner.sln`). Build configurations for these projects
were also added.

`ECMJobRunner.Application.csproj` targets both .NET Framework 4.8
and .NET 8.0, with properties such as `LangVersion`, `Nullable`,
and `PackageId` defined.

`ECMJobRunner.Infrastructure.csproj` also targets .NET Framework
4.8 and .NET 8.0. It includes conditional dependencies:
- For `net480`, it references `EntityFramework` 6.5.1.
- For `net8.0`, it references `Microsoft.EntityFrameworkCore`
  and related packages (version 8.0.11).
2026-07-09 11:48:10 +02:00
19c415da01 Add ECMJobRunner.Domain project to solution
A new project, `ECMJobRunner.Domain`, has been added to the solution file (`ECM.JobRunner.sln`). The project targets both .NET Framework 4.8 (`net480`) and .NET 8.0 (`net8.0`) and includes metadata for NuGet packaging, such as `PackageId`, `Authors`, and `RepositoryUrl`.

Build configurations for `Debug|Any CPU` and `Release|Any CPU` have been added to the solution configuration. The project file (`ECMJobRunner.Domain.csproj`) also specifies the generation of XML documentation files.
2026-07-09 10:52:57 +02:00
61 changed files with 5115 additions and 15 deletions

View File

@@ -3,11 +3,15 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.4.33103.184
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ECM.JobRunner.Web", "ECM.JobRunner.Web\ECM.JobRunner.Web.csproj", "{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Domain", "ECMJobRunner.Domain\ECMJobRunner.Domain.csproj", "{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}"
EndProject
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "ECM.JobRunner.Common", "ECM.JobRunner.Common\ECM.JobRunner.Common.vbproj", "{7EACF04E-525B-40C8-9207-B83FB847B090}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Infrastructure", "ECMJobRunner.Infrastructure\ECMJobRunner.Infrastructure.csproj", "{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}"
EndProject
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "ECM.JobRunner.Windows", "ECM.JobRunner.Windows\ECM.JobRunner.Windows.vbproj", "{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Application", "ECMJobRunner.Application\ECMJobRunner.Application.csproj", "{D97CD489-10D7-432C-9921-196F2E0505FF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.Tests", "ECMJobRunner.Tests\ECMJobRunner.Tests.csproj", "{F64352B6-32BB-4BDE-90FD-FB77482D44E0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ECMJobRunner.WebCron", "ECMJobRunner.WebCron\ECMJobRunner.WebCron.csproj", "{C60BC965-D293-EA64-B153-1941F0648DF4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -15,18 +19,26 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D309B8AA-A976-45C1-A5E1-10BD51C2BC40}.Release|Any CPU.Build.0 = Release|Any CPU
{7EACF04E-525B-40C8-9207-B83FB847B090}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EACF04E-525B-40C8-9207-B83FB847B090}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EACF04E-525B-40C8-9207-B83FB847B090}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EACF04E-525B-40C8-9207-B83FB847B090}.Release|Any CPU.Build.0 = Release|Any CPU
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2D8E3AD4-ABBB-49DB-8BCA-817DF6925275}.Release|Any CPU.Build.0 = Release|Any CPU
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CB94ADEF-59FE-4D7A-83EF-2D57CD325B8F}.Release|Any CPU.Build.0 = Release|Any CPU
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0DC84EFF-0002-4A40-ADDE-D3FE3778D6AA}.Release|Any CPU.Build.0 = Release|Any CPU
{D97CD489-10D7-432C-9921-196F2E0505FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D97CD489-10D7-432C-9921-196F2E0505FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D97CD489-10D7-432C-9921-196F2E0505FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D97CD489-10D7-432C-9921-196F2E0505FF}.Release|Any CPU.Build.0 = Release|Any CPU
{F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F64352B6-32BB-4BDE-90FD-FB77482D44E0}.Release|Any CPU.Build.0 = Release|Any CPU
{C60BC965-D293-EA64-B153-1941F0648DF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C60BC965-D293-EA64-B153-1941F0648DF4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C60BC965-D293-EA64-B153-1941F0648DF4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C60BC965-D293-EA64-B153-1941F0648DF4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View 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

View File

@@ -0,0 +1,116 @@
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Behaviors
{
/// <summary>
/// Pipeline behavior that executes the check SQL query for DEX jobs
/// Runs after MainQueryExecutionBehavior and validates query results
/// </summary>
/// <typeparam name="TRequest">The request type</typeparam>
/// <typeparam name="TResponse">The response type</typeparam>
public class CheckQueryExecutionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ISQLExecutor _executor;
private readonly DexJobOptions _options;
/// <summary>
/// Initializes a new instance of CheckQueryExecutionBehavior
/// </summary>
/// <param name="executor">SQL executor for query execution</param>
/// <param name="options">DEX job configuration options</param>
public CheckQueryExecutionBehavior(ISQLExecutor executor, IOptions<DexJobOptions> options)
{
_executor = executor;
_options = options.Value;
}
/// <summary>
/// Handles the pipeline behavior
/// Executes check query if request is TriggeringDEXJobCommand
/// </summary>
#if NET48
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
#else
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
if (request is TriggeringDEXJobCommand command)
{
await ExecuteCheckQueryAsync(command, cancellationToken);
}
return await next();
}
private async Task ExecuteCheckQueryAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(command.Job.SqlCheckQuery))
{
var sqlCheckQuery = Regex.Replace(
command.Job.SqlCheckQuery!,
_options.Placeholders.BatchId.Pattern,
command.BatchId,
_options.Placeholders.BatchId.RegexOptions);
try
{
var result = await _executor.ExecuteQueryAsync<CheckQueryResult>(sqlCheckQuery, cancel);
if (_options.Error.CheckQuery.OnUnexpectedResult == ErrorAction.Stop)
{
if (result is null)
throw new JobSqlException(
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
reason: "Check Query returned nothing.",
query: sqlCheckQuery,
innerException: null);
else if (result.ReturnValue <= 0)
throw new JobSqlException(
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
reason: $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was any value greater than 0.",
query: sqlCheckQuery,
innerException: null);
}
}
catch (Exception ex)
{
if (_options.Error.CheckQuery.OnExecution == ErrorAction.Stop)
throw new JobSqlException(
jobName: "Triggering DEX",
processName: "Check Query",
batchId: command.BatchId,
reason: null,
query: sqlCheckQuery,
innerException: ex
);
}
}
else if (_options.Error.CheckQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
{
throw new JobSqlException(
jobName:"Triggering DEX",
processName:"Check Query",
batchId:command.BatchId,
reason: "SQL Check Query is null or empty",
query: null,
innerException: null
);
}
}
}
}

View File

@@ -0,0 +1,113 @@
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Behaviors
{
/// <summary>
/// Pipeline behavior that executes the main SQL query for DEX jobs
/// Runs before the command handler and validates query results
/// </summary>
/// <typeparam name="TRequest">The request type</typeparam>
/// <typeparam name="TResponse">The response type</typeparam>
public class MainQueryExecutionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ISQLExecutor _executor;
private readonly DexJobOptions _options;
/// <summary>
/// Initializes a new instance of MainQueryExecutionBehavior
/// </summary>
/// <param name="executor">SQL executor for query execution</param>
/// <param name="options">DEX job configuration options</param>
public MainQueryExecutionBehavior(ISQLExecutor executor, IOptions<DexJobOptions> options)
{
_executor = executor;
_options = options.Value;
}
/// <summary>
/// Handles the pipeline behavior
/// Executes main query if request is TriggeringDEXJobCommand
/// </summary>
#if NET48
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
#else
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
if (request is TriggeringDEXJobCommand command)
{
await ExecuteMainQueryAsync(command, cancellationToken);
}
return await next();
}
private async Task ExecuteMainQueryAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(command.Job.SqlMainQuery))
{
var sqlMainQuery = Regex.Replace(
command.Job.SqlMainQuery!,
_options.Placeholders.BatchId.Pattern,
command.BatchId,
_options.Placeholders.BatchId.RegexOptions);
try
{
var result = await _executor.ExecuteQueryAsync<MainQueryResult>(sqlMainQuery, cancel);
if (_options.Error.MainQuery.OnUnexpectedResult == ErrorAction.Stop)
{
if (result is null)
throw new JobSqlException(
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
reason: "Main Query returned nothing.",
query: sqlMainQuery,
innerException: null);
else if (result.ReturnValue is not null)
throw new JobSqlException(
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
reason: $"The query unexpectedly returned the value {result.ReturnValue}. The expected value was null.",
query: sqlMainQuery, innerException: null);
}
}
catch (Exception ex)
{
if (_options.Error.MainQuery.OnExecution == ErrorAction.Stop)
throw new JobSqlException(
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
reason: null,
query: sqlMainQuery,
innerException: ex);
}
}
else if (_options.Error.MainQuery.IfNullOrWhiteSpace == ErrorAction.Stop)
{
throw new JobSqlException(
jobName: "Triggering DEX",
processName: "Main Query",
batchId: command.BatchId,
reason: "SQL Check Query is null or empty",
query: null,
innerException: null);
}
}
}
}

View File

@@ -0,0 +1,81 @@
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using MediatR;
using Microsoft.Extensions.Options;
using ReC.Client;
using ReC.Client.Api;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.Behaviors
{
/// <summary>
/// Pipeline behavior that sends ReC HTTP request for DEX jobs
/// Runs after CheckQueryExecutionBehavior and invokes ReC API
/// </summary>
/// <typeparam name="TRequest">The request type</typeparam>
/// <typeparam name="TResponse">The response type</typeparam>
public class ReCRequestExecutionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ReCClient _reCClient;
private readonly DexJobOptions _options;
/// <summary>
/// Initializes a new instance of ReCRequestExecutionBehavior
/// </summary>
/// <param name="reCClient">ReC client for HTTP requests</param>
/// <param name="options">DEX job configuration options</param>
public ReCRequestExecutionBehavior(ReCClient reCClient, IOptions<DexJobOptions> options)
{
_reCClient = reCClient;
_options = options.Value;
}
/// <summary>
/// Handles the pipeline behavior
/// Sends ReC request if request is TriggeringDEXJobCommand
/// </summary>
#if NET48
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
#else
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
#endif
{
if (request is TriggeringDEXJobCommand command)
{
await SendReCRequestAsync(command, cancellationToken);
}
return await next();
}
private async Task SendReCRequestAsync(TriggeringDEXJobCommand command, CancellationToken cancel)
{
try
{
await _reCClient.RecActions.InvokeAsync(command.Job.ProfileId, new InvokeReferences()
{
BatchId = command.BatchId,
}, cancel);
}
catch (Exception ex)
{
if (_options.Error.ReCRequest.OnSending == ErrorAction.Stop)
{
throw new JobHttpException(
jobName: "Triggering DEX",
processName: "ReC Http Request",
batchId: command.BatchId,
reason: null,
clientLibrary: "ReC.Client",
clientMethod: "RecActions.InvokeAsync",
innerException: ex);
}
}
}
}
}

View 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,
}
}

View 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; }
}
}

View 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; }
}
}

View 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; }
}
}

View 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; }
}
}

View File

@@ -0,0 +1,47 @@
using System;
namespace ECMJobRunner.Application.Common.Exceptions
{
/// <summary>
/// Exception thrown when attempting to execute a job for an inactive profile
/// Extends JobException with profile-specific context
/// </summary>
public class InactiveProfileException : JobException
{
/// <summary>
/// Initializes a new instance of InactiveProfileException
/// </summary>
/// <param name="profileId">ID of the inactive profile</param>
/// <param name="profileName">Name of the inactive profile (nullable)</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
/// <remarks>
/// Use this exception when:
/// - Attempting to trigger DEX job batch for an inactive profile
/// - Attempting to execute individual jobs from an inactive profile
/// - Profile is deactivated during execution
/// </remarks>
public InactiveProfileException(long profileId, string? profileName, string batchId)
: base(
jobName: "Profile Execution",
processName: "Profile Active Status Validation",
batchId: batchId,
reason: "The profile is marked as inactive and cannot be executed",
innerException: null,
("Profile ID", profileId.ToString(), false),
("Profile Name", profileName, true))
{
ProfileId = profileId;
ProfileName = profileName;
}
/// <summary>
/// Gets the ID of the inactive profile
/// </summary>
public long ProfileId { get; }
/// <summary>
/// Gets the name of the inactive profile (nullable)
/// </summary>
public string? ProfileName { get; }
}
}

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
namespace ECMJobRunner.Application.Common.Exceptions
{
/// <summary>
/// Base exception class for job execution failures
/// Provides a flexible structure for capturing job context and detailed error information
/// </summary>
public class JobException : Exception
{
/// <summary>
/// Initializes a new instance of JobException with detailed context information
/// </summary>
/// <param name="jobName">Name of the job that failed (e.g., "SQL Main Query", "ReC Request")</param>
/// <param name="processName">Name of the process/stage being executed (e.g., "MainQueryExecution", "CheckQueryValidation")</param>
/// <param name="batchId">Unique batch identifier for tracking the execution</param>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="innerException">The underlying exception that caused the failure (nullable)</param>
/// <param name="details">Additional contextual details as name-value pairs with optional null-handling</param>
/// <remarks>
/// The details parameter accepts tuples with:
/// - Name: Display name of the detail
/// - Value: String value of the detail (nullable)
/// - IgnoreIfNull: If true, the detail is omitted from the message when value is null
/// </remarks>
public JobException(string jobName, string processName, string batchId, string? reason, Exception? innerException, params (string Name, string? Value, bool IgnoreIfNull)[] details)
: base(
Message(jobName,
[
("Process Name", processName, false),
("Batch Id", batchId, false),
("Reason", reason, true),
..details
]),
innerException)
{
JobName = jobName;
ProcessName = processName;
BatchId = batchId;
}
/// <summary>
/// Gets the name of the job that failed
/// </summary>
public string JobName { get; }
/// <summary>
/// Gets the name of the process/stage that was being executed when the failure occurred
/// </summary>
public string ProcessName { get; }
/// <summary>
/// Gets the unique batch identifier for tracking the execution
/// </summary>
public string BatchId { get; }
/// <summary>
/// Generates a formatted error message with job context and details
/// </summary>
/// <param name="jobName">Name of the job that failed</param>
/// <param name="details">Collection of name-value pairs with optional null-handling</param>
/// <returns>Formatted multi-line error message with visual separators</returns>
/// <remarks>
/// Message format:
/// <code>
/// {jobName} could not be completed.
/// ─────────────────────────────────────────
/// Process Name: {processName}
/// Batch Id: {batchId}
/// {additional details...}
/// ─────────────────────────────────────────
/// </code>
/// Details with IgnoreIfNull=true are omitted when their value is null.
/// </remarks>
internal static string Message(string jobName, IEnumerable<(string Name, string? Value, bool IgnoreIfNull)> details)
{
var message = new System.Text.StringBuilder();
message.AppendLine($"{jobName} could not be completed.");
message.AppendLine("─────────────────────────────────────────");
foreach (var (name, value, ignoreNullValue) in details)
{
if (ignoreNullValue && value is null)
continue;
message.AppendLine($" {name}: {value}");
}
message.AppendLine("─────────────────────────────────────────");
return message.ToString();
}
}
}

View File

@@ -0,0 +1,46 @@
using System;
namespace ECMJobRunner.Application.Common.Exceptions
{
/// <summary>
/// Exception for HTTP client-related job failures (e.g., ReC API calls, REST requests)
/// Extends JobException with client library and method context
/// </summary>
public class JobHttpException : JobException
{
/// <summary>
/// Initializes a new instance of JobHttpException with HTTP client context
/// </summary>
/// <param name="jobName">Name of the job that failed (e.g., "ReC Request", "API Call")</param>
/// <param name="processName">Name of the process/stage being executed</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="clientLibrary">Name of the HTTP client library used (e.g., "ReC.Client", "HttpClient") (nullable)</param>
/// <param name="clientMethod">Name of the client method that failed (e.g., "ExecuteAsync", "PostAsync") (nullable)</param>
/// <param name="innerException">The underlying exception that caused the failure (nullable)</param>
/// <remarks>
/// Use this exception for HTTP-related failures such as:
/// - ReC API request failures
/// - REST API communication errors
/// - HTTP client timeout/network issues
/// - Authentication/authorization failures
/// </remarks>
public JobHttpException(string jobName, string processName, string batchId, string? reason, string? clientLibrary, string? clientMethod, Exception? innerException) : base(jobName, processName, batchId, reason, innerException,
("Client Library", clientLibrary, true),
("Client Method", clientMethod, true))
{
ClientLibrary = clientLibrary;
ClientMethod = clientMethod;
}
/// <summary>
/// Gets the name of the HTTP client library that was used (e.g., "ReC.Client", "HttpClient")
/// </summary>
public string? ClientLibrary { get; }
/// <summary>
/// Gets the name of the client method that failed (e.g., "ExecuteAsync", "PostAsync")
/// </summary>
public string? ClientMethod { get; }
}
}

View File

@@ -0,0 +1,46 @@
using System;
namespace ECMJobRunner.Application.Common.Exceptions
{
/// <summary>
/// Exception for SQL query execution failures
/// Extends JobException with SQL query context for debugging
/// </summary>
public class JobSqlException : JobException
{
/// <summary>
/// Initializes a new instance of JobSqlException with SQL query context
/// </summary>
/// <param name="jobName">Name of the job that failed (e.g., "Main Query Execution", "Check Query")</param>
/// <param name="processName">Name of the process/stage being executed</param>
/// <param name="batchId">Unique batch identifier for tracking</param>
/// <param name="reason">Human-readable reason for the failure (nullable)</param>
/// <param name="query">The SQL query that failed (nullable, for debugging purposes)</param>
/// <param name="innerException">The underlying SQL exception (nullable)</param>
/// <remarks>
/// Use this exception for SQL-related failures such as:
/// - Main query execution errors
/// - Check query validation failures
/// - Database connection issues
/// - SQL syntax errors
/// - Query timeout exceptions
///
/// The Query property can be logged for debugging but should be handled carefully
/// to avoid exposing sensitive data in production logs.
/// </remarks>
public JobSqlException(string jobName, string processName, string batchId, string? reason, string? query, Exception? innerException) : base(jobName, processName, batchId, reason, innerException,
("Query", query, true))
{
Query = query;
}
/// <summary>
/// Gets the SQL query that failed (nullable)
/// </summary>
/// <remarks>
/// This property is marked as virtual to allow derived classes to customize query handling
/// (e.g., sanitizing sensitive data, truncating long queries)
/// </remarks>
public virtual string? Query { get; }
}
}

View 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);
}
}

View File

@@ -0,0 +1,23 @@
using AutoMapper;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Domain.Entities;
namespace ECMJobRunner.Application.Common.Mapping
{
/// <summary>
/// AutoMapper profile for CfgProfile and ProfileSqlJob mappings
/// Maps domain entities to DTOs
/// </summary>
public class ProfileMappingProfile : Profile
{
public ProfileMappingProfile()
{
// CfgProfile -> CfgProfileDto
CreateMap<CfgProfile, CfgProfileDto>()
.ForMember(dest => dest.SqlJobs, opt => opt.MapFrom(src => src.SqlJobs));
// ProfileSqlJob -> ProfileSqlJobDto
CreateMap<ProfileSqlJob, ProfileSqlJobDto>();
}
}
}

View File

@@ -0,0 +1,105 @@
using ECMJobRunner.Application.Common.Constants;
using System.Text.RegularExpressions;
namespace ECMJobRunner.Application.Common.Options
{
/// <summary>
/// Configuration options for DEX job execution
/// </summary>
public class DexJobOptions
{
/// <summary>
/// Error handling options for DEX job operations
/// </summary>
public record DexJobErrorHandlingOptions
{
/// <summary>
/// Error handling options for SQL query execution
/// </summary>
public record SqlQueryErrorHandlingOptions
{
/// <summary>
/// Action to take when query execution fails
/// </summary>
public ErrorAction OnExecution { get; set; } = ErrorAction.Stop;
/// <summary>
/// Action to take when query is null or whitespace
/// </summary>
public ErrorAction IfNullOrWhiteSpace { get; set; } = ErrorAction.Ignore;
/// <summary>
/// Action to take when query returns unexpected result
/// </summary>
public ErrorAction OnUnexpectedResult { get; set; } = ErrorAction.Stop;
}
/// <summary>
/// Error handling options for HTTP requests
/// </summary>
public record HttpRequestErrorHandlingOptions
{
/// <summary>
/// Action to take when HTTP request fails
/// </summary>
public ErrorAction OnSending { get; set; } = ErrorAction.Stop;
}
/// <summary>
/// Error handling for main SQL query
/// </summary>
public SqlQueryErrorHandlingOptions MainQuery { get; set; } = new();
/// <summary>
/// Error handling for check SQL query
/// </summary>
public SqlQueryErrorHandlingOptions CheckQuery { get; set; } = new();
/// <summary>
/// Error handling for ReC HTTP request
/// </summary>
public HttpRequestErrorHandlingOptions ReCRequest { get; set; } = new();
}
/// <summary>
/// Error handling configuration
/// </summary>
public DexJobErrorHandlingOptions Error { get; set; } = new();
/// <summary>
/// Placeholder configuration for dynamic value replacement
/// </summary>
public record PlaceHolderOptions
{
/// <summary>
/// Configuration for a single placeholder
/// </summary>
public record PlaceHolder
{
/// <summary>
/// Regex pattern to match the placeholder
/// </summary>
public string Pattern { get; set; } = null!;
/// <summary>
/// Regex options for pattern matching
/// </summary>
public RegexOptions RegexOptions { get; set; } = RegexOptions.IgnoreCase;
}
/// <summary>
/// BatchId placeholder configuration
/// </summary>
public PlaceHolder BatchId { get; set; } = new()
{
Pattern = "#INT#BATCH_ID",
RegexOptions = RegexOptions.IgnoreCase
};
}
/// <summary>
/// Placeholder configuration
/// </summary>
public PlaceHolderOptions Placeholders { get; set; } = new();
}
}

View File

@@ -0,0 +1,92 @@
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Domain.Interfaces;
using MediatR;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob.Commands
{
/// <summary>
/// Command to trigger DEX job batch for a profile
/// Executes all SQL jobs associated with a profile ID
/// </summary>
public class TriggeringDEXJobBatchCommand : IRequest<Unit>
{
/// <summary>
/// Profile ID to trigger all associated SQL jobs
/// </summary>
public long ProfileId { get; set; }
}
/// <summary>
/// Handler for TriggeringDEXJobBatchCommand
/// Retrieves all SQL jobs for a profile and executes them sequentially
/// Validates that the profile is active before execution
/// </summary>
public class TriggeringDEXJobBatchCommandHandler(ICfgProfileRepository profileRepo, IProfileSqlJobRepository jobRepo, ISender sender)
: IRequestHandler<TriggeringDEXJobBatchCommand, Unit>
{
/// <summary>
/// Handles the TriggeringDEXJobBatchCommand
/// Creates a unique batch ID and triggers individual job commands
/// </summary>
/// <param name="request">The command containing the profile ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Unit value indicating completion</returns>
/// <exception cref="InactiveProfileException">Thrown when the profile is not active</exception>
public async Task<Unit> Handle(TriggeringDEXJobBatchCommand request, CancellationToken cancellationToken)
{
var batchId = CreateBatchId();
// Retrieve the profile to check if it's active
var profile = await profileRepo.GetByIdAsync(request.ProfileId, cancellationToken);
// Check if profile exists and is active
if (profile == null)
{
throw new InvalidOperationException($"Profile with ID {request.ProfileId} not found.");
}
if (!profile.Active)
{
throw new InactiveProfileException(profile.Id, profile.ProfileName, batchId);
}
// Retrieve all jobs for the profile
var jobs = await jobRepo.FindAsync(j => j.ProfileId == request.ProfileId, cancellationToken);
foreach (var job in jobs)
{
await sender.Send(new TriggeringDEXJobCommand
{
Job = job,
BatchId = batchId
}, cancellationToken);
}
return Unit.Value;
}
/// <summary>
/// Creates a unique batch ID based on current timestamp
/// </summary>
/// <returns>20-character timestamp string (yyyyMMddHHmmssfffffff truncated to 20 chars)</returns>
public static string CreateBatchId()
{
// Format: "yyyy" : Year (4 digits)
// "MM" : Month (2 digits)
// "dd" : Day (2 digits)
// "HH" : Hour (24-hour format, 2 digits)
// "mm" : Minute (2 digits)
// "ss" : Second (2 digits)
// "fffffff": Fractions of a second / 100-nanoseconds (7 digits)
string fullBatchId = DateTime.Now.ToString("yyyyMMddHHmmssfffffff");
// Limit to 20 characters as per specification
return fullBatchId.Substring(0, 20);
}
}
}

View File

@@ -0,0 +1,51 @@
using ECMJobRunner.Domain.Entities;
using MediatR;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob.Commands
{
/// <summary>
/// Command to trigger a single DEX job execution
/// Execution logic is handled by pipeline behaviors:
/// 1. MainQueryExecutionBehavior - executes main SQL query
/// 2. CheckQueryExecutionBehavior - validates with check SQL query
/// 3. ReCRequestExecutionBehavior - invokes ReC HTTP request
/// </summary>
public class TriggeringDEXJobCommand : IRequest<Unit>
{
/// <summary>
/// The SQL job to execute
/// </summary>
public ProfileSqlJob Job { get; set; } = null!;
/// <summary>
/// Unique batch identifier for this execution
/// </summary>
public string BatchId { get; set; } = null!;
}
/// <summary>
/// Handler for TriggeringDEXJobCommand
/// All execution logic is delegated to pipeline behaviors
/// This handler simply returns completion after behaviors execute
/// </summary>
public class TriggeringDEXJobCommandHandler : IRequestHandler<TriggeringDEXJobCommand, Unit>
{
/// <summary>
/// Handles the TriggeringDEXJobCommand
/// Returns immediately as behaviors perform all work
/// </summary>
/// <param name="request">The command containing job and batch ID</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Unit value indicating completion</returns>
public Task<Unit> Handle(TriggeringDEXJobCommand request, CancellationToken cancellationToken)
{
// All execution logic is handled by pipeline behaviors:
// - MainQueryExecutionBehavior
// - CheckQueryExecutionBehavior
// - ReCRequestExecutionBehavior
return Task.FromResult(Unit.Value);
}
}
}

View File

@@ -0,0 +1,124 @@
using AutoMapper;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Domain.Interfaces;
using MediatR;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Application.DEXJob.Queries
{
/// <summary>
/// Query to retrieve profiles with flexible filtering options
/// All query options are nullable - when no filters are specified, returns all profiles
/// </summary>
public class GetProfileQuery : IRequest<List<CfgProfileDto>>
{
/// <summary>
/// Profile ID to retrieve (optional)
/// When specified, returns only the profile with this ID
/// </summary>
public long? Id { get; set; }
/// <summary>
/// Filter by active status (optional)
/// When null, returns both active and inactive profiles
/// When true, returns only active profiles
/// When false, returns only inactive profiles
/// </summary>
public bool? Active { get; set; }
/// <summary>
/// Filter by profile type (optional)
/// When specified, returns only profiles with this type
/// Type: 0 = ADSync; 1 = GraphQL; 2 = SQL-Job; 3 = SQL and REST-Job
/// </summary>
public byte? TypeId { get; set; }
/// <summary>
/// Filter by profile name (optional)
/// When specified, returns profiles with matching name (case-insensitive contains)
/// </summary>
public string? ProfileName { get; set; }
/// <summary>
/// Include associated SQL jobs in the result (optional)
/// Default: true
/// </summary>
public bool IncludeSqlJobs { get; set; } = true;
}
/// <summary>
/// Handler for GetProfileQuery
/// Retrieves profiles with optional filtering and uses AutoMapper for DTO mapping
/// </summary>
public class GetProfileQueryHandler : IRequestHandler<GetProfileQuery, List<CfgProfileDto>>
{
private readonly ICfgProfileRepository _profileRepository;
private readonly IMapper _mapper;
public GetProfileQueryHandler(ICfgProfileRepository profileRepository, IMapper mapper)
{
_profileRepository = profileRepository;
_mapper = mapper;
}
public async Task<List<CfgProfileDto>> Handle(GetProfileQuery request, CancellationToken cancellationToken)
{
IEnumerable<Domain.Entities.CfgProfile> profiles;
// If ID is specified, get single profile by ID
if (request.Id.HasValue)
{
var profile = request.IncludeSqlJobs
? await _profileRepository.GetByIdWithSqlJobsAsync(request.Id.Value, cancellationToken)
: await _profileRepository.GetByIdAsync(request.Id.Value, cancellationToken);
profiles = profile != null ? new[] { profile } : [];
}
// Otherwise, get profiles with filters
else
{
// Get all profiles with SQL jobs if requested
if (request.IncludeSqlJobs)
{
// If Active filter is specified and true, use optimized method
if (request.Active.HasValue && request.Active.Value)
{
profiles = await _profileRepository.GetAllActiveWithSqlJobsAsync(cancellationToken);
}
else
{
// Generic query with filters
profiles = await _profileRepository.FindAsync(p => true, cancellationToken);
}
}
else
{
profiles = await _profileRepository.GetAllAsync(cancellationToken);
}
// Apply filters
if (request.Active.HasValue)
{
profiles = profiles.Where(p => p.Active == request.Active.Value);
}
if (request.TypeId.HasValue)
{
profiles = profiles.Where(p => p.TypeId == request.TypeId.Value);
}
if (!string.IsNullOrWhiteSpace(request.ProfileName))
{
var searchName = request.ProfileName.ToLowerInvariant();
profiles = profiles.Where(p => p.ProfileName.ToLowerInvariant().Contains(searchName));
}
}
// Use AutoMapper to map entities to DTOs
return _mapper.Map<List<CfgProfileDto>>(profiles.ToList());
}
}
}

View File

@@ -0,0 +1,44 @@
using ECMJobRunner.Application.Behaviors;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using ReC.Client;
using System.Reflection;
namespace ECMJobRunner.Application
{
/// <summary>
/// Extension methods for configuring Application layer services
/// </summary>
public static class DependencyInjection
{
/// <summary>
/// Adds Application layer services to the dependency injection container
/// Registers MediatR, pipeline behaviors, and AutoMapper
/// </summary>
/// <param name="services">The service collection</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddJobRunnerServices(this IServiceCollection services, string recClientApiUrl)
{
var assembly = Assembly.GetExecutingAssembly();
// Register MediatR with all handlers from this assembly
#if NET48
services.AddMediatR(assembly);
#else
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
#endif
services.AddRecClient(recClientApiUrl, opt =>
{
opt.LogSuccessfulRequests = true;
});
// Register pipeline behaviors in execution order
// Order matters: MainQuery -> CheckQuery -> ReCRequest
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(MainQueryExecutionBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CheckQueryExecutionBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ReCRequestExecutionBehavior<,>));
return services;
}
}
}

View File

@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net480;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).xml</DocumentationFile>
<PackageId>ECMJobRunner.Application</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>ECMJobRunner.Application</Product>
<Copyright>Copyright 2026</Copyright>
<RepositoryUrl>http://git.dd:3000/AppStd/ECMJobRunner.git</RepositoryUrl>
<PackageTags>digital data ecm job runner application</PackageTags>
<!-- Suppress AutoMapper vulnerability warning (known issue, acceptable for this project) -->
<NoWarn>$(NoWarn);NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
<!-- AutoMapper for .NET Framework 4.8 (matching Infrastructure) -->
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
<!-- MediatR for .NET Framework 4.8 -->
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<!-- AutoMapper for .NET 8 (matching Infrastructure) -->
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<!-- MediatR for .NET 8 -->
<PackageReference Include="MediatR" Version="12.4.1" />
</ItemGroup>
</Project>

View 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

View 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

View 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>

View 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; }
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ECMJobRunner.Domain.Entities
{
/// <summary>
/// Profile Execution History
/// Stores the execution history and results of job runner profiles
/// Result ID: 0 = OK; 1 = ERROR; 2 = WARNING
/// </summary>
[Table("TBJR_OUT_PROFILE_HISTORY", Schema = "dbo")]
public class ProfileHistory
{
/// <summary>
/// Primary Key
/// </summary>
[Key]
[Column("PK_TBJR_OUT_PROFILE_HISTORY_ID")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
/// <summary>
/// Foreign Key to Profile
/// </summary>
[Required]
[Column("FK_TBJR_CFG_PROFILE_ID")]
[ForeignKey(nameof(CfgProfile))]
public long ProfileId { get; set; }
/// <summary>
/// Result ID: 0 = OK; 1 = ERROR; 2 = WARNING
/// </summary>
[Required]
[Column("RESULT_ID")]
public byte ResultId { get; set; }
/// <summary>
/// Result text/message
/// </summary>
[Required]
[Column("RESULT_TEXT")]
public string ResultText { get; set; } = string.Empty;
/// <summary>
/// Created by (max 50 chars)
/// </summary>
[Required]
[Column("ADDED_WHO")]
[MaxLength(50)]
public string AddedWho { get; set; } = "DEFAULT";
/// <summary>
/// Created at
/// </summary>
[Required]
[Column("ADDED_WHEN")]
public DateTime AddedWhen { get; set; } = DateTime.Now;
/// <summary>
/// Modified by (max 50 chars)
/// </summary>
[Column("CHANGED_WHO")]
[MaxLength(50)]
public string? ChangedWho { get; set; }
/// <summary>
/// Modified at
/// </summary>
[Column("CHANGED_WHEN")]
public DateTime? ChangedWhen { get; set; }
// Navigation properties
/// <summary>
/// Associated Profile
/// </summary>
public virtual CfgProfile? CfgProfile { get; set; }
}
}

View 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; }
}
}

View 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);
}
}

View 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
}
}

View 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
}
}

View File

@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Domain.Interfaces
{
/// <summary>
/// Generic repository interface for data access operations
/// </summary>
/// <typeparam name="TEntity">Entity type</typeparam>
public interface IRepository<TEntity> where TEntity : class
{
/// <summary>
/// Get entity by ID asynchronously
/// </summary>
Task<TEntity?> GetByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>
/// Get all entities asynchronously
/// </summary>
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Find entities by predicate asynchronously
/// </summary>
Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
/// <summary>
/// Get single entity by predicate asynchronously
/// </summary>
Task<TEntity?> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
/// <summary>
/// Add new entity from DTO asynchronously
/// Maps DTO to entity and adds it
/// </summary>
/// <typeparam name="TDto">DTO type</typeparam>
/// <param name="dto">DTO containing values for new entity</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Created entity</returns>
Task<TEntity> AddAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) where TDto : class;
/// <summary>
/// Add multiple entities from DTOs asynchronously
/// Maps DTOs to entities and adds them
/// </summary>
/// <typeparam name="TDto">DTO type</typeparam>
/// <param name="dtos">DTOs containing values for new entities</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Number of entities added</returns>
Task<int> AddRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) where TDto : class;
/// <summary>
/// Update entities matching predicate with DTO values asynchronously
/// Maps DTO properties onto matching entities
/// </summary>
/// <typeparam name="TDto">DTO type</typeparam>
/// <param name="predicate">Predicate to find entities</param>
/// <param name="dto">DTO containing values to update</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Number of entities updated</returns>
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class;
/// <summary>
/// Update single entity matching predicate with DTO values asynchronously
/// Throws exception if multiple entities match
/// </summary>
/// <typeparam name="TDto">DTO type</typeparam>
/// <param name="predicate">Predicate to find entity</param>
/// <param name="dto">DTO containing values to update</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if entity was found and updated, false otherwise</returns>
Task<bool> UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class;
/// <summary>
/// Delete entities matching predicate asynchronously (hard delete)
/// </summary>
/// <param name="predicate">Predicate to find entities to delete</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Number of entities deleted</returns>
Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
/// <summary>
/// Delete single entity matching predicate asynchronously (hard delete)
/// Throws exception if multiple entities match
/// </summary>
/// <param name="predicate">Predicate to find entity to delete</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if entity was found and deleted, false otherwise</returns>
Task<bool> DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
/// <summary>
/// Save all changes asynchronously
/// </summary>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
}

View 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

View 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
}
}

View 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
}
}

View File

@@ -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>

View 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
}
}
}

View File

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

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,188 @@
#if NET48
using System.Data.Entity;
#else
using Microsoft.EntityFrameworkCore;
#endif
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using ECMJobRunner.Domain.Interfaces;
using ECMJobRunner.Infrastructure.Data;
namespace ECMJobRunner.Infrastructure.Repositories
{
/// <summary>
/// Generic repository implementation for Entity Framework
/// Uses AutoMapper for DTO mapping
/// </summary>
/// <typeparam name="TEntity">Entity type</typeparam>
public class Repository<TEntity> : IRepository<TEntity> where TEntity : class
{
/// <summary>
/// Database context
/// </summary>
protected readonly JobRunnerDbContext _context;
/// <summary>
/// DbSet for the entity
/// </summary>
protected readonly DbSet<TEntity> _dbSet;
/// <summary>
/// AutoMapper instance for DTO mapping
/// </summary>
protected readonly IMapper _mapper;
/// <summary>
/// Constructor
/// </summary>
public Repository(JobRunnerDbContext context, IMapper mapper)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
_dbSet = context.Set<TEntity>();
}
/// <inheritdoc/>
public virtual async Task<TEntity?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
#if NET48
return await _dbSet.FindAsync(cancellationToken, id);
#else
return await _dbSet.FindAsync(new object[] { id }, cancellationToken);
#endif
}
/// <inheritdoc/>
public virtual async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _dbSet.ToListAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.Where(predicate).ToListAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<TEntity?> SingleOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
return await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<TEntity> AddAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
if (dto == null) throw new ArgumentNullException(nameof(dto));
var entity = _mapper.Map<TEntity>(dto);
#if NET48
_dbSet.Add(entity);
await Task.CompletedTask;
#else
await _dbSet.AddAsync(entity, cancellationToken);
#endif
await SaveChangesAsync(cancellationToken);
return entity;
}
/// <inheritdoc/>
public virtual async Task<int> AddRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) where TDto : class
{
if (dtos == null) throw new ArgumentNullException(nameof(dtos));
var dtoList = dtos.ToList();
if (!dtoList.Any())
return 0;
var entities = _mapper.Map<List<TEntity>>(dtoList);
#if NET48
_dbSet.AddRange(entities);
await Task.CompletedTask;
#else
await _dbSet.AddRangeAsync(entities, cancellationToken);
#endif
return await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
if (dto == null) throw new ArgumentNullException(nameof(dto));
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
if (!entities.Any())
return 0;
foreach (var entity in entities)
{
_mapper.Map(dto, entity);
}
return await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<bool> UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class
{
if (dto == null) throw new ArgumentNullException(nameof(dto));
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
if (entity == null)
return false;
_mapper.Map(dto, entity);
await SaveChangesAsync(cancellationToken);
return true;
}
/// <inheritdoc/>
public virtual async Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
if (!entities.Any())
return 0;
_dbSet.RemoveRange(entities);
return await SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
public virtual async Task<bool> DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
{
var entity = await SingleOrDefaultAsync(predicate, cancellationToken);
if (entity == null)
return false;
_dbSet.Remove(entity);
await SaveChangesAsync(cancellationToken);
return true;
}
/// <inheritdoc/>
public virtual async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
#if NET48
return await _context.SaveChangesAsync();
#else
return await _context.SaveChangesAsync(cancellationToken);
#endif
}
}
}

View File

@@ -0,0 +1,57 @@
#if NET48
using System.Data.Entity;
using System.Linq;
#else
using Microsoft.EntityFrameworkCore;
#endif
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Infrastructure.Data;
using System.Threading;
using System.Threading.Tasks;
namespace ECMJobRunner.Infrastructure.Services
{
/// <summary>
/// Entity Framework implementation of ISQLExecutor
/// Executes raw SQL queries and maps results to DTOs
/// </summary>
public class SQLExecutor : ISQLExecutor
{
private readonly JobRunnerDbContext _context;
/// <summary>
/// Initializes a new instance of SQLExecutor
/// </summary>
/// <param name="context">The database context</param>
public SQLExecutor(JobRunnerDbContext context)
{
_context = context;
}
/// <summary>
/// Executes a SQL query and maps the result to the specified type
/// </summary>
/// <typeparam name="TResult">The type to map the query result to</typeparam>
/// <param name="sql">The SQL query to execute</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The mapped result or null if no result</returns>
public async Task<TResult?> ExecuteQueryAsync<TResult>(string sql, CancellationToken cancellationToken = default)
{
#if NET48
// Entity Framework 6 implementation
var result = await _context.Database
.SqlQuery<TResult>(sql)
.FirstOrDefaultAsync(cancellationToken);
return result;
#else
// Entity Framework Core implementation
var result = await _context.Database
.SqlQueryRaw<TResult>(sql)
.FirstOrDefaultAsync(cancellationToken);
return result;
#endif
}
}
}

View File

@@ -0,0 +1,234 @@
using ECMJobRunner.Application.Behaviors;
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Domain.Entities;
using FluentAssertions;
using MediatR;
using Microsoft.Extensions.Options;
using Moq;
using System;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace ECMJobRunner.Tests.Application
{
public class CheckQueryExecutionBehaviorTests
{
private readonly Mock<ISQLExecutor> _mockExecutor;
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
private readonly CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit> _behavior;
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
public CheckQueryExecutionBehaviorTests()
{
_mockExecutor = new Mock<ISQLExecutor>();
_mockOptions = new Mock<IOptions<DexJobOptions>>();
_mockOptions.Setup(o => o.Value).Returns(new DexJobOptions());
_behavior = new CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_mockNext = new Mock<RequestHandlerDelegate<Unit>>();
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
}
[Fact]
public async Task Handle_WithValidCheckQuery_ExecutesSuccessfully()
{
// Arrange
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CheckQueryResult { ReturnValue = 5 });
// Act
#if NET48
var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
result.Should().Be(Unit.Value);
_mockExecutor.Verify(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
_mockNext.Verify(n => n(), Times.Once);
}
[Theory]
[InlineData(1)]
[InlineData(10)]
[InlineData(100)]
public async Task Handle_WithPositiveReturnValue_DoesNotThrow(int returnValue)
{
// Arrange
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CheckQueryResult { ReturnValue = returnValue });
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().NotThrowAsync();
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-10)]
public async Task Handle_WithZeroOrNegativeReturnValue_ThrowsDEXJobException(int returnValue)
{
// Arrange
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CheckQueryResult { ReturnValue = returnValue });
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage($"*unexpectedly returned the value {returnValue}*");
}
[Fact]
public async Task Handle_WithNullCheckQuery_IgnoresByDefault()
{
// Arrange
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = null },
BatchId = "20260711143025123456"
};
// Act
#if NET48
var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
result.Should().Be(Unit.Value);
_mockExecutor.Verify(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
_mockNext.Verify(n => n(), Times.Once);
}
[Fact]
public async Task Handle_WithNullCheckQueryAndStopOption_ThrowsDEXJobException()
{
// Arrange
var options = new DexJobOptions
{
Error = new DexJobOptions.DexJobErrorHandlingOptions
{
CheckQuery = new DexJobOptions.DexJobErrorHandlingOptions.SqlQueryErrorHandlingOptions
{
IfNullOrWhiteSpace = ErrorAction.Stop
}
}
};
_mockOptions.Setup(o => o.Value).Returns(options);
var behavior = new CheckQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = null },
BatchId = "20260711143025123456"
};
// Act
#if NET48
Func<Task> act = async () => await behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage("*SQL Check Query is null or empty*");
}
[Fact]
public async Task Handle_WithBatchIdPlaceholder_ReplacesCorrectly()
{
// Arrange
var batchId = "20260711143025123456";
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table WHERE BatchId = '#INT#BATCH_ID'" },
BatchId = batchId
};
string? capturedSql = null;
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, CancellationToken>((sql, _) => capturedSql = sql)
.ReturnsAsync(new CheckQueryResult { ReturnValue = 1 });
// Act
#if NET48
await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
capturedSql.Should().Contain(batchId);
capturedSql.Should().NotContain("#INT#BATCH_ID");
}
[Fact]
public async Task Handle_WithNullResult_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlCheckQuery = "SELECT COUNT(*) AS [Return Value] FROM Table" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<CheckQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((CheckQueryResult?)null);
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage("*Check Query returned nothing*");
}
}
}

View 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);
}
}
}

View File

@@ -0,0 +1,252 @@
using ECMJobRunner.Application.Behaviors;
using ECMJobRunner.Application.Common.Constants;
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Exceptions;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Domain.Entities;
using FluentAssertions;
using MediatR;
using Microsoft.Extensions.Options;
using Moq;
using System;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace ECMJobRunner.Tests.Application
{
public class MainQueryExecutionBehaviorTests
{
private readonly Mock<ISQLExecutor> _mockExecutor;
private readonly Mock<IOptions<DexJobOptions>> _mockOptions;
private readonly MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit> _behavior;
private readonly Mock<RequestHandlerDelegate<Unit>> _mockNext;
public MainQueryExecutionBehaviorTests()
{
_mockExecutor = new Mock<ISQLExecutor>();
_mockOptions = new Mock<IOptions<DexJobOptions>>();
_mockOptions.Setup(o => o.Value).Returns(new DexJobOptions());
_behavior = new MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
_mockNext = new Mock<RequestHandlerDelegate<Unit>>();
_mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
}
[Fact]
public async Task Handle_WithValidMainQuery_ExecutesSuccessfully()
{
// Arrange
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MainQueryResult { ReturnValue = null });
// Act
#if NET48
var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
result.Should().Be(Unit.Value);
_mockExecutor.Verify(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
_mockNext.Verify(n => n(), Times.Once);
}
[Fact]
public async Task Handle_WithNullReturnValue_DoesNotThrow()
{
// Arrange
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MainQueryResult { ReturnValue = null });
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().NotThrowAsync();
}
[Fact]
public async Task Handle_WithNonNullReturnValue_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table VALUES (1)" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new MainQueryResult { ReturnValue = 1 });
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage("*unexpectedly returned the value 1*");
}
[Fact]
public async Task Handle_WithNullMainQuery_IgnoresByDefault()
{
// Arrange
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = null },
BatchId = "20260711143025123456"
};
// Act
#if NET48
var result = await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
var result = await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
result.Should().Be(Unit.Value);
_mockExecutor.Verify(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
_mockNext.Verify(n => n(), Times.Once);
}
[Fact]
public async Task Handle_WithNullMainQueryAndStopOption_ThrowsDEXJobException()
{
// Arrange
var options = new DexJobOptions
{
Error = new DexJobOptions.DexJobErrorHandlingOptions
{
MainQuery = new DexJobOptions.DexJobErrorHandlingOptions.SqlQueryErrorHandlingOptions
{
IfNullOrWhiteSpace = ErrorAction.Stop
}
}
};
_mockOptions.Setup(o => o.Value).Returns(options);
var behavior = new MainQueryExecutionBehavior<TriggeringDEXJobCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = null },
BatchId = "20260711143025123456"
};
// Act
#if NET48
Func<Task> act = async () => await behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage("*SQL Main Query is null or empty*");
}
[Fact]
public async Task Handle_WithBatchIdPlaceholder_ReplacesCorrectly()
{
// Arrange
var batchId = "20260711143025123456";
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INSERT INTO Table (BatchId) VALUES ('#INT#BATCH_ID')" },
BatchId = batchId
};
string? capturedSql = null;
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, CancellationToken>((sql, _) => capturedSql = sql)
.ReturnsAsync(new MainQueryResult { ReturnValue = null });
// Act
#if NET48
await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
capturedSql.Should().Contain(batchId);
capturedSql.Should().NotContain("#INT#BATCH_ID");
}
[Fact]
public async Task Handle_WithExecutionError_ThrowsDEXJobException()
{
// Arrange
var command = new TriggeringDEXJobCommand
{
Job = new ProfileSqlJob { SqlMainQuery = "INVALID SQL" },
BatchId = "20260711143025123456"
};
_mockExecutor
.Setup(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SQL error"));
// Act
#if NET48
Func<Task> act = async () => await _behavior.Handle(command, CancellationToken.None, _mockNext.Object);
#else
Func<Task> act = async () => await _behavior.Handle(command, _mockNext.Object, CancellationToken.None);
#endif
// Assert
await act.Should().ThrowAsync<JobException>()
.WithMessage("*SQL Main Query*");
}
[Fact]
public async Task Handle_WithNonDEXJobCommand_SkipsExecution()
{
// Arrange
var otherCommand = new OtherCommand();
var behavior = new MainQueryExecutionBehavior<OtherCommand, Unit>(_mockExecutor.Object, _mockOptions.Object);
var mockNext = new Mock<RequestHandlerDelegate<Unit>>();
mockNext.Setup(n => n()).ReturnsAsync(Unit.Value);
// Act
#if NET48
var result = await behavior.Handle(otherCommand, CancellationToken.None, mockNext.Object);
#else
var result = await behavior.Handle(otherCommand, mockNext.Object, CancellationToken.None);
#endif
// Assert
result.Should().Be(Unit.Value);
_mockExecutor.Verify(e => e.ExecuteQueryAsync<MainQueryResult>(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
mockNext.Verify(n => n(), Times.Once);
}
private class OtherCommand : IRequest<Unit> { }
}
}

View File

@@ -0,0 +1,136 @@
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.Common.Interfaces;
using ECMJobRunner.Application.Common.Options;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Domain.Entities;
using ECMJobRunner.Domain.Interfaces;
using FluentAssertions;
using MediatR;
using Microsoft.Extensions.Options;
using Moq;
using ReC.Client;
using ReC.Client.Api;
using System;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace ECMJobRunner.Tests.Application
{
public class TriggeringDEXJobBatchCommandTests
{
private readonly Mock<IProfileSqlJobRepository> _mockJobRepo;
private readonly Mock<ISender> _mockSender;
private readonly TriggeringDEXJobBatchCommandHandler _handler;
public TriggeringDEXJobBatchCommandTests()
{
_mockJobRepo = new Mock<IProfileSqlJobRepository>();
_mockSender = new Mock<ISender>();
_handler = new TriggeringDEXJobBatchCommandHandler(_mockJobRepo.Object, _mockSender.Object);
}
[Fact]
public async Task Handle_WithValidProfileId_ExecutesAllJobs()
{
// Arrange
var profileId = 123;
var jobs = new[]
{
new ProfileSqlJob { Id = 1, ProfileId = profileId, SqlMainQuery = "SELECT 1", SqlCheckQuery = "SELECT 2" },
new ProfileSqlJob { Id = 2, ProfileId = profileId, SqlMainQuery = "SELECT 3", SqlCheckQuery = "SELECT 4" }
};
_mockJobRepo
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(jobs);
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().Be(Unit.Value);
_mockSender.Verify(
s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()),
Times.Exactly(2),
"Should send command for each job");
}
[Fact]
public async Task Handle_WithNoJobs_CompletesWithoutError()
{
// Arrange
var profileId = 999;
_mockJobRepo
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<ProfileSqlJob>());
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().Be(Unit.Value);
_mockSender.Verify(
s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()),
Times.Never,
"Should not send any commands when no jobs found");
}
[Fact]
public void CreateBatchId_ReturnsValidFormat()
{
// Act
var batchId = TriggeringDEXJobBatchCommandHandler.CreateBatchId();
// Assert
batchId.Should().NotBeNullOrEmpty();
batchId.Should().HaveLength(20, "BatchId should be exactly 20 characters");
batchId.Should().MatchRegex(@"^\d{20}$", "BatchId should contain only digits");
}
[Fact]
public void CreateBatchId_GeneratesUniqueBatchIds()
{
// Act
var batchId1 = TriggeringDEXJobBatchCommandHandler.CreateBatchId();
var batchId2 = TriggeringDEXJobBatchCommandHandler.CreateBatchId();
// Assert
batchId1.Should().NotBe(batchId2, "Consecutive batch IDs should be different");
}
[Fact]
public async Task Handle_PassesBatchIdToCommands()
{
// Arrange
var profileId = 123;
var job = new ProfileSqlJob { Id = 1, ProfileId = profileId, SqlMainQuery = "SELECT 1" };
_mockJobRepo
.Setup(r => r.FindAsync(It.IsAny<Expression<Func<ProfileSqlJob, bool>>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { job });
TriggeringDEXJobCommand? capturedCommand = null;
_mockSender
.Setup(s => s.Send(It.IsAny<TriggeringDEXJobCommand>(), It.IsAny<CancellationToken>()))
.Callback<IRequest<Unit>, CancellationToken>((cmd, _) => capturedCommand = cmd as TriggeringDEXJobCommand)
.ReturnsAsync(Unit.Value);
var command = new TriggeringDEXJobBatchCommand { ProfileId = profileId };
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
capturedCommand.Should().NotBeNull();
capturedCommand!.BatchId.Should().NotBeNullOrEmpty();
capturedCommand.BatchId.Should().HaveLength(20);
capturedCommand.Job.Should().Be(job);
}
}
}

View File

@@ -0,0 +1,164 @@
using ECMJobRunner.Application.Common.Exceptions;
using FluentAssertions;
using System;
using Xunit;
namespace ECMJobRunner.Tests.Application
{
public class TriggeringDEXJobCommandExceptionTests
{
[Fact]
public void JobSqlException_HasCorrectMessage()
{
// Arrange
var jobName = "Main Query Execution";
var processName = "MainQueryExecution";
var batchId = "12345678901234567890";
var reason = "SQL syntax error";
var query = "SELECT * FROM NonExistentTable";
// Act
var exception = new JobSqlException(
jobName,
processName,
batchId,
reason,
query,
null);
// Assert
exception.JobName.Should().Be(jobName);
exception.ProcessName.Should().Be(processName);
exception.BatchId.Should().Be(batchId);
exception.Query.Should().Be(query);
exception.Message.Should().Contain(jobName);
exception.Message.Should().Contain(processName);
exception.Message.Should().Contain(batchId);
exception.Message.Should().Contain(reason);
exception.Message.Should().Contain(query);
}
[Fact]
public void JobSqlException_WithInnerException_PreservesInnerException()
{
// Arrange
var innerException = new InvalidOperationException("Database connection failed");
var exception = new JobSqlException(
"Main Query",
"Execution",
"12345678901234567890",
"Connection error",
"SELECT 1",
innerException);
// Assert
exception.InnerException.Should().Be(innerException);
exception.InnerException!.Message.Should().Be("Database connection failed");
}
[Fact]
public void JobHttpException_HasCorrectMessage()
{
// Arrange
var jobName = "ReC Request Execution";
var processName = "ReCRequestExecution";
var batchId = "12345678901234567890";
var reason = "HTTP 500 Internal Server Error";
var clientLibrary = "ReC.Client";
var clientMethod = "ExecuteAsync";
// Act
var exception = new JobHttpException(
jobName,
processName,
batchId,
reason,
clientLibrary,
clientMethod,
null);
// Assert
exception.JobName.Should().Be(jobName);
exception.ProcessName.Should().Be(processName);
exception.BatchId.Should().Be(batchId);
exception.ClientLibrary.Should().Be(clientLibrary);
exception.ClientMethod.Should().Be(clientMethod);
exception.Message.Should().Contain(jobName);
exception.Message.Should().Contain(processName);
exception.Message.Should().Contain(batchId);
exception.Message.Should().Contain(reason);
exception.Message.Should().Contain(clientLibrary);
exception.Message.Should().Contain(clientMethod);
}
[Fact]
public void JobHttpException_WithInnerException_PreservesInnerException()
{
// Arrange
var innerException = new TimeoutException("Request timed out");
var exception = new JobHttpException(
"ReC Request",
"Execution",
"12345678901234567890",
"Timeout error",
"ReC.Client",
"ExecuteAsync",
innerException);
// Assert
exception.InnerException.Should().Be(innerException);
exception.InnerException!.Message.Should().Be("Request timed out");
}
[Fact]
public void JobException_OmitsNullValues_WhenIgnoreIfNullIsTrue()
{
// Arrange & Act
var exception = new JobException(
"Test Job",
"Test Process",
"12345678901234567890",
null, // reason is null
null,
("Custom Detail", "Value", false));
// Assert
exception.Message.Should().NotContain("Reason:"); // Should be omitted because it's null and IgnoreIfNull=true
exception.Message.Should().Contain("Custom Detail: Value");
}
[Fact]
public void JobException_IncludesNullValues_WhenIgnoreIfNullIsFalse()
{
// Arrange & Act
var exception = new JobException(
"Test Job",
"Test Process",
"12345678901234567890",
null,
null,
("Custom Detail", null, false)); // IgnoreIfNull=false
// Assert
exception.Message.Should().Contain("Custom Detail:"); // Should be included even though value is null
}
[Fact]
public void JobException_FormatsMessageWithSeparators()
{
// Arrange & Act
var exception = new JobException(
"Test Job",
"Test Process",
"12345678901234567890",
"Test reason",
null);
// Assert
exception.Message.Should().Contain("─────────────────────────────────────────");
exception.Message.Should().Contain("Test Job could not be completed.");
exception.Message.Should().Contain("Process Name: Test Process");
exception.Message.Should().Contain("Batch Id: 12345678901234567890");
}
}
}

View 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; }
}
}

View File

@@ -0,0 +1,85 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net480;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Suppress AutoMapper vulnerability warning for .NET Framework 4.8 -->
<NoWarn>$(NoWarn);NU1903</NoWarn>
</PropertyGroup>
<ItemGroup>
<!-- Test Framework - Common for both frameworks -->
<PackageReference Include="coverlet.collector" Version="6.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- Fluent Assertions for Better Test Readability -->
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<!-- Mocking Framework -->
<PackageReference Include="Moq" Version="4.20.72" />
<!-- Fake Data Generation (common for both frameworks) -->
<PackageReference Include="Bogus" Version="35.6.1" />
</ItemGroup>
<!-- .NET Framework 4.8 specific packages -->
<ItemGroup Condition="'$(TargetFramework)' == 'net480'">
<!-- Dependency Injection & Hosting for .NET Framework 4.8 -->
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<!-- AutoMapper for .NET Framework 4.8 (matching Infrastructure) -->
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
<!-- MediatR for .NET Framework 4.8 -->
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<!-- ReC.Client for HTTP mocking -->
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
</ItemGroup>
<!-- .NET 8.0 specific packages -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<!-- Dependency Injection & Hosting for .NET 8 -->
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<!-- AutoMapper for .NET 8 (matching Infrastructure) -->
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<!-- MediatR for .NET 8 -->
<PackageReference Include="MediatR" Version="12.4.1" />
<!-- ReC.Client for HTTP mocking -->
<PackageReference Include="ReC.Client" Version="2.0.0-beta" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<!-- Project References -->
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
<ProjectReference Include="..\ECMJobRunner.Infrastructure\ECMJobRunner.Infrastructure.csproj" />
<ProjectReference Include="..\ECMJobRunner.Application\ECMJobRunner.Application.csproj" />
</ItemGroup>
</Project>

View 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());
}
}

View 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();
}
}

View 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());
}
}
}

View 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);
}
}

View File

@@ -0,0 +1,17 @@
using Hangfire.Dashboard;
namespace ECMJobRunner.WebCron
{
/// <summary>
/// Authorization filter that allows all users to access Hangfire Dashboard
/// WARNING: This is for development only! Use proper authentication in production.
/// </summary>
public class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
// Allow all users - FOR DEVELOPMENT ONLY
return true;
}
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.23" />
<PackageReference Include="Hangfire.Core" Version="1.8.23" />
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.SqlServer" Version="1.8.23" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.9" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="7.0.0" />
<PackageReference Include="Serilog.UI" Version="3.2.0" />
<PackageReference Include="Serilog.UI.SqliteProvider" Version="1.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ECMJobRunner.Application\ECMJobRunner.Application.csproj" />
<ProjectReference Include="..\ECMJobRunner.Domain\ECMJobRunner.Domain.csproj" />
<ProjectReference Include="..\ECMJobRunner.Infrastructure\ECMJobRunner.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,21 @@
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.DEXJob.Commands;
using MediatR;
namespace ECMJobRunner.WebCron.Extensions;
public static class DtoExtensions
{
public static string JobId(this CfgProfileDto profile)
{
return $"profile-{profile.Id}-{profile.ProfileName.Replace(' ', '_').ToLowerInvariant()}";
}
public static TriggeringDEXJobBatchCommand ToJob(this CfgProfileDto profile)
{
return new TriggeringDEXJobBatchCommand
{
ProfileId = profile.Id,
};
}
}

View File

@@ -0,0 +1,71 @@
using ECMJobRunner.Application.Common.Dtos;
using ECMJobRunner.Application.DEXJob.Commands;
using ECMJobRunner.Application.DEXJob.Queries;
using ECMJobRunner.WebCron.Extensions;
using Hangfire;
using MediatR;
using System.Collections.Concurrent;
namespace ECMJobRunner.WebCron
{
public class ProfileManager(ILogger<ProfileManager> Logger, IServiceScopeFactory ScopeFactory, IRecurringJobManager JobManager) : BackgroundService
{
private readonly ConcurrentDictionary<string, CfgProfileDto> Profiles = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
while (!stoppingToken.IsCancellationRequested)
{
if (Logger.IsEnabled(LogLevel.Information))
{
Logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
// Create a scope to resolve scoped services (ISQLExecutor used by MediatR pipeline)
using var scope = ScopeFactory.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var profiles = await mediator.Send(new GetProfileQuery()
{
Active = true,
IncludeSqlJobs = true
}, stoppingToken);
foreach (var profile in profiles)
{
if (Profiles.TryGetValue(profile.JobId(), out var currentProfile)
&& currentProfile.Schedule == profile.Schedule)
continue;
// Add or update recurring job using MediatR command
JobManager.AddOrUpdate<IMediator>(
profile.JobId(),
mediator => mediator.Send(profile.ToJob(), CancellationToken.None),
profile.Schedule,
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.Local
}
);
// Store/update in local cache
Profiles[profile.JobId()] = profile;
Logger.LogInformation("Job {JobId} registered with schedule: {Schedule}",
profile.JobId(), profile.Schedule);
}
await Task.Delay(1000, stoppingToken);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "An error occurred in ProfileManager.");
}
}
}
}

View File

@@ -0,0 +1,150 @@
using ECMJobRunner.Application;
using ECMJobRunner.Infrastructure;
using ECMJobRunner.WebCron;
using Hangfire;
using Hangfire.SqlServer;
using Microsoft.Data.SqlClient;
using Serilog;
using Serilog.Ui.Core.Extensions;
using Serilog.Ui.SqliteDataProvider.Extensions;
using Serilog.Ui.Web.Extensions;
// Enable Serilog self-diagnostics
Serilog.Debugging.SelfLog.Enable(msg => Console.WriteLine($"[SERILOG] {msg}"));
// Build temporary configuration to read log directory
var tempConfig = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
// Get log directory from configuration
var logDirectory = tempConfig.GetValue<string>("Logging:LogDirectory")
?? throw new InvalidOperationException("Logging:LogDirectory not found in configuration.");
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
Console.WriteLine($"[INFO] SQLite Log Database Path: {sqliteDbPath}");
// Configure Serilog with SQLite sink
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Hangfire", Serilog.Events.LogEventLevel.Information)
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.SQLite(sqliteDbPath, storeTimestampInUtc: true)
.Enrich.FromLogContext()
.CreateLogger();
try
{
Log.Information("Starting ECMJobRunner.WebCron application");
var builder = WebApplication.CreateBuilder(args);
// Use Serilog for logging
builder.Host.UseSerilog();
// Configure Windows Service hosting if enabled
if (builder.Configuration.GetValue<bool>("HostingOptions:UseWindowsService"))
{
builder.Host.UseWindowsService();
}
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddHostedService<ProfileManager>();
// Register services
var cnnStr = builder.Configuration.GetConnectionString("SDD-VMP04-SQL17")
?? throw new InvalidOperationException("Connection string 'SDD-VMP04-SQL17' not found.");
builder.Services.AddJobRunnerInfrastructure(cnnStr);
var recClientApiUrl = builder.Configuration.GetValue<string>("ReC:ApiUrl")
?? throw new InvalidOperationException("ReC:ApiUrl not found.");
builder.Services.AddJobRunnerServices(recClientApiUrl);
// Get Hangfire storage configuration
var useInMemory = builder.Configuration.GetValue<bool>("Hangfire:InMemory");
// Add Hangfire services with configurable storage
builder.Services.AddHangfire(configuration =>
{
configuration
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings();
// Configure storage based on appsettings
if (useInMemory)
{
configuration.UseInMemoryStorage();
}
else // Use SQL Server storage
{
configuration.UseSqlServerStorage(cnnStr, new SqlServerStorageOptions
{
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
QueuePollInterval = TimeSpan.Zero,
UseRecommendedIsolationLevel = true,
DisableGlobalLocks = true,
SqlClientFactory = SqlClientFactory.Instance
});
}
});
// Add Hangfire server
builder.Services.AddHangfireServer();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Add Serilog.UI with SQLite provider - use same path from configuration
var serilogUiLogDirectory = builder.Configuration.GetValue<string>("Logging:LogDirectory")
?? throw new InvalidOperationException("Logging:LogDirectory not found in configuration.");
var serilogUiDbPath = Path.Combine(serilogUiLogDirectory, "logs.db");
builder.Services.AddSerilogUi(logUIOpt =>
{
logUIOpt.UseSqliteServer(dbOpt =>
{
dbOpt.WithConnectionString($"Data Source={serilogUiDbPath}");
dbOpt.WithTable("Logs");
});
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
// Add Hangfire Dashboard with no authentication (for development)
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[] { new AllowAllDashboardAuthorizationFilter() }
});
// Add Serilog.UI Dashboard
app.UseSerilogUi();
app.MapControllers();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:17567",
"sslPort": 44300
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "hangfire",
"applicationUrl": "http://localhost:5271",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "hangfire",
"applicationUrl": "https://localhost:7027;http://localhost:5271",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "hangfire",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,10 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Hangfire": "Information"
}
}
}

View File

@@ -0,0 +1,22 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"LogDirectory": "E:\\LogFiles\\Digital Data\\ECMJobRunner.WebCron"
},
"ConnectionStrings": {
"SDD-VMP04-SQL17": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
},
"AllowedHosts": "*",
"HostingOptions": {
"UseWindowsService": false
},
"Hangfire": {
"InMemory": true
},
"ReC": {
"ApiUrl": "http://172.24.12.39:90"
}
}