Files
PaperlessMCP/PaperlessMCP.Tests/Client/PaperlessClientTests.cs
T
Axel Larsson b0ab0dd5dd fix(client): send populated request bodies for create/update/bulk-edit
Create/update operations were sending empty request bodies to Paperless,
producing two visible failure modes:

1. POST creates (correspondents, tags, document types, storage paths,
   custom fields) returned `400 {"name":["This field is required."]}`
   even when the caller passed a valid name.
2. PATCH updates (documents, correspondents, tags, etc.) returned 200
   and bumped the row's `modified` timestamp, but no fields actually
   changed — server-side this looked like a successful no-op PATCH.
3. Bulk edit operations (`api/documents/bulk_edit/`,
   `api/bulk_edit_objects/`) failed because the inner `parameters` field
   went out as `{}`, so add_tag/remove_tag/etc. arrived without a tag id.

Reproducer: `correspondents.create(name="ACME Corp")` against a real
Paperless instance returns 400 with the body above.

Root causes are two related serialization patterns where the
compile-time type of the value to serialize is `object`:

- `PostWithResultAsync` / `PatchWithResultAsync` accept the request as
  `object` and pass it to `PostAsJsonAsync<TValue>` /
  `JsonContent.Create<T>(value, ...)`. The generic `T` is inferred as
  `object`, and through the configured DI HttpClient pipeline
  (DelegatingHandler + Polly retry policy) the body that reaches the
  wire is empty, even though the same JsonContent's
  `ReadAsStringAsync()` returns the expected JSON.
- `BulkEditDocumentsAsync` / `BulkEditObjectsAsync` wrap the call args
  in an anonymous type whose `parameters` field has compile-time type
  `object?`. System.Text.Json serializes that property against
  `object`, producing `"parameters":{}` regardless of the runtime value.

Fix: serialize against the runtime type explicitly and materialize the
JSON into a `StringContent` before the request leaves this client.
For bulk edits, build the body via `JsonObject` and serialize the
`parameters` payload against its runtime type.

Adds wire-format pinning tests for create / update / bulk_edit /
bulk_edit_objects that capture the outbound request body and assert the
expected payload shape. These do not reproduce the original bug under
MockHttp (the test helper bypasses the DI handler chain), but they pin
the new serialization for future refactors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:52:36 -07:00

626 lines
20 KiB
C#

