Refactor: Rename DocumentOperator to DocumentService

Updated project and solution files to rename and restructure
`DocumentOperator` to `DocumentService`. Modified project
references in `DocumentService.API.csproj`, `DocumentService.Application.csproj`,
and `DocumentService.Infrastructure.csproj` to reflect the new
naming convention. Updated `DocumentService.sln` to remove
old project references, add new ones, and adjust configuration
and nested project mappings accordingly. This change aligns
the project structure with the new naming convention.
This commit is contained in:
2026-08-30 21:33:25 +02:00
parent 050cb55bf9
commit 207c778f3a
108 changed files with 37 additions and 37 deletions

View File

@@ -0,0 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<None Remove="TestData\Pdfs\pdfWithSwissQRCode.pdf" />
<None Remove="TestData\Pdfs\valid.pdf" />
<None Remove="TestData\Pdfs\pdfWithMoreThanOneAttachment.pdf" />
<None Remove="TestData\Pdfs\encryptedPdf.pdf" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="TestData\Pdfs\pdfWithSwissQRCode.pdf" />
<EmbeddedResource Include="TestData\Pdfs\valid.pdf" />
<EmbeddedResource Include="TestData\Pdfs\pdfWithMoreThanOneAttachment.pdf" />
<EmbeddedResource Include="TestData\Pdfs\encryptedPdf.pdf" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentOperator.API\DocumentService.API.csproj" />
<ProjectReference Include="..\DocumentOperator.Domain\DocumentService.Domain.csproj" />
<ProjectReference Include="..\DocumentOperator.Application\DocumentService.Application.csproj" />
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentService.Infrastructure.csproj" />
<ProjectReference Include="..\DocumentService.Client\DocumentService.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,137 @@
using DocumentService.API.Controllers; // For ExtractSwissQrCodeBase64Request DTO
using DocumentService.Application.Common.DTOs;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Xunit;
namespace DocumentService.Tests.Integration.API;
public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
private readonly JsonSerializerOptions _jsonOptions;
public ExtractSwissQrCodeEndpointTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
}
[Fact]
public async Task POST_ExtractSwissQrCode_ValidRequest_Returns200()
{
// Arrange
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentService.Tests.TestData.Pdfs.valid.pdf");
var request = new ExtractSwissQrCodeBase64Request
{
Base64Pdf = validPdfBase64
};
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/qr-code/extract-swiss", request);
// Assert
// Note: The test PDF (valid.pdf) may not actually contain a Swiss QR Code
// so this test might return 404. We're testing the endpoint wiring here.
// A real test would need a PDF with a Swiss QR Code on the last page.
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
if (response.StatusCode == HttpStatusCode.OK)
{
var result = await response.Content.ReadFromJsonAsync<SwissQrCodeExtractionResult>(_jsonOptions);
result.Should().NotBeNull();
result.Bill.Should().NotBeNull();
result.RawLines.Should().NotBeEmpty();
}
}
[Fact]
public async Task POST_ExtractSwissQrCode_InvalidBase64_Returns400()
{
// Arrange
var request = new ExtractSwissQrCodeBase64Request
{
Base64Pdf = "INVALID_BASE64!!!"
};
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/qr-code/extract-swiss", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_ExtractSwissQrCode_NullReferences_AcceptedByValidation()
{
// Arrange: References are OPTIONAL - null should not cause validation error (400)
// Using pdfWithSwissQRCode.pdf which actually has a QR code, so we get 200
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentService.Tests.TestData.Pdfs.pdfWithSwissQRCode.pdf");
var request = new
{
References = (List<string>?)null, // Optional field - should not cause 400
Base64Pdf = validPdfBase64
};
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/qr-code/extract-swiss", request);
// Assert: Should NOT be 400 (validation error), should be 200 or 404
response.StatusCode.Should().NotBe(HttpStatusCode.BadRequest,
"null References should be accepted (optional field)");
// If extraction succeeds, verify empty references array
if (response.StatusCode == HttpStatusCode.OK)
{
var result = await response.Content.ReadFromJsonAsync<SwissQrCodeExtractionResult>();
result.Should().NotBeNull();
}
}
[Fact]
public async Task POST_ExtractSwissQrCode_EmptyPdf_Returns400()
{
// Arrange
var request = new
{
References = new List<string> { "REF-001" },
Base64Pdf = (string?)null
};
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/qr-code/extract-swiss", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
/// <summary>
/// Helper method to load embedded resource as Base64 string
/// </summary>
private static string GetEmbeddedResourceAsBase64(string resourceName)
{
var assembly = typeof(ExtractSwissQrCodeEndpointTests).Assembly;
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
}
using var memoryStream = new MemoryStream();
stream.CopyTo(memoryStream);
return Convert.ToBase64String(memoryStream.ToArray());
}
}

View File

@@ -0,0 +1,383 @@
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Models.Requests;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using Xunit;
namespace DocumentService.Tests.Integration.API;
/// <summary>
/// Integration tests for PdfAttachmentController.
/// Tests /api/pdf/attachments/check endpoint with both multipart and Base64 input.
/// </summary>
public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public PdfAttachmentControllerTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
#region Helper Methods
/// <summary>
/// Loads a test PDF from embedded resources.
/// </summary>
private static async Task<byte[]> LoadTestPdfAsync(string filename)
{
var assembly = typeof(PdfAttachmentControllerTests).Assembly;
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{filename}";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
return ms.ToArray();
}
#endregion
#region Base64 JSON Tests
[Fact]
public async Task POST_CheckAttachments_Base64_PdfWithoutAttachments_Returns200()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AttachmentCheckResult>();
result.Should().NotBeNull();
result!.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
result.Attachments.Should().BeEmpty();
}
[Fact]
public async Task POST_CheckAttachments_Base64_PdfWithMultipleAttachments_Returns200()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AttachmentCheckResult>();
result.Should().NotBeNull();
result!.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().Be(6, "PDF has exactly 6 attachments");
result.Attachments.Should().HaveCount(6);
// Verify each attachment has required properties
foreach (var attachment in result.Attachments)
{
attachment.FileName.Should().NotBeNullOrEmpty();
attachment.MimeType.Should().NotBeNullOrEmpty();
attachment.Size.Should().BeGreaterThan(0);
}
}
[Fact]
public async Task POST_CheckAttachments_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new CheckPdfAttachmentsRequest { Base64Pdf = "invalid-base64!!!" };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_CheckAttachments_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new CheckPdfAttachmentsRequest { Base64Pdf = string.Empty };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// Empty Base64 causes FormatException or empty stream error
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
#region Multipart/Form-Data Tests
[Fact]
public async Task POST_CheckAttachments_Multipart_PdfWithoutAttachments_Returns200()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(pdfBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "pdfWithSwissQRCode.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/attachments/check", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AttachmentCheckResult>();
result.Should().NotBeNull();
result!.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
result.Attachments.Should().BeEmpty();
}
[Fact]
public async Task POST_CheckAttachments_Multipart_PdfWithMultipleAttachments_Returns200()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(pdfBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "pdfWithMoreThanOneAttachment.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/attachments/check", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AttachmentCheckResult>();
result.Should().NotBeNull();
result!.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().Be(6);
result.Attachments.Should().HaveCount(6);
// Verify first attachment details
var firstAttachment = result.Attachments.First();
firstAttachment.FileName.Should().NotBeNullOrEmpty();
firstAttachment.MimeType.Should().NotBeNullOrEmpty();
firstAttachment.Size.Should().BeGreaterThan(0);
}
[Fact]
public async Task POST_CheckAttachments_Multipart_MissingFile_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent(); // No file added
// Act
var response = await _client.PostAsync("/api/pdf/attachments/check", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task POST_CheckAttachments_Multipart_CorruptedPdf_Returns500()
{
// Arrange
byte[] corruptedBytes = "This is not a valid PDF content"u8.ToArray();
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(corruptedBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "corrupted.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/attachments/check", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
var problemDetails = await response.Content.ReadAsStringAsync();
// Note: Generic error message for security reasons (doesn't expose internal details)
problemDetails.Should().Contain("error");
}
#endregion
#region Edge Cases
[Fact]
public async Task POST_CheckAttachments_Base64_PdfWithSwissQrCode_Returns200()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AttachmentCheckResult>();
result.Should().NotBeNull();
result!.HasAttachments.Should().BeFalse("Swiss QR PDF has no attachments");
result.AttachmentCount.Should().Be(0);
}
#endregion
#region Extract Attachments Tests (Multipart)
[Fact]
public async Task POST_ExtractAttachments_Multipart_ValidPdfWithAttachments_Returns200WithZip()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(pdfBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "test.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/zip");
response.Content.Headers.ContentDisposition?.FileName.Should().Be("attachments.zip");
byte[] zipBytes = await response.Content.ReadAsByteArrayAsync();
zipBytes.Should().NotBeEmpty("ZIP should contain data");
// Verify ZIP structure
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
}
[Fact]
public async Task POST_ExtractAttachments_Multipart_EmptyPdf_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(Array.Empty<byte>());
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "empty.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task POST_ExtractAttachments_Multipart_CorruptedPdf_Returns500()
{
// Arrange
byte[] corruptedBytes = "This is not a valid PDF content"u8.ToArray();
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(corruptedBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "corrupted.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
}
#endregion
#region Extract Attachments Tests (Base64)
[Fact]
public async Task POST_ExtractAttachments_Base64_ValidPdfWithAttachments_Returns200WithZip()
{
// Arrange
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
string base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/zip");
response.Content.Headers.ContentDisposition?.FileName.Should().Be("attachments.zip");
byte[] zipBytes = await response.Content.ReadAsByteArrayAsync();
zipBytes.Should().NotBeEmpty("ZIP should contain data");
// Verify ZIP structure
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
}
[Fact]
public async Task POST_ExtractAttachments_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = "invalid-base64!!!" };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_ExtractAttachments_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = string.Empty };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
}

View File

