a37630aeac
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>
417 lines
11 KiB
C#
417 lines
11 KiB
C#
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
|
|
}
|