using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Hubs; using Cleanuparr.Infrastructure.Models; using Cleanuparr.Infrastructure.Services; using Cleanuparr.Infrastructure.Tests.TestHelpers; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; using Quartz; using Quartz.Impl.Matchers; using Shouldly; using Xunit; namespace Cleanuparr.Infrastructure.Tests.Services; public class JobManagementServiceTests { private readonly ILogger _logger; private readonly ISchedulerFactory _schedulerFactory; private readonly IScheduler _scheduler; private readonly IHubContext _hubContext; private readonly JobManagementService _service; public JobManagementServiceTests() { _logger = Substitute.For>(); _schedulerFactory = Substitute.For(); _scheduler = Substitute.For(); _hubContext = Substitute.For>(); _schedulerFactory.GetScheduler(Arg.Any()) .Returns(_scheduler); _service = new JobManagementService(_logger, _schedulerFactory, _hubContext); } #region StartJob Tests [Fact] public async Task StartJob_WithInvalidDirectCronExpression_ReturnsFalse() { // Arrange var jobType = JobType.QueueCleaner; var invalidCron = "invalid-cron"; // Act var result = await _service.StartJob(jobType, directCronExpression: invalidCron); // Assert result.ShouldBeFalse(); } [Fact] public async Task StartJob_JobDoesNotExist_ReturnsFalse() { // Arrange var jobType = JobType.QueueCleaner; var cronExpression = "0 0/5 * * * ?"; // Every 5 minutes _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(false); // Act var result = await _service.StartJob(jobType, directCronExpression: cronExpression); // Assert result.ShouldBeFalse(); _logger.ReceivedLogContaining(LogLevel.Error, "does not exist"); } [Fact] public async Task StartJob_WithValidCronExpression_ReturnsTrue() { // Arrange var jobType = JobType.QueueCleaner; var cronExpression = "0 0/5 * * * ?"; // Every 5 minutes _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(true); _scheduler.GetTriggersOfJob(Arg.Any(), Arg.Any()) .Returns(new List()); _scheduler.ScheduleJob(Arg.Any(), Arg.Any()) .Returns(DateTimeOffset.Now); // Act var result = await _service.StartJob(jobType, directCronExpression: cronExpression); // Assert result.ShouldBeTrue(); await _scheduler.Received(1).ScheduleJob(Arg.Any(), Arg.Any()); await _scheduler.Received(1).ResumeJob(Arg.Any(), Arg.Any()); } [Fact] public async Task StartJob_WithSchedule_ReturnsTrue() { // Arrange var jobType = JobType.MalwareBlocker; var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes }; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(true); _scheduler.GetTriggersOfJob(Arg.Any(), Arg.Any()) .Returns(new List()); _scheduler.ScheduleJob(Arg.Any(), Arg.Any()) .Returns(DateTimeOffset.Now); // Act var result = await _service.StartJob(jobType, schedule: schedule); // Assert result.ShouldBeTrue(); await _scheduler.Received(1).ScheduleJob(Arg.Any(), Arg.Any()); } [Fact] public async Task StartJob_WithNoScheduleOrCron_CreatesOneTimeTrigger() { // Arrange var jobType = JobType.DownloadCleaner; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(true); _scheduler.GetTriggersOfJob(Arg.Any(), Arg.Any()) .Returns(new List()); _scheduler.ScheduleJob(Arg.Any(), Arg.Any()) .Returns(DateTimeOffset.Now); // Act var result = await _service.StartJob(jobType); // Assert result.ShouldBeTrue(); await _scheduler.Received(1).ScheduleJob( Arg.Is(t => t.Key.Name.Contains("onetime")), Arg.Any()); } [Fact] public async Task StartJob_CleansUpExistingTriggers_BeforeSchedulingNew() { // Arrange var jobType = JobType.QueueCleaner; var cronExpression = "0 0/5 * * * ?"; var existingTrigger = Substitute.For(); existingTrigger.Key.Returns(new TriggerKey("existing-trigger")); _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(true); _scheduler.GetTriggersOfJob(Arg.Any(), Arg.Any()) .Returns(new List { existingTrigger }); _scheduler.ScheduleJob(Arg.Any(), Arg.Any()) .Returns(DateTimeOffset.Now); // Act var result = await _service.StartJob(jobType, directCronExpression: cronExpression); // Assert result.ShouldBeTrue(); await _scheduler.Received(1).UnscheduleJob( Arg.Is(k => k.Name == "existing-trigger"), Arg.Any()); } [Fact] public async Task StartJob_WhenSchedulerThrows_ReturnsFalse() { // Arrange var jobType = JobType.QueueCleaner; var cronExpression = "0 0/5 * * * ?"; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Scheduler error")); // Act var result = await _service.StartJob(jobType, directCronExpression: cronExpression); // Assert result.ShouldBeFalse(); } #endregion #region StopJob Tests [Fact] public async Task StopJob_JobDoesNotExist_ReturnsFalse() { // Arrange var jobType = JobType.QueueCleaner; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(false); // Act var result = await _service.StopJob(jobType); // Assert result.ShouldBeFalse(); } [Fact] public async Task StopJob_JobExists_CleansUpTriggersAndReturnsTrue() { // Arrange var jobType = JobType.MalwareBlocker; var trigger = Substitute.For(); trigger.Key.Returns(new TriggerKey("test-trigger")); _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(true); _scheduler.GetTriggersOfJob(Arg.Any(), Arg.Any()) .Returns(new List { trigger }); // Act var result = await _service.StopJob(jobType); // Assert result.ShouldBeTrue(); await _scheduler.Received(1).UnscheduleJob(Arg.Any(), Arg.Any()); } [Fact] public async Task StopJob_WhenSchedulerThrows_ReturnsFalse() { // Arrange var jobType = JobType.QueueCleaner; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Scheduler error")); // Act var result = await _service.StopJob(jobType); // Assert result.ShouldBeFalse(); } #endregion #region GetJob Tests [Fact] public async Task GetJob_JobDoesNotExist_ReturnsNotFoundStatus() { // Arrange var jobType = JobType.QueueCleaner; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(false); // Act var result = await _service.GetJob(jobType); // Assert result.Status.ShouldBe("Not Found"); result.Name.ShouldBe("QueueCleaner"); } [Fact] public async Task GetJob_JobExistsNoTriggers_ReturnsNotScheduledStatus() { // Arrange var jobType = JobType.QueueCleaner; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(true); _scheduler.GetTriggersOfJob(Arg.Any(), Arg.Any()) .Returns(new List()); // Act var result = await _service.GetJob(jobType); // Assert result.Status.ShouldBe("Not Scheduled"); } [Theory] [InlineData(TriggerState.Normal, "Scheduled")] [InlineData(TriggerState.Paused, "Paused")] [InlineData(TriggerState.Complete, "Complete")] [InlineData(TriggerState.Error, "Error")] [InlineData(TriggerState.Blocked, "Running")] [InlineData(TriggerState.None, "Not Scheduled")] public async Task GetJob_WithTrigger_ReturnsCorrectStatus(TriggerState triggerState, string expectedStatus) { // Arrange var jobType = JobType.QueueCleaner; var trigger = Substitute.For(); trigger.Key.Returns(new TriggerKey("test-trigger")); trigger.GetNextFireTimeUtc().Returns(DateTimeOffset.UtcNow.AddMinutes(5)); trigger.GetPreviousFireTimeUtc().Returns(DateTimeOffset.UtcNow.AddMinutes(-5)); _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(true); _scheduler.GetTriggersOfJob(Arg.Any(), Arg.Any()) .Returns(new List { trigger }); _scheduler.GetTriggerState(Arg.Any(), Arg.Any()) .Returns(triggerState); // Act var result = await _service.GetJob(jobType); // Assert result.Status.ShouldBe(expectedStatus); } [Fact] public async Task GetJob_WhenSchedulerThrows_ReturnsErrorStatus() { // Arrange var jobType = JobType.QueueCleaner; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Scheduler error")); // Act var result = await _service.GetJob(jobType); // Assert result.Status.ShouldBe("Error"); } #endregion #region GetAllJobs Tests [Fact] public async Task GetAllJobs_NoJobs_ReturnsEmptyList() { // Arrange _scheduler.GetJobGroupNames(Arg.Any()) .Returns(new List()); // Act var result = await _service.GetAllJobs(); // Assert result.ShouldBeEmpty(); } [Fact] public async Task GetAllJobs_WithJobs_ReturnsJobList() { // Arrange var jobKey = new JobKey("QueueCleaner"); var trigger = Substitute.For(); trigger.Key.Returns(new TriggerKey("test-trigger")); trigger.GetNextFireTimeUtc().Returns(DateTimeOffset.UtcNow.AddMinutes(5)); _scheduler.GetJobGroupNames(Arg.Any()) .Returns(new List { "DEFAULT" }); _scheduler.GetJobKeys(Arg.Any>(), Arg.Any()) .Returns(new HashSet { jobKey }); _scheduler.GetTriggersOfJob(Arg.Any(), Arg.Any()) .Returns(new List { trigger }); _scheduler.GetTriggerState(Arg.Any(), Arg.Any()) .Returns(TriggerState.Normal); // Act var result = await _service.GetAllJobs(); // Assert result.ShouldHaveSingleItem(); result[0].Name.ShouldBe("QueueCleaner"); result[0].Status.ShouldBe("Scheduled"); } [Fact] public async Task GetAllJobs_WhenSchedulerThrows_ReturnsEmptyList() { // Arrange _scheduler.GetJobGroupNames(Arg.Any()) .ThrowsAsync(new Exception("Scheduler error")); // Act var result = await _service.GetAllJobs(); // Assert result.ShouldBeEmpty(); } #endregion #region TriggerJobOnce Tests [Fact] public async Task TriggerJobOnce_JobDoesNotExist_ReturnsFalse() { // Arrange var jobType = JobType.QueueCleaner; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(false); // Act var result = await _service.TriggerJobOnce(jobType); // Assert result.ShouldBeFalse(); } [Fact] public async Task TriggerJobOnce_JobExists_TriggersJobAndReturnsTrue() { // Arrange var jobType = JobType.MalwareBlocker; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(true); _scheduler.ScheduleJob(Arg.Any(), Arg.Any()) .Returns(DateTimeOffset.Now); // Act var result = await _service.TriggerJobOnce(jobType); // Assert result.ShouldBeTrue(); await _scheduler.Received(1).ScheduleJob( Arg.Is(t => t.Key.Name.Contains("immediate") && t.Key.Name.Contains("manual")), Arg.Any()); } [Fact] public async Task TriggerJobOnce_WhenSchedulerThrows_ReturnsFalse() { // Arrange var jobType = JobType.QueueCleaner; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Scheduler error")); // Act var result = await _service.TriggerJobOnce(jobType); // Assert result.ShouldBeFalse(); } #endregion #region UpdateJobSchedule Tests [Fact] public async Task UpdateJobSchedule_NullSchedule_ThrowsArgumentNullException() { // Arrange var jobType = JobType.QueueCleaner; // Act & Assert await Should.ThrowAsync(() => _service.UpdateJobSchedule(jobType, null!)); } [Fact] public async Task UpdateJobSchedule_JobDoesNotExist_ReturnsFalse() { // Arrange var jobType = JobType.QueueCleaner; var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes }; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(false); // Act var result = await _service.UpdateJobSchedule(jobType, schedule); // Assert result.ShouldBeFalse(); } [Fact] public async Task UpdateJobSchedule_ValidSchedule_ReturnsTrue() { // Arrange var jobType = JobType.DownloadCleaner; var schedule = new JobSchedule { Every = 10, Type = ScheduleUnit.Minutes }; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(true); _scheduler.GetTriggersOfJob(Arg.Any(), Arg.Any()) .Returns(new List()); _scheduler.ScheduleJob(Arg.Any(), Arg.Any()) .Returns(DateTimeOffset.Now); // Act var result = await _service.UpdateJobSchedule(jobType, schedule); // Assert result.ShouldBeTrue(); await _scheduler.Received(1).ScheduleJob(Arg.Any(), Arg.Any()); } [Fact] public async Task UpdateJobSchedule_WhenSchedulerThrows_ReturnsFalse() { // Arrange var jobType = JobType.QueueCleaner; var schedule = new JobSchedule { Every = 5, Type = ScheduleUnit.Minutes }; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Scheduler error")); // Act var result = await _service.UpdateJobSchedule(jobType, schedule); // Assert result.ShouldBeFalse(); } #endregion #region GetMainTrigger Tests [Fact] public async Task GetMainTrigger_JobDoesNotExist_ReturnsNull() { // Arrange var jobType = JobType.QueueCleaner; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(false); // Act var result = await _service.GetMainTrigger(jobType); // Assert result.ShouldBeNull(); } [Fact] public async Task GetMainTrigger_TriggerExists_ReturnsTrigger() { // Arrange var jobType = JobType.MalwareBlocker; var expectedTriggerKey = new TriggerKey("MalwareBlocker-trigger"); var trigger = Substitute.For(); trigger.Key.Returns(expectedTriggerKey); _scheduler.CheckExists(Arg.Any(), Arg.Any()) .Returns(true); _scheduler.GetTrigger(expectedTriggerKey, Arg.Any()) .Returns(trigger); // Act var result = await _service.GetMainTrigger(jobType); // Assert result.ShouldNotBeNull(); result.Key.ShouldBe(expectedTriggerKey); } [Fact] public async Task GetMainTrigger_WhenSchedulerThrows_ReturnsNull() { // Arrange var jobType = JobType.QueueCleaner; _scheduler.CheckExists(Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("Scheduler error")); // Act var result = await _service.GetMainTrigger(jobType); // Assert result.ShouldBeNull(); } #endregion }