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; /// /// Unit tests for DevExpressPdfProcessor. /// Tests PDF validation and metadata extraction. /// 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 /// /// Loads a test PDF from embedded resources. /// /// Name of the PDF file (e.g., "valid.pdf") /// PDF content as byte array 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(); } /// /// Loads a test PDF from embedded resources as Stream. /// /// Name of the PDF file (e.g., "valid.pdf") /// PDF content as MemoryStream (caller must dispose) private static MemoryStream LoadTestPdfAsStream(string filename) { byte[] pdfBytes = LoadTestPdf(filename); return new MemoryStream(pdfBytes); } /// /// Converts byte array to MemoryStream for testing /// 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 act = async () => await _sut.ValidateAsync(pdfStream!); // Assert await act.Should().ThrowAsync() .WithMessage("*pdfStream*", "null input should be rejected"); } [Fact] public async Task ValidateAsync_EmptyStream_ThrowsBadRequestException() { // Arrange var pdfStream = new MemoryStream(); // Act Func act = async () => await _sut.ValidateAsync(pdfStream); // Assert await act.Should().ThrowAsync() .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 act = async () => await _sut.ValidateAsync(ToStream(pdfBytes)); // Assert await act.Should().ThrowAsync() .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 act = async () => await _sut.CheckAttachmentsAsync(pdfStream); // Assert await act.Should().ThrowAsync() .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 act = async () => await _sut.CheckAttachmentsAsync(ToStream(pdfBytes)); // Assert // DevExpress throws ArgumentException for invalid PDF data await act.Should().ThrowAsync("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 act = async () => await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes)); // Assert await act.Should().ThrowAsync() .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 act = async () => await _sut.ExtractAttachmentsAsync(emptyStream); // Assert await act.Should().ThrowAsync() .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 act = async () => await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes)); // Assert // DevExpress throws exception for invalid PDF data await act.Should().ThrowAsync("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 { 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 { 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 { pdf1, pdf2 }; var pageRanges = new List { "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 { pdf1, pdf2 }; var pageRanges = new List { 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 { pdf1 }; // Act var act = async () => await _sut.MergePdfsAsync(streams); // Assert await act.Should().ThrowAsync() .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 { pdf1, emptyStream }; // Act var act = async () => await _sut.MergePdfsAsync(streams); // Assert await act.Should().ThrowAsync() .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 { pdf1, pdf2 }; // Act var act = async () => await _sut.MergePdfsAsync(streams); // Assert await act.Should().ThrowAsync(); } [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 { pdf1, pdf2 }; var pageRanges = new List { "1", "abc" }; // Invalid format // Act var act = async () => await _sut.MergePdfsAsync(streams, pageRanges); // Assert await act.Should().ThrowAsync() .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 { pdf1, pdf2 }; var pageRanges = new List { "1-99", null }; // 99 exceeds pdf1 page count // Act var act = async () => await _sut.MergePdfsAsync(streams, pageRanges); // Assert await act.Should().ThrowAsync() .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 { pdf1, pdf2 }; var pageRanges = new List { "1" }; // Only 1 range for 2 PDFs // Act var act = async () => await _sut.MergePdfsAsync(streams, pageRanges); // Assert await act.Should().ThrowAsync() .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() .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() .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() .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() .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() .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() .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() .WithMessage("*Invalid color format*"); } #endregion }