@@ -0,0 +1,500 @@
using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using DocumentService.API.Controllers;
using DocumentService.Domain.Models.ValueObjects;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
namespace DocumentService.Tests.Integration.API;
public class PdfOperationsControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public PdfOperationsControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
private static Stream LoadTestPdfAsStream(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{fileName}";
return assembly.GetManifestResourceStream(resourceName)
?? throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
}
private static string LoadTestPdfAsBase64(string fileName)
{
using var stream = LoadTestPdfAsStream(fileName);
using var ms = new MemoryStream();
stream.CopyTo(ms);
return Convert.ToBase64String(ms.ToArray());
}
#region Merge Endpoint Tests (existing - keeping for reference)
[Fact]
public async Task MergeFromFiles_ValidPdfs_ReturnsMergedPdf()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdf1Stream = LoadTestPdfAsStream("valid.pdf");
using var pdf2Stream = LoadTestPdfAsStream("valid.pdf");
var pdf1Content = new StreamContent(pdf1Stream);
var pdf2Content = new StreamContent(pdf2Stream);
pdf1Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
pdf2Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdf1Content, "files", "file1.pdf");
content.Add(pdf2Content, "files", "file2.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/pdf");
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
mergedPdf.Should().NotBeEmpty();
mergedPdf.Length.Should().BeGreaterThan(100); // Sanity check
}
[Fact]
public async Task MergeFromBase64_ValidPdfs_ReturnsMergedPdf()
{
// Arrange
string base64Pdf1 = LoadTestPdfAsBase64("valid.pdf");
string base64Pdf2 = LoadTestPdfAsBase64("valid.pdf");
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { base64Pdf1, base64Pdf2 },
PageRanges = null
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
mergedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task MergeFromBase64_WithPageRanges_ReturnsMergedPdf()
{
// Arrange
string base64Pdf1 = LoadTestPdfAsBase64("valid.pdf");
string base64Pdf2 = LoadTestPdfAsBase64("valid.pdf");
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { base64Pdf1, base64Pdf2 },
PageRanges = new List<string?> { "1", "1" } // Only first page from each
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
mergedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task MergeFromFiles_OnePdf_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var pdfContent = new StreamContent(pdfStream);
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdfContent, "files", "file1.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task MergeFromBase64_InvalidBase64_Returns400()
{
// Arrange
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { "INVALID_BASE64!!!", "ANOTHER_INVALID" }
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task MergeFromBase64_InvalidPageRange_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new MergePdfsBase64Request
{
Base64Pdfs = new List<string> { base64Pdf, base64Pdf },
PageRanges = new List<string?> { "999-1000", null } // Exceeds page count
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task MergeFromFiles_CorruptedPdf_Returns500()
{
// Arrange
using var content = new MultipartFormDataContent();
byte[] corruptedData = "NOT A PDF FILE"u8.ToArray();
var corruptedContent = new ByteArrayContent(corruptedData);
corruptedContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
using var validPdfStream = LoadTestPdfAsStream("valid.pdf");
var validContent = new StreamContent(validPdfStream);
validContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(corruptedContent, "files", "corrupted.pdf");
content.Add(validContent, "files", "valid.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
}
#endregion
#region Annotate Endpoint Tests
[Fact]
public async Task AnnotateFromFile_TextMarkupHighlight_ReturnsAnnotatedPdf()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var pdfContent = new StreamContent(pdfStream);
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdfContent, "file", "test.pdf");
content.Add(new StringContent(AnnotationType.TextMarkup.ToString()), "annotationType");
content.Add(new StringContent("1"), "pageNumber");
content.Add(new StringContent("100"), "x1");
content.Add(new StringContent("100"), "y1");
content.Add(new StringContent("200"), "x2");
content.Add(new StringContent("120"), "y2");
content.Add(new StringContent("Important text"), "content");
content.Add(new StringContent("Test Author"), "author");
content.Add(new StringContent("FFFF00"), "color");
content.Add(new StringContent(TextMarkupStyle.Highlight.ToString()), "textMarkupStyle");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType?.MediaType.Should().Be("application/pdf");
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
annotatedPdf.Length.Should().BeGreaterThan(100);
}
[Fact]
public async Task AnnotateFromBase64_FreeText_ReturnsAnnotatedPdf()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.FreeText,
PageNumber = 1,
X1 = 50,
Y1 = 50,
X2 = 150,
Y2 = 100,
Content = "Free text annotation",
Author = "John Doe",
Color = "FF0000"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromBase64_StickyNote_ReturnsAnnotatedPdf()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.StickyNote,
PageNumber = 1,
X1 = 300,
Y1 = 300,
X2 = 320,
Y2 = 320,
Content = "Please review this section",
Author = "Reviewer"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromFile_Circle_ReturnsAnnotatedPdf()
{
// Arrange
using var content = new MultipartFormDataContent();
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var pdfContent = new StreamContent(pdfStream);
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(pdfContent, "file", "test.pdf");
content.Add(new StringContent(AnnotationType.Circle.ToString()), "annotationType");
content.Add(new StringContent("1"), "pageNumber");
content.Add(new StringContent("100"), "x1");
content.Add(new StringContent("200"), "y1");
content.Add(new StringContent("200"), "x2");
content.Add(new StringContent("300"), "y2");
content.Add(new StringContent("Circle annotation"), "content");
content.Add(new StringContent("00FF00"), "color");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromBase64_Square_ReturnsAnnotatedPdf()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.Square,
PageNumber = 1,
X1 = 250,
Y1 = 250,
X2 = 350,
Y2 = 350,
Color = "0000FF"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
annotatedPdf.Should().NotBeEmpty();
}
[Fact]
public async Task AnnotateFromBase64_InvalidBase64_Returns400()
{
// Arrange
var request = new AddAnnotationBase64Command
{
Base64Pdf = "INVALID_BASE64!!!",
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_InvalidPageNumber_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.FreeText,
PageNumber = 999, // Exceeds page count
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100,
Content = "Test"
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_FreeTextWithoutContent_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.FreeText,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100
// Missing required Content
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_TextMarkupWithoutStyle_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100
// Missing required TextMarkupStyle
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AnnotateFromBase64_InvalidColorFormat_Returns400()
{
// Arrange
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
var request = new AddAnnotationBase64Command
{
Base64Pdf = base64Pdf,
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
X1 = 0,
Y1 = 0,
X2 = 100,
Y2 = 100,
Color = "INVALID" // Invalid hex format
};
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
}

View File

@@ -0,0 +1,320 @@
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Models.Requests;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using Xunit;
namespace DocumentService.Tests.Integration.API;
public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public PdfValidationControllerTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
#region Base64 JSON Tests
[Fact]
public async Task POST_ValidatePdf_Base64_ValidPdf_Returns200()
{
// Arrange
// Verwende ein echtes Test-PDF (embedded resource aus Unit Tests)
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
{
if (stream == null)
{
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
pdfBytes = ms.ToArray();
}
var base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ValidatePdfBase64Request { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<PdfValidationResult>();
result.Should().NotBeNull();
result!.PageCount.Should().BeGreaterThan(0);
result.FileSizeBytes.Should().BeGreaterThan(0);
result.PdfVersion.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task POST_ValidatePdf_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new ValidatePdfBase64Request { Base64Pdf = "invalid-base64!!!" }; // Kein gültiges Base64
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_ValidatePdf_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new ValidatePdfBase64Request { Base64Pdf = string.Empty }; // Leerer String
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// Empty Base64 causes FormatException or empty stream error
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
#region Multipart/Form-Data Tests
[Fact]
public async Task POST_ValidatePdf_Multipart_ValidPdf_Returns200()
{
// Arrange
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
{
if (stream == null)
{
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
pdfBytes = ms.ToArray();
}
// Create multipart/form-data content
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(pdfBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "test.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/validation/validate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<PdfValidationResult>();
result.Should().NotBeNull();
result!.PageCount.Should().BeGreaterThan(0);
result.FileSizeBytes.Should().BeGreaterThan(0);
result.PdfVersion.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task POST_ValidatePdf_Multipart_EmptyFile_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(Array.Empty<byte>());
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "empty.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/validation/validate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("cannot be empty");
}
[Fact]
public async Task POST_ValidatePdf_Multipart_NoFile_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
// Act
var response = await _client.PostAsync("/api/pdf/validation/validate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
#region PDF/A Validation Tests (Base64)
[Fact]
public async Task POST_ValidatePdfA_Base64_ValidPdf_Returns200()
{
// Arrange
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
{
if (stream == null)
{
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
pdfBytes = ms.ToArray();
}
var base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ValidatePdfABase64Request { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<PdfAValidationResult>();
result.Should().NotBeNull();
result!.IsValid.Should().BeTrue();
result.PageCount.Should().BeGreaterThan(0);
result.PdfVersion.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task POST_ValidatePdfA_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new ValidatePdfABase64Request { Base64Pdf = "invalid-base64!!!" };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// FormatException message contains "Base-64" (with hyphen)
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
}
[Fact]
public async Task POST_ValidatePdfA_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new ValidatePdfABase64Request { Base64Pdf = string.Empty };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
// Empty Base64 causes FormatException or empty stream error
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
}
#endregion
#region PDF/A Validation Tests (Multipart)
[Fact]
public async Task POST_ValidatePdfA_Multipart_ValidPdf_Returns200()
{
// Arrange
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
{
if (stream == null)
{
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
pdfBytes = ms.ToArray();
}
// Create multipart/form-data content
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(pdfBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "test.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/validation/validate-pdfa", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<PdfAValidationResult>();
result.Should().NotBeNull();
result!.IsValid.Should().BeTrue();
result.PageCount.Should().BeGreaterThan(0);
result.PdfVersion.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task POST_ValidatePdfA_Multipart_EmptyFile_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(Array.Empty<byte>());
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "empty.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/validation/validate-pdfa", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("cannot be empty");
}
[Fact]
public async Task POST_ValidatePdfA_Multipart_NoFile_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
// Act
var response = await _client.PostAsync("/api/pdf/validation/validate-pdfa", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
}

View File

@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100 ../../../schemas/UN_CEFACT/CrossIndustryInvoice_100pD16B.xsd">
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:ID>2021_10</ram:ID>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">20210924</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>1</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>Project management</ram:Name>
<ram:Description/>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>500.000000</ram:ChargeAmount>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="C62">2.00</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>1000.00</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>2</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>Consulting</ram:Name>
<ram:Description/>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>40.000000</ram:ChargeAmount>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="C62">5.00</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>200.00</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:ApplicableHeaderTradeAgreement>
<ram:BuyerReference>139877</ram:BuyerReference>
<ram:SellerTradeParty>
<ram:Name>Webware Internet Solutions GmbH</ram:Name>
<ram:SpecifiedLegalOrganization>
<ram:ID>HRB 15635</ram:ID>
</ram:SpecifiedLegalOrganization>
<ram:DefinedTradeContact>
<ram:PersonName>John Doe</ram:PersonName>
<ram:TelephoneUniversalCommunication>
<ram:CompleteNumber>+49(0)561-560123456</ram:CompleteNumber>
</ram:TelephoneUniversalCommunication>
<ram:EmailURIUniversalCommunication>
<ram:URIID>johndoe@webware24.de</ram:URIID>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
<ram:PostalTradeAddress>
<ram:PostcodeCode>34130</ram:PostcodeCode>
<ram:LineOne>Teichstr. 14-16</ram:LineOne>
<ram:CityName>Kassel</ram:CityName>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:URIUniversalCommunication>
<ram:URIID schemeID="9930">DE279247134</ram:URIID>
</ram:URIUniversalCommunication>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="FC">262/481/0918</ram:ID>
</ram:SpecifiedTaxRegistration>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">DE279247134</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>Agoratech</ram:Name>
<ram:PostalTradeAddress>
<ram:PostcodeCode>34130</ram:PostcodeCode>
<ram:LineOne>Teichstr. 14-16</ram:LineOne>
<ram:CityName>Kassel</ram:CityName>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:URIUniversalCommunication>
<ram:URIID schemeID="9930">DE319642369</ram:URIID>
</ram:URIUniversalCommunication>
</ram:BuyerTradeParty>
</ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeDelivery>
<ram:ActualDeliverySupplyChainEvent>
<ram:OccurrenceDateTime>
<udt:DateTimeString format="102">20211101</udt:DateTimeString>
</ram:OccurrenceDateTime>
</ram:ActualDeliverySupplyChainEvent>
</ram:ApplicableHeaderTradeDelivery>
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementPaymentMeans>
<ram:TypeCode>42</ram:TypeCode>
<ram:PayeePartyCreditorFinancialAccount>
<ram:IBANID/>
</ram:PayeePartyCreditorFinancialAccount>
<ram:PayeeSpecifiedCreditorFinancialInstitution>
<ram:BICID/>
</ram:PayeeSpecifiedCreditorFinancialInstitution>
</ram:SpecifiedTradeSettlementPaymentMeans>
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>228</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:BasisAmount>1200</ram:BasisAmount>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount>1200</ram:LineTotalAmount>
<ram:ChargeTotalAmount>0</ram:ChargeTotalAmount>
<ram:AllowanceTotalAmount>0</ram:AllowanceTotalAmount>
<ram:TaxBasisTotalAmount>1200.00</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount currencyID="EUR">228.00</ram:TaxTotalAmount>
<ram:GrandTotalAmount>1428.00</ram:GrandTotalAmount>
<ram:TotalPrepaidAmount>0.00</ram:TotalPrepaidAmount>
<ram:DuePayableAmount>1428.00</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>

View File

@@ -0,0 +1,30 @@
%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
4 0 obj
<< /Filter /Standard /V 2 /R 3 /Length 128 /P -3904 /O (OwnerHash12345678901234567890123456) /U (UserHash12345678901234567890123456) >>
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000206 00000 n
trailer
<< /Size 5 /Root 1 0 R /Encrypt 4 0 R >>
startxref
370
%%EOF

Binary file not shown.

View File

@@ -0,0 +1,108 @@
using AutoMapper;
using DocumentService.Application.CheckPdfAttachments.Queries;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentService.Tests.Unit.Application.CheckPdfAttachments;
public class CheckPdfAttachmentsQueryHandlerTests
{
private readonly Mock<IPdfProcessor> _mockPdfProcessor;
private readonly Mock<IMapper> _mockMapper;
private readonly CheckPdfAttachmentsQueryHandler _sut;
public CheckPdfAttachmentsQueryHandlerTests()
{
_mockPdfProcessor = new Mock<IPdfProcessor>();
_mockMapper = new Mock<IMapper>();
_sut = new CheckPdfAttachmentsQueryHandler(_mockPdfProcessor.Object, _mockMapper.Object);
}
[Fact]
public async Task Handle_PdfWithAttachments_ReturnsAttachmentDetails()
{
// Arrange
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream("fake-pdf"u8.ToArray()) };
var domainResult = new AttachmentInfo(
hasAttachments: true, attachmentCount: 2,
attachments: [new("invoice.xml", "text/xml", 1024), new("metadata.json", "application/json", 512)]);
var expectedDto = new AttachmentCheckResult
{
HasAttachments = true, AttachmentCount = 2,
Attachments =
[
new() { FileName = "invoice.xml", MimeType = "text/xml", Size = 1024 },
new() { FileName = "metadata.json", MimeType = "application/json", Size = 512 }
]
};
_mockPdfProcessor.Setup(p => p.CheckAttachmentsAsync(It.IsAny<Stream>())).ReturnsAsync(domainResult);
_mockMapper.Setup(m => m.Map<AttachmentCheckResult>(domainResult)).Returns(expectedDto);
// Act
var result = await _sut.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().Be(2);
result.Attachments.Should().HaveCount(2);
result.Attachments[0].FileName.Should().Be("invoice.xml");
result.Attachments[0].MimeType.Should().Be("text/xml");
result.Attachments[0].Size.Should().Be(1024);
_mockPdfProcessor.Verify(p => p.CheckAttachmentsAsync(It.IsAny<Stream>()), Times.Once);
_mockMapper.Verify(m => m.Map<AttachmentCheckResult>(domainResult), Times.Once);
}
[Fact]
public async Task Handle_PdfWithNoAttachments_ReturnsEmptyResult()
{
// Arrange
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream("fake-pdf"u8.ToArray()) };
var domainResult = AttachmentInfo.Empty;
var expectedDto = new AttachmentCheckResult { HasAttachments = false, AttachmentCount = 0, Attachments = [] };
_mockPdfProcessor.Setup(p => p.CheckAttachmentsAsync(It.IsAny<Stream>())).ReturnsAsync(domainResult);
_mockMapper.Setup(m => m.Map<AttachmentCheckResult>(domainResult)).Returns(expectedDto);
// Act
var result = await _sut.Handle(query, CancellationToken.None);
// Assert
result.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
result.Attachments.Should().BeEmpty();
_mockPdfProcessor.Verify(p => p.CheckAttachmentsAsync(It.IsAny<Stream>()), Times.Once);
}
[Fact]
public async Task Handle_ProcessorThrowsBadRequest_PropagatesException()
{
// Arrange
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream("fake-pdf"u8.ToArray()) };
_mockPdfProcessor.Setup(p => p.CheckAttachmentsAsync(It.IsAny<Stream>()))
.ThrowsAsync(new BadRequestException("PDF stream cannot be empty"));
// Act & Assert
var ex = await Assert.ThrowsAsync<BadRequestException>(
() => _sut.Handle(query, CancellationToken.None));
ex.Message.Should().Be("PDF stream cannot be empty");
}
[Fact]
public async Task Handle_ProcessorThrowsGenericException_PropagatesException()
{
// Arrange
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream("fake-pdf"u8.ToArray()) };
_mockPdfProcessor.Setup(p => p.CheckAttachmentsAsync(It.IsAny<Stream>()))
.ThrowsAsync(new InvalidOperationException("Unexpected error"));
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.Handle(query, CancellationToken.None));
}
}

View File

@@ -0,0 +1,126 @@
using DocumentService.Application.AddAnnotation;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
using DocumentService.Domain.Models.ValueObjects;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentService.Tests.Unit.Application.Features.AddAnnotation;
public class AddAnnotationHandlerTests
{
private readonly Mock<IPdfProcessor> _mockProcessor;
private readonly AddAnnotationHandler _handler;
private static readonly byte[] FakePdfBytes = "%PDF"u8.ToArray();
public AddAnnotationHandlerTests()
{
_mockProcessor = new Mock<IPdfProcessor>();
_handler = new AddAnnotationHandler(_mockProcessor.Object);
}
[Fact]
public async Task Handle_TextMarkupAnnotation_ReturnsAnnotatedBytes()
{
// Arrange
var command = new AddAnnotationCommand
{
PdfStream = new MemoryStream(FakePdfBytes),
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
Rectangle = (10, 20, 200, 50),
TextMarkupStyle = TextMarkupStyle.Highlight,
Author = "TestUser",
Color = "FFFF00"
};
var expected = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x01 };
_mockProcessor.Setup(x => x.AddAnnotationAsync(
It.IsAny<Stream>(), AnnotationType.TextMarkup, 1,
It.IsAny<(double, double, double, double)>(),
null, "TestUser", "FFFF00", TextMarkupStyle.Highlight, AnnotationOrigin.BottomLeft))
.ReturnsAsync(expected);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeEquivalentTo(expected);
_mockProcessor.Verify(x => x.AddAnnotationAsync(
It.IsAny<Stream>(), AnnotationType.TextMarkup, 1,
It.IsAny<(double, double, double, double)>(),
null, "TestUser", "FFFF00", TextMarkupStyle.Highlight, AnnotationOrigin.BottomLeft), Times.Once);
}
[Fact]
public async Task Handle_FreeTextAnnotation_PassesContentToProcessor()
{
// Arrange
var command = new AddAnnotationCommand
{
PdfStream = new MemoryStream(FakePdfBytes),
AnnotationType = AnnotationType.FreeText,
PageNumber = 2,
Rectangle = (50, 100, 300, 150),
Content = "Review this section"
};
var expected = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x02 };
_mockProcessor.Setup(x => x.AddAnnotationAsync(
It.IsAny<Stream>(), AnnotationType.FreeText, 2,
It.IsAny<(double, double, double, double)>(),
"Review this section", null, null, null, AnnotationOrigin.BottomLeft))
.ReturnsAsync(expected);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeEquivalentTo(expected);
}
[Fact]
public async Task Handle_ProcessorThrowsBadRequest_PropagatesException()
{
// Arrange
var command = new AddAnnotationCommand
{
PdfStream = new MemoryStream(FakePdfBytes),
AnnotationType = AnnotationType.Circle,
PageNumber = 99,
Rectangle = (0, 0, 100, 100)
};
_mockProcessor.Setup(x => x.AddAnnotationAsync(
It.IsAny<Stream>(), It.IsAny<AnnotationType>(), It.IsAny<int>(),
It.IsAny<(double, double, double, double)>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<TextMarkupStyle?>(), It.IsAny<AnnotationOrigin>()))
.ThrowsAsync(new BadRequestException("Page number 99 exceeds document page count"));
// Act & Assert
var ex = await Assert.ThrowsAsync<BadRequestException>(
() => _handler.Handle(command, CancellationToken.None));
ex.Message.Should().Contain("Page number 99");
}
[Fact]
public async Task Handle_ProcessorThrowsGenericException_PropagatesException()
{
// Arrange
var command = new AddAnnotationCommand
{
PdfStream = new MemoryStream(FakePdfBytes),
AnnotationType = AnnotationType.Square,
PageNumber = 1,
Rectangle = (0, 0, 100, 100)
};
_mockProcessor.Setup(x => x.AddAnnotationAsync(
It.IsAny<Stream>(), It.IsAny<AnnotationType>(), It.IsAny<int>(),
It.IsAny<(double, double, double, double)>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<TextMarkupStyle?>(), It.IsAny<AnnotationOrigin>()))
.ThrowsAsync(new Exception("Unexpected error"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _handler.Handle(command, CancellationToken.None));
}
}

View File

@@ -0,0 +1,133 @@
using DocumentService.Application.AddStamp;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
using DocumentService.Domain.Models.ValueObjects;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentService.Tests.Unit.Application.Features.AddStamp;
public class AddStampHandlerTests
{
private readonly Mock<IPdfProcessor> _mockProcessor;
private readonly AddStampHandler _handler;
private static readonly byte[] FakePdfBytes = "%PDF"u8.ToArray();
public AddStampHandlerTests()
{
_mockProcessor = new Mock<IPdfProcessor>();
_handler = new AddStampHandler(_mockProcessor.Object);
}
[Fact]
public async Task Handle_TextStamp_ReturnsStampedBytes()
{
// Arrange
var command = new AddStampCommand
{
PdfStream = new MemoryStream(FakePdfBytes),
StampType = StampType.Text,
Position = (100, 200),
Text = "CONFIDENTIAL"
};
var expected = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x03 };
_mockProcessor.Setup(x => x.AddStampAsync(
It.IsAny<Stream>(), StampType.Text, null,
It.IsAny<(double, double)>(), null,
AnnotationOrigin.BottomLeft, "CONFIDENTIAL",
null, null, null, null, null,
StampPlacement.Foreground, null, null))
.ReturnsAsync(expected);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeEquivalentTo(expected);
_mockProcessor.Verify(x => x.AddStampAsync(
It.IsAny<Stream>(), StampType.Text, null,
It.IsAny<(double, double)>(), null,
AnnotationOrigin.BottomLeft, "CONFIDENTIAL",
null, null, null, null, null,
StampPlacement.Foreground, null, null), Times.Once);
}
[Fact]
public async Task Handle_PredefinedStamp_PassesPredefinedTypeToProcessor()
{
// Arrange
var command = new AddStampCommand
{
PdfStream = new MemoryStream(FakePdfBytes),
StampType = StampType.Predefined,
Position = (50, 50),
PredefinedType = PredefinedStampType.Approved,
Opacity = 0.8
};
var expected = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x04 };
_mockProcessor.Setup(x => x.AddStampAsync(
It.IsAny<Stream>(), StampType.Predefined, null,
It.IsAny<(double, double)>(), null,
AnnotationOrigin.BottomLeft, null, null, null, null,
0.8, null, StampPlacement.Foreground, null, PredefinedStampType.Approved))
.ReturnsAsync(expected);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeEquivalentTo(expected);
}
[Fact]
public async Task Handle_ProcessorThrowsBadRequest_PropagatesException()
{
// Arrange
var command = new AddStampCommand
{
PdfStream = new MemoryStream(FakePdfBytes),
StampType = StampType.Text,
Position = (0, 0),
Text = "DRAFT",
Opacity = 2.0 // invalid
};
_mockProcessor.Setup(x => x.AddStampAsync(
It.IsAny<Stream>(), It.IsAny<StampType>(), It.IsAny<int[]?>(),
It.IsAny<(double, double)>(), It.IsAny<(double, double)?>(),
It.IsAny<AnnotationOrigin>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<double?>(), It.IsAny<string?>(), It.IsAny<double?>(),
It.IsAny<double?>(), It.IsAny<StampPlacement>(),
It.IsAny<byte[]?>(), It.IsAny<PredefinedStampType?>()))
.ThrowsAsync(new BadRequestException("Opacity must be between 0.0 and 1.0"));
// Act & Assert
var ex = await Assert.ThrowsAsync<BadRequestException>(
() => _handler.Handle(command, CancellationToken.None));
ex.Message.Should().Contain("Opacity");
}
[Fact]
public async Task Handle_ProcessorThrowsGenericException_PropagatesException()
{
// Arrange
var command = new AddStampCommand
{
PdfStream = new MemoryStream(FakePdfBytes),
StampType = StampType.Text,
Position = (0, 0),
Text = "TEST"
};
_mockProcessor.Setup(x => x.AddStampAsync(
It.IsAny<Stream>(), It.IsAny<StampType>(), It.IsAny<int[]?>(),
It.IsAny<(double, double)>(), It.IsAny<(double, double)?>(),
It.IsAny<AnnotationOrigin>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<double?>(), It.IsAny<string?>(), It.IsAny<double?>(),
It.IsAny<double?>(), It.IsAny<StampPlacement>(),
It.IsAny<byte[]?>(), It.IsAny<PredefinedStampType?>()))
.ThrowsAsync(new Exception("Unexpected"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _handler.Handle(command, CancellationToken.None));
}
}

View File

@@ -0,0 +1,121 @@
using DocumentService.Application.Common.Interfaces;
using DocumentService.Application.ConvertToPdfA;
using DocumentService.Application.ConvertFromPdfA;
using DocumentService.Domain.Common.Exceptions;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentService.Tests.Unit.Application.Features.ConvertPdfA;
public class ConvertToPdfAHandlerTests
{
private readonly Mock<IPdfProcessor> _mockProcessor;
private readonly ConvertToPdfACommandHandler _handler;
private static readonly byte[] FakePdfBytes = "%PDF"u8.ToArray();
public ConvertToPdfAHandlerTests()
{
_mockProcessor = new Mock<IPdfProcessor>();
_handler = new ConvertToPdfACommandHandler(_mockProcessor.Object);
}
[Fact]
public async Task Handle_ValidPdfAndLevel_ReturnsConvertedBytes()
{
// Arrange
var command = new ConvertToPdfACommand { PdfStream = new MemoryStream(FakePdfBytes), PdfALevel = "PDF/A-3b" };
var expected = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x0A };
_mockProcessor.Setup(x => x.ConvertToPdfAAsync(It.IsAny<Stream>(), "PDF/A-3b")).ReturnsAsync(expected);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeEquivalentTo(expected);
_mockProcessor.Verify(x => x.ConvertToPdfAAsync(It.IsAny<Stream>(), "PDF/A-3b"), Times.Once);
}
[Fact]
public async Task Handle_ProcessorThrowsNotImplemented_PropagatesException()
{
// Arrange
var command = new ConvertToPdfACommand { PdfStream = new MemoryStream(FakePdfBytes), PdfALevel = "PDF/A-1b" };
_mockProcessor.Setup(x => x.ConvertToPdfAAsync(It.IsAny<Stream>(), "PDF/A-1b"))
.ThrowsAsync(new NotImplementedException("PDF/A conversion not yet implemented"));
// Act & Assert
var ex = await Assert.ThrowsAsync<NotImplementedException>(
() => _handler.Handle(command, CancellationToken.None));
ex.Message.Should().Contain("not yet implemented");
}
[Fact]
public async Task Handle_ProcessorThrowsBadRequest_PropagatesException()
{
// Arrange
var command = new ConvertToPdfACommand { PdfStream = new MemoryStream(FakePdfBytes), PdfALevel = "PDF/A-2b" };
_mockProcessor.Setup(x => x.ConvertToPdfAAsync(It.IsAny<Stream>(), "PDF/A-2b"))
.ThrowsAsync(new BadRequestException("PDF stream cannot be empty"));
// Act & Assert
var ex = await Assert.ThrowsAsync<BadRequestException>(
() => _handler.Handle(command, CancellationToken.None));
ex.Message.Should().Contain("empty");
}
}
public class ConvertFromPdfAHandlerTests
{
private readonly Mock<IPdfProcessor> _mockProcessor;
private readonly ConvertFromPdfACommandHandler _handler;
private static readonly byte[] FakePdfBytes = "%PDF"u8.ToArray();
public ConvertFromPdfAHandlerTests()
{
_mockProcessor = new Mock<IPdfProcessor>();
_handler = new ConvertFromPdfACommandHandler(_mockProcessor.Object);
}
[Fact]
public async Task Handle_ValidPdfA_ReturnsStandardPdfBytes()
{
// Arrange
var command = new ConvertFromPdfACommand { PdfStream = new MemoryStream(FakePdfBytes) };
var expected = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x0B };
_mockProcessor.Setup(x => x.ConvertFromPdfAAsync(It.IsAny<Stream>())).ReturnsAsync(expected);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeEquivalentTo(expected);
_mockProcessor.Verify(x => x.ConvertFromPdfAAsync(It.IsAny<Stream>()), Times.Once);
}
[Fact]
public async Task Handle_ProcessorThrowsBadRequest_PropagatesException()
{
// Arrange
var command = new ConvertFromPdfACommand { PdfStream = new MemoryStream(FakePdfBytes) };
_mockProcessor.Setup(x => x.ConvertFromPdfAAsync(It.IsAny<Stream>()))
.ThrowsAsync(new BadRequestException("PDF stream must be at position 0"));
// Act & Assert
var ex = await Assert.ThrowsAsync<BadRequestException>(
() => _handler.Handle(command, CancellationToken.None));
ex.Message.Should().Contain("position 0");
}
[Fact]
public async Task Handle_ProcessorThrowsGenericException_PropagatesException()
{
// Arrange
var command = new ConvertFromPdfACommand { PdfStream = new MemoryStream(FakePdfBytes) };
_mockProcessor.Setup(x => x.ConvertFromPdfAAsync(It.IsAny<Stream>()))
.ThrowsAsync(new Exception("Unexpected error"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _handler.Handle(command, CancellationToken.None));
}
}

View File

@@ -0,0 +1,80 @@
using DocumentService.Application.Common.Interfaces;
using DocumentService.Application.ExtractPdfAttachments;
using DocumentService.Domain.Common.Exceptions;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentService.Tests.Unit.Application.Features.ExtractAttachments;
public class ExtractPdfAttachmentsHandlerTests
{
private readonly Mock<IPdfProcessor> _mockProcessor;
private readonly ExtractPdfAttachmentsCommandHandler _handler;
private static readonly byte[] FakePdfBytes = "%PDF"u8.ToArray();
public ExtractPdfAttachmentsHandlerTests()
{
_mockProcessor = new Mock<IPdfProcessor>();
_handler = new ExtractPdfAttachmentsCommandHandler(_mockProcessor.Object);
}
[Fact]
public async Task Handle_PdfWithAttachments_ReturnsZipBytes()
{
// Arrange
var command = new ExtractPdfAttachmentsCommand { PdfStream = new MemoryStream(FakePdfBytes) };
// ZIP magic bytes PK
var expected = new byte[] { 0x50, 0x4B, 0x03, 0x04, 0x00, 0x00 };
_mockProcessor.Setup(x => x.ExtractAttachmentsAsync(It.IsAny<Stream>())).ReturnsAsync(expected);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeEmpty();
result[0].Should().Be(0x50, "ZIP starts with 'P'");
result[1].Should().Be(0x4B, "ZIP continues with 'K'");
_mockProcessor.Verify(x => x.ExtractAttachmentsAsync(It.IsAny<Stream>()), Times.Once);
}
[Fact]
public async Task Handle_PdfWithNoAttachments_ThrowsNotFoundException()
{
// Arrange
var command = new ExtractPdfAttachmentsCommand { PdfStream = new MemoryStream(FakePdfBytes) };
_mockProcessor.Setup(x => x.ExtractAttachmentsAsync(It.IsAny<Stream>()))
.ThrowsAsync(new NotFoundException("PDF does not contain any attachments"));
// Act & Assert
var ex = await Assert.ThrowsAsync<NotFoundException>(
() => _handler.Handle(command, CancellationToken.None));
ex.Message.Should().Contain("attachments");
}
[Fact]
public async Task Handle_EmptyStream_ThrowsBadRequestException()
{
// Arrange
var command = new ExtractPdfAttachmentsCommand { PdfStream = new MemoryStream(FakePdfBytes) };
_mockProcessor.Setup(x => x.ExtractAttachmentsAsync(It.IsAny<Stream>()))
.ThrowsAsync(new BadRequestException("PDF stream cannot be empty"));
// Act & Assert
var ex = await Assert.ThrowsAsync<BadRequestException>(
() => _handler.Handle(command, CancellationToken.None));
ex.Message.Should().Contain("empty");
}
[Fact]
public async Task Handle_ProcessorThrowsGenericException_PropagatesException()
{
// Arrange
var command = new ExtractPdfAttachmentsCommand { PdfStream = new MemoryStream(FakePdfBytes) };
_mockProcessor.Setup(x => x.ExtractAttachmentsAsync(It.IsAny<Stream>()))
.ThrowsAsync(new Exception("Unexpected error"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _handler.Handle(command, CancellationToken.None));
}
}

View File

@@ -0,0 +1,95 @@
using DocumentService.Application.Common.Interfaces;
using DocumentService.Application.MergePdfs;
using DocumentService.Domain.Common.Exceptions;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentService.Tests.Unit.Application.Features.MergePdfs;
public class MergePdfsCommandHandlerTests
{
private readonly Mock<IPdfProcessor> _mockProcessor;
private readonly MergePdfsCommandHandler _handler;
public MergePdfsCommandHandlerTests()
{
_mockProcessor = new Mock<IPdfProcessor>();
_handler = new MergePdfsCommandHandler(_mockProcessor.Object);
}
[Fact]
public async Task Handle_TwoStreams_ReturnsMergedBytes()
{
// Arrange
var stream1 = new MemoryStream("%PDF-1"u8.ToArray());
var stream2 = new MemoryStream("%PDF-2"u8.ToArray());
var command = new MergePdfsCommand { PdfStreams = [stream1, stream2] };
var expected = "%PDF-merged"u8.ToArray();
_mockProcessor.Setup(x => x.MergePdfsAsync(It.IsAny<IReadOnlyList<Stream>>(), null))
.ReturnsAsync(expected);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull().And.NotBeEmpty();
result.Should().BeEquivalentTo(expected);
_mockProcessor.Verify(x => x.MergePdfsAsync(It.IsAny<IReadOnlyList<Stream>>(), null), Times.Once);
}
[Fact]
public async Task Handle_WithPageRanges_PassesRangesToProcessor()
{
// Arrange
var stream1 = new MemoryStream("%PDF-1"u8.ToArray());
var stream2 = new MemoryStream("%PDF-2"u8.ToArray());
var pageRanges = new List<string?> { "1-2", null };
var command = new MergePdfsCommand { PdfStreams = [stream1, stream2], PageRanges = pageRanges };
var expected = new byte[] { 0x25, 0x50, 0x44, 0x46 };
_mockProcessor.Setup(x => x.MergePdfsAsync(It.IsAny<IReadOnlyList<Stream>>(), pageRanges))
.ReturnsAsync(expected);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().BeEquivalentTo(expected);
_mockProcessor.Verify(x => x.MergePdfsAsync(It.IsAny<IReadOnlyList<Stream>>(), pageRanges), Times.Once);
}
[Fact]
public async Task Handle_ProcessorThrowsBadRequest_PropagatesException()
{
// Arrange
var command = new MergePdfsCommand
{
PdfStreams = [new MemoryStream("%PDF-1"u8.ToArray()), new MemoryStream("%PDF-2"u8.ToArray())]
};
_mockProcessor.Setup(x => x.MergePdfsAsync(It.IsAny<IReadOnlyList<Stream>>(), null))
.ThrowsAsync(new BadRequestException("At least 2 PDF files are required"));
// Act & Assert
var ex = await Assert.ThrowsAsync<BadRequestException>(
() => _handler.Handle(command, CancellationToken.None));
ex.Message.Should().Be("At least 2 PDF files are required");
}
[Fact]
public async Task Handle_ProcessorThrowsGenericException_PropagatesException()
{
// Arrange
var command = new MergePdfsCommand
{
PdfStreams = [new MemoryStream("%PDF"u8.ToArray()), new MemoryStream("%PDF"u8.ToArray())]
};
_mockProcessor.Setup(x => x.MergePdfsAsync(It.IsAny<IReadOnlyList<Stream>>(), null))
.ThrowsAsync(new InvalidOperationException("Unexpected error"));
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => _handler.Handle(command, CancellationToken.None));
}
}

View File

@@ -0,0 +1,108 @@
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Application.ValidatePdf.Queries;
using DocumentService.Domain.Common.Exceptions;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentService.Tests.Unit.Application.Features.ValidatePdf;
public class ValidatePdfHandlerTests
{
private readonly Mock<IPdfProcessor> _mockPdfProcessor;
private readonly ValidatePdfQueryHandler _handler;
public ValidatePdfHandlerTests()
{
_mockPdfProcessor = new Mock<IPdfProcessor>();
_handler = new ValidatePdfQueryHandler(_mockPdfProcessor.Object);
}
[Fact]
public async Task Handle_ValidPdf_ReturnsResult()
{
// Arrange
var query = new ValidatePdfQuery { PdfStream = new MemoryStream("%PDF"u8.ToArray()) };
var expected = new PdfValidationResult(5, 1024, 1024 / 1024.0 / 1024.0, "1.4", false, 0, false);
_mockPdfProcessor.Setup(x => x.ValidateAsync(It.IsAny<Stream>())).ReturnsAsync(expected);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.PageCount.Should().Be(5);
result.FileSizeBytes.Should().Be(1024);
result.PdfVersion.Should().Be("1.4");
result.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
result.IsEncrypted.Should().BeFalse();
_mockPdfProcessor.Verify(x => x.ValidateAsync(It.IsAny<Stream>()), Times.Once);
}
[Fact]
public async Task Handle_EncryptedPdf_ReturnsIsEncryptedTrue()
{
// Arrange
var query = new ValidatePdfQuery { PdfStream = new MemoryStream("%PDF"u8.ToArray()) };
var expected = new PdfValidationResult(1, 512, 512 / 1024.0 / 1024.0, "1.7", false, 0, true);
_mockPdfProcessor.Setup(x => x.ValidateAsync(It.IsAny<Stream>())).ReturnsAsync(expected);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.IsEncrypted.Should().BeTrue();
result.PageCount.Should().Be(1);
_mockPdfProcessor.Verify(x => x.ValidateAsync(It.IsAny<Stream>()), Times.Once);
}
[Fact]
public async Task Handle_PdfWithAttachments_ReturnsAttachmentData()
{
// Arrange
var query = new ValidatePdfQuery { PdfStream = new MemoryStream("%PDF"u8.ToArray()) };
var expected = new PdfValidationResult(2, 4096, 4096 / 1024.0 / 1024.0, "1.6", true, 3, false);
_mockPdfProcessor.Setup(x => x.ValidateAsync(It.IsAny<Stream>())).ReturnsAsync(expected);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().Be(3);
_mockPdfProcessor.Verify(x => x.ValidateAsync(It.IsAny<Stream>()), Times.Once);
}
[Fact]
public async Task Handle_ProcessorThrowsBadRequest_PropagatesException()
{
// Arrange
var query = new ValidatePdfQuery { PdfStream = new MemoryStream("%PDF"u8.ToArray()) };
_mockPdfProcessor.Setup(x => x.ValidateAsync(It.IsAny<Stream>()))
.ThrowsAsync(new BadRequestException("Invalid PDF format"));
// Act & Assert
var ex = await Assert.ThrowsAsync<BadRequestException>(
() => _handler.Handle(query, CancellationToken.None));
ex.Message.Should().Be("Invalid PDF format");
_mockPdfProcessor.Verify(x => x.ValidateAsync(It.IsAny<Stream>()), Times.Once);
}
[Fact]
public async Task Handle_ProcessorThrowsGenericException_PropagatesException()
{
// Arrange
var query = new ValidatePdfQuery { PdfStream = new MemoryStream("%PDF"u8.ToArray()) };
_mockPdfProcessor.Setup(x => x.ValidateAsync(It.IsAny<Stream>()))
.ThrowsAsync(new InvalidOperationException("Unexpected error"));
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => _handler.Handle(query, CancellationToken.None));
}
}

View File

@@ -0,0 +1,129 @@
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Application.ValidatePdfA.Queries;
using DocumentService.Domain.Common.Exceptions;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentService.Tests.Unit.Application.Features.ValidatePdfA;
public class ValidatePdfAQueryHandlerTests
{
private readonly Mock<IPdfProcessor> _mockPdfProcessor;
private readonly ValidatePdfAQueryHandler _handler;
public ValidatePdfAQueryHandlerTests()
{
_mockPdfProcessor = new Mock<IPdfProcessor>();
_handler = new ValidatePdfAQueryHandler(_mockPdfProcessor.Object);
}
[Fact]
public async Task Handle_ValidCompliantPdfA_ReturnsCompliantResult()
{
// Arrange
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream("%PDF"u8.ToArray()) };
var expected = new PdfAValidationResult
{
IsValid = true, PdfVersion = "1.7", PageCount = 3, FileSize = 2048,
Encrypted = false, PdfAVersion = "PDF/A-3b", PdfACompliant = true,
Errors = [], Warnings = []
};
_mockPdfProcessor.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>())).ReturnsAsync(expected);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.IsValid.Should().BeTrue();
result.PageCount.Should().Be(3);
result.PdfVersion.Should().Be("1.7");
result.PdfAVersion.Should().Be("PDF/A-3b");
result.PdfACompliant.Should().BeTrue();
result.Encrypted.Should().BeFalse();
result.Errors.Should().BeEmpty();
result.Warnings.Should().BeEmpty();
_mockPdfProcessor.Verify(x => x.ValidatePdfAAsync(It.IsAny<Stream>()), Times.Once);
}
[Fact]
public async Task Handle_NonCompliantPdfA_ReturnsErrorsAndWarnings()
{
// Arrange
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream("%PDF"u8.ToArray()) };
var expected = new PdfAValidationResult
{
IsValid = false, PdfVersion = "1.4", PageCount = 2, FileSize = 1024,
Encrypted = false, PdfAVersion = null, PdfACompliant = false,
Errors = ["Missing XMP metadata", "Invalid color space"],
Warnings = ["Embedded font not subset"]
};
_mockPdfProcessor.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>())).ReturnsAsync(expected);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.IsValid.Should().BeFalse();
result.PdfACompliant.Should().BeFalse();
result.PdfAVersion.Should().BeNull();
result.Errors.Should().HaveCount(2).And.Contain("Missing XMP metadata").And.Contain("Invalid color space");
result.Warnings.Should().HaveCount(1).And.Contain("Embedded font not subset");
_mockPdfProcessor.Verify(x => x.ValidatePdfAAsync(It.IsAny<Stream>()), Times.Once);
}
[Fact]
public async Task Handle_EncryptedPdf_ReturnsEncryptedFlagAndErrors()
{
// Arrange
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream("%PDF"u8.ToArray()) };
var expected = new PdfAValidationResult
{
IsValid = false, PdfVersion = "1.7", PageCount = 1, FileSize = 512,
Encrypted = true, PdfAVersion = null, PdfACompliant = false,
Errors = ["PDF/A documents cannot be encrypted"],
Warnings = []
};
_mockPdfProcessor.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>())).ReturnsAsync(expected);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Encrypted.Should().BeTrue();
result.IsValid.Should().BeFalse();
result.PdfACompliant.Should().BeFalse();
result.Errors.Should().Contain("PDF/A documents cannot be encrypted");
_mockPdfProcessor.Verify(x => x.ValidatePdfAAsync(It.IsAny<Stream>()), Times.Once);
}
[Fact]
public async Task Handle_ProcessorThrowsBadRequest_PropagatesException()
{
// Arrange
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream("%PDF"u8.ToArray()) };
_mockPdfProcessor.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>()))
.ThrowsAsync(new BadRequestException("Invalid PDF format"));
// Act & Assert
var ex = await Assert.ThrowsAsync<BadRequestException>(
() => _handler.Handle(query, CancellationToken.None));
ex.Message.Should().Be("Invalid PDF format");
}
[Fact]
public async Task Handle_ProcessorThrowsGenericException_PropagatesException()
{
// Arrange
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream("%PDF"u8.ToArray()) };
_mockPdfProcessor.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>()))
.ThrowsAsync(new Exception("Unexpected processing error"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(() => _handler.Handle(query, CancellationToken.None));
}
}

