using System; using System.Collections.Generic; using System.Threading.Tasks; using Moq; using Xunit; using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Exceptions; using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Core.Tests.Services { public class TaskServiceTests { private const string TestNode = "pve1"; private const string TestUpid = "UPID:pve1:000ABC:00000001:5F1234AB:qmstart:100:root@pam:"; private static PveSession CreateSession() { return new PveSession("pve1.example.com", 8006, true, "PVE:root@pam:TEST_TOKEN"); } [Fact] public void GetTask_HappyPath_ReturnsCorrectFields() { // Arrange var json = @"{ ""data"": { ""upid"": ""UPID:pve1:000ABC:00000001:5F1234AB:qmstart:100:root@pam:"", ""type"": ""qmstart"", ""status"": ""stopped"", ""exitstatus"": ""OK"", ""node"": ""pve1"", ""starttime"": 1595000000, ""user"": ""root@pam"", ""id"": ""100"" } }"; var mockClient = new Mock(); mockClient.Setup(c => c.GetAsync(It.IsAny())) .ReturnsAsync(json); var service = new TaskService(mockClient.Object); var session = CreateSession(); // Act var task = service.GetTask(session, TestNode, TestUpid); // Assert Assert.Equal(TestUpid, task.Upid); Assert.Equal("qmstart", task.Type); Assert.Equal("stopped", task.Status); Assert.Equal("OK", task.ExitStatus); Assert.Equal(TestNode, task.Node); Assert.Equal("root@pam", task.User); Assert.Equal("100", task.Id); Assert.True(task.IsSuccessful); } [Fact] public void GetTask_NullSession_ThrowsArgumentNullException() { // Arrange var mockClient = new Mock(); var service = new TaskService(mockClient.Object); // Act & Assert var ex = Assert.Throws(() => service.GetTask(null!, TestNode, TestUpid)); Assert.Equal("session", ex.ParamName); } [Fact] public void GetTaskLog_HappyPath_ReturnsLogEntries() { // Arrange var json = @"{ ""data"": [ { ""n"": 1, ""t"": ""starting task qmstart"" }, { ""n"": 2, ""t"": ""VM 100 started"" }, { ""n"": 3, ""t"": ""TASK OK"" } ] }"; var mockClient = new Mock(); mockClient.Setup(c => c.GetAsync(It.IsAny())) .ReturnsAsync(json); var service = new TaskService(mockClient.Object); var session = CreateSession(); // Act var logs = service.GetTaskLog(session, TestNode, TestUpid); // Assert Assert.Equal(3, logs.Length); Assert.Equal(1, logs[0].LineNumber); Assert.Equal("starting task qmstart", logs[0].Text); Assert.Equal("TASK OK", logs[2].Text); } [Fact] public void GetTasks_HappyPath_ReturnsCorrectCount() { // Arrange var json = @"{ ""data"": [ { ""upid"": ""UPID:pve1:000001:00000001:5F1234AB:qmstart:100:root@pam:"", ""type"": ""qmstart"", ""status"": ""stopped"", ""exitstatus"": ""OK"", ""user"": ""root@pam"", ""id"": ""100"" }, { ""upid"": ""UPID:pve1:000002:00000002:5F1234AC:qmstop:101:root@pam:"", ""type"": ""qmstop"", ""status"": ""running"", ""user"": ""root@pam"", ""id"": ""101"" } ] }"; var mockClient = new Mock(); mockClient.Setup(c => c.GetAsync(It.Is(s => s.Contains("tasks?")))) .ReturnsAsync(json); var service = new TaskService(mockClient.Object); var session = CreateSession(); // Act var tasks = service.GetTasks(session, TestNode, vmid: 100, typeFilter: "qmstart", limit: 10); // Assert Assert.Equal(2, tasks.Length); Assert.Equal("qmstart", tasks[0].Type); Assert.Equal(TestNode, tasks[0].Node); Assert.Equal(TestNode, tasks[1].Node); mockClient.Verify(c => c.GetAsync(It.Is(s => s.Contains("limit=10") && s.Contains("vmid=100") && s.Contains("typefilter=qmstart"))), Times.Once); } [Fact] public void WaitForTask_CompletesWithOK_ReturnsTask() { // Arrange var json = @"{ ""data"": { ""upid"": ""UPID:pve1:000ABC:00000001:5F1234AB:qmstart:100:root@pam:"", ""type"": ""qmstart"", ""status"": ""stopped"", ""exitstatus"": ""OK"", ""user"": ""root@pam"" } }"; var mockClient = new Mock(); mockClient.Setup(c => c.GetAsync(It.IsAny())) .ReturnsAsync(json); var service = new TaskService(mockClient.Object); var session = CreateSession(); // Act var task = service.WaitForTask(session, TestNode, TestUpid, timeout: TimeSpan.FromSeconds(5), pollInterval: TimeSpan.FromSeconds(1)); // Assert Assert.Equal("stopped", task.Status); Assert.Equal("OK", task.ExitStatus); Assert.True(task.IsSuccessful); } [Fact] public void WaitForTask_FailedExitStatus_ThrowsPveTaskFailedException() { // Arrange var json = @"{ ""data"": { ""upid"": ""UPID:pve1:000ABC:00000001:5F1234AB:qmstart:100:root@pam:"", ""type"": ""qmstart"", ""status"": ""stopped"", ""exitstatus"": ""ERROR: VM 100 already running"", ""user"": ""root@pam"" } }"; var mockClient = new Mock(); mockClient.Setup(c => c.GetAsync(It.IsAny())) .ReturnsAsync(json); var service = new TaskService(mockClient.Object); var session = CreateSession(); // Act & Assert var ex = Assert.Throws(() => service.WaitForTask(session, TestNode, TestUpid, timeout: TimeSpan.FromSeconds(5), pollInterval: TimeSpan.FromSeconds(1))); Assert.Equal(TestUpid, ex.Upid); Assert.Equal("ERROR: VM 100 already running", ex.ExitStatus); } [Fact] public void WaitForTask_Timeout_ThrowsPveTaskTimeoutException() { // Arrange — task stays "running" forever var json = @"{ ""data"": { ""upid"": ""UPID:pve1:000ABC:00000001:5F1234AB:qmstart:100:root@pam:"", ""type"": ""qmstart"", ""status"": ""running"", ""user"": ""root@pam"" } }"; var mockClient = new Mock(); mockClient.Setup(c => c.GetAsync(It.IsAny())) .ReturnsAsync(json); var service = new TaskService(mockClient.Object); var session = CreateSession(); // Act & Assert — use very short timeout so the test completes quickly var timeout = TimeSpan.FromMilliseconds(100); var ex = Assert.Throws(() => service.WaitForTask(session, TestNode, TestUpid, timeout: timeout, pollInterval: TimeSpan.FromMilliseconds(50))); Assert.Equal(TestUpid, ex.Upid); Assert.Equal(timeout, ex.Timeout); } [Fact] public void WaitForTask_AlreadyStopped_ChecksStatusBeforeSleeping() { // Arrange var json = @"{ ""data"": { ""upid"": ""UPID:pve1:000ABC:00000001:5F1234AB:qmstart:100:root@pam:"", ""status"": ""stopped"", ""exitstatus"": ""OK"", ""user"": ""root@pam"" } }"; var mockClient = new Mock(); mockClient.Setup(c => c.GetAsync(It.IsAny())) .ReturnsAsync(json); var service = new TaskService(mockClient.Object); var session = CreateSession(); // Act — a long poll interval would dominate the elapsed time if the // implementation slept before its first status check. var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var task = service.WaitForTask(session, TestNode, TestUpid, timeout: TimeSpan.FromSeconds(30), pollInterval: TimeSpan.FromSeconds(10)); stopwatch.Stop(); // Assert Assert.True(task.IsSuccessful); Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Expected an immediate return for an already-stopped task, took {stopwatch.Elapsed}"); mockClient.Verify(c => c.GetAsync(It.IsAny()), Times.Once); } [Fact] public void WaitForTask_PollIntervalBelowMinimum_IsClampedToOneSecond() { // Arrange — task reports "running" once, then "stopped". var runningJson = @"{ ""data"": { ""status"": ""running"", ""user"": ""root@pam"" } }"; var stoppedJson = @"{ ""data"": { ""status"": ""stopped"", ""exitstatus"": ""OK"", ""user"": ""root@pam"" } }"; var mockClient = new Mock(); mockClient.SetupSequence(c => c.GetAsync(It.IsAny())) .ReturnsAsync(runningJson) .ReturnsAsync(stoppedJson); var service = new TaskService(mockClient.Object); var session = CreateSession(); // Act — a zero poll interval must be clamped to the 1-second minimum, // not passed through to Thread.Sleep(0). var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var task = service.WaitForTask(session, TestNode, TestUpid, timeout: TimeSpan.FromSeconds(30), pollInterval: TimeSpan.Zero); stopwatch.Stop(); // Assert Assert.True(task.IsSuccessful); Assert.True(stopwatch.Elapsed >= TimeSpan.FromMilliseconds(900), $"Expected the clamp to force at least a ~1-second wait, took {stopwatch.Elapsed}"); mockClient.Verify(c => c.GetAsync(It.IsAny()), Times.Exactly(2)); } [Fact] public void WaitForTask_ProgressCallback_InvokedOnEachPoll() { // Arrange — two polls report "running", the third reports "stopped". var runningJson = @"{ ""data"": { ""status"": ""running"", ""user"": ""root@pam"" } }"; var stoppedJson = @"{ ""data"": { ""status"": ""stopped"", ""exitstatus"": ""OK"", ""user"": ""root@pam"" } }"; var mockClient = new Mock(); mockClient.SetupSequence(c => c.GetAsync(It.IsAny())) .ReturnsAsync(runningJson) .ReturnsAsync(runningJson) .ReturnsAsync(stoppedJson); var service = new TaskService(mockClient.Object); var session = CreateSession(); var seenStatuses = new List(); // Act var task = service.WaitForTask(session, TestNode, TestUpid, timeout: TimeSpan.FromSeconds(30), pollInterval: TimeSpan.FromSeconds(1), progressCallback: t => seenStatuses.Add(t.Status)); // Assert Assert.True(task.IsSuccessful); Assert.Equal(new List { "running", "running", "stopped" }, seenStatuses); } [Fact] public void StopTask_CallsDeleteAsyncWithCorrectPath() { // Arrange var mockClient = new Mock(); mockClient.Setup(c => c.DeleteAsync(It.IsAny())) .ReturnsAsync("{}"); var service = new TaskService(mockClient.Object); var session = CreateSession(); // Act service.StopTask(session, TestNode, TestUpid); // Assert var encodedUpid = Uri.EscapeDataString(TestUpid); mockClient.Verify(c => c.DeleteAsync( It.Is(s => s == $"nodes/{TestNode}/tasks/{encodedUpid}")), Times.Once); } } }