using FrameworkBLL.SchedulerTaskGenerator;
using FrameworkDAL.CustomCode.SchedulerTaskGenerator;
using FrameworkDAL.DTO.SchedulerTaskGenerator;
using GB5Shared.Connection;
using GB5Shared.DTO.Framework.CommonConfig;
using GB5Shared.DTO.Framework.Login;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Quartz;
using Xunit;
namespace FrameworkTests;
///
/// The safe synthetic-test harness Track A's own doc comment asks for before flipping
/// SchedulerRun:QuartzNativeBootstrapEnabled on a real host (tracker §45):
/// "keep this OFF until a throwaway synthetic test job has been verified to fire correctly
/// exactly once via a manually-synced trigger." Both real controllers that would have been
/// that manual verification path (SchedulerTestController, SchedulerBindingController) turned
/// out to be fully commented-out dead stubs — this is the harness that didn't exist.
///
/// This exercises the REAL QuartzSyncService.SyncJobAsync (trigger-building) and the REAL
/// SchedulerJobExecutorQuartzJob.Execute (the fire handler) against a genuine in-memory Quartz
/// scheduler — not a fake/simulated one — with everything else (ISchedulerTaskDAL,
/// IApplicationConnection, IJobEngineHubNotifier) mocked. No live SQL Server, no ADO persistent
/// store, no SchedulerRun:Enable flag, and no real tenant/MJOBDEFINE/TSCHEDULER row is ever
/// touched — the "job" here is a synthetic, made-up JobId/TenantId that exists only in this
/// test's own mocks.
///
public class SchedulerTaskGeneratorSyntheticTests
{
// Deliberately out-of-range, made-up IDs — guarantees this test can never collide with a
// real tenant/job even if accidentally pointed at a real DB (it never is; ISchedulerTaskDAL
// is fully mocked here).
private const int SyntheticJobId = 999_000_001;
private const int SyntheticTenantId = 999_000_002;
private static readonly IConfiguration EmptyConfig = new ConfigurationBuilder().Build();
private static async Task<(ISchedulerFactory Factory, IScheduler Scheduler)> StartInMemorySchedulerAsync()
{
var services = new ServiceCollection();
services.AddLogging();
// In-memory RAMJobStore only — the whole point is proving fire-exactly-once behavior
// without touching SchedulerRun:Server/QuartzStoreDatabaseName or any real persistent
// ADO store.
services.AddQuartz(q => q.UseInMemoryStore());
var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService();
var scheduler = await factory.GetScheduler().ConfigureAwait(false);
await scheduler.Start().ConfigureAwait(false);
return (factory, scheduler);
}
private static SchedulerTaskDTO SyntheticOneTimeJob(DateTime scheduledUtc) => new()
{
JobId = SyntheticJobId,
TenantId = SyntheticTenantId,
JobName = "SYNTHETIC_TEST_JOB — never a real MJOBDEFINE row",
// 1=Active (tracker §46 fix) — GB5's standard entity status enum, not the inverted
// "0=active" convention QuartzSyncService.SyncJobAsync previously (incorrectly) required.
Status = 1,
SchedulerStatus = 1,
SchedulerType = "ONE_TIME",
ScheduledDate = scheduledUtc,
};
[Fact]
public async Task Test_SyntheticOneTimeJob_FiresExactlyOnce_ViaRealQuartzInMemoryScheduler()
{
var (factory, scheduler) = await StartInMemorySchedulerAsync();
try
{
var dal = new Mock();
var hubNotifier = new Mock();
hubNotifier.Setup(h => h.PushJobStarted(It.IsAny(), It.IsAny(), It.IsAny()))
.Returns(Task.CompletedTask);
// Fires ~300ms from now — fast enough for a unit test, far enough ahead that Quartz
// genuinely schedules it rather than treating it as already-due/misfired.
var scheduledUtc = DateTime.UtcNow.AddMilliseconds(300);
dal.Setup(d => d.LoadJobForSyncAsync(SyntheticJobId, It.IsAny(), It.IsAny()))
.ReturnsAsync(SyntheticOneTimeJob(scheduledUtc));
var fireCount = 0;
var fired = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
dal.Setup(d => d.CreatePendingExecutionAsync(
SyntheticJobId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(() =>
{
var count = Interlocked.Increment(ref fireCount);
if (count == 1) fired.TrySetResult();
return 1L; // a real (fake) JobExecutionId — non-zero, so the concurrency guard doesn't skip it
});
dal.Setup(d => d.LoadJobDetailsAsync(1, It.IsAny(), It.IsAny()))
.ReturnsAsync(SyntheticOneTimeJob(scheduledUtc));
// Real SchedulerJobExecutorQuartzJob, resolved the same way Quartz's own DI job
// factory resolves it in production — registered into the container the in-memory
// scheduler's job factory is pointed at.
var testServices = new ServiceCollection();
testServices.AddLogging();
testServices.AddSingleton(dal.Object);
testServices.AddSingleton(hubNotifier.Object);
testServices.AddTransient();
var testProvider = testServices.BuildServiceProvider();
scheduler.JobFactory = new Quartz.Simpl.MicrosoftDependencyInjectionJobFactory(
testProvider, Options.Create(new QuartzOptions()));
var quartzSyncService = new QuartzSyncService(
factory,
dal.Object,
new Mock().Object,
MockDbConfig(),
NullLogger.Instance,
EmptyConfig);
await quartzSyncService.SyncJobAsync(SyntheticJobId, new LoginDTO { ClientId = SyntheticTenantId, UserId = -1 })
.ConfigureAwait(false);
// Real proof: wait for the REAL Quartz scheduler to actually fire the REAL executor —
// not a simulated callback.
var completed = await Task.WhenAny(fired.Task, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.True(completed == fired.Task, "Synthetic ONE_TIME job never fired within 5s — Track A's trigger-building/dispatch is broken.");
// "Exactly once" — a ONE_TIME trigger doesn't repeat; confirm it didn't somehow fire
// twice (e.g. a misfire-handling bug re-firing it).
await Task.Delay(TimeSpan.FromMilliseconds(500));
Assert.Equal(1, fireCount);
dal.Verify(d => d.CreatePendingExecutionAsync(
SyntheticJobId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once);
}
finally
{
await scheduler.Shutdown(waitForJobsToComplete: true).ConfigureAwait(false);
}
}
[Fact]
public async Task Test_SyntheticJob_ConcurrencyGuard_SkipsWhenCreatePendingExecutionReturnsZero()
{
// CreatePendingExecutionAsync returning 0 means "a prior run is still Pending/InProgress
// and this job doesn't allow concurrent execution" (SchedulerJobExecutorQuartzJob.cs's own
// documented contract) — the executor must treat this as a normal skip, not an error, and
// must never call LoadJobDetailsAsync/PushJobStarted for a skipped fire.
var (factory, scheduler) = await StartInMemorySchedulerAsync();
try
{
var dal = new Mock();
var hubNotifier = new Mock();
var scheduledUtc = DateTime.UtcNow.AddMilliseconds(300);
dal.Setup(d => d.LoadJobForSyncAsync(SyntheticJobId, It.IsAny(), It.IsAny()))
.ReturnsAsync(SyntheticOneTimeJob(scheduledUtc));
var called = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
dal.Setup(d => d.CreatePendingExecutionAsync(
SyntheticJobId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(() => { called.TrySetResult(); return 0L; }); // concurrency guard blocked it
var testServices = new ServiceCollection();
testServices.AddLogging();
testServices.AddSingleton(dal.Object);
testServices.AddSingleton(hubNotifier.Object);
testServices.AddTransient();
var testProvider = testServices.BuildServiceProvider();
scheduler.JobFactory = new Quartz.Simpl.MicrosoftDependencyInjectionJobFactory(
testProvider, Options.Create(new QuartzOptions()));
var quartzSyncService = new QuartzSyncService(
factory,
dal.Object,
new Mock().Object,
MockDbConfig(),
NullLogger.Instance,
EmptyConfig);
await quartzSyncService.SyncJobAsync(SyntheticJobId, new LoginDTO { ClientId = SyntheticTenantId, UserId = -1 })
.ConfigureAwait(false);
var completed = await Task.WhenAny(called.Task, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.True(completed == called.Task, "Synthetic job never fired within 5s.");
dal.Verify(d => d.LoadJobDetailsAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
hubNotifier.Verify(h => h.PushJobStarted(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
finally
{
await scheduler.Shutdown(waitForJobsToComplete: true).ConfigureAwait(false);
}
}
private static IOptionsSnapshot MockDbConfig()
{
var m = new Mock>();
m.Setup(o => o.Value).Returns(new Gb5SystemDTO());
return m.Object;
}
}