View File

@@ -0,0 +1,270 @@
using DocumentService.Application.AddAnnotation;
using DocumentService.Application.AddStamp;
using DocumentService.Application.ConvertToPdfA;
using DocumentService.Application.MergePdfs;
using DocumentService.Domain.Models.ValueObjects;
using FluentAssertions;
using FluentValidation;
using FluentValidation.TestHelper;
using Xunit;
namespace DocumentService.Tests.Unit.Application.Validators;
// ─── MergePdfsCommandValidator ────────────────────────────────────────────────
public class MergePdfsCommandValidatorTests
{
private readonly MergePdfsCommandValidator _validator = new();
[Fact]
public void Validate_TwoOrMoreStreams_PassesValidation()
{
var cmd = new MergePdfsCommand
{
PdfStreams = [new MemoryStream(), new MemoryStream()]
};
_validator.TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Validate_SingleStream_FailsValidation()
{
var cmd = new MergePdfsCommand { PdfStreams = [new MemoryStream()] };
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PdfStreams);
}
[Fact]
public void Validate_NullStreams_FailsValidation()
{
var cmd = new MergePdfsCommand { PdfStreams = null! };
var result = _validator.TestValidate(cmd);
result.Errors.Should().NotBeEmpty();
}
}
// ─── AddAnnotationValidator ───────────────────────────────────────────────────
public class AddAnnotationValidatorTests
{
private readonly AddAnnotationValidator _validator = new();
[Fact]
public void Validate_ValidCircleAnnotation_PassesValidation()
{
var cmd = new AddAnnotationCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
Rectangle = (10, 20, 200, 100)
};
_validator.TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Validate_FreeTextWithoutContent_FailsValidation()
{
var cmd = new AddAnnotationCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
AnnotationType = AnnotationType.FreeText,
PageNumber = 1,
Rectangle = (0, 0, 100, 50),
Content = null
};
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Content);
}
[Fact]
public void Validate_TextMarkupWithoutStyle_FailsValidation()
{
var cmd = new AddAnnotationCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
Rectangle = (0, 0, 100, 50),
TextMarkupStyle = null
};
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.TextMarkupStyle);
}
[Fact]
public void Validate_InvalidColorFormat_FailsValidation()
{
var cmd = new AddAnnotationCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
Rectangle = (0, 0, 100, 50),
Color = "GGGGGG" // invalid hex
};
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Color);
}
[Fact]
public void Validate_ValidColorFormat_PassesValidation()
{
var cmd = new AddAnnotationCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
Rectangle = (0, 0, 100, 50),
Color = "FF0000"
};
_validator.TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Validate_ZeroPageNumber_FailsValidation()
{
var cmd = new AddAnnotationCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
AnnotationType = AnnotationType.Circle,
PageNumber = 0,
Rectangle = (0, 0, 100, 50)
};
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PageNumber);
}
[Fact]
public void Validate_InvalidRectangle_FailsValidation()
{
var cmd = new AddAnnotationCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
AnnotationType = AnnotationType.Circle,
PageNumber = 1,
Rectangle = (100, 100, 50, 50) // X2 < X1
};
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Rectangle);
}
}
// ─── AddStampValidator ────────────────────────────────────────────────────────
public class AddStampValidatorTests
{
private readonly AddStampValidator _validator = new();
[Fact]
public void Validate_ValidTextStamp_PassesValidation()
{
var cmd = new AddStampCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
StampType = StampType.Text,
Position = (100, 200),
Text = "DRAFT"
};
_validator.TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
}
[Fact]
public void Validate_TextStampWithoutText_FailsValidation()
{
var cmd = new AddStampCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
StampType = StampType.Text,
Position = (100, 200),
Text = null
};
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Text);
}
[Fact]
public void Validate_ImageStampWithoutBytes_FailsValidation()
{
var cmd = new AddStampCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
StampType = StampType.Image,
Position = (0, 0),
ImageBytes = null
};
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.ImageBytes);
}
[Fact]
public void Validate_PredefinedStampWithoutType_FailsValidation()
{
var cmd = new AddStampCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
StampType = StampType.Predefined,
Position = (0, 0),
PredefinedType = null
};
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PredefinedType);
}
[Fact]
public void Validate_OpacityAboveOne_FailsValidation()
{
var cmd = new AddStampCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
StampType = StampType.Text,
Position = (0, 0),
Text = "TEST",
Opacity = 1.5
};
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Opacity!.Value);
}
[Fact]
public void Validate_InvalidColorHex_FailsValidation()
{
var cmd = new AddStampCommand
{
PdfStream = new MemoryStream("%PDF"u8.ToArray()),
StampType = StampType.Text,
Position = (0, 0),
Text = "TEST",
Color = "XYZ"
};
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.Color!);
}
}
// ─── ConvertToPdfACommandValidator ───────────────────────────────────────────
public class ConvertToPdfAValidatorTests
{
private readonly ConvertToPdfACommandValidator _validator = new();
[Fact]
public void Validate_ValidPdfALevel_PassesValidation()
{
foreach (var level in new[] { "PDF/A-1b", "PDF/A-2b", "PDF/A-3b", "PDF/A-3u" })
{
var cmd = new ConvertToPdfACommand { PdfStream = new MemoryStream(), PdfALevel = level };
_validator.TestValidate(cmd).ShouldNotHaveAnyValidationErrors();
}
}
[Fact]
public void Validate_InvalidPdfALevel_FailsValidation()
{
var cmd = new ConvertToPdfACommand { PdfStream = new MemoryStream(), PdfALevel = "PDF/A-99z" };
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PdfALevel);
}
[Fact]
public void Validate_EmptyPdfALevel_FailsValidation()
{
var cmd = new ConvertToPdfACommand { PdfStream = new MemoryStream(), PdfALevel = "" };
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PdfALevel);
}
[Fact]
public void Validate_NullStream_FailsValidation()
{
var cmd = new ConvertToPdfACommand { PdfStream = null!, PdfALevel = "PDF/A-1b" };
_validator.TestValidate(cmd).ShouldHaveValidationErrorFor(x => x.PdfStream);
}
}

