Files
PSProxmoxVE/tests/PSProxmoxVE.Core.Tests/Errors/PveErrorMapperTests.cs
T
goodolclint-claude[bot] 142f18af1d fix: map PVE failures to typed error records on every cmdlet (#221)
PveApiException carried the status, resource, method and API message an
ErrorRecord needs, but only about 25 of 194 cmdlets built a record. Every
other failing call escaped into PowerShell's default wrapper, so a 403, a
404 and an unreachable server all arrived as ErrorCategory.NotSpecified
with error id PveApiException and a null TargetObject. $_.CategoryInfo,
-ErrorAction filtering and typed catch blocks had nothing to work with.

The classification is a pure function in PSProxmoxVE.Core so it has an
offline test path: PveErrorMapper.Describe returns a kind, a stable error
id and a target derived from the exception. PveCmdletBase translates the
kind to an ErrorCategory and builds the record.

The try/catch is a template method rather than 194 hand-written blocks:
ProcessRecord is sealed and wraps a new abstract ProcessPveRecord in one
catch filtered by PveErrorMapper.IsRecognized, so only the module's own
exception types are mapped and every other exception reaches the engine
exactly as before, including the ones carrying their own error record.
The 192 cmdlets on the base class rename their override; behaviour stays
terminating, as an escaped exception already was.

Closes #155

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

143 lines
5.9 KiB
C#

using System;
using System.Net;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Errors;
using PSProxmoxVE.Core.Exceptions;
using Xunit;
namespace PSProxmoxVE.Core.Tests.Errors
{
public class PveErrorMapperTests
{
private static PveApiException Api(HttpStatusCode status, string resource = "nodes/pve1/qemu/100")
=> new PveApiException(status, "denied", resource, "GET");
[Theory]
[InlineData(HttpStatusCode.BadRequest, PveErrorKind.InvalidArgument)]
[InlineData(HttpStatusCode.Unauthorized, PveErrorKind.AuthenticationError)]
[InlineData(HttpStatusCode.Forbidden, PveErrorKind.PermissionDenied)]
[InlineData(HttpStatusCode.NotFound, PveErrorKind.ObjectNotFound)]
[InlineData(HttpStatusCode.RequestTimeout, PveErrorKind.OperationTimeout)]
[InlineData(HttpStatusCode.GatewayTimeout, PveErrorKind.OperationTimeout)]
[InlineData(HttpStatusCode.ServiceUnavailable, PveErrorKind.ConnectionError)]
[InlineData(HttpStatusCode.InternalServerError, PveErrorKind.ResourceUnavailable)]
[InlineData(HttpStatusCode.BadGateway, PveErrorKind.ResourceUnavailable)]
[InlineData(HttpStatusCode.Conflict, PveErrorKind.InvalidOperation)]
[InlineData(HttpStatusCode.MethodNotAllowed, PveErrorKind.InvalidOperation)]
[InlineData((HttpStatusCode)599, PveErrorKind.ResourceUnavailable)]
[InlineData((HttpStatusCode)418, PveErrorKind.InvalidOperation)]
[InlineData((HttpStatusCode)600, PveErrorKind.InvalidOperation)]
public void Describe_MapsApiStatusToKind(HttpStatusCode status, PveErrorKind expected)
{
Assert.Equal(expected, PveErrorMapper.Describe(Api(status)).Kind);
}
[Fact]
public void Describe_ApiErrorIdCarriesStatusAndResource()
{
var descriptor = PveErrorMapper.Describe(Api(HttpStatusCode.NotFound));
Assert.Equal("PveApi.404.nodes/pve1/qemu/100", descriptor.ErrorId);
Assert.Equal("nodes/pve1/qemu/100", descriptor.Target);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Describe_ApiErrorIdOmitsBlankResource(string resource)
{
var descriptor = PveErrorMapper.Describe(Api(HttpStatusCode.Forbidden, resource));
Assert.Equal("PveApi.403", descriptor.ErrorId);
Assert.Null(descriptor.Target);
}
[Fact]
public void Describe_MapsNotConnectedToConnectionError()
{
var descriptor = PveErrorMapper.Describe(new PveNotConnectedException());
Assert.Equal(PveErrorKind.ConnectionError, descriptor.Kind);
Assert.Equal("PveNotConnected", descriptor.ErrorId);
Assert.Null(descriptor.Target);
}
[Fact]
public void Describe_MapsSessionExpiredToAuthenticationError()
{
var descriptor = PveErrorMapper.Describe(new PveSessionExpiredException());
Assert.Equal(PveErrorKind.AuthenticationError, descriptor.Kind);
Assert.Equal("PveSessionExpired", descriptor.ErrorId);
Assert.Null(descriptor.Target);
}
[Fact]
public void Describe_MapsTaskFailedToOperationStoppedWithUpidTarget()
{
var descriptor = PveErrorMapper.Describe(new PveTaskFailedException("UPID:pve1:1", "boom"));
Assert.Equal(PveErrorKind.OperationStopped, descriptor.Kind);
Assert.Equal("PveTaskFailed", descriptor.ErrorId);
Assert.Equal("UPID:pve1:1", descriptor.Target);
}
[Fact]
public void Describe_MapsTaskTimeoutToOperationTimeoutWithUpidTarget()
{
var descriptor = PveErrorMapper.Describe(
new PveTaskTimeoutException("UPID:pve1:2", TimeSpan.FromSeconds(5)));
Assert.Equal(PveErrorKind.OperationTimeout, descriptor.Kind);
Assert.Equal("PveTaskTimeout", descriptor.ErrorId);
Assert.Equal("UPID:pve1:2", descriptor.Target);
}
[Fact]
public void Describe_MapsVersionExceptionToInvalidOperation()
{
var descriptor = PveErrorMapper.Describe(
new PveVersionException(9, 0, new PveVersion(8, 4)));
Assert.Equal(PveErrorKind.InvalidOperation, descriptor.Kind);
Assert.Equal("PveVersionTooOld", descriptor.ErrorId);
Assert.Null(descriptor.Target);
}
[Fact]
public void Describe_LeavesUnrecognisedExceptionUnclassified()
{
var descriptor = PveErrorMapper.Describe(new InvalidOperationException("x"));
Assert.Equal(PveErrorKind.NotSpecified, descriptor.Kind);
Assert.Equal("InvalidOperationException", descriptor.ErrorId);
Assert.Null(descriptor.Target);
}
[Fact]
public void IsRecognized_AcceptsEveryModuleException()
{
Assert.True(PveErrorMapper.IsRecognized(Api(HttpStatusCode.NotFound)));
Assert.True(PveErrorMapper.IsRecognized(new PveNotConnectedException()));
Assert.True(PveErrorMapper.IsRecognized(new PveSessionExpiredException()));
Assert.True(PveErrorMapper.IsRecognized(new PveTaskFailedException("u", "boom")));
Assert.True(PveErrorMapper.IsRecognized(new PveTaskTimeoutException("u", TimeSpan.Zero)));
Assert.True(PveErrorMapper.IsRecognized(new PveVersionException(9, 0, new PveVersion(8, 4))));
}
[Fact]
public void IsRecognized_RejectsExceptionsTheModuleDoesNotClassify()
{
Assert.False(PveErrorMapper.IsRecognized(new ArgumentException("x")));
Assert.False(PveErrorMapper.IsRecognized(new TimeoutException("x")));
Assert.False(PveErrorMapper.IsRecognized(new InvalidOperationException("x")));
}
[Fact]
public void Describe_RejectsNull()
{
Assert.Throws<ArgumentNullException>(() => PveErrorMapper.Describe(null!));
}
}
}