using System.Net;
using FluentAssertions;
using PaperlessMCP.Models.Correspondents;
using RichardSzalay.MockHttp;
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;
namespace PaperlessMCP.Tests.Client;
public class PaperlessClientTests : IDisposable
{
private readonly MockHttpClientFactory _factory;
public PaperlessClientTests()
{
_factory = new MockHttpClientFactory();
}
public void Dispose()
{
_factory.Dispose();
}
#region Ping Tests
[Fact]
public async Task PingAsync_WhenSuccessful_ReturnsSuccess()
{
// Arrange
_factory.SetupGet("api/status/", """{"pngx_version": "2.0.0", "server_os": "Linux"}""");
// Act
var (success, version, error) = await _factory.Client.PingAsync();
// Assert
success.Should().BeTrue();
version.Should().Be("2.0.0");
error.Should().BeNull();
}
[Fact]
public async Task PingAsync_WhenUnauthorized_ReturnsFailure()
{
// Arrange
_factory.SetupGetWithStatus("api/status/", HttpStatusCode.Unauthorized);
// Act
var (success, version, error) = await _factory.Client.PingAsync();
// Assert
success.Should().BeFalse();
version.Should().BeNull();
error.Should().Contain("401");
}
#endregion
#region Document Tests
[Fact]
public async Task SearchDocumentsAsync_WithQuery_ReturnsResults()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(5));
// Act
var result = await _factory.Client.SearchDocumentsAsync(query: "test");
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(5);
result.Results.Should().HaveCount(5);
}
[Fact]
public async Task SearchDocumentsAsync_WithFilters_ReturnsFilteredResults()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(2));
// Act
var result = await _factory.Client.SearchDocumentsAsync(
query: "invoice",
tags: [1, 2],
correspondent: 3,
documentType: 4);
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(2);
}
[Fact]
public async Task GetDocumentAsync_WhenExists_ReturnsDocument()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "My Document"));
// Act
var result = await _factory.Client.GetDocumentAsync(1);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(1);
result.Title.Should().Be("My Document");
}
[Fact]
public async Task GetDocumentAsync_WhenNotFound_ReturnsNull()
{
// Arrange
_factory.SetupGetWithStatus("api/documents/999/", HttpStatusCode.NotFound);
// Act
var result = await _factory.Client.GetDocumentAsync(999);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task DeleteDocumentAsync_WhenSuccessful_ReturnsTrue()
{
// Arrange
_factory.SetupDelete("api/documents/1/", HttpStatusCode.NoContent);
// Act
var result = await _factory.Client.DeleteDocumentAsync(1);
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task DeleteDocumentAsync_WhenNotFound_ReturnsFalse()
{
// Arrange
_factory.SetupDelete("api/documents/999/", HttpStatusCode.NotFound);
// Act
var result = await _factory.Client.DeleteDocumentAsync(999);
// Assert
result.Should().BeFalse();
}
[Fact]
public async Task GetDocumentDownloadInfo_ReturnsCorrectUrls()
{
// Act
var result = _factory.Client.GetDocumentDownloadInfo(1, "Test Doc", "test.pdf");
// Assert
result.Id.Should().Be(1);
result.Title.Should().Be("Test Doc");
result.OriginalFileName.Should().Be("test.pdf");
result.DownloadUrl.Should().Be("https://paperless.example.com/api/documents/1/download/");
result.PreviewUrl.Should().Be("https://paperless.example.com/api/documents/1/preview/");
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
[Fact]
public async Task GetTagsAsync_ReturnsTagList()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/tags/*")
.Respond("application/json", TestFixtures.Tags.CreateTagListJson(5));
// Act
var result = await _factory.Client.GetTagsAsync();
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(5);
result.Results.Should().HaveCount(5);
}
[Fact]
public async Task GetTagAsync_WhenExists_ReturnsTag()
{
// Arrange
_factory.SetupGet("api/tags/1/", TestFixtures.Tags.CreateTagJson(1, "Important"));
// Act
var result = await _factory.Client.GetTagAsync(1);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(1);
result.Name.Should().Be("Important");
}
[Fact]
public async Task CreateTagAsync_WhenSuccessful_ReturnsTag()
{
// Arrange
_factory.SetupPost("api/tags/", TestFixtures.Tags.CreateTagJson(5, "New Tag"));
// Act
var result = await _factory.Client.CreateTagAsync(new TagCreateRequest { Name = "New Tag" });
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(5);
result.Name.Should().Be("New Tag");
}
[Fact]
public async Task DeleteTagAsync_WhenSuccessful_ReturnsTrue()
{
// Arrange
_factory.SetupDelete("api/tags/1/", HttpStatusCode.NoContent);
// Act
var result = await _factory.Client.DeleteTagAsync(1);
// Assert
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
[Fact]
public async Task GetCorrespondentsAsync_ReturnsCorrespondentList()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/correspondents/*")
.Respond("application/json", TestFixtures.Correspondents.CreateCorrespondentListJson(3));
// Act
var result = await _factory.Client.GetCorrespondentsAsync();
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(3);
}
[Fact]
public async Task CreateCorrespondentAsync_WhenSuccessful_ReturnsCorrespondent()
{
// Arrange
_factory.SetupPost("api/correspondents/", TestFixtures.Correspondents.CreateCorrespondentJson(1, "ACME Corp"));
// Act
var result = await _factory.Client.CreateCorrespondentAsync(new CorrespondentCreateRequest { Name = "ACME Corp" });
// Assert
result.Should().NotBeNull();
result!.Name.Should().Be("ACME Corp");
}
#endregion
#region Document Type Tests
[Fact]
public async Task GetDocumentTypesAsync_ReturnsDocumentTypeList()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/document_types/*")
.Respond("application/json", TestFixtures.DocumentTypes.CreateDocumentTypeListJson(4));
// Act
var result = await _factory.Client.GetDocumentTypesAsync();
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(4);
}
[Fact]
public async Task CreateDocumentTypeAsync_WhenSuccessful_ReturnsDocumentType()
{
// Arrange
_factory.SetupPost("api/document_types/", TestFixtures.DocumentTypes.CreateDocumentTypeJson(1, "Invoice"));
// Act
var result = await _factory.Client.CreateDocumentTypeAsync(new DocumentTypeCreateRequest { Name = "Invoice" });
// Assert
result.Should().NotBeNull();
result!.Name.Should().Be("Invoice");
}
#endregion
#region Storage Path Tests
[Fact]
public async Task GetStoragePathsAsync_ReturnsStoragePathList()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/storage_paths/*")
.Respond("application/json", TestFixtures.StoragePaths.CreateStoragePathListJson(2));
// Act
var result = await _factory.Client.GetStoragePathsAsync();
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(2);
}
[Fact]
public async Task CreateStoragePathAsync_WhenSuccessful_ReturnsStoragePath()
{
// Arrange
_factory.SetupPost("api/storage_paths/", TestFixtures.StoragePaths.CreateStoragePathJson(1, "Archive"));
// Act
var result = await _factory.Client.CreateStoragePathAsync(new StoragePathCreateRequest
{
Name = "Archive",
Path = "{correspondent}/{year}"
});
// Assert
result.Should().NotBeNull();
result!.Name.Should().Be("Archive");
}
#endregion
#region Custom Field Tests
[Fact]
public async Task GetCustomFieldsAsync_ReturnsCustomFieldList()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/custom_fields/*")
.Respond("application/json", TestFixtures.CustomFields.CreateCustomFieldListJson(3));
// Act
var result = await _factory.Client.GetCustomFieldsAsync();
// Assert
result.Should().NotBeNull();
result.Count.Should().Be(3);
}
[Fact]
public async Task CreateCustomFieldAsync_WhenSuccessful_ReturnsCustomField()
{
// Arrange
_factory.SetupPost("api/custom_fields/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "Invoice Number"));
// Act
var result = await _factory.Client.CreateCustomFieldAsync(new CustomFieldCreateRequest
{
Name = "Invoice Number",
DataType = "string"
});
// Assert
result.Should().NotBeNull();
result!.Name.Should().Be("Invoice Number");
}
#endregion
#region Bulk Operations Tests
[Fact]
public async Task BulkEditDocumentsAsync_WhenSuccessful_ReturnsTrue()
{
// Arrange
_factory.SetupPost("api/documents/bulk_edit/", "{}");
// Act
var result = await _factory.Client.BulkEditDocumentsAsync([1, 2, 3], "add_tag", new { tag = 5 });
// Assert
result.Should().BeTrue();
}
[Fact]
public async Task BulkEditObjectsAsync_WhenSuccessful_ReturnsTrue()
{
// Arrange
_factory.SetupPost("api/bulk_edit_objects/", "{}");
// Act
var result = await _factory.Client.BulkEditObjectsAsync([1, 2], "tags", "delete");
// Assert
result.Should().BeTrue();
}
#endregion
#region Request Body Serialization Tests
// Wire-format documentation tests. These capture the outbound request body and
// assert the expected payload reaches the HTTP layer. They DO NOT reproduce the
// production bug that motivated the fix — that bug only manifested through the DI
// HttpClient pipeline (DelegatingHandler + Polly retry policy), and these tests
// wire MockHttp directly into the HttpClient, bypassing that chain. Treat these as
// pinning tests for the new code path. A higher-fidelity regression test would
// need to register PaperlessClient through ServiceCollection with the same handler
// pipeline used in Program.cs.
[Fact]
public async Task CreateCorrespondentAsync_SendsNameInRequestBody()
{
// Arrange — capture the actual outbound body
string? capturedBody = null;
_factory.MockHandler
.When(HttpMethod.Post, "https://paperless.example.com/api/correspondents/")
.With(req =>
{
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
return true;
})
.Respond("application/json", TestFixtures.Correspondents.CreateCorrespondentJson(1, "ACME Corp"));
// Act
await _factory.Client.CreateCorrespondentAsync(new CorrespondentCreateRequest { Name = "ACME Corp" });
// Assert — the body must include the name on the wire.
capturedBody.Should().NotBeNullOrEmpty();
capturedBody.Should().Contain("\"name\":\"ACME Corp\"");
}
[Fact]
public async Task UpdateDocumentAsync_SendsUpdatedFieldsInRequestBody()
{
// Arrange
string? capturedBody = null;
_factory.MockHandler
.When(HttpMethod.Patch, "https://paperless.example.com/api/documents/42/")
.With(req =>
{
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
return true;
})
.Respond("application/json", "{\"id\":42}");
// Act
await _factory.Client.UpdateDocumentAsync(42, new DocumentUpdateRequest
{
Title = "New Title",
Correspondent = 7,
Tags = new List<int> { 1, 2 }
});
// Assert — the PATCH body must contain the updated fields on the wire.
capturedBody.Should().NotBeNullOrEmpty();
capturedBody.Should().Contain("\"title\":\"New Title\"");
capturedBody.Should().Contain("\"correspondent\":7");
capturedBody.Should().Contain("\"tags\":[1,2]");
}
[Fact]
public async Task BulkEditDocumentsAsync_SerializesInnerParametersWithRuntimeType()
{
// Arrange
string? capturedBody = null;
_factory.MockHandler
.When(HttpMethod.Post, "https://paperless.example.com/api/documents/bulk_edit/")
.With(req =>
{
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
return true;
})
.Respond("application/json", "{}");
// Act
await _factory.Client.BulkEditDocumentsAsync([1, 2, 3], "add_tag", new { tag = 5 });
// Assert — the inner `parameters` field must contain the actual tag, not `{}`.
capturedBody.Should().NotBeNullOrEmpty();
capturedBody.Should().Contain("\"documents\":[1,2,3]");
capturedBody.Should().Contain("\"method\":\"add_tag\"");
capturedBody.Should().Contain("\"parameters\":{\"tag\":5}");
}
[Fact]
public async Task BulkEditObjectsAsync_SerializesInnerParametersWithRuntimeType()
{
// Arrange
string? capturedBody = null;
_factory.MockHandler
.When(HttpMethod.Post, "https://paperless.example.com/api/bulk_edit_objects/")
.With(req =>
{
capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
return true;
})
.Respond("application/json", "{}");
// Act
await _factory.Client.BulkEditObjectsAsync([10, 20], "tags", "set_permissions",
new { owner = 3, set_permissions = new { view = new { users = new[] { 1 } } } });
// Assert
capturedBody.Should().NotBeNullOrEmpty();
capturedBody.Should().Contain("\"objects\":[10,20]");
capturedBody.Should().Contain("\"object_type\":\"tags\"");
capturedBody.Should().Contain("\"operation\":\"set_permissions\"");
capturedBody.Should().Contain("\"owner\":3");
}
#endregion
}