refactor: route the CloudInit/Templates cmdlets through their service seams (#126) (#200)

Get-PveCloudInitConfig and Get-PveTemplate each built their own PveHttpClient
inline. They now go through CloudInitService and TemplateService, which is
offline-testable per ADR 0021.

Get-PveCloudInitConfig's shipped output is the full PveVmConfig (per its own
synopsis), not the CI-key-filtered PveCloudInitConfig that
CloudInitService.GetCloudInitConfig already returns for a different purpose.
CloudInitService gains GetFullVmConfig, a thin delegation to the existing
VmService.GetVmConfig (already used by Get-PveVmConfig) rather than a
parallel HTTP implementation.

Get-PveTemplate's TemplateService.GetTemplates now forwards VmService.GetVms's
onNodeSkipped callback, matching Get-PveVm/Get-PveContainer, so an unreachable
node during the all-nodes listing produces a warning instead of a silently
short result. Two review-found regressions were fixed before commit: an empty
-Node value used to mean "query all nodes" and now does again (it had
started reaching the API literally as nodes//qemu), and the per-node warning
above.

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:11:50 +00:00
committed by GitHub
parent 3fa7eeb023
commit 3f7f69b250
6 changed files with 215 additions and 58 deletions
@@ -156,5 +156,54 @@ namespace PSProxmoxVE.Core.Tests.Services
{
Assert.Throws<ArgumentNullException>(() => new CloudInitService(null!));
}
[Fact]
public void GetFullVmConfig_ReturnsFullConfig_IncludingCloudInitAndOtherFields()
{
// Arrange
var json = @"{""data"": {
""ciuser"": ""ubuntu"",
""ipconfig0"": ""ip=dhcp"",
""cores"": 4,
""memory"": 8192
}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("nodes/pve1/qemu/100/config")).ReturnsAsync(json);
var service = new CloudInitService(mockClient.Object);
// Act
var config = service.GetFullVmConfig(CreateSession(), "pve1", 100);
// Assert — cloud-init fields and every other config field are both present
Assert.Equal("ubuntu", config.CiUser);
Assert.Equal("ip=dhcp", config.IpConfig0);
Assert.Equal(4, config.Cores);
Assert.Equal(8192, config.Memory);
mockClient.Verify(c => c.GetAsync("nodes/pve1/qemu/100/config"), Times.Once);
}
[Fact]
public void GetFullVmConfig_EscapesNodeInPath()
{
// Arrange
var json = @"{""data"": {}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("nodes/pve%20node/qemu/100/config")).ReturnsAsync(json);
var service = new CloudInitService(mockClient.Object);
// Act
service.GetFullVmConfig(CreateSession(), "pve node", 100);
// Assert
mockClient.Verify(c => c.GetAsync("nodes/pve%20node/qemu/100/config"), Times.Once);
}
[Fact]
public void GetFullVmConfig_NullSession_ThrowsArgumentNullException()
{
var service = new CloudInitService(new Mock<IPveHttpClient>().Object);
Assert.Throws<ArgumentNullException>(() => service.GetFullVmConfig(null!, "pve1", 100));
}
}
}
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Net;
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
@@ -101,5 +103,115 @@ namespace PSProxmoxVE.Core.Tests.Services
{
Assert.Throws<ArgumentNullException>("client", () => new TemplateService(null!));
}
[Fact]
public void GetTemplates_SingleNode_ReturnsOnlyTemplateFlaggedVms()
{
// Arrange
var json = @"{""data"": [
{""vmid"": 100, ""name"": ""web-template"", ""template"": 1},
{""vmid"": 101, ""name"": ""running-vm"", ""template"": 0},
{""vmid"": 102, ""name"": ""db-template"", ""template"": 1}
]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync($"nodes/{Node}/qemu")).ReturnsAsync(json);
var service = new TemplateService(mockClient.Object);
// Act
var templates = service.GetTemplates(CreateSession(), Node);
// Assert
Assert.Equal(2, templates.Length);
Assert.All(templates, t => Assert.Equal(1, t.Template));
Assert.Contains(templates, t => t.VmId == 100);
Assert.Contains(templates, t => t.VmId == 102);
mockClient.Verify(c => c.GetAsync($"nodes/{Node}/qemu"), Times.Once);
}
[Fact]
public void GetTemplates_EscapesNodeInPath()
{
// Arrange
var json = @"{""data"": []}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("nodes/pve%20node/qemu")).ReturnsAsync(json);
var service = new TemplateService(mockClient.Object);
// Act
service.GetTemplates(CreateSession(), "pve node");
// Assert
mockClient.Verify(c => c.GetAsync("nodes/pve%20node/qemu"), Times.Once);
}
[Fact]
public void GetTemplates_NullSession_ThrowsArgumentNullException()
{
var service = new TemplateService(new Mock<IPveHttpClient>().Object);
Assert.Throws<ArgumentNullException>("session", () => service.GetTemplates(null!, Node));
}
[Fact]
public void GetTemplates_AllNodes_AggregatesAcrossNodesAndStampsNode()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("nodes"))
.ReturnsAsync(@"{""data"": [{""node"": ""pve1""}, {""node"": ""pve2""}]}");
mockClient.Setup(c => c.GetAsync("nodes/pve1/qemu"))
.ReturnsAsync(@"{""data"": [{""vmid"": 100, ""template"": 1}, {""vmid"": 101, ""template"": 0}]}");
mockClient.Setup(c => c.GetAsync("nodes/pve2/qemu"))
.ReturnsAsync(@"{""data"": [{""vmid"": 200, ""template"": 1}]}");
var service = new TemplateService(mockClient.Object);
// Act
var templates = service.GetTemplates(CreateSession());
// Assert
Assert.Equal(2, templates.Length);
Assert.Contains(templates, t => t.VmId == 100 && t.Node == "pve1");
Assert.Contains(templates, t => t.VmId == 200 && t.Node == "pve2");
}
[Fact]
public void GetTemplates_AllNodes_UnreachableNodeIsSkippedAndReported()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("nodes"))
.ReturnsAsync(@"{""data"": [{""node"": ""pve1""}, {""node"": ""pve2""}]}");
mockClient.Setup(c => c.GetAsync("nodes/pve1/qemu"))
.ReturnsAsync(@"{""data"": [{""vmid"": 100, ""template"": 1}]}");
mockClient.Setup(c => c.GetAsync("nodes/pve2/qemu"))
.ThrowsAsync(new PveApiException(HttpStatusCode.InternalServerError, "internal error", "nodes/pve2/qemu", "GET"));
var service = new TemplateService(mockClient.Object);
// Act
var skipped = new List<string>();
var templates = service.GetTemplates(CreateSession(), onNodeSkipped: (node, ex) => skipped.Add(node));
// Assert
var template = Assert.Single(templates);
Assert.Equal(100, template.VmId);
Assert.Equal(new[] { "pve2" }, skipped);
}
[Fact]
public void GetTemplates_AllNodes_PermissionErrorOnOneNodePropagates()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("nodes"))
.ReturnsAsync(@"{""data"": [{""node"": ""pve1""}, {""node"": ""pve2""}]}");
mockClient.Setup(c => c.GetAsync("nodes/pve1/qemu"))
.ReturnsAsync(@"{""data"": [{""vmid"": 100, ""template"": 1}]}");
mockClient.Setup(c => c.GetAsync("nodes/pve2/qemu"))
.ThrowsAsync(new PveApiException(HttpStatusCode.Forbidden, "permission denied", "nodes/pve2/qemu", "GET"));
var service = new TemplateService(mockClient.Object);
// Act & Assert
Assert.Throws<PveApiException>(() => service.GetTemplates(CreateSession()));
}
}
}