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:
@@ -508,4 +508,118 @@ public class PaperlessClientTests : IDisposable
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
|
||||
@@ -397,16 +397,26 @@ public class PaperlessClient
|
||||
object? parameters = null,
|
||||
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,
|
||||
method,
|
||||
parameters
|
||||
["documents"] = System.Text.Json.Nodes.JsonNode.Parse(JsonSerializer.Serialize(documentIds, JsonOptions)),
|
||||
["method"] = method,
|
||||
};
|
||||
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
|
||||
{
|
||||
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;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -634,17 +644,26 @@ public class PaperlessClient
|
||||
object? parameters = null,
|
||||
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,
|
||||
object_type = objectType,
|
||||
operation,
|
||||
parameters
|
||||
["objects"] = System.Text.Json.Nodes.JsonNode.Parse(JsonSerializer.Serialize(objectIds, JsonOptions)),
|
||||
["object_type"] = objectType,
|
||||
["operation"] = operation,
|
||||
};
|
||||
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
|
||||
{
|
||||
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;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -683,7 +702,15 @@ public class PaperlessClient
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -707,7 +734,12 @@ public class PaperlessClient
|
||||
{
|
||||
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);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
|
||||
Reference in New Issue
Block a user