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
|
#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,
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user