fix: Wait-PveTask delegates to TaskService.WaitForTask (#178)

WaitPveTaskCmdlet hand-rolled its own poll loop (while(true) /
Thread.Sleep / JObject.Parse), the exact pattern ADR 0001 forbids.
It now delegates to TaskService.WaitForTask, passing a progress
callback that drives WriteProgress — the seam WaitForTask was built
for and that nothing called until now.

Preserves the cmdlet's own documented contract (an omitted -Timeout
waits indefinitely) by passing a 100-year sentinel instead of null,
since TaskService.WaitForTask treats a null timeout as its own
10-minute default, not infinite. Reuses one PveHttpClient for the
whole wait instead of letting TaskService open a fresh one per poll.

Also strips the redundant trailing 'null, null, null' default
arguments from 12 other WaitForTask call sites so they use the short
form, per the issue. Left CopyPveContainerCmdlet.cs,
ImportPveOvaCmdlet.cs and NewPveVmCmdlet.cs alone — issue #135 is
touching those concurrently.

Adds three xUnit tests to TaskServiceTests.cs proving the behavior
the inline loop got wrong: no sleep before the first status check,
the 1-second MinPollInterval clamp, and the progress callback firing
on every poll. Mutation-tested by breaking each behavior in turn and
confirming the corresponding test fails.

Closes #140

Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
goodolclint-claude[bot]
2026-09-02 19:27:31 +00:00
committed by GitHub
parent 8ec09c2b84
commit 17832f15d9
14 changed files with 143 additions and 69 deletions
@@ -239,6 +239,103 @@ namespace PSProxmoxVE.Core.Tests.Services
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<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync(It.IsAny<string>()))
.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<string>()), 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<IPveHttpClient>();
mockClient.SetupSequence(c => c.GetAsync(It.IsAny<string>()))
.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<string>()), 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<IPveHttpClient>();
mockClient.SetupSequence(c => c.GetAsync(It.IsAny<string>()))
.ReturnsAsync(runningJson)
.ReturnsAsync(runningJson)
.ReturnsAsync(stoppedJson);
var service = new TaskService(mockClient.Object);
var session = CreateSession();
var seenStatuses = new List<string?>();
// 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<string?> { "running", "running", "stopped" }, seenStatuses);
}
[Fact]
public void StopTask_CallsDeleteAsyncWithCorrectPath()
{