Files
PSProxmoxVE/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs
T
goodolclint-claude[bot] 0026ef2b57 refactor: typed models for the remaining dictionary-returning services (#157) (#229)
NodeService.GetNodeConfig/GetNodeDns, ClusterConfigService.GetClusterConfig,
BackupService.GetNotBackedUp and VmService.GetGuestExecStatus returned raw
Dictionary/List<Dictionary> instead of a Pve* model, per issue #157. Each now
has a typed model under Models/{Nodes,Cluster,Backup,Vms}/ with [JsonProperty]
for documented fields and a [JsonExtensionData]-backed AdditionalProperties
catch-all, following the PveVmConfig pattern. The five consuming cmdlets and
their [OutputType] attributes are updated to match.

GetClusterConfig also fixes a latent bug: GET /cluster/config returns a JSON
array (a directory index), but the old code did `data is JObject obj ? ... :
empty dict`, which silently always returned an empty dictionary since data was
a JArray. PveClusterConfigEntry decodes the array correctly and exposes a
typed Name property (the array items' schema documents no named fields, but
the endpoint's "links" metadata gives the child-URL template as "{name}").

The guest-exec poll loop in InvokePveVmGuestExecCmdlet keeps its exact
Stopwatch + Thread.Sleep(1000) structure (ADR 0001 accepted exception); only
the type it reads from changed. A TolerantBooleanConverter was added so
PveGuestExecStatus.Exited keeps accepting PVE's boolean/integer/string forms,
matching what ApiValueHelper.IsExited already tolerated for the old
dictionary path.

Reviewed with codex-rescue, correctness-reviewer and api-compat-reviewer
before commit; both real findings above (the name/subdir key and the Exited
string-form regression) came from that pass and are mutation-tested.

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

401 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Moq;
using Newtonsoft.Json.Linq;
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 ClusterConfigServiceTests
{
private static PveSession CreateSession()
{
return new PveSession("pve.example.com", 8006, false,
"root@pam!testtoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
}
[Fact]
public void GetClusterConfig_ReturnsTypedEntriesWithUnknownKeyInAdditionalProperties()
{
// Arrange — GET /cluster/config is a directory index: an array of
// entries, not a single object. The item schema documents no named
// fields, but the endpoint's "links" metadata gives the child-URL
// template as "{name}", so every entry carries a "name" key.
var json = @"{""data"": [{""name"": ""nodes""}, {""name"": ""totem"", ""extra"": ""x""}]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/config")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var config = service.GetClusterConfig(CreateSession());
// Assert
Assert.Equal(2, config.Count);
Assert.Equal("nodes", config[0].Name);
Assert.Equal("totem", config[1].Name);
Assert.Equal("x", config[1].AdditionalProperties["extra"]);
mockClient.Verify(c => c.GetAsync("cluster/config"), Times.Once);
}
[Fact]
public void CreateCluster_PostsClusterNameAndReturnsUpid()
{
// Arrange
var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:cluster_create::root@pam:""}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var upid = service.CreateCluster(CreateSession(), "test-cluster");
// Assert
Assert.Contains("cluster_create", upid);
mockClient.Verify(c => c.PostAsync(
"cluster/config",
It.Is<Dictionary<string, string>>(d =>
d["clustername"] == "test-cluster")),
Times.Once);
}
[Fact]
public void CreateCluster_WithOptionalParams_IncludesThemInPost()
{
// Arrange
var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:cluster_create::root@pam:""}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
var links = new Dictionary<string, string> { ["link0"] = "10.0.0.1" };
// Act
service.CreateCluster(CreateSession(), "test-cluster", links, nodeid: 1, votes: 2);
// Assert
mockClient.Verify(c => c.PostAsync(
"cluster/config",
It.Is<Dictionary<string, string>>(d =>
d["clustername"] == "test-cluster" &&
d["link0"] == "10.0.0.1" &&
d["nodeid"] == "1" &&
d["votes"] == "2")),
Times.Once);
}
[Fact]
public void GetConfigNodes_ReturnsNodeArray()
{
// Arrange
var json = @"{""data"": [
{""node"": ""pve1"", ""nodeid"": 1, ""ring0_addr"": ""10.0.0.1"", ""quorum_votes"": 1},
{""node"": ""pve2"", ""nodeid"": 2, ""ring0_addr"": ""10.0.0.2"", ""quorum_votes"": 1}
]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/config/nodes")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var nodes = service.GetConfigNodes(CreateSession());
// Assert
Assert.Equal(2, nodes.Length);
Assert.Equal("pve1", nodes[0].Name);
Assert.Equal(1, nodes[0].NodeId);
Assert.Equal("10.0.0.1", nodes[0].Ring0Addr);
Assert.Equal(1, nodes[0].QuorumVotes);
Assert.Equal("pve2", nodes[1].Name);
Assert.Equal(2, nodes[1].NodeId);
mockClient.Verify(c => c.GetAsync("cluster/config/nodes"), Times.Once);
}
[Fact]
public void AddConfigNode_PostsToEncodedUrlAndReturnsUpid()
{
// Arrange
var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:addnode::root@pam:""}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config/nodes/pve2",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var upid = service.AddConfigNode(CreateSession(), "pve2", newNodeIp: "10.0.0.2");
// Assert
Assert.Contains("addnode", upid);
mockClient.Verify(c => c.PostAsync(
"cluster/config/nodes/pve2",
It.Is<Dictionary<string, string>>(d =>
d["new_node_ip"] == "10.0.0.2")),
Times.Once);
}
[Fact]
public void RemoveConfigNode_CallsDeleteWithEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.DeleteAsync("cluster/config/nodes/pve2"))
.ReturnsAsync("{}");
var service = new ClusterConfigService(mockClient.Object);
// Act
service.RemoveConfigNode(CreateSession(), "pve2");
// Assert
mockClient.Verify(c => c.DeleteAsync("cluster/config/nodes/pve2"), Times.Once);
}
[Fact]
public void GetJoinInfo_ReturnsClusterJoinInfo()
{
// Arrange
var json = @"{""data"": {
""config_digest"": ""abc123"",
""preferred_node"": ""pve1"",
""nodelist"": [{""name"": ""pve1"", ""ring0_addr"": ""10.0.0.1""}],
""totem"": {""version"": ""2""}
}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/config/join")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var joinInfo = service.GetJoinInfo(CreateSession());
// Assert
Assert.Equal("abc123", joinInfo.ConfigDigest);
Assert.Equal("pve1", joinInfo.PreferredNode);
Assert.NotNull(joinInfo.Nodelist);
Assert.Single(joinInfo.Nodelist);
Assert.NotNull(joinInfo.Totem);
mockClient.Verify(c => c.GetAsync("cluster/config/join"), Times.Once);
}
[Fact]
public void GetJoinInfo_WithNode_AppendsQueryString()
{
// Arrange
var json = @"{""data"": {""config_digest"": ""abc123"", ""preferred_node"": ""pve1""}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/config/join?node=pve1")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var joinInfo = service.GetJoinInfo(CreateSession(), "pve1");
// Assert
Assert.Equal("pve1", joinInfo.PreferredNode);
mockClient.Verify(c => c.GetAsync("cluster/config/join?node=pve1"), Times.Once);
}
[Fact]
public void JoinCluster_PostsRequiredParamsAndReturnsUpid()
{
// Arrange
var json = @"{""data"": ""UPID:pve2:000ABC:00000001:5F1234AB:join::root@pam:""}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config/join",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var upid = service.JoinCluster(CreateSession(), "10.0.0.1", "AA:BB:CC:DD", "secret");
// Assert
Assert.Contains("join", upid);
mockClient.Verify(c => c.PostAsync(
"cluster/config/join",
It.Is<Dictionary<string, string>>(d =>
d["hostname"] == "10.0.0.1" &&
d["fingerprint"] == "AA:BB:CC:DD" &&
d["password"] == "secret")),
Times.Once);
}
[Fact]
public void GetClusterOptions_ReturnsPveClusterOptions()
{
// Arrange
var json = @"{""data"": {
""keyboard"": ""en-us"",
""language"": ""en"",
""console"": ""html5"",
""fencing"": ""watchdog"",
""email_from"": ""admin@example.com"",
""max_workers"": 4
}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/options")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var options = service.GetClusterOptions(CreateSession());
// Assert
Assert.Equal("en-us", options.Keyboard);
Assert.Equal("en", options.Language);
Assert.Equal("html5", options.Console);
Assert.Equal("watchdog", options.Fencing);
Assert.Equal("admin@example.com", options.EmailFrom);
Assert.Equal(4, options.MaxWorkers);
mockClient.Verify(c => c.GetAsync("cluster/options"), Times.Once);
}
[Fact]
public void SetClusterOptions_CallsPutAsyncWithCorrectResource()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PutAsync(
"cluster/options",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new ClusterConfigService(mockClient.Object);
var opts = new Dictionary<string, string>
{
["keyboard"] = "de",
["language"] = "de"
};
// Act
service.SetClusterOptions(CreateSession(), opts);
// Assert
mockClient.Verify(c => c.PutAsync(
"cluster/options",
It.Is<Dictionary<string, string>>(d =>
d["keyboard"] == "de" && d["language"] == "de")),
Times.Once);
}
[Fact]
public void GetNextId_ReturnsInt()
{
// Arrange
var json = @"{""data"": ""100""}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var nextId = service.GetNextId(CreateSession());
// Assert
Assert.Equal(100, nextId);
mockClient.Verify(c => c.GetAsync("cluster/nextid"), Times.Once);
}
[Fact]
public void GetNextId_WithVmid_AppendsQueryString()
{
// Arrange
var json = @"{""data"": ""200""}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/nextid?vmid=200")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var nextId = service.GetNextId(CreateSession(), 200);
// Assert
Assert.Equal(200, nextId);
mockClient.Verify(c => c.GetAsync("cluster/nextid?vmid=200"), Times.Once);
}
[Fact]
public void GetNextId_InvalidResponse_ThrowsInvalidOperationException()
{
// Arrange
var json = @"{""data"": ""not-a-number""}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => service.GetNextId(CreateSession()));
Assert.Contains("unexpected next VMID value", ex.Message);
}
[Fact]
public void GetNextId_NullDataField_ThrowsInvalidOperationException()
{
// Arrange
var json = @"{""notdata"": ""100""}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => service.GetNextId(CreateSession()));
Assert.Contains("did not contain a 'data' field", ex.Message);
}
[Fact]
public void Constructor_NullClient_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new ClusterConfigService(null!));
}
[Fact]
public void WaitForQuorum_ReturnsOnceClusterIsQuorate()
{
var notQuorate = @"{""data"": [{""type"": ""cluster"", ""name"": ""c1"", ""quorate"": 0}]}";
var quorate = @"{""data"": [{""type"": ""cluster"", ""name"": ""c1"", ""quorate"": 1}]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.SetupSequence(c => c.GetAsync("cluster/status"))
.ReturnsAsync(notQuorate)
.ReturnsAsync(quorate);
var service = new ClusterConfigService(mockClient.Object);
service.WaitForQuorum(CreateSession(), TimeSpan.FromSeconds(30));
mockClient.Verify(c => c.GetAsync("cluster/status"), Times.Exactly(2));
}
[Fact]
public void WaitForQuorum_ThrowsWhenQuorumNeverReached()
{
var notQuorate = @"{""data"": [{""type"": ""cluster"", ""name"": ""c1"", ""quorate"": 0}]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/status")).ReturnsAsync(notQuorate);
var service = new ClusterConfigService(mockClient.Object);
Assert.Throws<TimeoutException>(() =>
service.WaitForQuorum(CreateSession(), TimeSpan.FromMilliseconds(1)));
}
[Fact]
public void WaitForQuorum_RetriesWhileTheApiIsRestarting()
{
var quorate = @"{""data"": [{""type"": ""cluster"", ""name"": ""c1"", ""quorate"": 1}]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.SetupSequence(c => c.GetAsync("cluster/status"))
.ThrowsAsync(new PveApiException(HttpStatusCode.InternalServerError,
"cluster not ready", "cluster/status", "GET"))
.ReturnsAsync(quorate);
var service = new ClusterConfigService(mockClient.Object);
service.WaitForQuorum(CreateSession(), TimeSpan.FromSeconds(30));
mockClient.Verify(c => c.GetAsync("cluster/status"), Times.Exactly(2));
}
}
}