From 089bb7c81eb543c5dd730530f74e2887ed8bb31d Mon Sep 17 00:00:00 2001 From: "goodolclint-claude[bot]" <323206664+goodolclint-claude[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:59:51 +0000 Subject: [PATCH] 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> --- src/PSProxmoxVE.Core/Client/IPveHttpClient.cs | 6 --- src/PSProxmoxVE.Core/Client/PveHttpClient.cs | 16 ++----- .../Exceptions/PveAuthenticationException.cs | 23 ---------- .../Services/BackupService.cs | 17 +------ .../Services/ClusterConfigService.cs | 30 +++++-------- .../Services/ContainerService.cs | 36 ++++++--------- .../Services/NetworkService.cs | 18 +------- src/PSProxmoxVE.Core/Services/NodeService.cs | 19 +------- .../Services/SnapshotService.cs | 22 ++------- .../Services/StorageService.cs | 22 ++------- .../Services/TemplateService.cs | 18 +------- src/PSProxmoxVE.Core/Services/VmService.cs | 31 +++++-------- .../Utilities/ApiValueHelper.cs | 8 ++-- .../Utilities/PveTaskResponse.cs | 36 +++++++++++++++ .../Cluster/GetPveClusterStatusCmdlet.cs | 2 +- .../Cmdlets/Network/NewPveSdnSubnetCmdlet.cs | 8 +--- .../Cmdlets/Network/SetPveSdnSubnetCmdlet.cs | 8 +--- src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs | 38 ++++++++++++++-- .../Cmdlets/Storage/SendPveFileCmdlet.cs | 10 ++--- .../Cmdlets/Vms/ImportPveOvaCmdlet.cs | 22 +-------- .../Services/BackupServiceTests.cs | 1 + .../Services/ClusterConfigServiceTests.cs | 39 ---------------- .../Services/NodeServiceTests.cs | 2 + .../Services/TemplateServiceTests.cs | 1 + .../Services/VmServiceTests.cs | 1 + tests/PSProxmoxVE.Core.Tests/TestHelper.cs | 39 ---------------- .../Utilities/ApiValueHelperTests.cs | 3 +- .../Utilities/PveTaskResponseTests.cs | 45 +++++++++++++++++++ 28 files changed, 188 insertions(+), 333 deletions(-) delete mode 100644 src/PSProxmoxVE.Core/Exceptions/PveAuthenticationException.cs create mode 100644 src/PSProxmoxVE.Core/Utilities/PveTaskResponse.cs create mode 100644 tests/PSProxmoxVE.Core.Tests/Utilities/PveTaskResponseTests.cs diff --git a/src/PSProxmoxVE.Core/Client/IPveHttpClient.cs b/src/PSProxmoxVE.Core/Client/IPveHttpClient.cs index 4783140..7e73d2c 100644 --- a/src/PSProxmoxVE.Core/Client/IPveHttpClient.cs +++ b/src/PSProxmoxVE.Core/Client/IPveHttpClient.cs @@ -35,12 +35,6 @@ namespace PSProxmoxVE.Core.Client /// Synchronous wrapper for . string Post(string resource, Dictionary? data = null); - /// Synchronous wrapper for . - string Put(string resource, Dictionary? data = null); - - /// Synchronous wrapper for . - string Delete(string resource); - /// /// Uploads a file to a Proxmox VE storage endpoint using MultipartFormDataContent. /// diff --git a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs index ffc530f..2f8ad84 100644 --- a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs +++ b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs @@ -20,9 +20,7 @@ namespace PSProxmoxVE.Core.Client /// public class PveHttpClient : IPveHttpClient { -#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type private readonly PveSession? _session; -#pragma warning restore CS8625 private readonly string _baseUrl; private readonly HttpClient _httpClient; private readonly HttpMessageHandler _handler; @@ -266,14 +264,6 @@ namespace PSProxmoxVE.Core.Client public string Post(string resource, Dictionary? data = null) => PostAsync(resource, data).GetAwaiter().GetResult(); - /// Synchronous wrapper for . - public string Put(string resource, Dictionary? data = null) => - PutAsync(resource, data).GetAwaiter().GetResult(); - - /// Synchronous wrapper for . - public string Delete(string resource) => - DeleteAsync(resource).GetAwaiter().GetResult(); - // ------------------------------------------------------------------------- // ISO / file upload // ------------------------------------------------------------------------- @@ -434,9 +424,11 @@ namespace PSProxmoxVE.Core.Client private async Task SendOnceAsync(HttpRequestMessage request, string resource, string httpMethod) { HttpResponseMessage response; + string body; try { response = await _httpClient.SendAsync(request).ConfigureAwait(false); + body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); } catch (TaskCanceledException ex) { @@ -452,12 +444,12 @@ namespace PSProxmoxVE.Core.Client } catch (HttpRequestException ex) { + // Covers both a failed connection and a stream drop mid-body-read, so every + // HttpRequestException PveHttpClient can throw arrives as PveApiException. throw new PveApiException(HttpStatusCode.ServiceUnavailable, ex.Message, resource, httpMethod, ex); } - var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - if (!response.IsSuccessStatusCode) { var errorMessage = ExtractErrorMessage(body, response.ReasonPhrase ?? response.StatusCode.ToString()); diff --git a/src/PSProxmoxVE.Core/Exceptions/PveAuthenticationException.cs b/src/PSProxmoxVE.Core/Exceptions/PveAuthenticationException.cs deleted file mode 100644 index 083b20f..0000000 --- a/src/PSProxmoxVE.Core/Exceptions/PveAuthenticationException.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; - -namespace PSProxmoxVE.Core.Exceptions -{ - /// Exception thrown when authentication to a Proxmox VE server fails. - public class PveAuthenticationException : Exception - { - /// Initializes a new instance with the specified error message. - /// The error message describing the authentication failure. - public PveAuthenticationException(string message) - : base(message) - { - } - - /// Initializes a new instance with the specified error message and inner exception. - /// The error message describing the authentication failure. - /// The exception that caused this failure. - public PveAuthenticationException(string message, Exception innerException) - : base(message, innerException) - { - } - } -} diff --git a/src/PSProxmoxVE.Core/Services/BackupService.cs b/src/PSProxmoxVE.Core/Services/BackupService.cs index 4c5ba9b..53010d6 100644 --- a/src/PSProxmoxVE.Core/Services/BackupService.cs +++ b/src/PSProxmoxVE.Core/Services/BackupService.cs @@ -47,7 +47,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/vzdump", config) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -151,20 +151,5 @@ namespace PSProxmoxVE.Core.Services return JsonHelper.ToListOfDictionaries(data as JArray); }); } - - // ------------------------------------------------------------------------- - // Private helpers - // ------------------------------------------------------------------------- - - private static PveTask ParseTask(string response, string node) - { - var data = JObject.Parse(response)["data"]; - if (data?.Type == JTokenType.String) - return new PveTask { Upid = data.ToString(), Node = node }; - - var task = data?.ToObject() ?? new PveTask(); - task.Node = node; - return task; - } } } diff --git a/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs b/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs index 560a9ae..1409f18 100644 --- a/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs +++ b/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs @@ -19,16 +19,24 @@ namespace PSProxmoxVE.Core.Services private static readonly TimeSpan DefaultQuorumTimeout = TimeSpan.FromSeconds(60); private static readonly TimeSpan QuorumPollInterval = TimeSpan.FromSeconds(2); + private readonly ClusterService _clusterService; + /// /// Initializes a new instance of the class. /// - public ClusterConfigService() { } + public ClusterConfigService() + { + _clusterService = new ClusterService(); + } /// /// Initializes a new instance of the class with an injected HTTP client. /// /// The HTTP client to use for API calls. The caller owns its lifetime. - public ClusterConfigService(IPveHttpClient client) : base(client) { } + public ClusterConfigService(IPveHttpClient client) : base(client) + { + _clusterService = new ClusterService(client); + } /// /// Returns the cluster configuration directory (GET /cluster/config). @@ -297,22 +305,6 @@ namespace PSProxmoxVE.Core.Services }); } - /// - /// Returns the current cluster status (GET /cluster/status). - /// Delegates to the same endpoint as . - /// - public PveClusterStatus[] GetClusterStatus(PveSession session) - { - if (session == null) throw new ArgumentNullException(nameof(session)); - - return Invoke(session, client => - { - var response = client.GetAsync("cluster/status").GetAwaiter().GetResult(); - var data = JObject.Parse(response)["data"]; - return data?.ToObject() ?? Array.Empty(); - }); - } - /// /// Blocks until the cluster reports quorum (GET /cluster/status, quorate = 1). /// @@ -335,7 +327,7 @@ namespace PSProxmoxVE.Core.Services { try { - foreach (var entry in GetClusterStatus(session)) + foreach (var entry in _clusterService.GetClusterStatus(session)) { if (string.Equals(entry.Type, "cluster", StringComparison.OrdinalIgnoreCase) && entry.Quorate == 1) diff --git a/src/PSProxmoxVE.Core/Services/ContainerService.cs b/src/PSProxmoxVE.Core/Services/ContainerService.cs index d6c8efb..557654a 100644 --- a/src/PSProxmoxVE.Core/Services/ContainerService.cs +++ b/src/PSProxmoxVE.Core/Services/ContainerService.cs @@ -6,6 +6,7 @@ using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Containers; using PSProxmoxVE.Core.Models.Vms; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Services { @@ -201,7 +202,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/snapshot", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -222,7 +223,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.DeleteAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -243,7 +244,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}/rollback") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -270,7 +271,7 @@ namespace PSProxmoxVE.Core.Services kvp => kvp.Value?.ToString() ?? string.Empty); var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -300,7 +301,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/status/shutdown", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -325,7 +326,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.DeleteAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}{queryString}") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -356,7 +357,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/clone", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -382,7 +383,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/migrate", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -422,7 +423,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PutAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/resize", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -447,7 +448,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/move_volume", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -467,7 +468,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/template") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -505,19 +506,8 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/lxc/{vmid}/status/{action}") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } - - private static PveTask ParseTask(string response, string node) - { - var data = JObject.Parse(response)["data"]; - if (data?.Type == JTokenType.String) - return new PveTask { Upid = data.ToString(), Node = node, Status = "running" }; - - var task = data?.ToObject() ?? new PveTask(); - task.Node = node; - return task; - } } } diff --git a/src/PSProxmoxVE.Core/Services/NetworkService.cs b/src/PSProxmoxVE.Core/Services/NetworkService.cs index 321891b..5309310 100644 --- a/src/PSProxmoxVE.Core/Services/NetworkService.cs +++ b/src/PSProxmoxVE.Core/Services/NetworkService.cs @@ -6,6 +6,7 @@ using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Network; using PSProxmoxVE.Core.Models.Vms; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Services { @@ -141,7 +142,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PutAsync($"nodes/{Uri.EscapeDataString(node)}/network") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -588,20 +589,5 @@ namespace PSProxmoxVE.Core.Services .GetAwaiter().GetResult(); }); } - - // ------------------------------------------------------------------------- - // Private helpers - // ------------------------------------------------------------------------- - - private static PveTask ParseTask(string response, string node) - { - var data = JObject.Parse(response)["data"]; - if (data?.Type == JTokenType.String) - return new PveTask { Upid = data.ToString(), Node = node, Status = "running" }; - - var task = data?.ToObject() ?? new PveTask(); - task.Node = node; - return task; - } } } diff --git a/src/PSProxmoxVE.Core/Services/NodeService.cs b/src/PSProxmoxVE.Core/Services/NodeService.cs index f08bff3..957a079 100644 --- a/src/PSProxmoxVE.Core/Services/NodeService.cs +++ b/src/PSProxmoxVE.Core/Services/NodeService.cs @@ -153,7 +153,7 @@ namespace PSProxmoxVE.Core.Services return Invoke(session, client => { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/startall", formData).GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -172,7 +172,7 @@ namespace PSProxmoxVE.Core.Services return Invoke(session, client => { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/stopall", formData).GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -193,20 +193,5 @@ namespace PSProxmoxVE.Core.Services return PveVersion.Parse(versionStr!); }); } - - // ------------------------------------------------------------------------- - // Private helpers - // ------------------------------------------------------------------------- - - private static PveTask ParseTask(string response, string node) - { - var data = JObject.Parse(response)["data"]; - if (data?.Type == JTokenType.String) - return new PveTask { Upid = data.ToString(), Node = node }; - - var task = data?.ToObject() ?? new PveTask(); - task.Node = node; - return task; - } } } diff --git a/src/PSProxmoxVE.Core/Services/SnapshotService.cs b/src/PSProxmoxVE.Core/Services/SnapshotService.cs index 0d180b0..4c2916b 100644 --- a/src/PSProxmoxVE.Core/Services/SnapshotService.cs +++ b/src/PSProxmoxVE.Core/Services/SnapshotService.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq; using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Vms; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Services { @@ -78,7 +79,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/snapshot", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -103,7 +104,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.DeleteAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -128,23 +129,8 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/snapshot/{Uri.EscapeDataString(snapname)}/rollback") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } - - // ------------------------------------------------------------------------- - // Private helpers - // ------------------------------------------------------------------------- - - private static PveTask ParseTask(string response, string node) - { - var data = JObject.Parse(response)["data"]; - if (data?.Type == JTokenType.String) - return new PveTask { Upid = data.ToString(), Node = node, Status = "running" }; - - var task = data?.ToObject() ?? new PveTask(); - task.Node = node; - return task; - } } } diff --git a/src/PSProxmoxVE.Core/Services/StorageService.cs b/src/PSProxmoxVE.Core/Services/StorageService.cs index 1560f92..322ef56 100644 --- a/src/PSProxmoxVE.Core/Services/StorageService.cs +++ b/src/PSProxmoxVE.Core/Services/StorageService.cs @@ -6,6 +6,7 @@ using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Storage; using PSProxmoxVE.Core.Models.Vms; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Services { @@ -137,7 +138,7 @@ namespace PSProxmoxVE.Core.Services checksumAlgorithm, progressCallback) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -182,7 +183,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/storage/{Uri.EscapeDataString(storage)}/download-url", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -337,23 +338,8 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/storage/{Uri.EscapeDataString(storage)}/content", config) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } - - // ------------------------------------------------------------------------- - // Private helpers - // ------------------------------------------------------------------------- - - private static PveTask ParseTask(string response, string node) - { - var data = JObject.Parse(response)["data"]; - if (data?.Type == JTokenType.String) - return new PveTask { Upid = data.ToString(), Node = node, Status = "running" }; - - var task = data?.ToObject() ?? new PveTask(); - task.Node = node; - return task; - } } } diff --git a/src/PSProxmoxVE.Core/Services/TemplateService.cs b/src/PSProxmoxVE.Core/Services/TemplateService.cs index 569b830..cabb42f 100644 --- a/src/PSProxmoxVE.Core/Services/TemplateService.cs +++ b/src/PSProxmoxVE.Core/Services/TemplateService.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq; using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Vms; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Services { @@ -68,7 +69,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/template") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -92,20 +93,5 @@ namespace PSProxmoxVE.Core.Services return _vmService.RemoveVm(session, node, vmid, purge); } - - // ------------------------------------------------------------------------- - // Private helpers - // ------------------------------------------------------------------------- - - private static PveTask ParseTask(string response, string node) - { - var data = JObject.Parse(response)["data"]; - if (data?.Type == JTokenType.String) - return new PveTask { Upid = data.ToString(), Node = node }; - - var task = data?.ToObject() ?? new PveTask(); - task.Node = node; - return task; - } } } diff --git a/src/PSProxmoxVE.Core/Services/VmService.cs b/src/PSProxmoxVE.Core/Services/VmService.cs index 1de2033..7562c17 100644 --- a/src/PSProxmoxVE.Core/Services/VmService.cs +++ b/src/PSProxmoxVE.Core/Services/VmService.cs @@ -249,7 +249,7 @@ namespace PSProxmoxVE.Core.Services // POST (not PUT) because import-from triggers a background task var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/config", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -276,7 +276,7 @@ namespace PSProxmoxVE.Core.Services kvp => kvp.Value?.ToString() ?? string.Empty); var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -312,7 +312,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/status/shutdown", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -343,7 +343,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/status/reboot", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -395,7 +395,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.DeleteAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}{queryString}") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -435,7 +435,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/clone", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -468,7 +468,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/migrate", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -504,7 +504,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PutAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/resize", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } @@ -521,21 +521,10 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/status/{action}") .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } - private static PveTask ParseTask(string response, string node) - { - var data = JObject.Parse(response)["data"]; - // Many endpoints return the UPID string directly as the data value - if (data?.Type == JTokenType.String) - return new PveTask { Upid = data.ToString(), Node = node }; - - var task = data?.ToObject() ?? new PveTask(); - task.Node = node; - return task; - } // ------------------------------------------------------------------------- // QEMU Guest Agent @@ -664,7 +653,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.PostAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/move_disk", formData) .GetAwaiter().GetResult(); - return ParseTask(response, node); + return PveTaskResponse.Parse(response, node); }); } diff --git a/src/PSProxmoxVE.Core/Utilities/ApiValueHelper.cs b/src/PSProxmoxVE.Core/Utilities/ApiValueHelper.cs index 8296cae..b20a601 100644 --- a/src/PSProxmoxVE.Core/Utilities/ApiValueHelper.cs +++ b/src/PSProxmoxVE.Core/Utilities/ApiValueHelper.cs @@ -10,8 +10,9 @@ namespace PSProxmoxVE.Core.Utilities { /// /// Determines if a value represents a true/exited state. - /// Accepts boolean true, integer 1 (as Int64 or Int32), and string "1" as true. - /// All other values (false, 0, "0", null, etc.) are false. + /// Accepts boolean true, integer 1 (as Int64, which is how Newtonsoft deserializes a + /// JSON integer), and string "1" as true. All other values (false, 0, "0", null, etc.) + /// are false. /// /// The value to check, typically from API response data. /// True if the value represents an exited/true state, false otherwise. @@ -26,9 +27,6 @@ namespace PSProxmoxVE.Core.Utilities if (value is long l) return l == 1L; - if (value is int i) - return i == 1; - if (value is string s) return s == "1"; diff --git a/src/PSProxmoxVE.Core/Utilities/PveTaskResponse.cs b/src/PSProxmoxVE.Core/Utilities/PveTaskResponse.cs new file mode 100644 index 0000000..28be3c1 --- /dev/null +++ b/src/PSProxmoxVE.Core/Utilities/PveTaskResponse.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json.Linq; +using PSProxmoxVE.Core.Models.Vms; + +namespace PSProxmoxVE.Core.Utilities +{ + /// + /// Parses the task returned by a mutating Proxmox VE API call. Most such endpoints return + /// the UPID as a bare string in data; a few return a task object directly. + /// + public static class PveTaskResponse + { + /// + /// Parses as a Proxmox VE API envelope and extracts the task + /// its data field describes, stamping with + /// . + /// + /// The raw JSON response body. + /// The cluster node the request was made against. + /// + /// A with and + /// set to "running" when data is a UPID + /// string; the deserialized task when data is an object; or an empty task + /// when data is null or absent. + /// + public static PveTask Parse(string json, string node) + { + var data = JObject.Parse(json)["data"]; + if (data?.Type == JTokenType.String) + return new PveTask { Upid = data.ToString(), Node = node, Status = "running" }; + + var task = data?.ToObject() ?? new PveTask(); + task.Node = node; + return task; + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterStatusCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterStatusCmdlet.cs index 50a8a45..c9dbb7a 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterStatusCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterStatusCmdlet.cs @@ -18,7 +18,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster protected override void ProcessRecord() { var session = GetSession(); - var service = new ClusterConfigService(); + var service = new ClusterService(); WriteVerbose("Getting cluster status..."); var statuses = service.GetClusterStatus(session); diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs index 819b566..aa4bbd7 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs @@ -48,12 +48,8 @@ namespace PSProxmoxVE.Cmdlets.Network var session = GetSession(); RequireVersion(session, "SDN", 6, 2, 8, 0); - if (!string.IsNullOrEmpty(DhcpRange) - && session.ServerVersion != null && !session.ServerVersion.IsAtLeast(8, 1)) - { - WriteWarning("The -DhcpRange parameter requires PVE 8.1 or later. " - + $"Connected server is PVE {session.ServerVersion}. The parameter will be sent but may be ignored."); - } + WarnIfBelowVersion(session, !string.IsNullOrEmpty(DhcpRange), 8, 1, + "The -DhcpRange parameter requires", "The parameter will be sent but may be ignored."); WriteVerbose($"Creating SDN subnet '{Subnet}' on VNet '{Vnet}'..."); var data = new Dictionary diff --git a/src/PSProxmoxVE/Cmdlets/Network/SetPveSdnSubnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/SetPveSdnSubnetCmdlet.cs index e223eed..4e15119 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/SetPveSdnSubnetCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/SetPveSdnSubnetCmdlet.cs @@ -47,12 +47,8 @@ namespace PSProxmoxVE.Cmdlets.Network var session = GetSession(); RequireVersion(session, "SDN", 6, 2, 8, 0); - if (!string.IsNullOrEmpty(DhcpRange) - && session.ServerVersion != null && !session.ServerVersion.IsAtLeast(8, 1)) - { - WriteWarning("The -DhcpRange parameter requires PVE 8.1 or later. " - + $"Connected server is PVE {session.ServerVersion}. The parameter will be sent but may be ignored."); - } + WarnIfBelowVersion(session, !string.IsNullOrEmpty(DhcpRange), 8, 1, + "The -DhcpRange parameter requires", "The parameter will be sent but may be ignored."); var service = new NetworkService(); diff --git a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs index c3bf6f8..7e2fd63 100644 --- a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs +++ b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs @@ -92,6 +92,40 @@ namespace PSProxmoxVE.Cmdlets } } + /// + /// Emits a soft warning when holds and the connected + /// server is below .. + /// Unlike , this never blocks the call: the parameter or + /// feature may simply be silently ignored by an older server. + /// + /// The authenticated PVE session. + /// True when the caller used the parameter/feature this warning covers. + /// Major version the parameter/feature requires. + /// Minor version the parameter/feature requires. + /// + /// The warning's leading clause, ending in "requires"/"require" (e.g. "The -DhcpRange + /// parameter requires"), so the full sentence reads + /// "<requirementClause> PVE <requiredMajor>.<requiredMinor> or later.". + /// + /// Trailing sentence describing what happens if the server is too old. + protected void WarnIfBelowVersion( + PveSession session, + bool condition, + int requiredMajor, + int requiredMinor, + string requirementClause, + string consequence) + { + if (!condition) return; + + var version = session.ServerVersion; + if (version == null || version.IsAtLeast(requiredMajor, requiredMinor)) return; + + WriteWarning( + $"{requirementClause} PVE {requiredMajor}.{requiredMinor} or later. " + + $"Connected server is PVE {version}. {consequence}"); + } + /// /// Waits for a PVE task to complete, then optionally polls VM status until /// it matches . Used by lifecycle cmdlets @@ -151,10 +185,6 @@ namespace PSProxmoxVE.Cmdlets { WriteVerbose($"Status poll failed, retrying: {ex.Message}"); } - catch (System.Net.Http.HttpRequestException ex) - { - WriteVerbose($"Status poll failed, retrying: {ex.Message}"); - } System.Threading.Thread.Sleep(2000); } diff --git a/src/PSProxmoxVE/Cmdlets/Storage/SendPveFileCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Storage/SendPveFileCmdlet.cs index 4b7ac7b..b4173bd 100644 --- a/src/PSProxmoxVE/Cmdlets/Storage/SendPveFileCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Storage/SendPveFileCmdlet.cs @@ -75,12 +75,10 @@ namespace PSProxmoxVE.Cmdlets.Storage var session = GetSession(); - if ((!string.IsNullOrEmpty(Checksum) || !string.IsNullOrEmpty(ChecksumAlgorithm)) - && session.ServerVersion != null && !session.ServerVersion.IsAtLeast(7, 1)) - { - WriteWarning("The -Checksum and -ChecksumAlgorithm parameters require PVE 7.1 or later. " - + $"Connected server is PVE {session.ServerVersion}. The upload will proceed without checksum verification."); - } + WarnIfBelowVersion(session, + !string.IsNullOrEmpty(Checksum) || !string.IsNullOrEmpty(ChecksumAlgorithm), 7, 1, + "The -Checksum and -ChecksumAlgorithm parameters require", + "The upload will proceed without checksum verification."); TimeSpan timeout; if (TimeoutSeconds.HasValue) diff --git a/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs index 2c74acd..10898fa 100644 --- a/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Vms/ImportPveOvaCmdlet.cs @@ -213,16 +213,7 @@ namespace PSProxmoxVE.Cmdlets.Vms if (!string.IsNullOrEmpty(uploadResult.Upid)) { WriteVerbose("Waiting for OVA upload task to complete on PVE..."); - var completedUpload = taskService.WaitForTask(session, Node, uploadResult.Upid); - if (completedUpload.ExitStatus != null && completedUpload.ExitStatus != "OK") - { - ThrowTerminatingError(new ErrorRecord( - new InvalidOperationException($"OVA upload task failed with status: {completedUpload.ExitStatus}"), - "OvaUploadFailed", - ErrorCategory.InvalidResult, - uploadResult.Upid)); - return; - } + taskService.WaitForTask(session, Node, uploadResult.Upid); } // Step 5: Create VM with disks and network in a single API call. @@ -298,16 +289,7 @@ namespace PSProxmoxVE.Cmdlets.Vms if (Wait.IsPresent && !string.IsNullOrEmpty(createTask.Upid)) { WriteVerbose("Waiting for VM creation + disk import to complete..."); - var completedCreate = taskService.WaitForTask(session, Node, createTask.Upid); - if (completedCreate.ExitStatus != null && completedCreate.ExitStatus != "OK") - { - ThrowTerminatingError(new ErrorRecord( - new InvalidOperationException($"OVA import task failed with status: {completedCreate.ExitStatus}"), - "OvaImportFailed", - ErrorCategory.InvalidResult, - createTask.Upid)); - return; - } + taskService.WaitForTask(session, Node, createTask.Upid); } // Step 8: Output the created VM diff --git a/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs index 727f96d..915b0bc 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs @@ -50,6 +50,7 @@ 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.PostAsync( $"nodes/{Node}/vzdump", It.Is>(d => d["vmid"] == "100" && d["storage"] == "local")), diff --git a/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs index e5db43a..185220c 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs @@ -318,34 +318,6 @@ namespace PSProxmoxVE.Core.Tests.Services 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(); - 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() { @@ -452,17 +424,6 @@ namespace PSProxmoxVE.Core.Tests.Services Assert.Throws(() => service.GetNextId(null!)); } - [Fact] - public void GetClusterStatus_NullSession_ThrowsArgumentNullException() - { - // Arrange - var mockClient = new Mock(); - var service = new ClusterConfigService(mockClient.Object); - - // Act & Assert - Assert.Throws(() => service.GetClusterStatus(null!)); - } - [Fact] public void Constructor_NullClient_ThrowsArgumentNullException() { diff --git a/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs index 4b01c07..49096ec 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs @@ -250,6 +250,7 @@ namespace PSProxmoxVE.Core.Tests.Services Assert.NotNull(task); Assert.Contains("startall", task.Upid); Assert.Equal("pve1", task.Node); + Assert.Equal("running", task.Status); mockClient.Verify(c => c.PostAsync( "nodes/pve1/startall", It.IsAny>()), @@ -275,6 +276,7 @@ namespace PSProxmoxVE.Core.Tests.Services Assert.NotNull(task); Assert.Contains("stopall", task.Upid); Assert.Equal("pve1", task.Node); + Assert.Equal("running", task.Status); mockClient.Verify(c => c.PostAsync( "nodes/pve1/stopall", It.IsAny>()), diff --git a/tests/PSProxmoxVE.Core.Tests/Services/TemplateServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/TemplateServiceTests.cs index 0e90373..2af0c22 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/TemplateServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/TemplateServiceTests.cs @@ -40,6 +40,7 @@ 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.PostAsync( $"nodes/{Node}/qemu/{VmId}/template", It.IsAny>()), diff --git a/tests/PSProxmoxVE.Core.Tests/Services/VmServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/VmServiceTests.cs index bca3048..6be6fbe 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/VmServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/VmServiceTests.cs @@ -130,6 +130,7 @@ namespace PSProxmoxVE.Core.Tests.Services // config lock; the native endpoint keeps the whole restart server-side. Assert.Equal($"nodes/{TestNode}/qemu/{TestVmId}/status/reboot", resource); Assert.Contains("qmreboot", task.Upid); + Assert.Equal("running", task.Status); } [Fact] diff --git a/tests/PSProxmoxVE.Core.Tests/TestHelper.cs b/tests/PSProxmoxVE.Core.Tests/TestHelper.cs index fa16deb..45f68a5 100644 --- a/tests/PSProxmoxVE.Core.Tests/TestHelper.cs +++ b/tests/PSProxmoxVE.Core.Tests/TestHelper.cs @@ -1,10 +1,4 @@ using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Moq; -using Moq.Protected; namespace PSProxmoxVE.Core.Tests { @@ -15,38 +9,5 @@ namespace PSProxmoxVE.Core.Tests var path = Path.Combine("Fixtures", filename); return File.ReadAllText(path); } - - public static Mock CreateMockHandler(string responseBody, HttpStatusCode statusCode = HttpStatusCode.OK) - { - var mock = new Mock(); - mock.Protected() - .Setup>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage - { - StatusCode = statusCode, - Content = new StringContent(responseBody) - }); - return mock; - } - - public static Mock CreateMockHandlerSequence(params (string body, HttpStatusCode status)[] responses) - { - var mock = new Mock(); - var setup = mock.Protected() - .SetupSequence>("SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()); - foreach (var (body, status) in responses) - { - setup = setup.ReturnsAsync(new HttpResponseMessage - { - StatusCode = status, - Content = new StringContent(body) - }); - } - return mock; - } } } diff --git a/tests/PSProxmoxVE.Core.Tests/Utilities/ApiValueHelperTests.cs b/tests/PSProxmoxVE.Core.Tests/Utilities/ApiValueHelperTests.cs index 1d69253..88f955a 100644 --- a/tests/PSProxmoxVE.Core.Tests/Utilities/ApiValueHelperTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Utilities/ApiValueHelperTests.cs @@ -9,7 +9,6 @@ namespace PSProxmoxVE.Core.Tests.Utilities [Theory] [InlineData(true)] [InlineData(1L)] - [InlineData(1)] [InlineData("1")] public void IsExited_TrueValues_ReturnsTrue(object value) { @@ -19,12 +18,12 @@ namespace PSProxmoxVE.Core.Tests.Utilities [Theory] [InlineData(false)] [InlineData(0L)] - [InlineData(0)] [InlineData("0")] [InlineData(null)] [InlineData("")] [InlineData("true")] [InlineData(2L)] + [InlineData(1)] [InlineData(42)] public void IsExited_FalseValues_ReturnsFalse(object? value) { diff --git a/tests/PSProxmoxVE.Core.Tests/Utilities/PveTaskResponseTests.cs b/tests/PSProxmoxVE.Core.Tests/Utilities/PveTaskResponseTests.cs new file mode 100644 index 0000000..8407af1 --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Utilities/PveTaskResponseTests.cs @@ -0,0 +1,45 @@ +using PSProxmoxVE.Core.Utilities; +using Xunit; + +namespace PSProxmoxVE.Core.Tests.Utilities +{ + public class PveTaskResponseTests + { + [Fact] + public void Parse_UpidString_ReturnsRunningTaskWithNode() + { + var task = PveTaskResponse.Parse( + "{\"data\":\"UPID:pve1:00001234:00005678:AABBCCDD:qmstart:100:root@pam:\"}", + "pve1"); + + Assert.Equal("UPID:pve1:00001234:00005678:AABBCCDD:qmstart:100:root@pam:", task.Upid); + Assert.Equal("pve1", task.Node); + Assert.Equal("running", task.Status); + } + + [Fact] + public void Parse_TaskObject_DeserializesAndStampsNode() + { + var task = PveTaskResponse.Parse( + "{\"data\":{\"upid\":\"UPID:pve2:00000001:00000002:00000003:vzdump::root@pam:\",\"status\":\"stopped\",\"exitstatus\":\"OK\",\"node\":\"ignored\"}}", + "pve2"); + + Assert.Equal("UPID:pve2:00000001:00000002:00000003:vzdump::root@pam:", task.Upid); + Assert.Equal("stopped", task.Status); + Assert.Equal("OK", task.ExitStatus); + Assert.Equal("pve2", task.Node); + } + + [Theory] + [InlineData("{\"data\":null}")] + [InlineData("{}")] + public void Parse_NullOrAbsentData_ReturnsEmptyTaskWithNode(string json) + { + var task = PveTaskResponse.Parse(json, "pve3"); + + Assert.Equal(string.Empty, task.Upid); + Assert.Null(task.Status); + Assert.Equal("pve3", task.Node); + } + } +}