Files
PSProxmoxVE/tests/PSProxmoxVE.Core.Tests/Services/TemplateServiceTests.cs
T
goodolclint-claude[bot] 089bb7c81e refactor: one task-response parser, and the dead code #154 names (part A) (#207)
* refactor: one task-response parser, and the dead code #154 names (part A)

Unifies the 8 byte-similar private ParseTask methods in BackupService,
ContainerService, NetworkService, NodeService, SnapshotService,
StorageService, TemplateService, and VmService into one shared
PveTaskResponse.Parse(json, node) utility, on the variant that stamps
Status = "running" for a bare-UPID response. BackupService, NodeService,
TemplateService, and VmService previously left Status null for that case;
their non--Wait task output now reports "running" like the other four
services already did.

Removes the duplicate ClusterConfigService.GetClusterStatus in favor of
ClusterService's; ClusterConfigService now holds a ClusterService built
from the same injected/default client, and WaitForQuorum and
Get-PveClusterStatus go through it.

Removes dead code named in issue #154 and its fold-in comments: the
never-called PveHttpClient/IPveHttpClient sync wrappers Put/Delete, the
never-thrown PveAuthenticationException, a #pragma around an
already-nullable field, the unreferenced TestHelper mock-handler helpers,
three hand-rolled version-warning blocks (now PveCmdletBase.WarnIfBelowVersion),
an unreachable catch(HttpRequestException) arm in WaitForStatusTransition,
dead ExitStatus-checking branches in ImportPveOvaCmdlet after WaitForTask
(which already throws on failure), and an unreachable int branch in
ApiValueHelper.IsExited.

Fixes the WaitForStatusTransition catch removal's premise: PveHttpClient
read the response body outside its HttpRequestException try block, so a
mid-body stream drop could still escape unwrapped. Moves the body read
inside the try so every HttpRequestException the client can throw becomes
a PveApiException, matching what the removed catch assumed.

Part of #154.

* Add service files: BackupService, ClusterConfigService, ContainerService, NetworkService

* Add service files: NodeService, SnapshotService, StorageService, TemplateService

* Add VmService and Utilities files

* Remove unused PveAuthenticationException (never thrown, caught, or tested)

* Add cmdlet files: GetPveClusterStatus, SDN subnets, PveCmdletBase, SendPveFile, ImportPveOva

* Add test files: BackupServiceTests, ClusterConfigServiceTests, NodeServiceTests

* Add remaining test files: TemplateServiceTests, VmServiceTests, TestHelper, ApiValueHelperTests, PveTaskResponseTests

* Add VmServiceTests

---------

Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
2026-09-03 00:59:51 +00:00

219 lines
8.6 KiB
C#

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
{
public class TemplateServiceTests
{
private const string Node = "pve1";
private const int VmId = 9000;
private static PveSession CreateSession()
{
return new PveSession("pve.example.com", 8006, false,
"root@pam!testtoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
}
[Fact]
public void CreateTemplate_CallsPostAsync_ReturnsUpid()
{
// Arrange
const string upid = "UPID:pve1:000ABC:00000001:5F1234AB:qmtemplate:9000:root@pam:";
var json = $@"{{""data"": ""{upid}""}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(json);
var service = new TemplateService(mockClient.Object);
// Act
var task = service.CreateTemplate(CreateSession(), Node, VmId);
// Assert
Assert.Equal(upid, task.Upid);
Assert.Equal(Node, task.Node);
Assert.Equal("running", task.Status);
mockClient.Verify(c => c.PostAsync(
$"nodes/{Node}/qemu/{VmId}/template",
It.IsAny<Dictionary<string, string>>()),
Times.Once);
}
[Fact]
public void CreateTemplate_WithTaskObject_ParsesCorrectly()
{
// Arrange — some PVE versions return a full task object instead of a bare UPID string
var json = @"{
""data"": {
""upid"": ""UPID:pve1:000ABC:00000001:5F1234AB:qmtemplate:9000:root@pam:"",
""type"": ""qmtemplate"",
""status"": ""running"",
""node"": ""pve1"",
""user"": ""root@pam""
}
}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(json);
var service = new TemplateService(mockClient.Object);
// Act
var task = service.CreateTemplate(CreateSession(), Node, VmId);
// Assert
Assert.Equal("UPID:pve1:000ABC:00000001:5F1234AB:qmtemplate:9000:root@pam:", task.Upid);
Assert.Equal("qmtemplate", task.Type);
Assert.Equal(Node, task.Node);
}
[Fact]
public void CreateTemplate_NullSession_ThrowsArgumentNullException()
{
var service = new TemplateService(new Mock<IPveHttpClient>().Object);
Assert.Throws<ArgumentNullException>("session", () => service.CreateTemplate(null!, Node, VmId));
}
[Fact]
public void CreateTemplate_NullNode_ThrowsArgumentNullException()
{
var service = new TemplateService(new Mock<IPveHttpClient>().Object);
Assert.Throws<ArgumentNullException>("node", () => service.CreateTemplate(CreateSession(), null!, VmId));
}
[Fact]
public void CreateTemplate_EmptyNode_ThrowsArgumentNullException()
{
var service = new TemplateService(new Mock<IPveHttpClient>().Object);
Assert.Throws<ArgumentNullException>("node", () => service.CreateTemplate(CreateSession(), " ", VmId));
}
[Fact]
public void Constructor_NullClient_ThrowsArgumentNullException()
{
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()));
}
}
}