using System; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.ScheduledTasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.ScheduledTasks; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks; /// /// Tests that gates the periodic (timer-driven) execution of gated /// tasks to the scan leader, while leaving non-gated tasks and manual/API-triggered runs unaffected. /// public class ScheduledTaskWorkerLeaderGatingTests { private const string GatedKey = "RefreshLibrary"; private const string NonGatedKey = "DeleteTranscodeFiles"; /// /// A non-leader must not enqueue a gated task when its periodic trigger fires. /// [Fact] [Trait("Category", "UnitTest")] public async Task PeriodicTrigger_NonLeader_GatedTask_DoesNotQueue() { var taskManager = new Mock(); var lease = CreateLease(isLeader: false); var options = CreateOptions(GatedKey); var task = new StubScheduledTask(GatedKey); using var worker = new ScheduledTaskWorker(task, CreateAppPaths(), taskManager.Object, NullLogger.Instance, lease.Object, options); await FireTriggerAsync(worker); taskManager.Verify(t => t.QueueScheduledTask(It.IsAny(), It.IsAny()), Times.Never); lease.Verify(l => l.TryAcquireOrRenewAsync(It.IsAny()), Times.Once); } /// /// The leader must enqueue a gated task when its periodic trigger fires. /// [Fact] [Trait("Category", "UnitTest")] public async Task PeriodicTrigger_Leader_GatedTask_Queues() { var taskManager = new Mock(); var lease = CreateLease(isLeader: true); var options = CreateOptions(GatedKey); var task = new StubScheduledTask(GatedKey); using var worker = new ScheduledTaskWorker(task, CreateAppPaths(), taskManager.Object, NullLogger.Instance, lease.Object, options); await FireTriggerAsync(worker); taskManager.Verify(t => t.QueueScheduledTask(task, It.IsAny()), Times.Once); } /// /// A non-gated task must always enqueue when its periodic trigger fires, even for a non-leader, and /// must not consult the scan-leader lease at all. /// [Fact] [Trait("Category", "UnitTest")] public async Task PeriodicTrigger_NonLeader_NonGatedTask_Queues() { var taskManager = new Mock(); var lease = CreateLease(isLeader: false); var options = CreateOptions(GatedKey); var task = new StubScheduledTask(NonGatedKey); using var worker = new ScheduledTaskWorker(task, CreateAppPaths(), taskManager.Object, NullLogger.Instance, lease.Object, options); await FireTriggerAsync(worker); taskManager.Verify(t => t.QueueScheduledTask(task, It.IsAny()), Times.Once); lease.Verify(l => l.TryAcquireOrRenewAsync(It.IsAny()), Times.Never); } /// /// A manual/API-triggered run goes through , which must run /// the task regardless of leadership and must not consult the scan-leader lease. /// [Fact] [Trait("Category", "UnitTest")] public async Task Execute_NonLeader_GatedTask_RunsAndIgnoresLease() { var realTaskManager = new TaskManager(CreateAppPaths(), new Mock>().Object); var lease = CreateLease(isLeader: false); var options = CreateOptions(GatedKey); var task = new StubScheduledTask(GatedKey); using var worker = new ScheduledTaskWorker(task, CreateAppPaths(), realTaskManager, NullLogger.Instance, lease.Object, options); await worker.Execute(new TaskOptions()); Assert.Equal(1, task.ExecuteCount); lease.Verify(l => l.TryAcquireOrRenewAsync(It.IsAny()), Times.Never); } private static Mock CreateLease(bool isLeader) { var lease = new Mock(); lease .Setup(l => l.TryAcquireOrRenewAsync(It.IsAny())) .ReturnsAsync(isLeader); return lease; } private static ScanLeaderOptions CreateOptions(params string[] gatedKeys) => new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60, GatedTaskKeys = gatedKeys }; private static IApplicationPaths CreateAppPaths() { var dir = Path.Combine(Path.GetTempPath(), "jf-scanleader-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); var appPaths = new Mock(); appPaths.Setup(p => p.DataPath).Returns(dir); appPaths.Setup(p => p.ConfigurationDirectoryPath).Returns(dir); return appPaths.Object; } private static async Task FireTriggerAsync(ScheduledTaskWorker worker) { var method = typeof(ScheduledTaskWorker).GetMethod( "OnTriggerTriggered", BindingFlags.NonPublic | BindingFlags.Instance); Assert.NotNull(method); method!.Invoke(worker, new object[] { new RecordingTrigger(), EventArgs.Empty }); // OnTriggerTriggered is async void; the queue decision completes synchronously against the mocked // lease, so a short delay lets any continuation settle before the assertion. await Task.Delay(100); } private sealed class StubScheduledTask : IScheduledTask { private readonly string _key; public StubScheduledTask(string key) { _key = key; } public int ExecuteCount { get; private set; } public string Name => "Stub Task"; public string Key => _key; public string Description => "Stub task for gating tests."; public string Category => "Tests"; public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) { ExecuteCount++; return Task.CompletedTask; } public IEnumerable GetDefaultTriggers() => Array.Empty(); } private sealed class RecordingTrigger : ITaskTrigger { #pragma warning disable CS0067 // Required by the interface but unused in this test double. public event EventHandler? Triggered; #pragma warning restore CS0067 public TaskOptions TaskOptions { get; } = new TaskOptions(); public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup) { } public void Stop() { } } }