test: add xUnit, Pester, and integration tests for cluster config + HA

xUnit (47 new tests, 429 total):
- ClusterConfigServiceTests: 25 tests covering all 14 service methods
  including URL encoding, null guards, and error responses
- HaServiceTests: 22 tests covering resources, groups, status, and rules
  with URI encoding verification (vm:100 → vm%3A100)

Pester (187 new tests, 1525 total):
- ClusterConfigCmdlets.Tests.ps1: 11 cmdlets — parameter metadata,
  ValidateSet/ValidateRange/ValidateLength, ShouldProcess, ConfirmImpact
- HaCmdlets.Tests.ps1: 14 cmdlets — same pattern

Integration (ClusterConfig.Integration.Tests.ps1):
- Full 2-node cluster lifecycle: create → join → options → HA groups → cleanup
- Uses root@pam ticket auth for cluster create/join (API tokens lack permission)
- Reconnects with API token after privileged operations
- Skip helpers for standalone (no node B) and non-PVE-9 environments

Fixes:
- GetClusterConfig returns JToken (not JObject) to handle array responses
  from /cluster/config on standalone nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-24 19:12:45 -05:00
parent 4310869943
commit 4e4bc11872
5 changed files with 2859 additions and 0 deletions
@@ -0,0 +1,470 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Moq;
using Newtonsoft.Json.Linq;
using Xunit;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
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_ReturnsJObject()
{
// Arrange
var json = @"{""data"": {""nodes"": {""pve1"": {}}, ""totem"": {""version"": ""2""}}}";
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.NotNull(config);
Assert.NotNull(config["nodes"]);
Assert.NotNull(config["totem"]);
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 GetTotem_ReturnsJObject()
{
// Arrange
var json = @"{""data"": {""version"": ""2"", ""secauth"": ""on"", ""cluster_name"": ""pve-cluster""}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/config/totem")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var totem = service.GetTotem(CreateSession());
// Assert
Assert.NotNull(totem);
Assert.Equal("2", totem["version"]?.ToString());
Assert.Equal("on", totem["secauth"]?.ToString());
Assert.Equal("pve-cluster", totem["cluster_name"]?.ToString());
mockClient.Verify(c => c.GetAsync("cluster/config/totem"), Times.Once);
}
[Fact]
public void GetApiVersion_ReturnsInt()
{
// Arrange
var json = @"{""data"": 10}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/config/apiversion")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var version = service.GetApiVersion(CreateSession());
// Assert
Assert.Equal(10, version);
mockClient.Verify(c => c.GetAsync("cluster/config/apiversion"), 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 GetClusterStatus_ReturnsStatusArray()
{
// Arrange
var json = @"{""data"": [
{""type"": ""cluster"", ""name"": ""pve-cluster"", ""nodes"": 3, ""quorate"": 1, ""version"": 5},
{""type"": ""node"", ""name"": ""pve1"", ""online"": 1, ""local"": 1, ""nodeid"": 1, ""ip"": ""10.0.0.1""}
]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/status")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act
var statuses = service.GetClusterStatus(CreateSession());
// Assert
Assert.Equal(2, statuses.Length);
Assert.Equal("cluster", statuses[0].Type);
Assert.Equal("pve-cluster", statuses[0].Name);
Assert.Equal(3, statuses[0].Nodes);
Assert.Equal(1, statuses[0].Quorate);
Assert.Equal("node", statuses[1].Type);
Assert.Equal("pve1", statuses[1].Name);
Assert.Equal(1, statuses[1].Online);
Assert.Equal("10.0.0.1", statuses[1].Ip);
mockClient.Verify(c => c.GetAsync("cluster/status"), 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 GetClusterConfig_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetClusterConfig(null!));
}
[Fact]
public void GetConfigNodes_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetConfigNodes(null!));
}
[Fact]
public void GetClusterOptions_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetClusterOptions(null!));
}
[Fact]
public void GetNextId_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetNextId(null!));
}
[Fact]
public void GetClusterStatus_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetClusterStatus(null!));
}
[Fact]
public void Constructor_NullClient_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new ClusterConfigService(null!));
}
}
}
@@ -0,0 +1,509 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Moq;
using Newtonsoft.Json.Linq;
using Xunit;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.HA;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Core.Tests.Services
{
public class HaServiceTests
{
private static PveSession CreateSession()
{
return new PveSession("pve.example.com", 8006, false,
"root@pam!testtoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
}
// -----------------------------------------------------------------
// Resources
// -----------------------------------------------------------------
[Fact]
public void GetResources_ReturnsResourceArray()
{
// Arrange
var json = @"{""data"": [
{""sid"": ""vm:100"", ""state"": ""started"", ""group"": ""grp1"", ""type"": ""vm"", ""max_relocate"": 1, ""max_restart"": 1},
{""sid"": ""ct:200"", ""state"": ""stopped"", ""type"": ""ct""}
]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/ha/resources")).ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var resources = service.GetResources(CreateSession());
// Assert
Assert.Equal(2, resources.Length);
Assert.Equal("vm:100", resources[0].Sid);
Assert.Equal("started", resources[0].State);
Assert.Equal("grp1", resources[0].Group);
Assert.Equal("vm", resources[0].Type);
Assert.Equal(1, resources[0].MaxRelocate);
Assert.Equal(1, resources[0].MaxRestart);
Assert.Equal("ct:200", resources[1].Sid);
Assert.Equal("stopped", resources[1].State);
mockClient.Verify(c => c.GetAsync("cluster/ha/resources"), Times.Once);
}
[Fact]
public void GetResource_ReturnsSingleResource()
{
// Arrange
var json = @"{""data"": {""sid"": ""vm:100"", ""state"": ""started"", ""group"": ""grp1"", ""type"": ""vm""}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/ha/resources/vm%3A100")).ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var resource = service.GetResource(CreateSession(), "vm:100");
// Assert
Assert.Equal("vm:100", resource.Sid);
Assert.Equal("started", resource.State);
Assert.Equal("grp1", resource.Group);
Assert.Equal("vm", resource.Type);
mockClient.Verify(c => c.GetAsync("cluster/ha/resources/vm%3A100"), Times.Once);
}
[Fact]
public void GetResource_EncodesColonInSid()
{
// Arrange
var json = @"{""data"": {""sid"": ""ct:200"", ""state"": ""stopped""}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/ha/resources/ct%3A200")).ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var resource = service.GetResource(CreateSession(), "ct:200");
// Assert
Assert.Equal("ct:200", resource.Sid);
mockClient.Verify(c => c.GetAsync("cluster/ha/resources/ct%3A200"), Times.Once);
}
[Fact]
public void CreateResource_PostsSidAndOptions()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/resources",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["state"] = "started",
["group"] = "grp1"
};
// Act
service.CreateResource(CreateSession(), "vm:100", options);
// Assert
mockClient.Verify(c => c.PostAsync(
"cluster/ha/resources",
It.Is<Dictionary<string, string>>(d =>
d["sid"] == "vm:100" &&
d["state"] == "started" &&
d["group"] == "grp1")),
Times.Once);
}
[Fact]
public void UpdateResource_PutsOptionsToEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PutAsync(
"cluster/ha/resources/vm%3A100",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["state"] = "stopped"
};
// Act
service.UpdateResource(CreateSession(), "vm:100", options);
// Assert
mockClient.Verify(c => c.PutAsync(
"cluster/ha/resources/vm%3A100",
It.Is<Dictionary<string, string>>(d =>
d["state"] == "stopped")),
Times.Once);
}
[Fact]
public void DeleteResource_CallsDeleteWithEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.DeleteAsync("cluster/ha/resources/vm%3A100"))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
// Act
service.DeleteResource(CreateSession(), "vm:100");
// Assert
mockClient.Verify(c => c.DeleteAsync("cluster/ha/resources/vm%3A100"), Times.Once);
}
[Fact]
public void MigrateResource_PostsNodeToEncodedUrl()
{
// Arrange
var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:hamigrate::root@pam:""}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/resources/vm%3A100/migrate",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var upid = service.MigrateResource(CreateSession(), "vm:100", "pve2");
// Assert
Assert.Contains("hamigrate", upid);
mockClient.Verify(c => c.PostAsync(
"cluster/ha/resources/vm%3A100/migrate",
It.Is<Dictionary<string, string>>(d =>
d["node"] == "pve2")),
Times.Once);
}
[Fact]
public void RelocateResource_PostsNodeToEncodedUrl()
{
// Arrange
var json = @"{""data"": ""UPID:pve1:000DEF:00000002:5F1234AC:harelocate::root@pam:""}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/resources/vm%3A100/relocate",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var upid = service.RelocateResource(CreateSession(), "vm:100", "pve3");
// Assert
Assert.Contains("harelocate", upid);
mockClient.Verify(c => c.PostAsync(
"cluster/ha/resources/vm%3A100/relocate",
It.Is<Dictionary<string, string>>(d =>
d["node"] == "pve3")),
Times.Once);
}
// -----------------------------------------------------------------
// Groups
// -----------------------------------------------------------------
[Fact]
public void GetGroups_ReturnsGroupArray()
{
// Arrange
var json = @"{""data"": [
{""group"": ""grp1"", ""nodes"": ""pve1:2,pve2:1"", ""restricted"": 1, ""nofailback"": 0, ""comment"": ""Primary group""},
{""group"": ""grp2"", ""nodes"": ""pve2,pve3""}
]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/ha/groups")).ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var groups = service.GetGroups(CreateSession());
// Assert
Assert.Equal(2, groups.Length);
Assert.Equal("grp1", groups[0].Group);
Assert.Equal("pve1:2,pve2:1", groups[0].Nodes);
Assert.Equal(1, groups[0].Restricted);
Assert.Equal(0, groups[0].NoFailback);
Assert.Equal("Primary group", groups[0].Comment);
Assert.Equal("grp2", groups[1].Group);
Assert.Equal("pve2,pve3", groups[1].Nodes);
mockClient.Verify(c => c.GetAsync("cluster/ha/groups"), Times.Once);
}
[Fact]
public void GetGroup_ReturnsSingleGroup()
{
// Arrange
var json = @"{""data"": {""group"": ""grp1"", ""nodes"": ""pve1:2,pve2:1"", ""restricted"": 0}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/ha/groups/grp1")).ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var group = service.GetGroup(CreateSession(), "grp1");
// Assert
Assert.Equal("grp1", group.Group);
Assert.Equal("pve1:2,pve2:1", group.Nodes);
Assert.Equal(0, group.Restricted);
mockClient.Verify(c => c.GetAsync("cluster/ha/groups/grp1"), Times.Once);
}
[Fact]
public void CreateGroup_PostsGroupNodesAndOptions()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/groups",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["restricted"] = "1",
["comment"] = "Test group"
};
// Act
service.CreateGroup(CreateSession(), "grp1", "pve1:2,pve2:1", options);
// Assert
mockClient.Verify(c => c.PostAsync(
"cluster/ha/groups",
It.Is<Dictionary<string, string>>(d =>
d["group"] == "grp1" &&
d["nodes"] == "pve1:2,pve2:1" &&
d["restricted"] == "1" &&
d["comment"] == "Test group")),
Times.Once);
}
[Fact]
public void DeleteGroup_CallsDeleteWithEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.DeleteAsync("cluster/ha/groups/grp1"))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
// Act
service.DeleteGroup(CreateSession(), "grp1");
// Assert
mockClient.Verify(c => c.DeleteAsync("cluster/ha/groups/grp1"), Times.Once);
}
// -----------------------------------------------------------------
// Status
// -----------------------------------------------------------------
[Fact]
public void GetStatus_ReturnsStatusArray()
{
// Arrange
var json = @"{""data"": [
{""id"": ""quorum"", ""type"": ""quorum"", ""node"": ""pve1"", ""status"": ""OK"", ""timestamp"": 1700000000},
{""id"": ""manager"", ""type"": ""manager"", ""node"": ""pve1"", ""status"": ""OK"", ""crm_state"": ""S_IDLE""},
{""id"": ""service:vm:100"", ""type"": ""service"", ""node"": ""pve1"", ""status"": ""started"", ""request_state"": ""started""}
]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/ha/status/current")).ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var statuses = service.GetStatus(CreateSession());
// Assert
Assert.Equal(3, statuses.Length);
Assert.Equal("quorum", statuses[0].Id);
Assert.Equal("quorum", statuses[0].Type);
Assert.Equal("pve1", statuses[0].Node);
Assert.Equal("OK", statuses[0].Status);
Assert.Equal(1700000000L, statuses[0].Timestamp);
Assert.Equal("manager", statuses[1].Id);
Assert.Equal("S_IDLE", statuses[1].CrmState);
Assert.Equal("service:vm:100", statuses[2].Id);
Assert.Equal("started", statuses[2].RequestState);
mockClient.Verify(c => c.GetAsync("cluster/ha/status/current"), Times.Once);
}
[Fact]
public void GetManagerStatus_ReturnsJObject()
{
// Arrange
var json = @"{""data"": {""manager_status"": {""master_node"": ""pve1""}, ""quorum"": {""node"": ""pve1""}}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/ha/status/manager_status")).ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var status = service.GetManagerStatus(CreateSession());
// Assert
Assert.NotNull(status);
Assert.NotNull(status["manager_status"]);
Assert.NotNull(status["quorum"]);
mockClient.Verify(c => c.GetAsync("cluster/ha/status/manager_status"), Times.Once);
}
// -----------------------------------------------------------------
// Rules
// -----------------------------------------------------------------
[Fact]
public void GetRules_ReturnsRuleArray()
{
// Arrange
var json = @"{""data"": [
{""rule"": ""rule1"", ""type"": ""location"", ""state"": ""enabled"", ""comment"": ""Location rule""},
{""rule"": ""rule2"", ""type"": ""colocation"", ""state"": ""disabled""}
]}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/ha/rules")).ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var rules = service.GetRules(CreateSession());
// Assert
Assert.Equal(2, rules.Length);
Assert.Equal("rule1", rules[0].Rule);
Assert.Equal("location", rules[0].Type);
Assert.Equal("enabled", rules[0].State);
Assert.Equal("Location rule", rules[0].Comment);
Assert.Equal("rule2", rules[1].Rule);
Assert.Equal("colocation", rules[1].Type);
Assert.Equal("disabled", rules[1].State);
mockClient.Verify(c => c.GetAsync("cluster/ha/rules"), Times.Once);
}
[Fact]
public void GetRule_ReturnsSingleRule()
{
// Arrange
var json = @"{""data"": {""rule"": ""rule1"", ""type"": ""location"", ""state"": ""enabled""}}";
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/ha/rules/rule1")).ReturnsAsync(json);
var service = new HaService(mockClient.Object);
// Act
var rule = service.GetRule(CreateSession(), "rule1");
// Assert
Assert.Equal("rule1", rule.Rule);
Assert.Equal("location", rule.Type);
Assert.Equal("enabled", rule.State);
mockClient.Verify(c => c.GetAsync("cluster/ha/rules/rule1"), Times.Once);
}
[Fact]
public void CreateRule_PostsOptions()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/rules",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["rule"] = "rule1",
["type"] = "location",
["state"] = "enabled"
};
// Act
service.CreateRule(CreateSession(), options);
// Assert
mockClient.Verify(c => c.PostAsync(
"cluster/ha/rules",
It.Is<Dictionary<string, string>>(d =>
d["rule"] == "rule1" &&
d["type"] == "location" &&
d["state"] == "enabled")),
Times.Once);
}
[Fact]
public void DeleteRule_CallsDeleteWithEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.DeleteAsync("cluster/ha/rules/rule1"))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
// Act
service.DeleteRule(CreateSession(), "rule1");
// Assert
mockClient.Verify(c => c.DeleteAsync("cluster/ha/rules/rule1"), Times.Once);
}
// -----------------------------------------------------------------
// Null session tests
// -----------------------------------------------------------------
[Fact]
public void GetResources_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetResources(null!));
}
[Fact]
public void GetGroups_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetGroups(null!));
}
[Fact]
public void GetStatus_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetStatus(null!));
}
[Fact]
public void GetRules_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetRules(null!));
}
[Fact]
public void Constructor_NullClient_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new HaService(null!));
}
}
}