feat: add proper error handling with full API error details

- Add ApiResult<T> type to carry success/failure with error details
- Add UpdateDocumentWithResultAsync and CreateTagWithResultAsync methods
- Update DocumentTools.Update and TagTools.Create to return actual
  HTTP status codes and response bodies in error responses
- Add comprehensive tests for error handling (18 new tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Barry Walker
2026-01-13 18:59:16 -05:00
parent 39768fdd45
commit c67781bac5
9 changed files with 462 additions and 33 deletions
+125
View File
@@ -0,0 +1,125 @@
using System.Net;
using FluentAssertions;
using PaperlessMCP.Client;
using Xunit;
namespace PaperlessMCP.Tests.Client;
public class ApiResultTests
{
#region Success Tests
[Fact]
public void Success_WithValue_ReturnsSuccessResult()
{
// Act
var result = ApiResult<string>.Success("test value");
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().Be("test value");
result.Error.Should().BeNull();
}
[Fact]
public void Success_ImplicitBoolConversion_ReturnsTrue()
{
// Arrange
var result = ApiResult<int>.Success(42);
// Act & Assert
bool isSuccess = result;
isSuccess.Should().BeTrue();
}
#endregion
#region Failure Tests
[Fact]
public void Failure_WithStatusCodeAndMessage_ReturnsFailureResult()
{
// Act
var result = ApiResult<string>.Failure(HttpStatusCode.NotFound, "Resource not found");
// Assert
result.IsSuccess.Should().BeFalse();
result.Value.Should().BeNull();
result.Error.Should().NotBeNull();
result.Error!.StatusCode.Should().Be(HttpStatusCode.NotFound);
result.Error.Message.Should().Be("Resource not found");
result.Error.ResponseBody.Should().BeNull();
}
[Fact]
public void Failure_WithResponseBody_IncludesBody()
{
// Arrange
var responseBody = """{"error": "Document not found"}""";
// Act
var result = ApiResult<string>.Failure(HttpStatusCode.NotFound, "Not found", responseBody);
// Assert
result.Error.Should().NotBeNull();
result.Error!.ResponseBody.Should().Be(responseBody);
}
[Fact]
public void Failure_WithApiError_ReturnsFailureResult()
{
// Arrange
var error = new ApiError(HttpStatusCode.BadRequest, "Invalid input", """{"field": "name is required"}""");
// Act
var result = ApiResult<object>.Failure(error);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Be(error);
}
[Fact]
public void Failure_ImplicitBoolConversion_ReturnsFalse()
{
// Arrange
var result = ApiResult<int>.Failure(HttpStatusCode.InternalServerError, "Server error");
// Act & Assert
bool isSuccess = result;
isSuccess.Should().BeFalse();
}
#endregion
#region ApiError Tests
[Fact]
public void ApiError_ToString_FormatsCorrectly()
{
// Arrange
var error = new ApiError(HttpStatusCode.Forbidden, "Access denied", null);
// Act
var result = error.ToString();
// Assert
result.Should().Be("HTTP 403 Forbidden: Access denied");
}
[Fact]
public void ApiError_ToString_IncludesResponseBody()
{
// Arrange
var error = new ApiError(HttpStatusCode.BadRequest, "Validation failed", """{"name": ["This field is required."]}""");
// Act
var result = error.ToString();
// Assert
result.Should().Contain("HTTP 400 BadRequest: Validation failed");
result.Should().Contain("""{"name": ["This field is required."]}""");
}
#endregion
}
@@ -6,6 +6,7 @@ using Xunit;
using PaperlessMCP.Models.CustomFields;
using PaperlessMCP.Models.DocumentTypes;
using PaperlessMCP.Models.StoragePaths;
using PaperlessMCP.Models.Documents;
using PaperlessMCP.Models.Tags;
using PaperlessMCP.Tests.Fixtures;
@@ -167,6 +168,57 @@ public class PaperlessClientTests : IDisposable
result.ThumbnailUrl.Should().Be("https://paperless.example.com/api/documents/1/thumb/");
}
[Fact]
public async Task UpdateDocumentWithResultAsync_WhenSuccessful_ReturnsSuccess()
{
// Arrange
_factory.SetupPatch("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Updated Title"));
// Act
var result = await _factory.Client.UpdateDocumentWithResultAsync(1, new DocumentUpdateRequest { Title = "Updated Title" });
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value!.Title.Should().Be("Updated Title");
result.Error.Should().BeNull();
}
[Fact]
public async Task UpdateDocumentWithResultAsync_WhenNotFound_ReturnsFailureWithDetails()
{
// Arrange
var errorBody = """{"detail": "Not found."}""";
_factory.SetupPatchWithError("api/documents/999/", HttpStatusCode.NotFound, errorBody);
// Act
var result = await _factory.Client.UpdateDocumentWithResultAsync(999, new DocumentUpdateRequest { Title = "New Title" });
// Assert
result.IsSuccess.Should().BeFalse();
result.Value.Should().BeNull();
result.Error.Should().NotBeNull();
result.Error!.StatusCode.Should().Be(HttpStatusCode.NotFound);
result.Error.ResponseBody.Should().Be(errorBody);
}
[Fact]
public async Task UpdateDocumentWithResultAsync_WhenBadRequest_ReturnsFailureWithDetails()
{
// Arrange
var errorBody = """{"title": ["This field may not be blank."]}""";
_factory.SetupPatchWithError("api/documents/1/", HttpStatusCode.BadRequest, errorBody);
// Act
var result = await _factory.Client.UpdateDocumentWithResultAsync(1, new DocumentUpdateRequest { Title = "" });
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().NotBeNull();
result.Error!.StatusCode.Should().Be(HttpStatusCode.BadRequest);
result.Error.ResponseBody.Should().Contain("This field may not be blank");
}
#endregion
#region Tag Tests
@@ -231,6 +283,56 @@ public class PaperlessClientTests : IDisposable
result.Should().BeTrue();
}
[Fact]
public async Task CreateTagWithResultAsync_WhenSuccessful_ReturnsSuccess()
{
// Arrange
_factory.SetupPost("api/tags/", TestFixtures.Tags.CreateTagJson(5, "New Tag"));
// Act
var result = await _factory.Client.CreateTagWithResultAsync(new TagCreateRequest { Name = "New Tag" });
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value!.Name.Should().Be("New Tag");
result.Error.Should().BeNull();
}
[Fact]
public async Task CreateTagWithResultAsync_WhenDuplicate_ReturnsFailureWithDetails()
{
// Arrange
var errorBody = """{"name": ["tag with this name already exists."]}""";
_factory.SetupPostWithError("api/tags/", HttpStatusCode.BadRequest, errorBody);
// Act
var result = await _factory.Client.CreateTagWithResultAsync(new TagCreateRequest { Name = "Existing Tag" });
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().NotBeNull();
result.Error!.StatusCode.Should().Be(HttpStatusCode.BadRequest);
result.Error.ResponseBody.Should().Contain("already exists");
}
[Fact]
public async Task CreateTagWithResultAsync_WhenUnauthorized_ReturnsFailureWithDetails()
{
// Arrange
var errorBody = """{"detail": "Authentication credentials were not provided."}""";
_factory.SetupPostWithError("api/tags/", HttpStatusCode.Unauthorized, errorBody);
// Act
var result = await _factory.Client.CreateTagWithResultAsync(new TagCreateRequest { Name = "Test" });
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().NotBeNull();
result.Error!.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
result.Error.ResponseBody.Should().Contain("Authentication credentials");
}
#endregion
#region Correspondent Tests