View File

@@ -0,0 +1,56 @@
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Reusable fake <see cref="HttpMessageHandler"/> for unit-testing HTTP clients.
/// Captures the outgoing request and returns the configured response.
/// </summary>
internal sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly HttpResponseMessage _response;
/// <summary>The last request that was sent through this handler.</summary>
public HttpRequestMessage? LastRequest { get; private set; }
public MockHttpMessageHandler(HttpResponseMessage response)
{
_response = response;
}
// ?? convenience factories ????????????????????????????????????????????????
/// <summary>Creates a handler that returns 200 OK with a JSON-serialised body.</summary>
public static MockHttpMessageHandler ReturningJson<T>(T body, HttpStatusCode status = HttpStatusCode.OK)
{
var json = JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var response = new HttpResponseMessage(status)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
return new MockHttpMessageHandler(response);
}
/// <summary>Creates a handler that returns 200 OK with raw bytes as the body.</summary>
public static MockHttpMessageHandler ReturningBytes(byte[] bytes, string mediaType = "application/octet-stream", HttpStatusCode status = HttpStatusCode.OK)
{
var response = new HttpResponseMessage(status)
{
Content = new ByteArrayContent(bytes) { Headers = { ContentType = new(mediaType) } }
};
return new MockHttpMessageHandler(response);
}
/// <summary>Creates a handler that returns the given status code with no body.</summary>
public static MockHttpMessageHandler ReturningStatus(HttpStatusCode status)
=> new(new HttpResponseMessage(status));
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(_response);
}
}

