Merge pull request #8 from elarsson1/fix/runtime-type-serialization

fix(client): send populated request bodies for create/update/bulk-edit
This commit is contained in:
Barry Walker
2026-05-05 12:27:33 -04:00
committed by GitHub
2 changed files with 159 additions and 13 deletions
@@ -508,4 +508,118 @@ public class PaperlessClientTests : IDisposable
} }
#endregion #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
} }
+45 -13
View File
@@ -397,16 +397,26 @@ public class PaperlessClient
object? parameters = null, object? parameters = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var request = new // Build the body via JsonObject so the inner `parameters` payload serializes
// against its runtime type. If we wrap in an anonymous type with `parameters`
// typed as `object?`, System.Text.Json emits `"parameters":{}` and Paperless
// rejects the request (e.g. add_tag without a tag id).
var rootNode = new System.Text.Json.Nodes.JsonObject
{ {
documents = documentIds, ["documents"] = System.Text.Json.Nodes.JsonNode.Parse(JsonSerializer.Serialize(documentIds, JsonOptions)),
method, ["method"] = method,
parameters
}; };
if (parameters != null)
{
rootNode["parameters"] = System.Text.Json.Nodes.JsonNode.Parse(
JsonSerializer.Serialize(parameters, parameters.GetType(), JsonOptions));
}
var jsonString = rootNode.ToJsonString(JsonOptions);
var content = new StringContent(jsonString, System.Text.Encoding.UTF8, "application/json");
try try
{ {
var response = await _httpClient.PostAsJsonAsync("api/documents/bulk_edit/", request, JsonOptions, cancellationToken).ConfigureAwait(false); var response = await _httpClient.PostAsync("api/documents/bulk_edit/", content, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
catch (Exception ex) catch (Exception ex)
@@ -634,17 +644,26 @@ public class PaperlessClient
object? parameters = null, object? parameters = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var request = new // Same fix as BulkEditDocumentsAsync — wrapping `parameters` in an anonymous
// type means its compile-time type is `object?` and the inner payload becomes
// `{}`.
var rootNode = new System.Text.Json.Nodes.JsonObject
{ {
objects = objectIds, ["objects"] = System.Text.Json.Nodes.JsonNode.Parse(JsonSerializer.Serialize(objectIds, JsonOptions)),
object_type = objectType, ["object_type"] = objectType,
operation, ["operation"] = operation,
parameters
}; };
if (parameters != null)
{
rootNode["parameters"] = System.Text.Json.Nodes.JsonNode.Parse(
JsonSerializer.Serialize(parameters, parameters.GetType(), JsonOptions));
}
var jsonString = rootNode.ToJsonString(JsonOptions);
var content = new StringContent(jsonString, System.Text.Encoding.UTF8, "application/json");
try try
{ {
var response = await _httpClient.PostAsJsonAsync("api/bulk_edit_objects/", request, JsonOptions, cancellationToken).ConfigureAwait(false); var response = await _httpClient.PostAsync("api/bulk_edit_objects/", content, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
catch (Exception ex) catch (Exception ex)
@@ -683,7 +702,15 @@ public class PaperlessClient
{ {
try try
{ {
var response = await _httpClient.PostAsJsonAsync(url, request, JsonOptions, cancellationToken).ConfigureAwait(false); // Serialize against the runtime type explicitly. Empirically, posting via
// PostAsJsonAsync(...) or PatchAsync(JsonContent.Create(...)) on this client
// (with the configured DelegatingHandler + Polly retry pipeline) sent an
// empty body to Paperless even though the JsonContent's own
// ReadAsStringAsync returned the expected JSON. Materializing the body into
// a StringContent up front sidesteps that.
var jsonString = JsonSerializer.Serialize(request, request.GetType(), JsonOptions);
var content = new StringContent(jsonString, System.Text.Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
@@ -707,7 +734,12 @@ public class PaperlessClient
{ {
try try
{ {
var content = JsonContent.Create(request, options: JsonOptions); // Same as PostWithResultAsync — explicit runtime-type serialization into
// StringContent. With JsonContent.Create(...) here the body reached
// Paperless empty (PATCH `{}` is a valid no-op so the row's modified
// timestamp updated but no fields actually changed).
var jsonString = JsonSerializer.Serialize(request, request.GetType(), JsonOptions);
var content = new StringContent(jsonString, System.Text.Encoding.UTF8, "application/json");
var response = await _httpClient.PatchAsync(url, content, cancellationToken).ConfigureAwait(false); var response = await _httpClient.PatchAsync(url, content, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)