refactor: route the container cmdlets through ContainerService (#126) (#199)

New-PveContainerSnapshot, Remove-PveContainerSnapshot and
Restore-PveContainerSnapshot each built their own PveHttpClient and parsed
the response inline while ContainerService.CreateContainerSnapshot /
RemoveContainerSnapshot / RollbackContainerSnapshot carried the same three
requests with no callers. The cmdlets now call the service, so the path
and form each sends is asserted offline (ADR 0021).

New-PveContainer already called ContainerService.CreateContainer but
allocated its container ID with a private PveHttpClient hitting
cluster/nextid; it now goes through ClusterConfigService.GetNextId, the
seam Copy-PveVm, Copy-PveContainer, New-PveVm and Import-PveOva already
use.

ContainerService.ParseTask's UPID-string branch now stamps
Status = "running", matching SnapshotService.ParseTask from the #126
pattern PR (#196). ParseTask is shared by every ContainerService lifecycle
method, so this also changes the non-Wait Status output of ten cmdlets
beyond the four converted here, from null to "running" — pinned with a
dedicated test.

Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
goodolclint-claude[bot]
2026-09-03 00:09:54 +00:00
committed by GitHub
parent 48bab3e4cb
commit 3fa7eeb023
6 changed files with 240 additions and 51 deletions
@@ -15,10 +15,236 @@ namespace PSProxmoxVE.Core.Tests.Services
{
private const string TestNode = "pve1";
private const int TestVmId = 100;
private const string CreateSnapshotUpid = "UPID:pve1:000ABC:00000001:5F1234AB:vzsnapshot:100:root@pam:";
private static PveSession CreateSession() =>
new PveSession("pve1.example.com", 8006, true, "PVE:root@pam:TEST_TOKEN");
private sealed class CapturedPost
{
public int Calls { get; set; }
public string? Path { get; set; }
public Dictionary<string, string>? Form { get; set; }
}
private static string UpidJson(string upid) => $@"{{""data"": ""{upid}""}}";
private static CapturedPost CapturePost(Mock<IPveHttpClient> mockClient, string json)
{
var captured = new CapturedPost();
mockClient.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
.Callback<string, Dictionary<string, string>?>((path, form) =>
{
captured.Calls++;
captured.Path = path;
captured.Form = form;
})
.ReturnsAsync(json);
return captured;
}
// ---------------------------------------------------------------------
// Snapshots: #126 (Containers area)
// ---------------------------------------------------------------------
[Fact]
public void CreateContainerSnapshot_NameOnly_SendsOnlySnapname()
{
var mockClient = new Mock<IPveHttpClient>();
var captured = CapturePost(mockClient, UpidJson(CreateSnapshotUpid));
var service = new ContainerService(mockClient.Object);
var task = service.CreateContainerSnapshot(CreateSession(), TestNode, TestVmId, "my-snap");
Assert.Equal(1, captured.Calls);
Assert.Equal($"nodes/{TestNode}/lxc/{TestVmId}/snapshot", captured.Path);
Assert.NotNull(captured.Form);
Assert.Equal("my-snap", captured.Form!["snapname"]);
Assert.False(captured.Form.ContainsKey("description"));
Assert.Single(captured.Form);
Assert.Equal(CreateSnapshotUpid, task.Upid);
Assert.Equal(TestNode, task.Node);
Assert.Equal("running", task.Status);
}
[Fact]
public void CreateContainerSnapshot_NullData_ReturnsEmptyUpidWithoutStatus()
{
var mockClient = new Mock<IPveHttpClient>();
CapturePost(mockClient, @"{""data"": null}");
var service = new ContainerService(mockClient.Object);
var task = service.CreateContainerSnapshot(CreateSession(), TestNode, TestVmId, "my-snap");
Assert.Equal(string.Empty, task.Upid);
Assert.Equal(TestNode, task.Node);
Assert.Null(task.Status);
}
[Fact]
public void CreateContainerSnapshot_ObjectShapedData_ReturnsTaskFields()
{
var json = $@"{{""data"": {{""upid"": ""{CreateSnapshotUpid}"", ""status"": ""stopped"", ""exitstatus"": ""OK""}}}}";
var mockClient = new Mock<IPveHttpClient>();
CapturePost(mockClient, json);
var service = new ContainerService(mockClient.Object);
var task = service.CreateContainerSnapshot(CreateSession(), TestNode, TestVmId, "my-snap");
Assert.Equal(CreateSnapshotUpid, task.Upid);
Assert.Equal(TestNode, task.Node);
Assert.Equal("stopped", task.Status);
Assert.Equal("OK", task.ExitStatus);
}
[Fact]
public void CreateContainerSnapshot_WithDescription_SendsDescription()
{
var mockClient = new Mock<IPveHttpClient>();
var captured = CapturePost(mockClient, UpidJson(CreateSnapshotUpid));
var service = new ContainerService(mockClient.Object);
service.CreateContainerSnapshot(CreateSession(), TestNode, TestVmId, "my-snap", "Test snapshot");
Assert.Equal(1, captured.Calls);
Assert.NotNull(captured.Form);
Assert.Equal("my-snap", captured.Form!["snapname"]);
Assert.Equal("Test snapshot", captured.Form["description"]);
Assert.Equal(2, captured.Form.Count);
}
[Fact]
public void CreateContainerSnapshot_EscapesNodeInPath()
{
var mockClient = new Mock<IPveHttpClient>();
var captured = CapturePost(mockClient, UpidJson(CreateSnapshotUpid));
var service = new ContainerService(mockClient.Object);
service.CreateContainerSnapshot(CreateSession(), "pve node", TestVmId, "my-snap");
Assert.Equal($"nodes/pve%20node/lxc/{TestVmId}/snapshot", captured.Path);
}
[Fact]
public void RemoveContainerSnapshot_CallsDeleteAsync_ReturnsRunningTask()
{
const string upid = "UPID:pve1:000DEF:00000002:5F1234AC:vzdelsnap:100:root@pam:";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.DeleteAsync(It.IsAny<string>()))
.ReturnsAsync(UpidJson(upid));
var service = new ContainerService(mockClient.Object);
var task = service.RemoveContainerSnapshot(CreateSession(), TestNode, TestVmId, "clean-install");
Assert.Equal(upid, task.Upid);
Assert.Equal(TestNode, task.Node);
Assert.Equal("running", task.Status);
mockClient.Verify(c => c.DeleteAsync($"nodes/{TestNode}/lxc/{TestVmId}/snapshot/clean-install"), Times.Once);
mockClient.VerifyNoOtherCalls();
}
[Fact]
public void RemoveContainerSnapshot_EscapesNodeAndSnapnameInPath()
{
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.DeleteAsync(It.IsAny<string>()))
.ReturnsAsync(UpidJson("UPID:pve1:000DEF:00000002:5F1234AC:vzdelsnap:100:root@pam:"));
var service = new ContainerService(mockClient.Object);
service.RemoveContainerSnapshot(CreateSession(), "pve node", TestVmId, "snap name");
mockClient.Verify(c => c.DeleteAsync($"nodes/pve%20node/lxc/{TestVmId}/snapshot/snap%20name"), Times.Once);
}
[Fact]
public void RollbackContainerSnapshot_CallsPostAsync_ReturnsRunningTask()
{
const string upid = "UPID:pve1:000GHI:00000003:5F1234AD:vzrollback:100:root@pam:";
var mockClient = new Mock<IPveHttpClient>();
var captured = CapturePost(mockClient, UpidJson(upid));
var service = new ContainerService(mockClient.Object);
var task = service.RollbackContainerSnapshot(CreateSession(), TestNode, TestVmId, "clean-install");
Assert.Equal(upid, task.Upid);
Assert.Equal(TestNode, task.Node);
Assert.Equal("running", task.Status);
Assert.Equal($"nodes/{TestNode}/lxc/{TestVmId}/snapshot/clean-install/rollback", captured.Path);
Assert.Null(captured.Form);
Assert.Equal(1, captured.Calls);
}
[Fact]
public void RollbackContainerSnapshot_EscapesNodeAndSnapnameInPath()
{
var mockClient = new Mock<IPveHttpClient>();
var captured = CapturePost(mockClient, UpidJson("UPID:pve1:000GHI:00000003:5F1234AD:vzrollback:100:root@pam:"));
var service = new ContainerService(mockClient.Object);
service.RollbackContainerSnapshot(CreateSession(), "pve node", TestVmId, "snap name");
Assert.Equal($"nodes/pve%20node/lxc/{TestVmId}/snapshot/snap%20name/rollback", captured.Path);
}
[Fact]
public void CreateContainerSnapshot_NullSession_ThrowsArgumentNullException()
{
var service = new ContainerService(new Mock<IPveHttpClient>().Object);
Assert.Throws<ArgumentNullException>("session",
() => service.CreateContainerSnapshot(null!, TestNode, TestVmId, "my-snap"));
}
[Fact]
public void RemoveContainerSnapshot_NullSession_ThrowsArgumentNullException()
{
var service = new ContainerService(new Mock<IPveHttpClient>().Object);
Assert.Throws<ArgumentNullException>("session",
() => service.RemoveContainerSnapshot(null!, TestNode, TestVmId, "my-snap"));
}
[Fact]
public void RollbackContainerSnapshot_NullSession_ThrowsArgumentNullException()
{
var service = new ContainerService(new Mock<IPveHttpClient>().Object);
Assert.Throws<ArgumentNullException>("session",
() => service.RollbackContainerSnapshot(null!, TestNode, TestVmId, "my-snap"));
}
[Fact]
public void RollbackContainerSnapshot_WhitespaceSnapname_ThrowsArgumentNullException()
{
var service = new ContainerService(new Mock<IPveHttpClient>().Object);
Assert.Throws<ArgumentNullException>("snapname",
() => service.RollbackContainerSnapshot(CreateSession(), TestNode, TestVmId, " "));
}
// ---------------------------------------------------------------------
// ParseTask "running" stamp: applies to every UPID-string response,
// not only the three snapshot methods (#126 reconciliation).
// ---------------------------------------------------------------------
[Fact]
public void RemoveContainer_UpidStringResponse_StampsRunningStatus()
{
var mockClient = new Mock<IPveHttpClient>();
mockClient
.Setup(c => c.DeleteAsync(It.IsAny<string>()))
.ReturnsAsync("{\"data\":\"UPID:pve1:00001234:00005678:6A970AAB:vzdestroy:100:root@pam:\"}");
var service = new ContainerService(mockClient.Object);
var task = service.RemoveContainer(CreateSession(), TestNode, TestVmId);
Assert.Equal("running", task.Status);
}
[Fact]
public void RemoveContainer_WithForceTrue_IncludesForceInQueryString()
{