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()
{
}
}
}