View File

@@ -0,0 +1,221 @@
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using DocumentService.Client.Models.Requests;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Unit tests for <see cref="PdfAttachmentClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made.
/// </summary>
public class PdfAttachmentClientTests
{
// ?? helpers ?????????????????????????????????????????????????????????????
private static (PdfAttachmentClient client, MockHttpMessageHandler handler) BuildJson<T>(T body)
{
var handler = MockHttpMessageHandler.ReturningJson(body);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance);
return (client, handler);
}
private static (PdfAttachmentClient client, MockHttpMessageHandler handler) BuildBytes(byte[] bytes, string mediaType = "application/zip")
{
var handler = MockHttpMessageHandler.ReturningBytes(bytes, mediaType);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance);
return (client, handler);
}
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
/// <summary>Builds a minimal valid ZIP containing the given entries.</summary>
private static byte[] BuildZip(Dictionary<string, string> entries)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var (name, content) in entries)
{
var entry = archive.CreateEntry(name);
using var writer = new StreamWriter(entry.Open());
writer.Write(content);
}
}
return ms.ToArray();
}
// ?? CheckAttachmentsAsync (Stream) ???????????????????????????????????????
[Fact]
public async Task CheckAttachmentsAsync_Stream_SendsMultipartPost()
{
// Arrange
var expected = new AttachmentCheckResult
{
HasAttachments = true,
AttachmentCount = 1,
Attachments = new List<AttachmentMetadata>
{
new() { FileName = "factur-x.xml", MimeType = "application/xml", Size = 512 }
}
};
var (client, handler) = BuildJson(expected);
// Act
var result = await client.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().Be(1);
result.Attachments[0].FileName.Should().Be("factur-x.xml");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/attachments/check");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task CheckAttachmentsAsync_Stream_PdfWithNoAttachments_ReturnsFalse()
{
// Arrange
var expected = new AttachmentCheckResult { HasAttachments = false, AttachmentCount = 0 };
var (client, _) = BuildJson(expected);
// Act
var result = await client.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
result.Attachments.Should().BeEmpty();
}
// ?? CheckAttachmentsAsync (byte[]) ???????????????????????????????????????
[Fact]
public async Task CheckAttachmentsAsync_Bytes_SendsJsonWithBase64()
{
// Arrange
var expected = new AttachmentCheckResult { HasAttachments = false };
var (client, handler) = BuildJson(expected);
// Act
await client.CheckAttachmentsAsync(FakePdfBytes());
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
}
// ?? ExtractAttachmentsAsync (Stream) — ZIP unzip ?????????????????????????
[Fact]
public async Task ExtractAttachmentsAsync_Stream_UnzipsAndReturnsDictionary()
{
// Arrange
var zipBytes = BuildZip(new Dictionary<string, string>
{
["factur-x.xml"] = "<invoice>test</invoice>",
["readme.txt"] = "Hello World"
});
var (client, handler) = BuildBytes(zipBytes);
// Act
var result = await client.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.Should().HaveCount(2);
result.Should().ContainKey("factur-x.xml");
result.Should().ContainKey("readme.txt");
using var xmlStream = result["factur-x.xml"];
var xmlContent = await new StreamReader(xmlStream).ReadToEndAsync();
xmlContent.Should().Be("<invoice>test</invoice>");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/attachments/extract");
// Cleanup
foreach (var s in result.Values) s.Dispose();
}
[Fact]
public async Task ExtractAttachmentsAsync_Stream_WithSingleEntry_ReturnsOneItem()
{
// Arrange
var zipBytes = BuildZip(new Dictionary<string, string>
{
["data.xml"] = "<root/>"
});
var (client, _) = BuildBytes(zipBytes);
// Act
var result = await client.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.Should().HaveCount(1);
result.Should().ContainKey("data.xml");
foreach (var s in result.Values) s.Dispose();
}
// ?? ExtractAttachmentsAsync (byte[]) — ZIP unzip ?????????????????????????
[Fact]
public async Task ExtractAttachmentsAsync_Bytes_SendsJsonAndUnzips()
{
// Arrange
var zipBytes = BuildZip(new Dictionary<string, string>
{
["invoice.xml"] = "<invoice/>"
});
var (client, handler) = BuildBytes(zipBytes);
// Act
var result = await client.ExtractAttachmentsAsync(FakePdfBytes());
// Assert
result.Should().ContainKey("invoice.xml");
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
foreach (var s in result.Values) s.Dispose();
}
// ?? HTTP error propagation ???????????????????????????????????????????????
[Fact]
public async Task CheckAttachmentsAsync_WhenApiReturns404_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.NotFound);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance);
// Act & Assert
await client.Invoking(c => c.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task ExtractAttachmentsAsync_WhenApiReturns500_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance);
// Act & Assert
await client.Invoking(c => c.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -0,0 +1,236 @@
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using DocumentService.Client.Models.Requests;
using DocumentService.Client.Models.ValueObjects;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Unit tests for <see cref="PdfOperationsClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made.
/// </summary>
public class PdfOperationsClientTests
{
// ?? helpers ?????????????????????????????????????????????????????????????
private static (PdfOperationsClient client, MockHttpMessageHandler handler) BuildBytes(byte[] bytes = null!)
{
var handler = MockHttpMessageHandler.ReturningBytes(bytes ?? "merged-pdf"u8.ToArray(), "application/pdf");
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfOperationsClient(httpClient, NullLogger<PdfOperationsClient>.Instance);
return (client, handler);
}
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
private static AddAnnotationBase64Request FakeAnnotationRequest() => new()
{
Base64Pdf = string.Empty,
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
X1 = 10, Y1 = 20, Width = 100, Height = 30,
Color = "FFFF00",
TextMarkupStyle = TextMarkupStyle.Highlight,
Origin = AnnotationOrigin.TopLeft
};
private static AddStampBase64Request FakeStampRequest() => new()
{
Base64Pdf = string.Empty,
StampType = StampType.Text,
X = 100, Y = 50,
Text = "CONFIDENTIAL",
FontSize = 24,
Color = "FF0000",
Opacity = 0.5,
Placement = StampPlacement.Foreground,
Origin = AnnotationOrigin.BottomLeft
};
// ?? MergeAsync (Streams) ?????????????????????????????????????????????????
[Fact]
public async Task MergeAsync_Streams_SendsMultipartWithAllFiles()
{
// Arrange
var (client, handler) = BuildBytes();
var streams = new List<Stream>
{
new MemoryStream(FakePdfBytes()),
new MemoryStream(FakePdfBytes())
};
// Act
using var result = await client.MergeAsync(streams);
// Assert
result.Should().NotBeNull();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/operations/merge");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
foreach (var s in streams) s.Dispose();
}
[Fact]
public async Task MergeAsync_Streams_ReturnsMergedPdfStream()
{
// Arrange
var expectedBytes = "merged-content"u8.ToArray();
var (client, _) = BuildBytes(expectedBytes);
// Act
using var result = await client.MergeAsync(new[] { new MemoryStream(FakePdfBytes()), new MemoryStream(FakePdfBytes()) });
var actualBytes = await result.ReadAllBytesAsync();
// Assert
actualBytes.Should().BeEquivalentTo(expectedBytes);
}
// ?? MergeAsync (byte[][]) ????????????????????????????????????????????????
[Fact]
public async Task MergeAsync_ByteArrays_SendsJsonWithBase64List()
{
// Arrange
var (client, handler) = BuildBytes();
// Act
using var result = await client.MergeAsync(new[] { FakePdfBytes(), FakePdfBytes() });
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdfs").GetArrayLength().Should().Be(2);
}
[Fact]
public async Task MergeAsync_ByteArrays_WithPageRanges_IncludesPageRangesInJson()
{
// Arrange
var (client, handler) = BuildBytes();
var pageRanges = new List<string?> { "1-2", null };
// Act
await client.MergeAsync(new[] { FakePdfBytes(), FakePdfBytes() }, pageRanges);
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
var body = await handler.LastRequest!.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("pageRanges").GetArrayLength().Should().Be(2);
}
// ?? AnnotateAsync (Stream) ????????????????????????????????????????????????
[Fact]
public async Task AnnotateAsync_Stream_SendsMultipartWithAnnotationFields()
{
// Arrange
var (client, handler) = BuildBytes();
var request = FakeAnnotationRequest();
// Act
using var result = await client.AnnotateAsync(new MemoryStream(FakePdfBytes()), request);
// Assert
result.Should().NotBeNull();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/operations/annotate");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
// ?? AnnotateAsync (byte[]) ????????????????????????????????????????????????
[Fact]
public async Task AnnotateAsync_Bytes_SendsJsonWithBase64Pdf()
{
// Arrange
var (client, handler) = BuildBytes();
var request = FakeAnnotationRequest();
// Act
await client.AnnotateAsync(FakePdfBytes(), request);
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
// annotationType serializes as integer by default (TextMarkup = 0)
doc.RootElement.GetProperty("annotationType").GetInt32().Should().Be((int)AnnotationType.TextMarkup);
}
// ?? StampAsync (Stream) ???????????????????????????????????????????????????
[Fact]
public async Task StampAsync_Stream_SendsMultipartWithStampFields()
{
// Arrange
var (client, handler) = BuildBytes();
var request = FakeStampRequest();
// Act
using var result = await client.StampAsync(new MemoryStream(FakePdfBytes()), request);
// Assert
result.Should().NotBeNull();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/operations/stamp");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
// ?? StampAsync (byte[]) ???????????????????????????????????????????????????
[Fact]
public async Task StampAsync_Bytes_SendsJsonWithBase64Pdf()
{
// Arrange
var (client, handler) = BuildBytes();
var request = FakeStampRequest();
// Act
await client.StampAsync(FakePdfBytes(), request);
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
// stampType serializes as integer by default (Text = 0)
doc.RootElement.GetProperty("stampType").GetInt32().Should().Be((int)StampType.Text);
}
// ?? HTTP error propagation ???????????????????????????????????????????????
[Fact]
public async Task MergeAsync_WhenApiReturns400_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.BadRequest);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfOperationsClient(httpClient, NullLogger<PdfOperationsClient>.Instance);
// Act & Assert
await client.Invoking(c => c.MergeAsync(new[] { new MemoryStream(FakePdfBytes()), new MemoryStream(FakePdfBytes()) }))
.Should().ThrowAsync<HttpRequestException>();
}
}
// ?? local helper extension ???????????????????????????????????????????????????
file static class StreamHelper
{
public static async Task<byte[]> ReadAllBytesAsync(this Stream stream)
{
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
return ms.ToArray();
}
}

View File

@@ -0,0 +1,183 @@
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using Xunit;
namespace DocumentService.Tests.Unit.Client;
public class PdfValidationClientTests
{
private static (PdfValidationClient client, MockHttpMessageHandler handler) Build<T>(T responseBody)
{
var handler = MockHttpMessageHandler.ReturningJson(responseBody);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
return (new PdfValidationClient(httpClient, NullLogger<PdfValidationClient>.Instance), handler);
}
private static (PdfValidationClient client, MockHttpMessageHandler handler) BuildError(HttpStatusCode code)
{
var handler = MockHttpMessageHandler.ReturningStatus(code);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
return (new PdfValidationClient(httpClient, NullLogger<PdfValidationClient>.Instance), handler);
}
private static byte[] FakePdfBytes() => "fake-pdf"u8.ToArray();
// ── ValidatePdfAsync (Stream) ────────────────────────────────────────────
[Fact]
public async Task ValidatePdfAsync_Stream_SendsMultipartPost()
{
var expected = new PdfValidationResult { PageCount = 3, PdfVersion = "1.7", FileSizeBytes = 2048, IsEncrypted = false };
var (client, handler) = Build(expected);
var result = await client.ValidatePdfAsync(new MemoryStream(FakePdfBytes()));
result.PageCount.Should().Be(3);
result.PdfVersion.Should().Be("1.7");
result.FileSizeBytes.Should().Be(2048);
result.IsEncrypted.Should().BeFalse();
handler.LastRequest!.Method.Should().Be(HttpMethod.Post);
handler.LastRequest.RequestUri!.PathAndQuery.Should().Be("/api/pdf/validation/validate");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task ValidatePdfAsync_Stream_EncryptedPdf_DeserializesIsEncryptedTrue()
{
var expected = new PdfValidationResult { PageCount = 1, IsEncrypted = true, PdfVersion = "1.6" };
var (client, _) = Build(expected);
var result = await client.ValidatePdfAsync(new MemoryStream(FakePdfBytes()));
result.IsEncrypted.Should().BeTrue("encrypted PDFs should surface IsEncrypted=true");
}
[Fact]
public async Task ValidatePdfAsync_Stream_ThrowsWhenApiReturnsNull()
{
var handler = MockHttpMessageHandler.ReturningJson<PdfValidationResult?>(null);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfValidationClient(httpClient, NullLogger<PdfValidationClient>.Instance);
await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task ValidatePdfAsync_Stream_WhenApiReturns400_ThrowsHttpRequestException()
{
var (client, _) = BuildError(HttpStatusCode.BadRequest);
await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task ValidatePdfAsync_Stream_WhenApiReturns500_ThrowsHttpRequestException()
{
var (client, _) = BuildError(HttpStatusCode.InternalServerError);
await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
// ── ValidatePdfAsync (byte[]) ────────────────────────────────────────────
[Fact]
public async Task ValidatePdfAsync_Bytes_SendsJsonWithBase64()
{
var expected = new PdfValidationResult { PageCount = 1, IsEncrypted = false, HasAttachments = true, AttachmentCount = 2 };
var (client, handler) = Build(expected);
var result = await client.ValidatePdfAsync(FakePdfBytes());
result.PageCount.Should().Be(1);
result.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().Be(2);
handler.LastRequest!.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
}
[Fact]
public async Task ValidatePdfAsync_Bytes_WhenApiReturns400_ThrowsHttpRequestException()
{
var (client, _) = BuildError(HttpStatusCode.BadRequest);
await client.Invoking(c => c.ValidatePdfAsync(FakePdfBytes()))
.Should().ThrowAsync<HttpRequestException>();
}
// ── ValidatePdfAAsync (Stream) ───────────────────────────────────────────
[Fact]
public async Task ValidatePdfAAsync_Stream_DeserializesAllFields()
{
var expected = new PdfAValidationResult
{
IsValid = true, PdfAVersion = "PDF/A-3b", PageCount = 2,
Errors = [], Warnings = []
};
var (client, handler) = Build(expected);
var result = await client.ValidatePdfAAsync(new MemoryStream(FakePdfBytes()));
result.IsValid.Should().BeTrue();
result.PdfAVersion.Should().Be("PDF/A-3b");
result.PageCount.Should().Be(2);
result.Errors.Should().BeEmpty();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/validation/validate-pdfa");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task ValidatePdfAAsync_Stream_NonCompliant_ReturnsErrors()
{
var expected = new PdfAValidationResult
{
IsValid = false,
Errors = ["Missing embedded font", "Encryption not allowed"],
Warnings = ["Manual verification recommended"]
};
var (client, _) = Build(expected);
var result = await client.ValidatePdfAAsync(new MemoryStream(FakePdfBytes()));
result.IsValid.Should().BeFalse();
result.Errors.Should().HaveCount(2).And.Contain("Missing embedded font");
result.Warnings.Should().HaveCount(1);
}
[Fact]
public async Task ValidatePdfAAsync_Stream_WhenApiReturns400_ThrowsHttpRequestException()
{
var (client, _) = BuildError(HttpStatusCode.BadRequest);
await client.Invoking(c => c.ValidatePdfAAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
// ── ValidatePdfAAsync (byte[]) ───────────────────────────────────────────
[Fact]
public async Task ValidatePdfAAsync_Bytes_SendsJsonToCorrectEndpoint()
{
var expected = new PdfAValidationResult { IsValid = true };
var (client, handler) = Build(expected);
await client.ValidatePdfAAsync(FakePdfBytes());
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/validation/validate-pdfa");
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
}
[Fact]
public async Task ValidatePdfAAsync_Bytes_WhenApiReturns500_ThrowsHttpRequestException()
{
var (client, _) = BuildError(HttpStatusCode.InternalServerError);
await client.Invoking(c => c.ValidatePdfAAsync(FakePdfBytes()))
.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -0,0 +1,123 @@
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Unit tests for <see cref="SwissQrCodeClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made.
/// </summary>
public class SwissQrCodeClientTests
{
// ?? helpers ?????????????????????????????????????????????????????????????
private static (SwissQrCodeClient client, MockHttpMessageHandler handler) Build<T>(T body)
{
var handler = MockHttpMessageHandler.ReturningJson(body);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new SwissQrCodeClient(httpClient, NullLogger<SwissQrCodeClient>.Instance);
return (client, handler);
}
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
// ?? ExtractSwissQrCodeAsync (Stream) — parsed Bill ???????????????????????
[Fact]
public async Task ExtractSwissQrCodeAsync_Stream_ParsedMode_SendsMultipartToCorrectEndpoint()
{
// Arrange
var expected = new SwissQrCodeExtractionResult { Bill = new { Iban = "CH93-0076-2011-6238-5295-7" } };
var (client, handler) = Build(expected);
// Act
var result = await client.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes()), raw: false);
// Assert
result.Should().NotBeNull();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=False");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task ExtractSwissQrCodeAsync_Stream_RawMode_SendsRawFlagInUrl()
{
// Arrange
var expected = new SwissQrCodeExtractionResult { RawLines = new List<string> { "SPC", "0200", "1" } };
var (client, handler) = Build(expected);
// Act
var result = await client.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes()), raw: true);
// Assert
result.RawLines.Should().HaveCount(3).And.StartWith("SPC");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=True");
}
// ?? ExtractSwissQrCodeAsync (byte[]) ?????????????????????????????????????
[Fact]
public async Task ExtractSwissQrCodeAsync_Bytes_SendsJsonWithBase64()
{
// Arrange
var expected = new SwissQrCodeExtractionResult();
var (client, handler) = Build(expected);
// Act
await client.ExtractSwissQrCodeAsync(FakePdfBytes(), raw: false);
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
}
[Fact]
public async Task ExtractSwissQrCodeAsync_Bytes_RawMode_IncludesRawFlagInUrl()
{
// Arrange
var expected = new SwissQrCodeExtractionResult { RawLines = new List<string> { "SPC" } };
var (client, handler) = Build(expected);
// Act
await client.ExtractSwissQrCodeAsync(FakePdfBytes(), raw: true);
// Assert
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=True");
}
// ?? HTTP error propagation ???????????????????????????????????????????????
[Fact]
public async Task ExtractSwissQrCodeAsync_WhenApiReturns404_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.NotFound);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new SwissQrCodeClient(httpClient, NullLogger<SwissQrCodeClient>.Instance);
// Act & Assert
await client.Invoking(c => c.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task ExtractSwissQrCodeAsync_WhenApiReturns500_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new SwissQrCodeClient(httpClient, NullLogger<SwissQrCodeClient>.Instance);
// Act & Assert
await client.Invoking(c => c.ExtractSwissQrCodeAsync(FakePdfBytes()))
.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -0,0 +1,199 @@
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Unit tests for <see cref="ZugferdClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made.
/// </summary>
public class ZugferdClientTests
{
// ?? helpers ?????????????????????????????????????????????????????????????
private static (ZugferdClient client, MockHttpMessageHandler handler) BuildJson<T>(T body)
{
var handler = MockHttpMessageHandler.ReturningJson(body);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new ZugferdClient(httpClient, NullLogger<ZugferdClient>.Instance);
return (client, handler);
}
private static (ZugferdClient client, MockHttpMessageHandler handler) BuildBytes(byte[] bytes, string mediaType = "application/xml")
{
var handler = MockHttpMessageHandler.ReturningBytes(bytes, mediaType);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new ZugferdClient(httpClient, NullLogger<ZugferdClient>.Instance);
return (client, handler);
}
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
private static byte[] FakeXmlBytes() => "<invoice>test</invoice>"u8.ToArray();
// ?? HasZugferdAsync (Stream) ?????????????????????????????????????????????
[Fact]
public async Task HasZugferdAsync_Stream_WhenZugferdPresent_ReturnsTrue()
{
// Arrange
var expected = new ZugferdCheckResult { HasZugferd = true, Version = "2.1", Profile = "EN 16931" };
var (client, handler) = BuildJson(expected);
// Act
var result = await client.HasZugferdAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.HasZugferd.Should().BeTrue();
result.Version.Should().Be("2.1");
result.Profile.Should().Be("EN 16931");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/has-zugferd");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task HasZugferdAsync_Stream_WhenNoZugferd_ReturnsFalse()
{
// Arrange
var expected = new ZugferdCheckResult { HasZugferd = false };
var (client, _) = BuildJson(expected);
// Act
var result = await client.HasZugferdAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.HasZugferd.Should().BeFalse();
result.Version.Should().BeNull();
}
// ?? HasZugferdAsync (byte[]) ?????????????????????????????????????????????
[Fact]
public async Task HasZugferdAsync_Bytes_SendsJsonWithBase64()
{
// Arrange
var expected = new ZugferdCheckResult { HasZugferd = true };
var (client, handler) = BuildJson(expected);
// Act
await client.HasZugferdAsync(FakePdfBytes());
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
}
// ?? ExtractZugferdAsync (Stream) — raw XML stream ?????????????????????????
[Fact]
public async Task ExtractZugferdAsync_Stream_SendsMultipartWithAsFileTrue()
{
// Arrange
var (client, handler) = BuildBytes(FakeXmlBytes());
// Act
using var result = await client.ExtractZugferdAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.Should().NotBeNull();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?asFile=true");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task ExtractZugferdAsync_Stream_ReturnsXmlContent()
{
// Arrange
var xmlBytes = "<root><invoice/></root>"u8.ToArray();
var (client, _) = BuildBytes(xmlBytes);
// Act
using var result = await client.ExtractZugferdAsync(new MemoryStream(FakePdfBytes()));
var content = await new StreamReader(result).ReadToEndAsync();
// Assert
content.Should().Be("<root><invoice/></root>");
}
// ?? ExtractZugferdAsync (byte[]) ?????????????????????????????????????????
[Fact]
public async Task ExtractZugferdAsync_Bytes_SendsJsonWithFormatFile()
{
// Arrange
var (client, handler) = BuildBytes(FakeXmlBytes());
// Act
using var result = await client.ExtractZugferdAsync(FakePdfBytes());
// Assert
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?format=file");
handler.LastRequest.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
}
// ?? ExtractZugferdAsResultAsync (Stream) ?????????????????????????????????
[Fact]
public async Task ExtractZugferdAsResultAsync_Stream_ReturnsStructuredResult()
{
// Arrange
var expected = new ZugferdExtractionResult
{
FileName = "factur-x.xml",
XmlContent = "<invoice/>",
Version = "2.1",
Profile = "EN 16931"
};
var (client, handler) = BuildJson(expected);
// Act
var result = await client.ExtractZugferdAsResultAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.FileName.Should().Be("factur-x.xml");
result.XmlContent.Should().Be("<invoice/>");
result.Version.Should().Be("2.1");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?asFile=false");
}
// ?? ExtractZugferdAsResultAsync (byte[]) ?????????????????????????????????
[Fact]
public async Task ExtractZugferdAsResultAsync_Bytes_SendsJsonWithFormatJson()
{
// Arrange
var expected = new ZugferdExtractionResult { FileName = "zugferd.xml", XmlContent = "<x/>" };
var (client, handler) = BuildJson(expected);
// Act
await client.ExtractZugferdAsResultAsync(FakePdfBytes());
// Assert
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?format=json");
handler.LastRequest.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
}
// ?? HTTP error propagation ???????????????????????????????????????????????
[Fact]
public async Task HasZugferdAsync_WhenApiReturns400_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.BadRequest);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new ZugferdClient(httpClient, NullLogger<ZugferdClient>.Instance);
// Act & Assert
await client.Invoking(c => c.HasZugferdAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -0,0 +1,905 @@
using System.Reflection;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
using DocumentService.Infrastructure.Services.PdfProcessing;
using FluentAssertions;
namespace DocumentService.Tests.Unit.Infrastructure.Services.PdfProcessing;
/// <summary>
/// Unit tests for DevExpressPdfProcessor.
/// Tests PDF validation and metadata extraction.
/// </summary>
public class DevExpressPdfProcessorTests
{
private readonly IPdfProcessor _sut; // SUT = System Under Test
public DevExpressPdfProcessorTests()
{
// Arrange: Create instance (wird später implementiert)
_sut = new DevExpressPdfProcessor();
}
#region Helper Methods
/// <summary>
/// Loads a test PDF from embedded resources.
/// </summary>
/// <param name="filename">Name of the PDF file (e.g., "valid.pdf")</param>
/// <returns>PDF content as byte array</returns>
private static byte[] LoadTestPdf(string filename)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{filename}";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
throw new FileNotFoundException(
$"Embedded resource '{resourceName}' not found. " +
$"Available resources: {string.Join(", ", assembly.GetManifestResourceNames())}");
}
using var memoryStream = new MemoryStream();
stream.CopyTo(memoryStream);
return memoryStream.ToArray();
}
/// <summary>
/// Loads a test PDF from embedded resources as Stream.
/// </summary>
/// <param name="filename">Name of the PDF file (e.g., "valid.pdf")</param>
/// <returns>PDF content as MemoryStream (caller must dispose)</returns>
private static MemoryStream LoadTestPdfAsStream(string filename)
{
byte[] pdfBytes = LoadTestPdf(filename);
return new MemoryStream(pdfBytes);
}
/// <summary>
/// Converts byte array to MemoryStream for testing
/// </summary>
private static MemoryStream ToStream(byte[] bytes) => new MemoryStream(bytes);
#endregion
#region ValidateAsync Tests
[Fact]
public async Task ValidateAsync_ValidPdf_ReturnsPdfValidationResult()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("valid.pdf");
// Act
var result = await _sut.ValidateAsync(ToStream(pdfBytes));
// Assert
result.Should().NotBeNull("a valid PDF should return a result");
result.PageCount.Should().BeGreaterThan(0, "PDF must have at least one page");
result.FileSizeBytes.Should().Be(pdfBytes.Length, "file size must match input");
result.PdfVersion.Should().NotBeNullOrEmpty("PDF version should be detected");
result.FileSizeMB.Should().BeApproximately(pdfBytes.Length / 1024.0 / 1024.0, 0.01, "FileSizeMB should be derived from FileSizeBytes");
}
[Fact]
public async Task ValidateAsync_ValidPdf_ReturnsCorrectPageCount()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("valid.pdf");
// Act
var result = await _sut.ValidateAsync(ToStream(pdfBytes));
// Assert
result.PageCount.Should().BeInRange(1, 100, "test PDF should have reasonable page count");
}
[Fact]
public async Task ValidateAsync_NullStream_ThrowsArgumentNullException()
{
// Arrange
Stream? pdfStream = null;
// Act
Func<Task> act = async () => await _sut.ValidateAsync(pdfStream!);
// Assert
await act.Should().ThrowAsync<ArgumentNullException>()
.WithMessage("*pdfStream*", "null input should be rejected");
}
[Fact]
public async Task ValidateAsync_EmptyStream_ThrowsBadRequestException()
{
// Arrange
var pdfStream = new MemoryStream();
// Act
Func<Task> act = async () => await _sut.ValidateAsync(pdfStream);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*empty*", "empty input should be rejected");
}
[Fact]
public async Task ValidateAsync_CorruptedPdf_ThrowsPdfProcessingException()
{
// Arrange
byte[] pdfBytes = "This is not a valid PDF content"u8.ToArray();
// Act
Func<Task> act = async () => await _sut.ValidateAsync(ToStream(pdfBytes));
// Assert
await act.Should().ThrowAsync<Exception>()
.WithMessage("*valid pdf*", "corrupted PDF should throw exception");
}
#endregion
#region Metadata Tests
[Fact]
public async Task ValidateAsync_ValidPdf_FileSizeMBCalculatedCorrectly()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("valid.pdf");
// Act
var result = await _sut.ValidateAsync(ToStream(pdfBytes));
// Assert
double expectedSizeMB = pdfBytes.Length / 1024.0 / 1024.0;
result.FileSizeMB.Should().BeApproximately(expectedSizeMB, 0.01,
"FileSizeMB should be calculated correctly from bytes");
}
#endregion
#region Encryption Detection Tests
[Fact]
public async Task ValidateAsync_NormalPdf_IsEncryptedFalse()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("valid.pdf");
// Act
var result = await _sut.ValidateAsync(ToStream(pdfBytes));
// Assert
result.IsEncrypted.Should().BeFalse("valid.pdf is not password-protected");
}
[Fact]
public async Task ValidatePdfAAsync_NormalPdf_EncryptedFalse()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("valid.pdf");
// Act
var result = await _sut.ValidatePdfAAsync(ToStream(pdfBytes));
// Assert
result.Encrypted.Should().BeFalse("valid.pdf is not password-protected");
}
[Fact]
public async Task DetectEncryption_TrailerWithEncryptEntry_ReturnsTrueViaRawBytes()
{
// Validates the encryption detection logic directly by crafting PDF bytes
// that contain /Encrypt in the trailer section (last 2KB).
// The byte-level detection must identify this as encrypted.
// Build a minimal fake PDF where the trailer contains /Encrypt
// (we are not loading it with DevExpress — just testing the byte scanner)
var trailerBytes = System.Text.Encoding.Latin1.GetBytes(
"\ntrailer\n<< /Size 5 /Root 1 0 R /Encrypt 4 0 R >>\nstartxref\n370\n%%EOF\n");
// Pad with enough bytes so the trailer is within last 2KB
var padding = new byte[100];
Array.Fill(padding, (byte)0x20);
var pdfBytes = padding.Concat(trailerBytes).ToArray();
// Access private method via reflection to test it in isolation
var processorType = typeof(DocumentService.Infrastructure.Services.PdfProcessing.DevExpressPdfProcessor);
var detectMethod = processorType.GetMethod("DetectEncryption",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
detectMethod.Should().NotBeNull("DetectEncryption method should exist");
var detected = (bool)detectMethod!.Invoke(null, [pdfBytes])!;
detected.Should().BeTrue("trailer with /Encrypt 4 0 R should be detected as encrypted");
}
[Fact]
public async Task DetectEncryption_NoEncryptEntry_ReturnsFalseViaRawBytes()
{
// A PDF without /Encrypt in trailer should return false
var pdfBytes = System.Text.Encoding.Latin1.GetBytes(
"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\nxref\n0 2\ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n10\n%%EOF\n");
var processorType = typeof(DocumentService.Infrastructure.Services.PdfProcessing.DevExpressPdfProcessor);
var detectMethod = processorType.GetMethod("DetectEncryption",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
var detected = (bool)detectMethod!.Invoke(null, [pdfBytes])!;
detected.Should().BeFalse("PDF without /Encrypt entry should not be detected as encrypted");
}
[Fact]
public async Task DetectEncryption_EncryptedKeywordNotAsStandaloneToken_ReturnsFalse()
{
// "/Encrypt" is NOT standalone if the next byte is a letter (e.g. "/Encryption", "/EncryptedData").
// Strategy 3 (full-file) checks token terminators.
// To isolate Strategy 3, we put the keyword well inside the file
// (not in first or last 2KB) so Strategies 1 and 2 don't fire.
// Build: 3KB prefix + "/Encryption more" + 3KB suffix + minimal trailer (no /Encrypt)
var prefix = new string('A', 3000);
var fakeContent = " /Encryption some data "; // /Encrypt followed by 'i' — not a terminator
var suffix = new string('B', 3000);
var normalTrailer = "\ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n10\n%%EOF\n";
var pdfBytes = System.Text.Encoding.Latin1.GetBytes(prefix + fakeContent + suffix + normalTrailer);
var processorType = typeof(DocumentService.Infrastructure.Services.PdfProcessing.DevExpressPdfProcessor);
var detectMethod = processorType.GetMethod("DetectEncryption",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
var detected = (bool)detectMethod!.Invoke(null, [pdfBytes])!;
// /Encryption has 'i' after /Encrypt — 'i' is not a PDF token terminator → should NOT match
detected.Should().BeFalse("'/Encryption' (with letter suffix) should not match the '/Encrypt ' token");
}
#endregion
#region Attachment Detection Tests
[Fact]
public async Task ValidateAsync_ValidPdf_DetectsAttachmentsCorrectly()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("valid.pdf");
// Act
var result = await _sut.ValidateAsync(ToStream(pdfBytes));
// Assert
result.Should().NotBeNull();
}
[Fact]
public async Task ValidateAsync_PdfWithMultipleAttachments_ReturnsCorrectCount()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithMoreThanOneAttachment.pdf");
// Act
var result = await _sut.ValidateAsync(ToStream(pdfBytes));
// Assert
result.HasAttachments.Should().BeTrue("PDF has multiple attachments");
result.AttachmentCount.Should().BeGreaterThan(1, "PDF has more than 1 attachment");
result.IsEncrypted.Should().BeFalse("this PDF is not encrypted");
}
#endregion
#region CheckAttachmentsAsync Tests
[Fact]
public async Task CheckAttachmentsAsync_PdfWithoutAttachments_ReturnsEmptyList()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var attachmentInfo = await _sut.CheckAttachmentsAsync(ToStream(pdfBytes));
// Assert
attachmentInfo.Should().NotBeNull("CheckAttachmentsAsync should return non-null result");
attachmentInfo.HasAttachments.Should().BeFalse("Swiss QR PDF has no attachments");
attachmentInfo.AttachmentCount.Should().Be(0, "Swiss QR PDF has no attachments");
attachmentInfo.Attachments.Should().BeEmpty("Swiss QR PDF has no attachments");
}
[Fact]
public async Task CheckAttachmentsAsync_PdfWithMultipleAttachments_ReturnsCorrectMetadata()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithMoreThanOneAttachment.pdf");
// Act
var attachmentInfo = await _sut.CheckAttachmentsAsync(ToStream(pdfBytes));
// Assert
attachmentInfo.Should().NotBeNull("CheckAttachmentAsync should return non-null result");
attachmentInfo.HasAttachments.Should().BeTrue("PDF has 6 attachments");
attachmentInfo.AttachmentCount.Should().Be(6, "PDF has exactly 6 attachments");
attachmentInfo.Attachments.Should().HaveCount(6, "Attachments list should contain 6 items");
// Verify each attachment has required properties
foreach (var attachment in attachmentInfo.Attachments)
{
attachment.FileName.Should().NotBeNullOrEmpty("each attachment must have a file name");
attachment.MimeType.Should().NotBeNullOrEmpty("each attachment must have a MIME type");
attachment.SizeBytes.Should().BeGreaterThan(0, "each attachment must have a size > 0");
}
}
[Fact]
public async Task CheckAttachmentsAsync_ValidPdf_HasAttachments()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("valid.pdf");
// Act
var attachmentInfo = await _sut.CheckAttachmentsAsync(ToStream(pdfBytes));
// Assert
attachmentInfo.Should().NotBeNull("CheckAttachmentsAsync should return non-null result");
// Note: valid.pdf contains embedded files (tested and confirmed)
attachmentInfo.HasAttachments.Should().BeTrue("valid.pdf contains attachments");
attachmentInfo.AttachmentCount.Should().BeGreaterThan(0, "valid.pdf has at least one attachment");
}
[Fact]
public async Task CheckAttachmentsAsync_EmptyBytes_ThrowsBadRequestException()
{
// Arrange
var pdfStream = new MemoryStream();
// Act
Func<Task> act = async () => await _sut.CheckAttachmentsAsync(pdfStream);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*empty*", "empty input should be rejected");
}
[Fact]
public async Task CheckAttachmentsAsync_CorruptedPdf_ThrowsException()
{
// Arrange
byte[] pdfBytes = "This is not a valid PDF content"u8.ToArray();
// Act
Func<Task> act = async () => await _sut.CheckAttachmentsAsync(ToStream(pdfBytes));
// Assert
// DevExpress throws ArgumentException for invalid PDF data
await act.Should().ThrowAsync<Exception>("corrupted PDF should throw exception");
}
#endregion
#region ExtractAttachmentsAsync Tests
[Fact]
public async Task ExtractAttachmentsAsync_PdfWithAttachments_ReturnsValidZip()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithMoreThanOneAttachment.pdf");
// Act
byte[] zipBytes = await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
// Assert
zipBytes.Should().NotBeEmpty("ZIP should contain data");
// Verify ZIP header (PK signature)
zipBytes[0].Should().Be(0x50, "ZIP magic bytes start with 'P'");
zipBytes[1].Should().Be(0x4B, "ZIP magic bytes continue with 'K'");
// Verify ZIP can be opened
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
}
[Fact(Skip = "valid.pdf may contain attachments - need dedicated PDF without attachments for this test")]
public async Task ExtractAttachmentsAsync_PdfWithoutAttachments_ThrowsNotFoundException()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("valid.pdf"); // No attachments
// Act
Func<Task> act = async () => await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
// Assert
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("*attachments*", "PDF without attachments should throw NotFoundException");
}
[Fact]
public async Task ExtractAttachmentsAsync_ExtractedFilesHaveCorrectNames()
{
// Arrange
byte[] pdfBytes = LoadTestPdf("pdfWithMoreThanOneAttachment.pdf");
// Act
byte[] zipBytes = await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
// Assert
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
// Verify all entries have valid names (not null/empty)
foreach (var entry in zipArchive.Entries)
{
entry.Name.Should().NotBeNullOrEmpty("each file should have a name");
}
}
[Fact]
public async Task ExtractAttachmentsAsync_EmptyStream_ThrowsBadRequestException()
{
// Arrange
using var emptyStream = new MemoryStream();
// Act
Func<Task> act = async () => await _sut.ExtractAttachmentsAsync(emptyStream);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*empty*", "empty stream should be rejected");
}
[Fact]
public async Task ExtractAttachmentsAsync_CorruptedPdf_ThrowsException()
{
// Arrange
byte[] pdfBytes = "This is not a valid PDF content"u8.ToArray();
// Act
Func<Task> act = async () => await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
// Assert
// DevExpress throws exception for invalid PDF data
await act.Should().ThrowAsync<Exception>("corrupted PDF should throw exception");
}
#endregion
#region MergePdfsAsync Tests
[Fact]
public async Task MergePdfsAsync_TwoPdfs_ReturnsMergedPdf()
{
// Arrange
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
var streams = new List<Stream> { pdf1, pdf2 };
// Act
byte[] mergedPdf = await _sut.MergePdfsAsync(streams);
// Assert
mergedPdf.Should().NotBeEmpty("merged PDF should contain data");
mergedPdf.Length.Should().BeGreaterThan(1000, "merged PDF should be reasonably sized");
// Verify it's a valid PDF by loading it
using var verifyStream = new MemoryStream(mergedPdf);
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
verifyProcessor.LoadDocument(verifyStream);
verifyProcessor.Document.Pages.Count.Should().BeGreaterOrEqualTo(2, "merged PDF should have at least 2 pages");
}
[Fact]
public async Task MergePdfsAsync_ThreePdfs_ReturnsMergedPdf()
{
// Arrange
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
using var pdf3 = LoadTestPdfAsStream("pdfWithMoreThanOneAttachment.pdf");
var streams = new List<Stream> { pdf1, pdf2, pdf3 };
// Act
byte[] mergedPdf = await _sut.MergePdfsAsync(streams);
// Assert
mergedPdf.Should().NotBeEmpty();
// Verify merged PDF
using var verifyStream = new MemoryStream(mergedPdf);
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
verifyProcessor.LoadDocument(verifyStream);
verifyProcessor.Document.Pages.Count.Should().BeGreaterOrEqualTo(3, "merged PDF should have at least 3 pages");
}
[Fact]
public async Task MergePdfsAsync_WithPageRanges_MergesSelectedPages()
{
// Arrange: Merge first page of each PDF
using var pdf1 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf"); // Multi-page
using var pdf2 = LoadTestPdfAsStream("pdfWithMoreThanOneAttachment.pdf"); // Multi-page
var streams = new List<Stream> { pdf1, pdf2 };
var pageRanges = new List<string?> { "1", "1" }; // Only first page from each
// Act
byte[] mergedPdf = await _sut.MergePdfsAsync(streams, pageRanges);
// Assert
mergedPdf.Should().NotBeEmpty();
// Verify merged PDF has exactly 2 pages
using var verifyStream = new MemoryStream(mergedPdf);
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
verifyProcessor.LoadDocument(verifyStream);
verifyProcessor.Document.Pages.Count.Should().Be(2, "should have exactly 2 pages (1 from each PDF)");
}
[Fact]
public async Task MergePdfsAsync_WithRangeFormat_MergesSelectedPages()
{
// Arrange: Merge 2 PDFs, second PDF with page range "1" (first page only)
using var pdf1 = LoadTestPdfAsStream("valid.pdf"); // 1 page
using var pdf2 = LoadTestPdfAsStream("pdfWithMoreThanOneAttachment.pdf"); // PDF with attachments
var streams = new List<Stream> { pdf1, pdf2 };
var pageRanges = new List<string?> { null, "1" }; // All pages from first, page 1 only from second
// Act
byte[] mergedPdf = await _sut.MergePdfsAsync(streams, pageRanges);
// Assert
mergedPdf.Should().NotBeEmpty();
// Verify merged PDF has at least 2 pages (1 from first + 1 from second)
using var verifyStream = new MemoryStream(mergedPdf);
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
verifyProcessor.LoadDocument(verifyStream);
verifyProcessor.Document.Pages.Count.Should().BeGreaterOrEqualTo(2, "should have at least 2 pages");
}
[Fact]
public async Task MergePdfsAsync_SinglePdf_ThrowsBadRequestException()
{
// Arrange: Only one PDF (minimum 2 required)
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
var streams = new List<Stream> { pdf1 };
// Act
var act = async () => await _sut.MergePdfsAsync(streams);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*at least 2*");
}
[Fact]
public async Task MergePdfsAsync_EmptyStream_ThrowsBadRequestException()
{
// Arrange: One valid PDF + one empty stream
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var emptyStream = new MemoryStream();
var streams = new List<Stream> { pdf1, emptyStream };
// Act
var act = async () => await _sut.MergePdfsAsync(streams);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*empty*");
}
[Fact]
public async Task MergePdfsAsync_StreamNotAtBeginning_ThrowsBadRequestException()
{
// Arrange: Valid PDFs but second stream not at Position=0
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
pdf2.Position = 10; // Move position away from beginning
var streams = new List<Stream> { pdf1, pdf2 };
// Act
var act = async () => await _sut.MergePdfsAsync(streams);
// Assert
await act.Should().ThrowAsync<BadRequestException>();
}
[Fact]
public async Task MergePdfsAsync_InvalidPageRange_ThrowsBadRequestException()
{
// Arrange: Invalid page range format
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
var streams = new List<Stream> { pdf1, pdf2 };
var pageRanges = new List<string?> { "1", "abc" }; // Invalid format
// Act
var act = async () => await _sut.MergePdfsAsync(streams, pageRanges);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*Invalid page*");
}
[Fact]
public async Task MergePdfsAsync_PageRangeExceedsPageCount_ThrowsBadRequestException()
{
// Arrange: Page range exceeds document page count
using var pdf1 = LoadTestPdfAsStream("valid.pdf"); // 1 page
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
var streams = new List<Stream> { pdf1, pdf2 };
var pageRanges = new List<string?> { "1-99", null }; // 99 exceeds pdf1 page count
// Act
var act = async () => await _sut.MergePdfsAsync(streams, pageRanges);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*exceeds document page count*");
}
[Fact]
public async Task MergePdfsAsync_PageRangesMismatch_ThrowsBadRequestException()
{
// Arrange: Page ranges count doesn't match streams count
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
var streams = new List<Stream> { pdf1, pdf2 };
var pageRanges = new List<string?> { "1" }; // Only 1 range for 2 PDFs
// Act
var act = async () => await _sut.MergePdfsAsync(streams, pageRanges);
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*must match*");
}
#endregion
#region AddAnnotationAsync Tests
[Fact]
public async Task AddAnnotationAsync_TextMarkupHighlight_ReturnsAnnotatedPdf()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var annotationType = Domain.Models.ValueObjects.AnnotationType.TextMarkup;
var rectangle = (X1: 100.0, Y1: 100.0, X2: 200.0, Y2: 120.0);
var textMarkupStyle = Domain.Models.ValueObjects.TextMarkupStyle.Highlight;
// Act
byte[] result = await _sut.AddAnnotationAsync(
pdfStream,
annotationType,
pageNumber: 1,
rectangle,
content: "Important text",
author: "Test Author",
color: "FFFF00", // Yellow
textMarkupStyle);
// Assert
result.Should().NotBeEmpty();
result.Length.Should().BeGreaterThan((int)pdfStream.Length); // Annotation adds bytes
}
[Fact]
public async Task AddAnnotationAsync_FreeText_ReturnsAnnotatedPdf()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var annotationType = Domain.Models.ValueObjects.AnnotationType.FreeText;
var rectangle = (X1: 50.0, Y1: 50.0, X2: 150.0, Y2: 100.0);
// Act
byte[] result = await _sut.AddAnnotationAsync(
pdfStream,
annotationType,
pageNumber: 1,
rectangle,
content: "Free text annotation",
author: "John Doe",
color: "FF0000"); // Red
// Assert
result.Should().NotBeEmpty();
result.Length.Should().BeGreaterThan((int)pdfStream.Length);
}
[Fact]
public async Task AddAnnotationAsync_StickyNote_ReturnsAnnotatedPdf()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var annotationType = Domain.Models.ValueObjects.AnnotationType.StickyNote;
var rectangle = (X1: 300.0, Y1: 300.0, X2: 320.0, Y2: 320.0);
// Act
byte[] result = await _sut.AddAnnotationAsync(
pdfStream,
annotationType,
pageNumber: 1,
rectangle,
content: "Please review this section",
author: "Reviewer");
// Assert
result.Should().NotBeEmpty();
}
[Fact]
public async Task AddAnnotationAsync_Circle_ReturnsAnnotatedPdf()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var annotationType = Domain.Models.ValueObjects.AnnotationType.Circle;
var rectangle = (X1: 100.0, Y1: 200.0, X2: 200.0, Y2: 300.0);
// Act
byte[] result = await _sut.AddAnnotationAsync(
pdfStream,
annotationType,
pageNumber: 1,
rectangle,
content: "Circle annotation",
color: "00FF00"); // Green
// Assert
result.Should().NotBeEmpty();
}
[Fact]
public async Task AddAnnotationAsync_Square_ReturnsAnnotatedPdf()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
var annotationType = Domain.Models.ValueObjects.AnnotationType.Square;
var rectangle = (X1: 250.0, Y1: 250.0, X2: 350.0, Y2: 350.0);
// Act
byte[] result = await _sut.AddAnnotationAsync(
pdfStream,
annotationType,
pageNumber: 1,
rectangle,
color: "0000FF"); // Blue
// Assert
result.Should().NotBeEmpty();
}
[Fact]
public async Task AddAnnotationAsync_EmptyStream_ThrowsBadRequestException()
{
// Arrange
using var emptyStream = new MemoryStream();
// Act
var act = async () => await _sut.AddAnnotationAsync(
emptyStream,
Domain.Models.ValueObjects.AnnotationType.FreeText,
pageNumber: 1,
rectangle: (0, 0, 100, 100),
content: "Test");
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*cannot be empty*");
}
[Fact]
public async Task AddAnnotationAsync_StreamNotAtPositionZero_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
pdfStream.Position = 10; // Move stream position
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.Circle,
pageNumber: 1,
rectangle: (0, 0, 100, 100));
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*must be at position 0*");
}
[Fact]
public async Task AddAnnotationAsync_InvalidPageNumber_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.FreeText,
pageNumber: 999, // Exceeds page count
rectangle: (0, 0, 100, 100),
content: "Test");
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*exceeds document page count*");
}
[Fact]
public async Task AddAnnotationAsync_FreeTextWithoutContent_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.FreeText,
pageNumber: 1,
rectangle: (0, 0, 100, 100),
content: null); // Missing required content
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*requires content*");
}
[Fact]
public async Task AddAnnotationAsync_StickyNoteWithoutContent_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.StickyNote,
pageNumber: 1,
rectangle: (0, 0, 20, 20),
content: ""); // Empty content
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*requires content*");
}
[Fact]
public async Task AddAnnotationAsync_TextMarkupWithoutStyle_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.TextMarkup,
pageNumber: 1,
rectangle: (0, 0, 100, 100),
textMarkupStyle: null); // Missing required style
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*requires textMarkupStyle*");
}
[Fact]
public async Task AddAnnotationAsync_InvalidColorFormat_ThrowsBadRequestException()
{
// Arrange
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
// Act
var act = async () => await _sut.AddAnnotationAsync(
pdfStream,
Domain.Models.ValueObjects.AnnotationType.Circle,
pageNumber: 1,
rectangle: (0, 0, 100, 100),
color: "INVALID"); // Invalid hex format
// Assert
await act.Should().ThrowAsync<BadRequestException>()
.WithMessage("*Invalid color format*");
}
#endregion
}

