Initial commit: Paperless-ngx MCP Server

A Model Context Protocol (MCP) server for Paperless-ngx document management.

Features:
- Full CRUD operations for documents, tags, correspondents, document types,
  storage paths, and custom fields
- Document upload with retry logic (base64 and file path)
- Bulk operations with dry-run support
- Search with full-text and metadata filtering
- Pagination support across all list operations
- Proper error handling with McpResponse wrapper

Built with .NET 10 and the official MCP SDK.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Barry Walker
2026-01-13 14:01:44 -05:00
commit a37630aeac
37 changed files with 6638 additions and 0 deletions
@@ -0,0 +1,416 @@
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.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.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/")
.Respond(req =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Headers.Add("X-Version", "2.0.0");
return response;
});
// 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/", 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/");
}
#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();
}
#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
}
@@ -0,0 +1,102 @@
using System.Net;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using PaperlessMCP.Client;
using PaperlessMCP.Configuration;
using RichardSzalay.MockHttp;
namespace PaperlessMCP.Tests.Fixtures;
public class MockHttpClientFactory : IDisposable
{
public MockHttpMessageHandler MockHandler { get; }
public HttpClient HttpClient { get; }
public PaperlessClient Client { get; }
public PaperlessOptions Options { get; }
public MockHttpClientFactory(string baseUrl = "https://paperless.example.com")
{
Options = new PaperlessOptions
{
BaseUrl = baseUrl,
ApiToken = "test-token",
MaxPageSize = 100
};
MockHandler = new MockHttpMessageHandler();
HttpClient = MockHandler.ToHttpClient();
HttpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
HttpClient.DefaultRequestHeaders.Add("Accept", "application/json; version=9");
var optionsMock = Substitute.For<IOptions<PaperlessOptions>>();
optionsMock.Value.Returns(Options);
var logger = Substitute.For<ILogger<PaperlessClient>>();
Client = new PaperlessClient(HttpClient, optionsMock, logger);
}
public MockedRequest SetupGet(string url, string responseJson, HttpStatusCode statusCode = HttpStatusCode.OK)
{
return MockHandler
.When(HttpMethod.Get, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}")
.Respond("application/json", responseJson);
}
public MockedRequest SetupGetWithStatus(string url, HttpStatusCode statusCode)
{
return MockHandler
.When(HttpMethod.Get, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}")
.Respond(statusCode);
}
public MockedRequest SetupPost(string url, string responseJson, HttpStatusCode statusCode = HttpStatusCode.OK)
{
return MockHandler
.When(HttpMethod.Post, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}")
.Respond("application/json", responseJson);
}
public MockedRequest SetupPostWithStatus(string url, HttpStatusCode statusCode)
{
return MockHandler
.When(HttpMethod.Post, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}")
.Respond(statusCode);
}
public MockedRequest SetupPatch(string url, string responseJson, HttpStatusCode statusCode = HttpStatusCode.OK)
{
return MockHandler
.When(HttpMethod.Patch, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}")
.Respond("application/json", responseJson);
}
public MockedRequest SetupPatchWithStatus(string url, HttpStatusCode statusCode)
{
return MockHandler
.When(HttpMethod.Patch, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}")
.Respond(statusCode);
}
public MockedRequest SetupDelete(string url, HttpStatusCode statusCode = HttpStatusCode.NoContent)
{
return MockHandler
.When(HttpMethod.Delete, $"{Options.BaseUrl.TrimEnd('/')}/{url.TrimStart('/')}")
.Respond(statusCode);
}
public void Dispose()
{
HttpClient.Dispose();
MockHandler.Dispose();
}
}
public static class MockHttpExtensions
{
public static MockedRequest RespondWithJson(this MockedRequest request, string json, HttpStatusCode statusCode = HttpStatusCode.OK)
{
return request.Respond(statusCode, "application/json", json);
}
}
+226
View File
@@ -0,0 +1,226 @@
using System.Text.Json;
using PaperlessMCP.Models.Common;
using PaperlessMCP.Models.Correspondents;
using PaperlessMCP.Models.CustomFields;
using PaperlessMCP.Models.Documents;
using PaperlessMCP.Models.DocumentTypes;
using PaperlessMCP.Models.StoragePaths;
using PaperlessMCP.Models.Tags;
namespace PaperlessMCP.Tests.Fixtures;
public static class TestFixtures
{
public static class Documents
{
public static Document CreateDocument(int id = 1, string title = "Test Document") => new()
{
Id = id,
Title = title,
Content = "This is test content for the document.",
Correspondent = 1,
DocumentType = 1,
StoragePath = 1,
Tags = [1, 2],
Created = DateTime.UtcNow.AddDays(-10),
Modified = DateTime.UtcNow,
Added = DateTime.UtcNow.AddDays(-5),
ArchiveSerialNumber = 1001,
OriginalFileName = "test_document.pdf",
ArchivedFileName = "test_document_archived.pdf",
Owner = 1,
CustomFields = []
};
public static DocumentSearchResult CreateSearchResult(int id = 1, string title = "Test Document", double score = 1.5, string? content = null) => new()
{
Id = id,
Title = title,
Content = content ?? "This is test content for the document.",
Correspondent = 1,
DocumentType = 1,
Tags = [1, 2],
Created = DateTime.UtcNow.AddDays(-10),
SearchHit = new SearchHit
{
Score = score,
Highlights = $"<span>test</span> content",
Rank = 1
}
};
public static PaginatedResult<DocumentSearchResult> CreateSearchResults(int count = 3, string? content = null) => new()
{
Count = count,
Next = count > 25 ? "http://example.com/api/documents/?page=2" : null,
Previous = null,
Results = Enumerable.Range(1, Math.Min(count, 25))
.Select(i => CreateSearchResult(i, $"Document {i}", content: content))
.ToList()
};
public static string CreateSearchResultsJson(int count = 3, string? content = null) =>
JsonSerializer.Serialize(CreateSearchResults(count, content));
/// <summary>
/// Creates a long content string for testing truncation behavior.
/// </summary>
public static string CreateLongContent(int length = 1000) =>
string.Join(" ", Enumerable.Repeat("Lorem ipsum dolor sit amet, consectetur adipiscing elit.", length / 56 + 1))[..length];
public static string CreateDocumentJson(int id = 1, string title = "Test Document") =>
JsonSerializer.Serialize(CreateDocument(id, title));
}
public static class Tags
{
public static Tag CreateTag(int id = 1, string name = "Test Tag") => new()
{
Id = id,
Slug = name.ToLower().Replace(" ", "-"),
Name = name,
Color = "#ff0000",
TextColor = "#ffffff",
Match = "",
MatchingAlgorithm = 0,
IsInboxTag = false,
DocumentCount = 5,
Owner = 1
};
public static PaginatedResult<Tag> CreateTagList(int count = 3) => new()
{
Count = count,
Next = null,
Previous = null,
Results = Enumerable.Range(1, count)
.Select(i => CreateTag(i, $"Tag {i}"))
.ToList()
};
public static string CreateTagJson(int id = 1, string name = "Test Tag") =>
JsonSerializer.Serialize(CreateTag(id, name));
public static string CreateTagListJson(int count = 3) =>
JsonSerializer.Serialize(CreateTagList(count));
}
public static class Correspondents
{
public static Correspondent CreateCorrespondent(int id = 1, string name = "Test Correspondent") => new()
{
Id = id,
Slug = name.ToLower().Replace(" ", "-"),
Name = name,
Match = "",
MatchingAlgorithm = 0,
DocumentCount = 10,
LastCorrespondence = DateTime.UtcNow.AddDays(-1),
Owner = 1
};
public static PaginatedResult<Correspondent> CreateCorrespondentList(int count = 3) => new()
{
Count = count,
Next = null,
Previous = null,
Results = Enumerable.Range(1, count)
.Select(i => CreateCorrespondent(i, $"Correspondent {i}"))
.ToList()
};
public static string CreateCorrespondentJson(int id = 1, string name = "Test Correspondent") =>
JsonSerializer.Serialize(CreateCorrespondent(id, name));
public static string CreateCorrespondentListJson(int count = 3) =>
JsonSerializer.Serialize(CreateCorrespondentList(count));
}
public static class DocumentTypes
{
public static DocumentType CreateDocumentType(int id = 1, string name = "Test Type") => new()
{
Id = id,
Slug = name.ToLower().Replace(" ", "-"),
Name = name,
Match = "",
MatchingAlgorithm = 0,
DocumentCount = 15,
Owner = 1
};
public static PaginatedResult<DocumentType> CreateDocumentTypeList(int count = 3) => new()
{
Count = count,
Next = null,
Previous = null,
Results = Enumerable.Range(1, count)
.Select(i => CreateDocumentType(i, $"Type {i}"))
.ToList()
};
public static string CreateDocumentTypeJson(int id = 1, string name = "Test Type") =>
JsonSerializer.Serialize(CreateDocumentType(id, name));
public static string CreateDocumentTypeListJson(int count = 3) =>
JsonSerializer.Serialize(CreateDocumentTypeList(count));
}
public static class StoragePaths
{
public static StoragePath CreateStoragePath(int id = 1, string name = "Test Path") => new()
{
Id = id,
Slug = name.ToLower().Replace(" ", "-"),
Name = name,
Path = "{correspondent}/{document_type}",
Match = "",
MatchingAlgorithm = 0,
DocumentCount = 8,
Owner = 1
};
public static PaginatedResult<StoragePath> CreateStoragePathList(int count = 3) => new()
{
Count = count,
Next = null,
Previous = null,
Results = Enumerable.Range(1, count)
.Select(i => CreateStoragePath(i, $"Path {i}"))
.ToList()
};
public static string CreateStoragePathJson(int id = 1, string name = "Test Path") =>
JsonSerializer.Serialize(CreateStoragePath(id, name));
public static string CreateStoragePathListJson(int count = 3) =>
JsonSerializer.Serialize(CreateStoragePathList(count));
}
public static class CustomFields
{
public static CustomField CreateCustomField(int id = 1, string name = "Test Field", string dataType = "string") => new()
{
Id = id,
Name = name,
DataType = dataType,
ExtraData = dataType == "select" ? new CustomFieldExtraData { SelectOptions = ["Option 1", "Option 2"] } : null
};
public static PaginatedResult<CustomField> CreateCustomFieldList(int count = 3) => new()
{
Count = count,
Next = null,
Previous = null,
Results = Enumerable.Range(1, count)
.Select(i => CreateCustomField(i, $"Field {i}"))
.ToList()
};
public static string CreateCustomFieldJson(int id = 1, string name = "Test Field") =>
JsonSerializer.Serialize(CreateCustomField(id, name));
public static string CreateCustomFieldListJson(int count = 3) =>
JsonSerializer.Serialize(CreateCustomFieldList(count));
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PaperlessMCP\PaperlessMCP.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,158 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using PaperlessMCP.Tests.Fixtures;
using RichardSzalay.MockHttp;
using PaperlessMCP.Tools;
using Xunit;
namespace PaperlessMCP.Tests.Tools;
public class CorrespondentToolsTests : IDisposable
{
private readonly MockHttpClientFactory _factory;
public CorrespondentToolsTests()
{
_factory = new MockHttpClientFactory();
}
public void Dispose()
{
_factory.Dispose();
}
[Fact]
public async Task List_ReturnsCorrespondentList()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/correspondents/*")
.Respond("application/json", TestFixtures.Correspondents.CreateCorrespondentListJson(5));
// Act
var result = await CorrespondentTools.List(_factory.Client);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetArrayLength().Should().Be(5);
}
[Fact]
public async Task Get_WhenExists_ReturnsCorrespondent()
{
// Arrange
_factory.SetupGet("api/correspondents/1/", TestFixtures.Correspondents.CreateCorrespondentJson(1, "ACME Corp"));
// Act
var result = await CorrespondentTools.Get(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("ACME Corp");
}
[Fact]
public async Task Get_WhenNotFound_ReturnsError()
{
// Arrange
_factory.SetupGetWithStatus("api/correspondents/999/", HttpStatusCode.NotFound);
// Act
var result = await CorrespondentTools.Get(_factory.Client, 999);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
[Fact]
public async Task Create_WhenSuccessful_ReturnsCreatedCorrespondent()
{
// Arrange
_factory.SetupPost("api/correspondents/", TestFixtures.Correspondents.CreateCorrespondentJson(1, "New Company"));
// Act
var result = await CorrespondentTools.Create(_factory.Client, "New Company");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("New Company");
}
[Fact]
public async Task Update_WhenSuccessful_ReturnsUpdatedCorrespondent()
{
// Arrange
_factory.SetupPatch("api/correspondents/1/", TestFixtures.Correspondents.CreateCorrespondentJson(1, "Updated Company"));
// Act
var result = await CorrespondentTools.Update(_factory.Client, 1, name: "Updated Company");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Updated Company");
}
[Fact]
public async Task Delete_WithoutConfirmation_ReturnsDryRun()
{
// Arrange
_factory.SetupGet("api/correspondents/1/", TestFixtures.Correspondents.CreateCorrespondentJson(1, "To Delete"));
// Act
var result = await CorrespondentTools.Delete(_factory.Client, 1, confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("CONFIRMATION_REQUIRED");
}
[Fact]
public async Task Delete_WithConfirmation_DeletesCorrespondent()
{
// Arrange
_factory.SetupDelete("api/correspondents/1/", HttpStatusCode.NoContent);
// Act
var result = await CorrespondentTools.Delete(_factory.Client, 1, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("deleted").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task BulkDelete_WithDryRun_ReturnsPreview()
{
// Act
var result = await CorrespondentTools.BulkDelete(_factory.Client, "1,2", dryRun: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeFalse();
}
[Fact]
public async Task BulkDelete_WithConfirmation_ExecutesDeletion()
{
// Arrange
_factory.SetupPost("api/bulk_edit_objects/", "{}");
// Act
var result = await CorrespondentTools.BulkDelete(_factory.Client, "1,2", dryRun: false, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeTrue();
}
}
@@ -0,0 +1,199 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using PaperlessMCP.Tests.Fixtures;
using RichardSzalay.MockHttp;
using PaperlessMCP.Tools;
using Xunit;
namespace PaperlessMCP.Tests.Tools;
public class CustomFieldToolsTests : IDisposable
{
private readonly MockHttpClientFactory _factory;
public CustomFieldToolsTests()
{
_factory = new MockHttpClientFactory();
}
public void Dispose()
{
_factory.Dispose();
}
[Fact]
public async Task List_ReturnsCustomFieldList()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/custom_fields/*")
.Respond("application/json", TestFixtures.CustomFields.CreateCustomFieldListJson(4));
// Act
var result = await CustomFieldTools.List(_factory.Client);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetArrayLength().Should().Be(4);
}
[Fact]
public async Task Get_WhenExists_ReturnsCustomField()
{
// Arrange
_factory.SetupGet("api/custom_fields/1/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "Invoice Number"));
// Act
var result = await CustomFieldTools.Get(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Invoice Number");
}
[Fact]
public async Task Get_WhenNotFound_ReturnsError()
{
// Arrange
_factory.SetupGetWithStatus("api/custom_fields/999/", HttpStatusCode.NotFound);
// Act
var result = await CustomFieldTools.Get(_factory.Client, 999);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
[Fact]
public async Task Create_StringField_ReturnsCreatedField()
{
// Arrange
_factory.SetupPost("api/custom_fields/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "Reference Number"));
// Act
var result = await CustomFieldTools.Create(_factory.Client, "Reference Number", "string");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Reference Number");
}
[Fact]
public async Task Create_SelectField_IncludesOptions()
{
// Arrange
var selectField = TestFixtures.CustomFields.CreateCustomField(1, "Status", "select");
_factory.SetupPost("api/custom_fields/", JsonSerializer.Serialize(selectField));
// Act
var result = await CustomFieldTools.Create(
_factory.Client,
"Status",
"select",
selectOptions: "Pending,Approved,Rejected");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task Update_WhenSuccessful_ReturnsUpdatedField()
{
// Arrange
_factory.SetupPatch("api/custom_fields/1/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "Updated Field"));
// Act
var result = await CustomFieldTools.Update(_factory.Client, 1, name: "Updated Field");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Updated Field");
}
[Fact]
public async Task Delete_WithoutConfirmation_ReturnsDryRun()
{
// Arrange
_factory.SetupGet("api/custom_fields/1/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "To Delete"));
// Act
var result = await CustomFieldTools.Delete(_factory.Client, 1, confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("CONFIRMATION_REQUIRED");
}
[Fact]
public async Task Delete_WithConfirmation_DeletesField()
{
// Arrange
_factory.SetupDelete("api/custom_fields/1/", HttpStatusCode.NoContent);
// Act
var result = await CustomFieldTools.Delete(_factory.Client, 1, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("deleted").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task Assign_WhenDocumentExists_AssignsFieldValue()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc"));
_factory.SetupGet("api/custom_fields/1/", TestFixtures.CustomFields.CreateCustomFieldJson(1, "Invoice Number"));
_factory.SetupPatch("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc"));
// Act
var result = await CustomFieldTools.Assign(_factory.Client, documentId: 1, fieldId: 1, value: "INV-001");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("document_id").GetInt32().Should().Be(1);
json.RootElement.GetProperty("result").GetProperty("field_id").GetInt32().Should().Be(1);
}
[Fact]
public async Task Assign_WhenDocumentNotFound_ReturnsError()
{
// Arrange
_factory.SetupGetWithStatus("api/documents/999/", HttpStatusCode.NotFound);
// Act
var result = await CustomFieldTools.Assign(_factory.Client, documentId: 999, fieldId: 1, value: "test");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
[Fact]
public async Task Assign_WhenFieldNotFound_ReturnsError()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc"));
_factory.SetupGetWithStatus("api/custom_fields/999/", HttpStatusCode.NotFound);
// Act
var result = await CustomFieldTools.Assign(_factory.Client, documentId: 1, fieldId: 999, value: "test");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
}
@@ -0,0 +1,582 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using PaperlessMCP.Tests.Fixtures;
using RichardSzalay.MockHttp;
using PaperlessMCP.Tools;
using Xunit;
namespace PaperlessMCP.Tests.Tools;
public class DocumentToolsTests : IDisposable
{
private readonly MockHttpClientFactory _factory;
public DocumentToolsTests()
{
_factory = new MockHttpClientFactory();
}
public void Dispose()
{
_factory.Dispose();
}
#region Search Tests
[Fact]
public async Task Search_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 DocumentTools.Search(_factory.Client, query: "invoice");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetArrayLength().Should().Be(5);
json.RootElement.GetProperty("meta").GetProperty("total").GetInt32().Should().Be(5);
}
[Fact]
public async Task Search_WithPagination_IncludesMetadata()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(50));
// Act
var result = await DocumentTools.Search(_factory.Client, page: 2, pageSize: 10);
// Assert
var json = JsonDocument.Parse(result);
var meta = json.RootElement.GetProperty("meta");
meta.GetProperty("page").GetInt32().Should().Be(2);
meta.GetProperty("page_size").GetInt32().Should().Be(10);
}
[Fact]
public async Task Search_WithFilters_PassesFiltersCorrectly()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(2));
// Act
var result = await DocumentTools.Search(
_factory.Client,
query: "test",
tags: "1,2",
correspondent: 3,
documentType: 4,
createdAfter: "2024-01-01",
createdBefore: "2024-12-31"
);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task Search_ByDefault_ExcludesContent()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(2));
// Act
var result = await DocumentTools.Search(_factory.Client, query: "test");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
var results = json.RootElement.GetProperty("result");
results.GetArrayLength().Should().Be(2);
// Content should be null when includeContent is false (default)
foreach (var doc in results.EnumerateArray())
{
doc.GetProperty("content").ValueKind.Should().Be(JsonValueKind.Null);
}
}
[Fact]
public async Task Search_WithIncludeContent_ReturnsContent()
{
// Arrange
var longContent = TestFixtures.Documents.CreateLongContent(1000);
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(2, longContent));
// Act
var result = await DocumentTools.Search(
_factory.Client,
query: "test",
includeContent: true,
contentMaxLength: 0); // Unlimited
// Assert
var json = JsonDocument.Parse(result);
var results = json.RootElement.GetProperty("result");
foreach (var doc in results.EnumerateArray())
{
var content = doc.GetProperty("content").GetString();
content.Should().NotBeNullOrEmpty();
content.Should().Be(longContent);
}
}
[Fact]
public async Task Search_WithContentMaxLength_TruncatesContent()
{
// Arrange
var longContent = TestFixtures.Documents.CreateLongContent(1000);
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(2, longContent));
// Act
var result = await DocumentTools.Search(
_factory.Client,
query: "test",
includeContent: true,
contentMaxLength: 100);
// Assert
var json = JsonDocument.Parse(result);
var results = json.RootElement.GetProperty("result");
foreach (var doc in results.EnumerateArray())
{
var content = doc.GetProperty("content").GetString();
content.Should().NotBeNullOrEmpty();
content!.Length.Should().Be(103); // 100 chars + "..."
content.Should().EndWith("...");
}
}
[Fact]
public async Task Search_ReturnsDocumentSummaryFields()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/documents/*")
.Respond("application/json", TestFixtures.Documents.CreateSearchResultsJson(1));
// Act
var result = await DocumentTools.Search(_factory.Client, query: "test");
// Assert
var json = JsonDocument.Parse(result);
var doc = json.RootElement.GetProperty("result")[0];
// DocumentSummary fields should be present
doc.GetProperty("id").GetInt32().Should().BeGreaterThan(0);
doc.GetProperty("title").GetString().Should().NotBeNullOrEmpty();
doc.GetProperty("correspondent").ValueKind.Should().NotBe(JsonValueKind.Undefined);
doc.GetProperty("document_type").ValueKind.Should().NotBe(JsonValueKind.Undefined);
doc.GetProperty("tags").GetArrayLength().Should().BeGreaterThanOrEqualTo(0);
doc.GetProperty("created").ValueKind.Should().NotBe(JsonValueKind.Undefined);
// SearchHit should be present
doc.GetProperty("__search_hit__").GetProperty("score").GetDouble().Should().BeGreaterThan(0);
}
#endregion
#region Get Tests
[Fact]
public async Task Get_WhenDocumentExists_ReturnsDocument()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Invoice"));
// Act
var result = await DocumentTools.Get(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("id").GetInt32().Should().Be(1);
json.RootElement.GetProperty("result").GetProperty("title").GetString().Should().Be("Test Invoice");
}
[Fact]
public async Task Get_WhenDocumentNotFound_ReturnsError()
{
// Arrange
_factory.SetupGetWithStatus("api/documents/999/", HttpStatusCode.NotFound);
// Act
var result = await DocumentTools.Get(_factory.Client, 999);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
#endregion
#region Download Tests
[Fact]
public async Task Download_WhenDocumentExists_ReturnsDownloadUrls()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc"));
// Act
var result = await DocumentTools.Download(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
var downloadResult = json.RootElement.GetProperty("result");
downloadResult.GetProperty("download_url").GetString().Should().Contain("/api/documents/1/download/");
downloadResult.GetProperty("preview_url").GetString().Should().Contain("/api/documents/1/preview/");
downloadResult.GetProperty("thumbnail_url").GetString().Should().Contain("/api/documents/1/thumb/");
}
[Fact]
public async Task Preview_WhenDocumentExists_ReturnsPreviewUrl()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc"));
// Act
var result = await DocumentTools.Preview(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("preview_url").GetString()
.Should().Contain("/api/documents/1/preview/");
}
[Fact]
public async Task Thumbnail_WhenDocumentExists_ReturnsThumbnailUrl()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Test Doc"));
// Act
var result = await DocumentTools.Thumbnail(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("thumbnail_url").GetString()
.Should().Contain("/api/documents/1/thumb/");
}
#endregion
#region Upload Tests
[Fact]
public async Task Upload_WithValidBase64_ReturnsTaskId()
{
// Arrange
var fileContent = Convert.ToBase64String("Test file content"u8.ToArray());
_factory.MockHandler
.When(HttpMethod.Post, "https://paperless.example.com/api/documents/post_document/")
.Respond("application/json", "\"task-uuid-12345\"");
// Act
var result = await DocumentTools.Upload(
_factory.Client,
fileContent,
"test.pdf",
title: "Test Upload");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("task_id").GetString().Should().Be("task-uuid-12345");
}
[Fact]
public async Task Upload_WithInvalidBase64_ReturnsValidationError()
{
// Act
var result = await DocumentTools.Upload(
_factory.Client,
"not-valid-base64!!!",
"test.pdf");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("VALIDATION");
}
[Fact]
public async Task UploadFromPath_WhenFileNotFound_ReturnsError()
{
// Act
var result = await DocumentTools.UploadFromPath(
_factory.Client,
"/nonexistent/path/to/file.pdf");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
[Fact]
public async Task UploadFromPath_WithRelativePath_ReturnsValidationError()
{
// Act
var result = await DocumentTools.UploadFromPath(
_factory.Client,
"relative/path/to/file.pdf");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("VALIDATION");
}
[Fact]
public async Task UploadFromPath_WithValidFile_ReturnsTaskId()
{
// Arrange
var tempFile = Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(tempFile, "Test file content for upload");
_factory.MockHandler
.When(HttpMethod.Post, "https://paperless.example.com/api/documents/post_document/")
.Respond("application/json", "\"task-uuid-from-path-12345\"");
// Act
var result = await DocumentTools.UploadFromPath(
_factory.Client,
tempFile,
title: "Test Path Upload");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("task_id").GetString().Should().Be("task-uuid-from-path-12345");
json.RootElement.GetProperty("result").GetProperty("file_name").GetString().Should().NotBeNullOrEmpty();
json.RootElement.GetProperty("result").GetProperty("file_size").GetInt64().Should().BeGreaterThan(0);
}
finally
{
File.Delete(tempFile);
}
}
[Fact]
public async Task UploadFromPath_ExpandsTildeToHome()
{
// This test verifies tilde expansion happens (even if file doesn't exist)
// Act
var result = await DocumentTools.UploadFromPath(
_factory.Client,
"~/nonexistent_test_file_12345.pdf");
// Assert - Should try to find the file (and fail with NOT_FOUND, not VALIDATION)
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
#endregion
#region Update Tests
[Fact]
public async Task Update_WhenSuccessful_ReturnsUpdatedDocument()
{
// Arrange
_factory.SetupPatch("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Updated Title"));
// Act
var result = await DocumentTools.Update(_factory.Client, 1, title: "Updated Title");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("title").GetString().Should().Be("Updated Title");
}
[Fact]
public async Task Update_WhenNotFound_ReturnsError()
{
// Arrange
_factory.SetupPatchWithStatus("api/documents/999/", HttpStatusCode.NotFound);
// Act
var result = await DocumentTools.Update(_factory.Client, 999, title: "New Title");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
#endregion
#region Delete Tests
[Fact]
public async Task Delete_WithoutConfirmation_ReturnsDryRun()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Doc to Delete"));
// Act
var result = await DocumentTools.Delete(_factory.Client, 1, confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("CONFIRMATION_REQUIRED");
}
[Fact]
public async Task Delete_WithConfirmation_DeletesDocument()
{
// Arrange
_factory.SetupDelete("api/documents/1/", HttpStatusCode.NoContent);
// Act
var result = await DocumentTools.Delete(_factory.Client, 1, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("deleted").GetBoolean().Should().BeTrue();
}
#endregion
#region Bulk Update Tests
[Fact]
public async Task BulkUpdate_WithDryRun_ReturnsPreview()
{
// Act
var result = await DocumentTools.BulkUpdate(
_factory.Client,
documentIds: "1,2,3",
operation: "add_tag",
value: 5,
dryRun: true,
confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeFalse();
}
[Fact]
public async Task BulkUpdate_WithConfirmation_ExecutesOperation()
{
// Arrange
_factory.SetupPost("api/documents/bulk_edit/", "{}");
// Act
var result = await DocumentTools.BulkUpdate(
_factory.Client,
documentIds: "1,2,3",
operation: "add_tag",
value: 5,
dryRun: false,
confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task BulkUpdate_WithInvalidOperation_ReturnsValidationError()
{
// Act
var result = await DocumentTools.BulkUpdate(
_factory.Client,
documentIds: "1,2,3",
operation: "invalid_operation",
dryRun: false,
confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("VALIDATION");
}
[Fact]
public async Task BulkUpdate_WithEmptyIds_ReturnsValidationError()
{
// Act
var result = await DocumentTools.BulkUpdate(
_factory.Client,
documentIds: "",
operation: "add_tag",
dryRun: false,
confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("VALIDATION");
}
#endregion
#region Reprocess Tests
[Fact]
public async Task Reprocess_WithoutConfirmation_ReturnsDryRun()
{
// Arrange
_factory.SetupGet("api/documents/1/", TestFixtures.Documents.CreateDocumentJson(1, "Doc to Reprocess"));
// Act
var result = await DocumentTools.Reprocess(_factory.Client, 1, confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("CONFIRMATION_REQUIRED");
}
[Fact]
public async Task Reprocess_WithConfirmation_QueuesReprocessing()
{
// Arrange
_factory.SetupPost("api/documents/bulk_edit/", "{}");
// Act
var result = await DocumentTools.Reprocess(_factory.Client, 1, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("status").GetString().Should().Be("queued");
}
#endregion
}
@@ -0,0 +1,146 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using PaperlessMCP.Tests.Fixtures;
using RichardSzalay.MockHttp;
using PaperlessMCP.Tools;
using Xunit;
namespace PaperlessMCP.Tests.Tools;
public class DocumentTypeToolsTests : IDisposable
{
private readonly MockHttpClientFactory _factory;
public DocumentTypeToolsTests()
{
_factory = new MockHttpClientFactory();
}
public void Dispose()
{
_factory.Dispose();
}
[Fact]
public async Task List_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 DocumentTypeTools.List(_factory.Client);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetArrayLength().Should().Be(4);
}
[Fact]
public async Task Get_WhenExists_ReturnsDocumentType()
{
// Arrange
_factory.SetupGet("api/document_types/1/", TestFixtures.DocumentTypes.CreateDocumentTypeJson(1, "Invoice"));
// Act
var result = await DocumentTypeTools.Get(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Invoice");
}
[Fact]
public async Task Get_WhenNotFound_ReturnsError()
{
// Arrange
_factory.SetupGetWithStatus("api/document_types/999/", HttpStatusCode.NotFound);
// Act
var result = await DocumentTypeTools.Get(_factory.Client, 999);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
[Fact]
public async Task Create_WhenSuccessful_ReturnsCreatedType()
{
// Arrange
_factory.SetupPost("api/document_types/", TestFixtures.DocumentTypes.CreateDocumentTypeJson(1, "Contract"));
// Act
var result = await DocumentTypeTools.Create(_factory.Client, "Contract");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Contract");
}
[Fact]
public async Task Update_WhenSuccessful_ReturnsUpdatedType()
{
// Arrange
_factory.SetupPatch("api/document_types/1/", TestFixtures.DocumentTypes.CreateDocumentTypeJson(1, "Updated Type"));
// Act
var result = await DocumentTypeTools.Update(_factory.Client, 1, name: "Updated Type");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Updated Type");
}
[Fact]
public async Task Delete_WithoutConfirmation_ReturnsDryRun()
{
// Arrange
_factory.SetupGet("api/document_types/1/", TestFixtures.DocumentTypes.CreateDocumentTypeJson(1, "To Delete"));
// Act
var result = await DocumentTypeTools.Delete(_factory.Client, 1, confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("CONFIRMATION_REQUIRED");
}
[Fact]
public async Task Delete_WithConfirmation_DeletesType()
{
// Arrange
_factory.SetupDelete("api/document_types/1/", HttpStatusCode.NoContent);
// Act
var result = await DocumentTypeTools.Delete(_factory.Client, 1, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("deleted").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task BulkDelete_WithConfirmation_ExecutesDeletion()
{
// Arrange
_factory.SetupPost("api/bulk_edit_objects/", "{}");
// Act
var result = await DocumentTypeTools.BulkDelete(_factory.Client, "1,2,3", dryRun: false, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeTrue();
}
}
@@ -0,0 +1,114 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using PaperlessMCP.Tests.Fixtures;
using RichardSzalay.MockHttp;
using PaperlessMCP.Tools;
using Xunit;
namespace PaperlessMCP.Tests.Tools;
public class HealthToolsTests : IDisposable
{
private readonly MockHttpClientFactory _factory;
public HealthToolsTests()
{
_factory = new MockHttpClientFactory();
}
public void Dispose()
{
_factory.Dispose();
}
[Fact]
public async Task Ping_WhenConnected_ReturnsSuccess()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/")
.Respond(req =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Headers.Add("X-Version", "2.5.0");
return response;
});
// Act
var result = await HealthTools.Ping(_factory.Client);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("connected").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task Ping_WhenConnectionFails_ReturnsError()
{
// Arrange
_factory.SetupGetWithStatus("api/", HttpStatusCode.Unauthorized);
// Act
var result = await HealthTools.Ping(_factory.Client);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("UPSTREAM_ERROR");
}
[Fact]
public async Task GetCapabilities_ReturnsCapabilitiesInfo()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/")
.Respond(req =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Headers.Add("X-Version", "2.5.0");
return response;
});
_factory.SetupGet("api/status/", "{}");
// Act
var result = await HealthTools.GetCapabilities(_factory.Client);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
var capabilities = json.RootElement.GetProperty("result");
capabilities.GetProperty("connected").GetBoolean().Should().BeTrue();
capabilities.GetProperty("endpoints").Should().NotBeNull();
capabilities.GetProperty("bulk_edit_methods").GetArrayLength().Should().BeGreaterThan(0);
}
[Fact]
public async Task GetCapabilities_IncludesAllEndpointCategories()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/")
.Respond(HttpStatusCode.OK);
_factory.SetupGet("api/status/", "{}");
// Act
var result = await HealthTools.GetCapabilities(_factory.Client);
// Assert
var json = JsonDocument.Parse(result);
var endpoints = json.RootElement.GetProperty("result").GetProperty("endpoints");
endpoints.GetProperty("documents").Should().NotBeNull();
endpoints.GetProperty("tags").Should().NotBeNull();
endpoints.GetProperty("correspondents").Should().NotBeNull();
endpoints.GetProperty("document_types").Should().NotBeNull();
endpoints.GetProperty("storage_paths").Should().NotBeNull();
endpoints.GetProperty("custom_fields").Should().NotBeNull();
}
}
@@ -0,0 +1,146 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using PaperlessMCP.Tests.Fixtures;
using RichardSzalay.MockHttp;
using PaperlessMCP.Tools;
using Xunit;
namespace PaperlessMCP.Tests.Tools;
public class StoragePathToolsTests : IDisposable
{
private readonly MockHttpClientFactory _factory;
public StoragePathToolsTests()
{
_factory = new MockHttpClientFactory();
}
public void Dispose()
{
_factory.Dispose();
}
[Fact]
public async Task List_ReturnsStoragePathList()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/storage_paths/*")
.Respond("application/json", TestFixtures.StoragePaths.CreateStoragePathListJson(3));
// Act
var result = await StoragePathTools.List(_factory.Client);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetArrayLength().Should().Be(3);
}
[Fact]
public async Task Get_WhenExists_ReturnsStoragePath()
{
// Arrange
_factory.SetupGet("api/storage_paths/1/", TestFixtures.StoragePaths.CreateStoragePathJson(1, "Archive"));
// Act
var result = await StoragePathTools.Get(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Archive");
}
[Fact]
public async Task Get_WhenNotFound_ReturnsError()
{
// Arrange
_factory.SetupGetWithStatus("api/storage_paths/999/", HttpStatusCode.NotFound);
// Act
var result = await StoragePathTools.Get(_factory.Client, 999);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
[Fact]
public async Task Create_WhenSuccessful_ReturnsCreatedPath()
{
// Arrange
_factory.SetupPost("api/storage_paths/", TestFixtures.StoragePaths.CreateStoragePathJson(1, "New Archive"));
// Act
var result = await StoragePathTools.Create(_factory.Client, "New Archive", "{correspondent}/{year}");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("New Archive");
}
[Fact]
public async Task Update_WhenSuccessful_ReturnsUpdatedPath()
{
// Arrange
_factory.SetupPatch("api/storage_paths/1/", TestFixtures.StoragePaths.CreateStoragePathJson(1, "Updated Path"));
// Act
var result = await StoragePathTools.Update(_factory.Client, 1, name: "Updated Path");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Updated Path");
}
[Fact]
public async Task Delete_WithoutConfirmation_ReturnsDryRun()
{
// Arrange
_factory.SetupGet("api/storage_paths/1/", TestFixtures.StoragePaths.CreateStoragePathJson(1, "To Delete"));
// Act
var result = await StoragePathTools.Delete(_factory.Client, 1, confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("CONFIRMATION_REQUIRED");
}
[Fact]
public async Task Delete_WithConfirmation_DeletesPath()
{
// Arrange
_factory.SetupDelete("api/storage_paths/1/", HttpStatusCode.NoContent);
// Act
var result = await StoragePathTools.Delete(_factory.Client, 1, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("deleted").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task BulkDelete_WithConfirmation_ExecutesDeletion()
{
// Arrange
_factory.SetupPost("api/bulk_edit_objects/", "{}");
// Act
var result = await StoragePathTools.BulkDelete(_factory.Client, "1,2", dryRun: false, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeTrue();
}
}
+206
View File
@@ -0,0 +1,206 @@
using System.Net;
using System.Text.Json;
using FluentAssertions;
using PaperlessMCP.Tests.Fixtures;
using RichardSzalay.MockHttp;
using PaperlessMCP.Tools;
using Xunit;
namespace PaperlessMCP.Tests.Tools;
public class TagToolsTests : IDisposable
{
private readonly MockHttpClientFactory _factory;
public TagToolsTests()
{
_factory = new MockHttpClientFactory();
}
public void Dispose()
{
_factory.Dispose();
}
[Fact]
public async Task List_ReturnsTagList()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/tags/*")
.Respond("application/json", TestFixtures.Tags.CreateTagListJson(5));
// Act
var result = await TagTools.List(_factory.Client);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetArrayLength().Should().Be(5);
json.RootElement.GetProperty("meta").GetProperty("total").GetInt32().Should().Be(5);
}
[Fact]
public async Task List_WithPagination_IncludesMetadata()
{
// Arrange
_factory.MockHandler
.When(HttpMethod.Get, "https://paperless.example.com/api/tags/*")
.Respond("application/json", TestFixtures.Tags.CreateTagListJson(10));
// Act
var result = await TagTools.List(_factory.Client, page: 2, pageSize: 5);
// Assert
var json = JsonDocument.Parse(result);
var meta = json.RootElement.GetProperty("meta");
meta.GetProperty("page").GetInt32().Should().Be(2);
meta.GetProperty("page_size").GetInt32().Should().Be(5);
}
[Fact]
public async Task Get_WhenTagExists_ReturnsTag()
{
// Arrange
_factory.SetupGet("api/tags/1/", TestFixtures.Tags.CreateTagJson(1, "Important"));
// Act
var result = await TagTools.Get(_factory.Client, 1);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("id").GetInt32().Should().Be(1);
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Important");
}
[Fact]
public async Task Get_WhenTagNotFound_ReturnsError()
{
// Arrange
_factory.SetupGetWithStatus("api/tags/999/", HttpStatusCode.NotFound);
// Act
var result = await TagTools.Get(_factory.Client, 999);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("NOT_FOUND");
}
[Fact]
public async Task Create_WhenSuccessful_ReturnsCreatedTag()
{
// Arrange
_factory.SetupPost("api/tags/", TestFixtures.Tags.CreateTagJson(5, "New Tag"));
// Act
var result = await TagTools.Create(_factory.Client, "New Tag", color: "#00ff00");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("New Tag");
}
[Fact]
public async Task Create_WhenFails_ReturnsError()
{
// Arrange
_factory.SetupPostWithStatus("api/tags/", HttpStatusCode.BadRequest);
// Act
var result = await TagTools.Create(_factory.Client, "Bad Tag");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("UPSTREAM_ERROR");
}
[Fact]
public async Task Update_WhenSuccessful_ReturnsUpdatedTag()
{
// Arrange
_factory.SetupPatch("api/tags/1/", TestFixtures.Tags.CreateTagJson(1, "Updated Name"));
// Act
var result = await TagTools.Update(_factory.Client, 1, name: "Updated Name");
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("name").GetString().Should().Be("Updated Name");
}
[Fact]
public async Task Delete_WithoutConfirmation_ReturnsDryRun()
{
// Arrange
_factory.SetupGet("api/tags/1/", TestFixtures.Tags.CreateTagJson(1, "Tag to Delete"));
// Act
var result = await TagTools.Delete(_factory.Client, 1, confirm: false);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("CONFIRMATION_REQUIRED");
}
[Fact]
public async Task Delete_WithConfirmation_DeletesTag()
{
// Arrange
_factory.SetupDelete("api/tags/1/", HttpStatusCode.NoContent);
// Act
var result = await TagTools.Delete(_factory.Client, 1, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("deleted").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task BulkDelete_WithDryRun_ReturnsPreview()
{
// Act
var result = await TagTools.BulkDelete(_factory.Client, "1,2,3", dryRun: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("result").GetProperty("affected_ids").GetArrayLength().Should().Be(3);
}
[Fact]
public async Task BulkDelete_WithConfirmation_ExecutesDeletion()
{
// Arrange
_factory.SetupPost("api/bulk_edit_objects/", "{}");
// Act
var result = await TagTools.BulkDelete(_factory.Client, "1,2,3", dryRun: false, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeTrue();
json.RootElement.GetProperty("result").GetProperty("executed").GetBoolean().Should().BeTrue();
}
[Fact]
public async Task BulkDelete_WithEmptyIds_ReturnsValidationError()
{
// Act
var result = await TagTools.BulkDelete(_factory.Client, "", dryRun: false, confirm: true);
// Assert
var json = JsonDocument.Parse(result);
json.RootElement.GetProperty("ok").GetBoolean().Should().BeFalse();
json.RootElement.GetProperty("error").GetProperty("code").GetString().Should().Be("VALIDATION");
}
}