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,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
}