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