View File

@@ -0,0 +1,223 @@
using System.Reflection;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
using DocumentService.Infrastructure.Services.QrCodeProcessing;
using FluentAssertions;
namespace DocumentService.Tests.Unit.Infrastructure.Services.QrCodeProcessing;
/// <summary>
/// Unit tests for DevExpressSwissQrCodeProcessor.
/// Tests Swiss QR Code extraction and parsing from PDFs.
/// </summary>
public class DevExpressSwissQrCodeProcessorTests
{
private readonly ISwissQrCodeProcessor _sut; // SUT = System Under Test
public DevExpressSwissQrCodeProcessorTests()
{
// Arrange: Create instance
_sut = new DevExpressSwissQrCodeProcessor();
}
#region Helper Methods
/// <summary>
/// Loads a test PDF from embedded resources as a Stream.
/// </summary>
/// <param name="filename">Name of the PDF file (e.g., "pdfWithSwissQRCode.pdf")</param>
/// <returns>PDF content as MemoryStream</returns>
private static Stream LoadTestPdf(string filename)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{filename}";
var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
throw new FileNotFoundException(
$"Embedded resource '{resourceName}' not found. " +
$"Available resources: {string.Join(", ", assembly.GetManifestResourceNames())}");
}
// Copy to MemoryStream so caller can reuse/seek
var memoryStream = new MemoryStream();
stream.CopyTo(memoryStream);
memoryStream.Position = 0; // Reset position for reading
return memoryStream;
}
#endregion
#region ExtractSwissQrCodeAsync Tests
[Fact(Skip = "Current test PDF uses QR format not compatible with ZXing library. Requires real Swiss QR Bill PDF from https://www.swiss-qr-invoice.org/")]
public async Task ExtractSwissQrCodeAsync_PdfWithSwissQrCode_ReturnsQrCodeData()
{
// Arrange
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, rawLines) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
bill.Should().NotBeNull("PDF contains a Swiss QR Code");
bill.Account.Should().NotBeNullOrEmpty("QR Code should contain IBAN");
bill.Currency.Should().NotBeNullOrEmpty("QR Code should contain currency");
bill.Creditor.Should().NotBeNull("QR Code should contain creditor");
bill.Creditor.Name.Should().NotBeNullOrEmpty("QR Code should contain creditor name");
rawLines.Should().NotBeEmpty("Raw lines should contain parsed QR code text");
}
[Fact(Skip = "Test PDF incompatible with ZXing - requires real Swiss QR Bill PDF")]
public async Task ExtractSwissQrCodeAsync_PdfWithSwissQrCode_ReturnsValidIban()
{
// Arrange
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
bill.Account.Should().MatchRegex(@"^CH\d{2}[A-Z0-9]{17}$",
"Swiss IBAN should match pattern: CH + 2 digits + 17 chars");
}
[Fact(Skip = "Test PDF incompatible with ZXing - requires real Swiss QR Bill PDF")]
public async Task ExtractSwissQrCodeAsync_PdfWithSwissQrCode_ReturnsCurrency()
{
// Arrange
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
bill.Currency.Should().BeOneOf("CHF", "EUR",
"Swiss QR Bill supports CHF and EUR currencies");
}
[Fact]
public async Task ExtractSwissQrCodeAsync_PdfWithoutQrCode_ThrowsSwissQrCodeNotFoundException()
{
// Arrange: valid.pdf doesn't contain a Swiss QR Code
using var pdfStream = LoadTestPdf("valid.pdf");
// Act & Assert
var act = async () => await _sut.ExtractSwissQrCodeAsync(pdfStream);
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("*No valid Swiss QR Code found*");
}
[Fact]
public async Task ExtractSwissQrCodeAsync_EmptyPdf_ThrowsException()
{
// Arrange: Empty stream
using var emptyStream = new MemoryStream();
// Act & Assert
var act = async () => await _sut.ExtractSwissQrCodeAsync(emptyStream);
await act.Should().ThrowAsync<Exception>()
.Where(ex => ex is ArgumentException);
}
[Fact]
public async Task ExtractSwissQrCodeAsync_NullInput_ThrowsArgumentNullException()
{
// Arrange
Stream nullStream = null!;
// Act & Assert
var act = async () => await _sut.ExtractSwissQrCodeAsync(nullStream);
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task ExtractSwissQrCodeAsync_InvalidPdfBytes_ThrowsPdfProcessingException()
{
// Arrange: Random bytes that are not a valid PDF
using var invalidStream = new MemoryStream("This is not a PDF file"u8.ToArray());
// Act & Assert
var act = async () => await _sut.ExtractSwissQrCodeAsync(invalidStream);
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task ExtractSwissQrCodeAsync_StreamNotAtBeginning_ThrowsBadRequestException()
{
// Arrange: Valid PDF but stream position is not at 0
using var pdfStream = LoadTestPdf("valid.pdf");
pdfStream.Position = 10; // Move position away from beginning
// Act & Assert
var act = async () => await _sut.ExtractSwissQrCodeAsync(pdfStream);
await act.Should().ThrowAsync<BadRequestException>();
}
#endregion
#region Swiss QR Code Content Validation Tests
[Fact(Skip = "Test PDF incompatible with ZXing - requires real Swiss QR Bill PDF")]
public async Task ExtractSwissQrCodeAsync_ValidQrCode_ParsesCreditorInformation()
{
// Arrange
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
bill.Creditor.Should().NotBeNull("Creditor information is required");
bill.Creditor.Name.Should().NotBeNullOrEmpty("Creditor name is required");
// Swiss QR Bill allows optional address fields
}
[Fact(Skip = "Test PDF incompatible with ZXing - requires real Swiss QR Bill PDF")]
public async Task ExtractSwissQrCodeAsync_ValidQrCode_ParsesDebtorInformationIfPresent()
{
// Arrange
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
// Debtor information is OPTIONAL in Swiss QR Bill Standard 2.0
// So we just check that the property exists (can be null)
bill.Should().NotBeNull();
// If debtor is present, it should have a name
if (bill.Debtor != null)
{
bill.Debtor.Name.Should().NotBeNullOrWhiteSpace();
}
}
[Fact(Skip = "Test PDF incompatible with ZXing - requires real Swiss QR Bill PDF")]
public async Task ExtractSwissQrCodeAsync_ValidQrCode_ParsesAmountIfPresent()
{
// Arrange
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
// Act
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
// Assert
// Amount is OPTIONAL in Swiss QR Bill (can be 0.00 or null for payment slips)
if (bill.Amount.HasValue)
{
bill.Amount.Value.Should().BeGreaterOrEqualTo(0.01m,
"If amount is specified, it should be >= 0.01");
}
}
#endregion
}