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.
906 lines
32 KiB
C#
906 lines
32 KiB
C#
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
|
|
}
|
|
|