diff --git a/PaperlessMCP.Tests/Client/PaperlessClientTests.cs b/PaperlessMCP.Tests/Client/PaperlessClientTests.cs index 2e52516..da303f7 100644 --- a/PaperlessMCP.Tests/Client/PaperlessClientTests.cs +++ b/PaperlessMCP.Tests/Client/PaperlessClientTests.cs @@ -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 { 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 } diff --git a/PaperlessMCP/Client/PaperlessClient.cs b/PaperlessMCP/Client/PaperlessClient.cs index 1fc1a68..fadb5a0 100644 --- a/PaperlessMCP/Client/PaperlessClient.cs +++ b/PaperlessMCP/Client/PaperlessClient.cs @@ -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)