diff --git a/src/PSProxmoxVE.Core/Services/SnapshotService.cs b/src/PSProxmoxVE.Core/Services/SnapshotService.cs
index 932c743..0d180b0 100644
--- a/src/PSProxmoxVE.Core/Services/SnapshotService.cs
+++ b/src/PSProxmoxVE.Core/Services/SnapshotService.cs
@@ -45,14 +45,14 @@ namespace PSProxmoxVE.Core.Services
}
///
- /// Creates a snapshot of a VM. Returns the task UPID.
+ /// Creates a snapshot of a VM. Returns the task PVE started.
///
/// The authenticated PVE session.
/// The cluster node name.
/// The VM ID.
/// Snapshot name (alphanumeric, no spaces).
/// Optional description.
- /// Whether to save VM RAM state (live snapshot). Default false.
+ /// Whether to save VM RAM state (live snapshot). Sent only when true.
public PveTask CreateSnapshot(
PveSession session,
string node,
@@ -67,11 +67,12 @@ namespace PSProxmoxVE.Core.Services
var formData = new Dictionary
{
- ["snapname"] = snapname,
- ["vmstate"] = vmstate ? "1" : "0"
+ ["snapname"] = snapname
};
if (!string.IsNullOrEmpty(description))
formData["description"] = description!;
+ if (vmstate)
+ formData["vmstate"] = "1";
return Invoke(session, client =>
{
@@ -82,7 +83,7 @@ namespace PSProxmoxVE.Core.Services
}
///
- /// Removes a snapshot from a VM. Returns the task UPID.
+ /// Removes a snapshot from a VM. Returns the task PVE started.
///
/// The authenticated PVE session.
/// The cluster node name.
@@ -107,7 +108,7 @@ namespace PSProxmoxVE.Core.Services
}
///
- /// Rolls a VM back to a snapshot. Returns the task UPID.
+ /// Rolls a VM back to a snapshot. Returns the task PVE started.
///
/// The authenticated PVE session.
/// The cluster node name.
@@ -139,7 +140,7 @@ namespace PSProxmoxVE.Core.Services
{
var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String)
- return new PveTask { Upid = data.ToString(), Node = node };
+ return new PveTask { Upid = data.ToString(), Node = node, Status = "running" };
var task = data?.ToObject() ?? new PveTask();
task.Node = node;
diff --git a/src/PSProxmoxVE/Cmdlets/Snapshots/NewPveSnapshotCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Snapshots/NewPveSnapshotCmdlet.cs
index 8231d3f..6dc0345 100644
--- a/src/PSProxmoxVE/Cmdlets/Snapshots/NewPveSnapshotCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Snapshots/NewPveSnapshotCmdlet.cs
@@ -1,8 +1,4 @@
-using System;
-using System.Collections.Generic;
using System.Management.Automation;
-using Newtonsoft.Json.Linq;
-using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
@@ -50,26 +46,15 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
return;
var session = GetSession();
- using var client = new PveHttpClient(session);
WriteVerbose($"Creating snapshot '{Name}' for VM {VmId}...");
- var data = new Dictionary
- {
- ["snapname"] = Name
- };
- if (!string.IsNullOrEmpty(Description)) data["description"] = Description!;
- if (IncludeVmState.IsPresent) data["vmstate"] = "1";
+ var service = new SnapshotService();
+ var task = service.CreateSnapshot(session, Node, VmId, Name, Description, IncludeVmState.IsPresent);
- var json = client.PostAsync($"nodes/{Uri.EscapeDataString(Node)}/qemu/{VmId}/snapshot", data).GetAwaiter().GetResult();
- var root = JObject.Parse(json);
- var upid = root["data"]?.ToString() ?? string.Empty;
-
- var task = new PveTask { Upid = upid, Node = Node, Status = "running" };
-
- if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
+ if (Wait.IsPresent && !string.IsNullOrEmpty(task.Upid))
{
var taskService = new TaskService();
- task = taskService.WaitForTask(session, Node, upid);
+ task = taskService.WaitForTask(session, Node, task.Upid);
}
WriteObject(task);
diff --git a/src/PSProxmoxVE/Cmdlets/Snapshots/RemovePveSnapshotCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Snapshots/RemovePveSnapshotCmdlet.cs
index ca66bd2..1ed5146 100644
--- a/src/PSProxmoxVE/Cmdlets/Snapshots/RemovePveSnapshotCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Snapshots/RemovePveSnapshotCmdlet.cs
@@ -1,7 +1,4 @@
-using System;
using System.Management.Automation;
-using Newtonsoft.Json.Linq;
-using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
@@ -45,18 +42,13 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
return;
WriteVerbose($"Removing snapshot '{Name}' from VM {VmId}...");
- using var client = new PveHttpClient(session);
+ var service = new SnapshotService();
+ var task = service.RemoveSnapshot(session, Node, VmId, Name);
- var json = client.DeleteAsync($"nodes/{Uri.EscapeDataString(Node)}/qemu/{VmId}/snapshot/{Uri.EscapeDataString(Name)}").GetAwaiter().GetResult();
- var root = JObject.Parse(json);
- var upid = root["data"]?.ToString() ?? string.Empty;
-
- var task = new PveTask { Upid = upid, Node = Node, Status = "running" };
-
- if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
+ if (Wait.IsPresent && !string.IsNullOrEmpty(task.Upid))
{
var taskService = new TaskService();
- task = taskService.WaitForTask(session, Node, upid);
+ task = taskService.WaitForTask(session, Node, task.Upid);
}
WriteObject(task);
diff --git a/src/PSProxmoxVE/Cmdlets/Snapshots/RestorePveSnapshotCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Snapshots/RestorePveSnapshotCmdlet.cs
index d987337..480c168 100644
--- a/src/PSProxmoxVE/Cmdlets/Snapshots/RestorePveSnapshotCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Snapshots/RestorePveSnapshotCmdlet.cs
@@ -1,7 +1,4 @@
-using System;
using System.Management.Automation;
-using Newtonsoft.Json.Linq;
-using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
@@ -48,18 +45,13 @@ namespace PSProxmoxVE.Cmdlets.Snapshots
return;
WriteVerbose($"Restoring snapshot '{Name}' on VM {VmId}...");
- using var client = new PveHttpClient(session);
+ var service = new SnapshotService();
+ var task = service.RollbackSnapshot(session, Node, VmId, Name);
- var json = client.PostAsync($"nodes/{Uri.EscapeDataString(Node)}/qemu/{VmId}/snapshot/{Uri.EscapeDataString(Name)}/rollback").GetAwaiter().GetResult();
- var root = JObject.Parse(json);
- var upid = root["data"]?.ToString() ?? string.Empty;
-
- var task = new PveTask { Upid = upid, Node = Node, Status = "running" };
-
- if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
+ if (Wait.IsPresent && !string.IsNullOrEmpty(task.Upid))
{
var taskService = new TaskService();
- task = taskService.WaitForTask(session, Node, upid);
+ task = taskService.WaitForTask(session, Node, task.Upid);
}
WriteObject(task);
diff --git a/tests/PSProxmoxVE.Core.Tests/Services/SnapshotServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/SnapshotServiceTests.cs
index 41f16b9..f682aaa 100644
--- a/tests/PSProxmoxVE.Core.Tests/Services/SnapshotServiceTests.cs
+++ b/tests/PSProxmoxVE.Core.Tests/Services/SnapshotServiceTests.cs
@@ -13,6 +13,14 @@ namespace PSProxmoxVE.Core.Tests.Services
{
private const string Node = "pve1";
private const int VmId = 100;
+ private const string CreateUpid = "UPID:pve1:000ABC:00000001:5F1234AB:qmsnapshot:100:root@pam:";
+
+ private sealed class CapturedPost
+ {
+ public int Calls { get; set; }
+ public string? Path { get; set; }
+ public Dictionary? Form { get; set; }
+ }
private static PveSession CreateSession()
{
@@ -20,6 +28,22 @@ namespace PSProxmoxVE.Core.Tests.Services
"root@pam!testtoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
}
+ private static string UpidJson(string upid) => $@"{{""data"": ""{upid}""}}";
+
+ private static CapturedPost CapturePost(Mock mockClient, string json)
+ {
+ var captured = new CapturedPost();
+ mockClient.Setup(c => c.PostAsync(It.IsAny(), It.IsAny>()))
+ .Callback?>((path, form) =>
+ {
+ captured.Calls++;
+ captured.Path = path;
+ captured.Form = form;
+ })
+ .ReturnsAsync(json);
+ return captured;
+ }
+
[Fact]
public void GetSnapshots_ReturnsSnapshotArray()
{
@@ -86,65 +110,154 @@ namespace PSProxmoxVE.Core.Tests.Services
}
[Fact]
- public void CreateSnapshot_CallsPostAsync_ReturnsUpid()
+ public void CreateSnapshot_NameOnly_SendsOnlySnapname()
{
// Arrange
- const string upid = "UPID:pve1:000ABC:00000001:5F1234AB:qmsnapshot:100:root@pam:";
- var json = $@"{{""data"": ""{upid}""}}";
var mockClient = new Mock();
- mockClient.Setup(c => c.PostAsync(It.IsAny(), It.IsAny>()))
- .ReturnsAsync(json);
+ var captured = CapturePost(mockClient, UpidJson(CreateUpid));
+ var service = new SnapshotService(mockClient.Object);
+ // Act
+ var task = service.CreateSnapshot(CreateSession(), Node, VmId, "my-snap");
+
+ // Assert
+ Assert.Equal(1, captured.Calls);
+ Assert.Equal($"nodes/{Node}/qemu/{VmId}/snapshot", captured.Path);
+ Assert.NotNull(captured.Form);
+ Assert.Equal("my-snap", captured.Form!["snapname"]);
+ Assert.False(captured.Form.ContainsKey("description"));
+ Assert.False(captured.Form.ContainsKey("vmstate"));
+ Assert.Single(captured.Form);
+
+ Assert.Equal(CreateUpid, task.Upid);
+ Assert.Equal(Node, task.Node);
+ Assert.Equal("running", task.Status);
+ }
+
+ [Fact]
+ public void CreateSnapshot_WithDescription_SendsDescription()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var captured = CapturePost(mockClient, UpidJson(CreateUpid));
+ var service = new SnapshotService(mockClient.Object);
+
+ // Act
+ service.CreateSnapshot(CreateSession(), Node, VmId, "my-snap", "Test snapshot");
+
+ // Assert
+ Assert.Equal(1, captured.Calls);
+ Assert.NotNull(captured.Form);
+ Assert.Equal("my-snap", captured.Form!["snapname"]);
+ Assert.Equal("Test snapshot", captured.Form["description"]);
+ Assert.False(captured.Form.ContainsKey("vmstate"));
+ Assert.Equal(2, captured.Form.Count);
+ }
+
+ [Fact]
+ public void CreateSnapshot_WithVmState_SendsVmstateOne()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var captured = CapturePost(mockClient, UpidJson(CreateUpid));
+ var service = new SnapshotService(mockClient.Object);
+
+ // Act
+ service.CreateSnapshot(CreateSession(), Node, VmId, "my-snap", vmstate: true);
+
+ // Assert
+ Assert.Equal(1, captured.Calls);
+ Assert.NotNull(captured.Form);
+ Assert.Equal("my-snap", captured.Form!["snapname"]);
+ Assert.Equal("1", captured.Form["vmstate"]);
+ Assert.False(captured.Form.ContainsKey("description"));
+ Assert.Equal(2, captured.Form.Count);
+ }
+
+ [Fact]
+ public void CreateSnapshot_AllFields_SendsExactForm()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var captured = CapturePost(mockClient, UpidJson(CreateUpid));
var service = new SnapshotService(mockClient.Object);
// Act
var task = service.CreateSnapshot(CreateSession(), Node, VmId, "my-snap", "Test snapshot", vmstate: true);
// Assert
- Assert.Equal(upid, task.Upid);
+ Assert.Equal(1, captured.Calls);
+ Assert.Equal($"nodes/{Node}/qemu/{VmId}/snapshot", captured.Path);
+ Assert.NotNull(captured.Form);
+ Assert.Equal("my-snap", captured.Form!["snapname"]);
+ Assert.Equal("Test snapshot", captured.Form["description"]);
+ Assert.Equal("1", captured.Form["vmstate"]);
+ Assert.Equal(3, captured.Form.Count);
+
+ Assert.Equal(CreateUpid, task.Upid);
Assert.Equal(Node, task.Node);
- mockClient.Verify(c => c.PostAsync(
- $"nodes/{Node}/qemu/{VmId}/snapshot",
- It.Is>(d =>
- d["snapname"] == "my-snap" &&
- d["vmstate"] == "1" &&
- d["description"] == "Test snapshot")),
- Times.Once);
+ Assert.Equal("running", task.Status);
}
[Fact]
- public void CreateSnapshot_WithoutDescription_OmitsDescriptionField()
+ public void CreateSnapshot_EscapesNodeInPath()
{
// Arrange
- const string upid = "UPID:pve1:000ABC:00000001:5F1234AB:qmsnapshot:100:root@pam:";
- var json = $@"{{""data"": ""{upid}""}}";
var mockClient = new Mock();
- mockClient.Setup(c => c.PostAsync(It.IsAny(), It.IsAny>()))
- .ReturnsAsync(json);
-
+ var captured = CapturePost(mockClient, UpidJson(CreateUpid));
var service = new SnapshotService(mockClient.Object);
// Act
- service.CreateSnapshot(CreateSession(), Node, VmId, "my-snap");
+ service.CreateSnapshot(CreateSession(), "pve node", VmId, "my-snap");
// Assert
- mockClient.Verify(c => c.PostAsync(
- It.IsAny(),
- It.Is>(d =>
- !d.ContainsKey("description") &&
- d["vmstate"] == "0")),
- Times.Once);
+ Assert.Equal($"nodes/pve%20node/qemu/{VmId}/snapshot", captured.Path);
}
[Fact]
- public void RemoveSnapshot_CallsDeleteAsync_ReturnsUpid()
+ public void CreateSnapshot_NullData_ReturnsEmptyUpidWithoutStatus()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ CapturePost(mockClient, @"{""data"": null}");
+ var service = new SnapshotService(mockClient.Object);
+
+ // Act
+ var task = service.CreateSnapshot(CreateSession(), Node, VmId, "my-snap");
+
+ // Assert
+ Assert.Equal(string.Empty, task.Upid);
+ Assert.Equal(Node, task.Node);
+ Assert.Null(task.Status);
+ }
+
+ [Fact]
+ public void CreateSnapshot_ObjectShapedData_ReturnsTaskFields()
+ {
+ // Arrange
+ var json = $@"{{""data"": {{""upid"": ""{CreateUpid}"", ""status"": ""stopped"", ""exitstatus"": ""OK""}}}}";
+ var mockClient = new Mock();
+ CapturePost(mockClient, json);
+ var service = new SnapshotService(mockClient.Object);
+
+ // Act
+ var task = service.CreateSnapshot(CreateSession(), Node, VmId, "my-snap");
+
+ // Assert
+ Assert.Equal(CreateUpid, task.Upid);
+ Assert.Equal(Node, task.Node);
+ Assert.Equal("stopped", task.Status);
+ Assert.Equal("OK", task.ExitStatus);
+ }
+
+ [Fact]
+ public void RemoveSnapshot_CallsDeleteAsync_ReturnsRunningTask()
{
// Arrange
const string upid = "UPID:pve1:000DEF:00000002:5F1234AC:qmdelsnap:100:root@pam:";
- var json = $@"{{""data"": ""{upid}""}}";
var mockClient = new Mock();
mockClient.Setup(c => c.DeleteAsync(It.IsAny()))
- .ReturnsAsync(json);
+ .ReturnsAsync(UpidJson(upid));
var service = new SnapshotService(mockClient.Object);
@@ -154,18 +267,35 @@ namespace PSProxmoxVE.Core.Tests.Services
// Assert
Assert.Equal(upid, task.Upid);
Assert.Equal(Node, task.Node);
+ Assert.Equal("running", task.Status);
mockClient.Verify(c => c.DeleteAsync($"nodes/{Node}/qemu/{VmId}/snapshot/clean-install"), Times.Once);
+ mockClient.VerifyNoOtherCalls();
}
[Fact]
- public void RollbackSnapshot_CallsPostAsync_ReturnsUpid()
+ public void RemoveSnapshot_EscapesNodeAndSnapnameInPath()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.DeleteAsync(It.IsAny()))
+ .ReturnsAsync(UpidJson("UPID:pve1:000DEF:00000002:5F1234AC:qmdelsnap:100:root@pam:"));
+
+ var service = new SnapshotService(mockClient.Object);
+
+ // Act
+ service.RemoveSnapshot(CreateSession(), "pve node", VmId, "snap name");
+
+ // Assert
+ mockClient.Verify(c => c.DeleteAsync($"nodes/pve%20node/qemu/{VmId}/snapshot/snap%20name"), Times.Once);
+ }
+
+ [Fact]
+ public void RollbackSnapshot_CallsPostAsync_ReturnsRunningTask()
{
// Arrange
const string upid = "UPID:pve1:000GHI:00000003:5F1234AD:qmrollback:100:root@pam:";
- var json = $@"{{""data"": ""{upid}""}}";
var mockClient = new Mock();
- mockClient.Setup(c => c.PostAsync(It.IsAny(), It.IsAny>()))
- .ReturnsAsync(json);
+ var captured = CapturePost(mockClient, UpidJson(upid));
var service = new SnapshotService(mockClient.Object);
@@ -175,10 +305,25 @@ namespace PSProxmoxVE.Core.Tests.Services
// Assert
Assert.Equal(upid, task.Upid);
Assert.Equal(Node, task.Node);
- mockClient.Verify(c => c.PostAsync(
- $"nodes/{Node}/qemu/{VmId}/snapshot/clean-install/rollback",
- It.IsAny>()),
- Times.Once);
+ Assert.Equal("running", task.Status);
+ Assert.Equal($"nodes/{Node}/qemu/{VmId}/snapshot/clean-install/rollback", captured.Path);
+ Assert.Null(captured.Form);
+ Assert.Equal(1, captured.Calls);
+ }
+
+ [Fact]
+ public void RollbackSnapshot_EscapesNodeAndSnapnameInPath()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var captured = CapturePost(mockClient, UpidJson("UPID:pve1:000GHI:00000003:5F1234AD:qmrollback:100:root@pam:"));
+ var service = new SnapshotService(mockClient.Object);
+
+ // Act
+ service.RollbackSnapshot(CreateSession(), "pve node", VmId, "snap name");
+
+ // Assert
+ Assert.Equal($"nodes/pve%20node/qemu/{VmId}/snapshot/snap%20name/rollback", captured.Path);
}
[Fact]