diff --git a/AGENTS.md b/AGENTS.md index f863516..62145a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ After completing all Phase 1-3 controllers (PdfValidation, PdfAttachment, SwissQ dotnet build ``` -**Run tests (19 tests as of Feature 2):** +**Run tests (30 tests as of Feature 3 - PDF/A Validation):** ```powershell dotnet test ``` @@ -273,7 +273,7 @@ Do NOT add `if (result.IsSuccess)` checks. Throw exceptions for errors. The midd | Controller | Status | Tests | |-----------|--------|-------| -| **PdfValidationController** | ✅ Partial (validate done, validate-pdfa pending) | 4 | +| **PdfValidationController** | ✅ DONE | 13 (7 validate + 6 validate-pdfa) | | **SwissQrCodeController** | ✅ DONE | 2 | | **PdfAttachmentController** | ⏳ Pending | 0 | | **PdfOperationsController** | ⏳ Pending | 0 | @@ -333,6 +333,61 @@ var stream = Assembly.GetExecutingAssembly() --- +## Test Structure & Strategy + +**3-folder structure (CORRECT approach by previous developer):** + +``` +DocumentOperator.Tests/ +├── Integration/ +│ └── API/ +│ ├── PdfValidationControllerTests.cs (13 tests) +│ └── ExtractSwissQrCodeEndpointTests.cs (2 tests) +├── TestData/ +│ └── Pdfs/ (EmbeddedResource PDFs) +├── Unit/ +│ ├── Application/ +│ │ └── Features/ +│ │ ├── ValidatePdf/ +│ │ │ └── ValidatePdfHandlerTests.cs (2 tests) +│ │ ├── ValidatePdfA/ +│ │ │ └── ValidatePdfAQueryHandlerTests.cs (4 tests) +│ │ └── ExtractSwissQrCode/ +│ │ └── ExtractSwissQrCodeHandlerTests.cs (2 tests) +│ └── Infrastructure/ +│ └── Services/ +│ └── PdfProcessing/ +│ └── DevExpressPdfProcessorTests.cs (7 tests) +``` + +**✅ Why this structure is CORRECT:** + +1. **Integration vs Unit separation:** + - **Integration:** WebApplicationFactory → REAL API calls (HTTP, middleware, MediatR pipeline, DevExpress) + - **Unit:** Mock-based ISOLATED tests (Handler only depends on mocked IPdfProcessor) + +2. **TestData centralization:** + - All 3 layers share same EmbeddedResource PDFs (no duplication) + - Accessed via `Assembly.GetManifestResourceStream()` + +3. **Vertical Slice compliance:** + - `Unit/Application/Features/ValidatePdf/` → Each feature's tests co-located + - Matches Application layer structure exactly + +4. **Test Pyramid:** + - **Unit tests (15):** Fast, isolated, many scenarios + - **Integration tests (15):** Slower, full pipeline, critical paths only + +**Test count:** 30 tests total (as of Feature 3 - PDF/A Validation) + +**FluentValidation in tests:** +- Base64 format validation happens in `ValidatePdfQueryValidator` and `ValidatePdfAQueryValidator` +- Prevents `FormatException` from reaching handler (caught as 400 Bad Request, not 500) +- Unit tests verify handler behavior with valid inputs only +- Integration tests verify full validation pipeline (including FluentValidation) + +--- + ## Swiss QR Code Feature (Feature 2) **Swiss QR Bill Standard 2.0** requires: diff --git a/DocumentOperator.API/README.md b/DocumentOperator.API/README.md new file mode 100644 index 0000000..9ec5dea --- /dev/null +++ b/DocumentOperator.API/README.md @@ -0,0 +1,387 @@ +# DocumentOperator API - Manual Testing Guide + +This guide contains manual test scenarios for validating the DocumentOperator API endpoints using Swagger UI or tools like Postman. + +--- + +## Prerequisites + +1. **Start the API:** + ```powershell + dotnet run --project DocumentOperator.API + ``` + Default URL: `https://localhost:5001` (check console output for actual port) + +2. **Open Swagger UI:** + Navigate to `https://localhost:/swagger` + +3. **Test PDFs:** + - Use PDFs from `fake-pdf/` folder (form.pdf, multi-page.pdf, one-page.pdf, with-image.pdf) + - Or use your own PDF files + +--- + +## Feature 1: Basic PDF Validation + +### Endpoint: `POST /api/pdf/validation/validate` + +#### Test Case 1.1: Valid PDF (Multipart Upload) +**Objective:** Verify basic PDF validation works with file upload + +**Steps:** +1. Open Swagger UI → `/api/pdf/validation/validate` +2. Click "Try it out" +3. Select **multipart/form-data** from dropdown +4. Click "Choose File" and select `fake-pdf/one-page.pdf` +5. Click "Execute" + +**Expected Result:** +- **Status Code:** 200 OK +- **Response Body:** + ```json + { + "pageCount": 1, + "fileSizeBytes": 7168, + "fileSizeMB": 0.01, + "pdfVersion": "1.4", + "hasAttachments": false, + "attachmentCount": 0 + } + ``` + +--- + +#### Test Case 1.2: Valid PDF (Base64 JSON) +**Objective:** Verify Base64 input works + +**Steps:** +1. Convert a PDF to Base64: + ```powershell + $bytes = [System.IO.File]::ReadAllBytes("fake-pdf/one-page.pdf") + $base64 = [Convert]::ToBase64String($bytes) + Write-Output $base64 + ``` +2. Open Swagger UI → `/api/pdf/validation/validate` +3. Click "Try it out" +4. Select **application/json** from dropdown +5. Paste into Request Body: + ```json + { + "base64Pdf": "" + } + ``` +6. Click "Execute" + +**Expected Result:** +- **Status Code:** 200 OK +- Same response as Test 1.1 + +--- + +#### Test Case 1.3: Invalid Base64 String +**Objective:** Verify validation rejects malformed Base64 + +**Steps:** +1. Open Swagger UI → `/api/pdf/validation/validate` +2. Select **application/json** +3. Paste into Request Body: + ```json + { + "base64Pdf": "invalid-base64!!!" + } + ``` +4. Click "Execute" + +**Expected Result:** +- **Status Code:** 400 Bad Request +- **Error Message:** Contains "Base64" + +--- + +#### Test Case 1.4: Empty File Upload +**Objective:** Verify empty files are rejected + +**Steps:** +1. Create an empty file (`empty.pdf`) +2. Upload via multipart/form-data + +**Expected Result:** +- **Status Code:** 400 Bad Request +- **Error Message:** Contains "cannot be empty" + +--- + +#### Test Case 1.5: Large Multi-Page PDF +**Objective:** Verify handling of larger PDFs + +**Steps:** +1. Upload `fake-pdf/multi-page.pdf` (49 KB) + +**Expected Result:** +- **Status Code:** 200 OK +- **Response:** + ```json + { + "pageCount": 3, + "fileSizeBytes": 49152, + "fileSizeMB": 0.05, + "pdfVersion": "1.7", + "hasAttachments": false, + "attachmentCount": 0 + } + ``` + +--- + +#### Test Case 1.6: PDF with Images +**Objective:** Verify image-heavy PDFs are processed + +**Steps:** +1. Upload `fake-pdf/with-image.pdf` (256 KB) + +**Expected Result:** +- **Status Code:** 200 OK +- **Response:** + ```json + { + "pageCount": 1, + "fileSizeBytes": 262144, + "fileSizeMB": 0.25, + "pdfVersion": "1.6", + "hasAttachments": false, + "attachmentCount": 0 + } + ``` + +--- + +## Feature 3: PDF/A Validation + +### Endpoint: `POST /api/pdf/validation/validate-pdfa` + +#### Test Case 3.1: PDF/A Compliant Document (Multipart) +**Objective:** Verify PDF/A validation detects conformance + +**Steps:** +1. Open Swagger UI → `/api/pdf/validation/validate-pdfa` +2. Select **multipart/form-data** +3. Upload a PDF/A-compliant PDF (if available) +4. Click "Execute" + +**Expected Result (if PDF/A compliant):** +- **Status Code:** 200 OK +- **Response:** + ```json + { + "isValid": true, + "pdfVersion": "1.7", + "pageCount": 1, + "fileSize": 12345, + "encrypted": false, + "pdfAVersion": "PDF/A-3b", + "pdfACompliant": true, + "errors": [], + "warnings": [] + } + ``` + +--- + +#### Test Case 3.2: Non-PDF/A Document +**Objective:** Verify regular PDFs are detected as non-compliant + +**Steps:** +1. Upload `fake-pdf/one-page.pdf` (regular PDF, NOT PDF/A) + +**Expected Result:** +- **Status Code:** 200 OK +- **Response:** + ```json + { + "isValid": true, + "pdfVersion": "1.4", + "pageCount": 1, + "fileSize": 7168, + "encrypted": false, + "pdfAVersion": null, + "pdfACompliant": false, + "errors": [], + "warnings": ["Manual verification recommended: PDF/A compliance requires all fonts to be embedded"] + } + ``` + +--- + +#### Test Case 3.3: Encrypted PDF +**Objective:** Verify encrypted PDFs are flagged + +**Steps:** +1. Create or obtain a password-protected PDF +2. Upload via multipart/form-data + +**Expected Result:** +- **Status Code:** 200 OK +- **Response:** + ```json + { + "isValid": true, + "pdfVersion": "1.7", + "pageCount": 1, + "fileSize": 12345, + "encrypted": true, + "pdfAVersion": null, + "pdfACompliant": false, + "errors": ["Encrypted PDFs cannot be PDF/A compliant"], + "warnings": [] + } + ``` + +--- + +#### Test Case 3.4: Invalid Base64 (PDF/A Endpoint) +**Objective:** Verify validation works on PDF/A endpoint + +**Steps:** +1. Select **application/json** +2. Paste: + ```json + { + "base64Pdf": "not-base64!!!" + } + ``` + +**Expected Result:** +- **Status Code:** 400 Bad Request +- **Error Message:** Contains "Base64" + +--- + +#### Test Case 3.5: Empty Request +**Objective:** Verify both inputs missing is rejected + +**Steps:** +1. Select **application/json** +2. Paste: + ```json + { + "pdfBytes": null, + "base64Pdf": "" + } + ``` + +**Expected Result:** +- **Status Code:** 400 Bad Request +- **Error Message:** "Either PdfBytes or Base64Pdf must be provided, but not both" + +--- + +## Feature 2: Swiss QR Code Extraction + +### Endpoint: `POST /api/swissqrcode/extract` + +#### Test Case 2.1: PDF with Swiss QR Code +**Objective:** Extract Swiss QR Bill from PDF + +**Steps:** +1. Open Swagger UI → `/api/swissqrcode/extract` +2. Select **multipart/form-data** +3. Upload a PDF containing Swiss QR Code on the **last page** +4. Click "Execute" + +**Expected Result (if QR code present):** +- **Status Code:** 200 OK +- **Response:** Contains Swiss QR Bill details (IBAN, amount, creditor, debtor, reference) + +--- + +#### Test Case 2.2: PDF without QR Code +**Objective:** Verify graceful handling when no QR code exists + +**Steps:** +1. Upload `fake-pdf/one-page.pdf` (no QR code) + +**Expected Result:** +- **Status Code:** 404 Not Found +- **Error Message:** "Swiss QR Code not found in PDF" + +--- + +## Common Error Scenarios + +### Test Case E1: Missing File in Multipart Request +**Steps:** +1. Any multipart endpoint +2. Don't select a file, click "Execute" + +**Expected Result:** +- **Status Code:** 400 Bad Request + +--- + +### Test Case E2: Both PdfBytes AND Base64Pdf Provided +**Steps:** +1. Attempt to send JSON with both fields populated + ```json + { + "pdfBytes": [1,2,3], + "base64Pdf": "dGVzdA==" + } + ``` + +**Expected Result:** +- **Status Code:** 400 Bad Request +- **Error Message:** "Either PdfBytes or Base64Pdf must be provided, but not both" + +--- + +### Test Case E3: Corrupted PDF File +**Steps:** +1. Create a text file with `.pdf` extension containing "FAKE PDF CONTENT" +2. Upload it + +**Expected Result:** +- **Status Code:** 500 Internal Server Error +- **Error Message:** Contains "PDF processing error" + +--- + +## Test Coverage Summary + +| Feature | Endpoint | Test Cases | +|---------|----------|------------| +| Basic PDF Validation | `POST /api/pdf/validation/validate` | 6 | +| PDF/A Validation | `POST /api/pdf/validation/validate-pdfa` | 5 | +| Swiss QR Code | `POST /api/swissqrcode/extract` | 2 | +| Error Handling | All endpoints | 3 | +| **TOTAL** | | **16 Manual Test Cases** | + +--- + +## Notes + +- All endpoints support **BOTH** `multipart/form-data` (file upload) AND `application/json` (Base64) +- FluentValidation runs before handlers (400 errors indicate validation failures) +- DevExpress evaluation warnings (DX1000/DX1001) are expected and can be ignored +- Test PDFs in `fake-pdf/` folder are small samples; use real-world PDFs for comprehensive testing + +--- + +## Quick PowerShell Helpers + +**Convert PDF to Base64:** +```powershell +$bytes = [System.IO.File]::ReadAllBytes("path\to\file.pdf") +$base64 = [Convert]::ToBase64String($bytes) +$base64 | Set-Clipboard # Copies to clipboard +``` + +**Create empty PDF for testing:** +```powershell +New-Item -Path "empty.pdf" -ItemType File -Force +``` + +**Check if file is valid PDF:** +```powershell +$header = Get-Content -Path "file.pdf" -TotalCount 1 -Encoding Byte +# Should start with: 0x25 0x50 0x44 0x46 (%PDF) +``` diff --git a/DocumentOperator.Application/ValidatePdfA/Validators/ValidatePdfAQueryValidator.cs b/DocumentOperator.Application/ValidatePdfA/Validators/ValidatePdfAQueryValidator.cs index bdca26e..8ee807f 100644 --- a/DocumentOperator.Application/ValidatePdfA/Validators/ValidatePdfAQueryValidator.cs +++ b/DocumentOperator.Application/ValidatePdfA/Validators/ValidatePdfAQueryValidator.cs @@ -14,5 +14,35 @@ public class ValidatePdfAQueryValidator : AbstractValidator (x.PdfBytes != null && x.PdfBytes.Length > 0) ^ !string.IsNullOrWhiteSpace(x.Base64Pdf)) .WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both"); + + When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () => + { + RuleFor(x => x.Base64Pdf!) + .Must(BeValidBase64) + .WithMessage("Base64Pdf must be a valid Base64 string"); + }); + + When(x => x.PdfBytes != null, () => + { + RuleFor(x => x.PdfBytes!) + .Must(bytes => bytes.Length > 0) + .WithMessage("PdfBytes cannot be empty"); + }); + } + + private static bool BeValidBase64(string base64) + { + if (string.IsNullOrWhiteSpace(base64)) + return false; + + try + { + Convert.FromBase64String(base64); + return true; + } + catch (FormatException) + { + return false; + } } } diff --git a/DocumentOperator.Tests/Integration/API/PdfValidationControllerTests.cs b/DocumentOperator.Tests/Integration/API/PdfValidationControllerTests.cs index a5a9ffd..c1f6890 100644 --- a/DocumentOperator.Tests/Integration/API/PdfValidationControllerTests.cs +++ b/DocumentOperator.Tests/Integration/API/PdfValidationControllerTests.cs @@ -1,5 +1,6 @@ using DocumentOperator.Application.Common.DTOs; using DocumentOperator.Application.ValidatePdf.Queries; +using DocumentOperator.Application.ValidatePdfA.Queries; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using System.Net; @@ -166,4 +167,151 @@ public class PdfValidationControllerTests : IClassFixture(); + 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 ValidatePdfAQuery { 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(); + problemDetails.Should().Contain("Base64"); + } + + [Fact] + public async Task POST_ValidatePdfA_Base64_EmptyPdf_Returns400() + { + // Arrange + var request = new ValidatePdfAQuery { 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(); + problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided"); + } + + #endregion + + #region PDF/A Validation Tests (Multipart) + + [Fact] + public async Task POST_ValidatePdfA_Multipart_ValidPdf_Returns200() + { + // Arrange + var assembly = typeof(PdfValidationControllerTests).Assembly; + var resourceName = "DocumentOperator.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(); + 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()); + 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 } diff --git a/DocumentOperator.Tests/Unit/Application/Features/ValidatePdfA/ValidatePdfAQueryHandlerTests.cs b/DocumentOperator.Tests/Unit/Application/Features/ValidatePdfA/ValidatePdfAQueryHandlerTests.cs new file mode 100644 index 0000000..934f8b2 --- /dev/null +++ b/DocumentOperator.Tests/Unit/Application/Features/ValidatePdfA/ValidatePdfAQueryHandlerTests.cs @@ -0,0 +1,213 @@ +using AutoMapper; +using DocumentOperator.Application.Common.DTOs; +using DocumentOperator.Application.Common.Interfaces; +using DocumentOperator.Application.ValidatePdfA.Queries; +using DocumentOperator.Domain.Common.Exceptions; +using DocumentOperator.Domain.Models.ValueObjects; +using FluentAssertions; +using Moq; +using Xunit; + +namespace DocumentOperator.Tests.Unit.Application.Features.ValidatePdfA; + +public class ValidatePdfAQueryHandlerTests +{ + private readonly Mock _mockPdfProcessor; + private readonly Mock _mockMapper; + private readonly ValidatePdfAQueryHandler _handler; + + public ValidatePdfAQueryHandlerTests() + { + _mockPdfProcessor = new Mock(); + _mockMapper = new Mock(); + _handler = new ValidatePdfAQueryHandler(_mockPdfProcessor.Object, _mockMapper.Object); + } + + [Fact] + public async Task Handle_ValidPdfA_ReturnsPdfAMetadata() + { + // Arrange + var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF" + var query = new ValidatePdfAQuery { PdfBytes = pdfBytes }; + + var domainMetadata = new PdfAMetadata( + isValid: true, + pdfVersion: "1.7", + pageCount: 3, + fileSizeBytes: 2048, + encrypted: false, + pdfaVersion: "PDF/A-3b", + pdfaCompliant: true, + errors: new List(), + warnings: new List() + ); + + var expectedDto = new PdfAValidationResult + { + IsValid = true, + PdfVersion = "1.7", + PageCount = 3, + FileSize = 2048, + Encrypted = false, + PdfAVersion = "PDF/A-3b", + PdfACompliant = true, + Errors = new List(), + Warnings = new List() + }; + + _mockPdfProcessor + .Setup(x => x.ValidatePdfAAsync(It.IsAny())) + .ReturnsAsync(domainMetadata); + + _mockMapper + .Setup(x => x.Map(domainMetadata)) + .Returns(expectedDto); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + 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()), Times.Once); + _mockMapper.Verify(x => x.Map(domainMetadata), Times.Once); + } + + [Fact] + public async Task Handle_NonCompliantPdfA_ReturnsErrorsAndWarnings() + { + // Arrange + var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF" + var query = new ValidatePdfAQuery { Base64Pdf = Convert.ToBase64String(pdfBytes) }; + + var errors = new List { "Missing XMP metadata", "Invalid color space" }; + var warnings = new List { "Embedded font not subset" }; + + var domainMetadata = new PdfAMetadata( + isValid: false, + pdfVersion: "1.4", + pageCount: 2, + fileSizeBytes: 1024, + encrypted: false, + pdfaVersion: null, + pdfaCompliant: false, + errors: errors, + warnings: warnings + ); + + var expectedDto = new PdfAValidationResult + { + IsValid = false, + PdfVersion = "1.4", + PageCount = 2, + FileSize = 1024, + Encrypted = false, + PdfAVersion = null, + PdfACompliant = false, + Errors = errors, + Warnings = warnings + }; + + _mockPdfProcessor + .Setup(x => x.ValidatePdfAAsync(It.IsAny())) + .ReturnsAsync(domainMetadata); + + _mockMapper + .Setup(x => x.Map(domainMetadata)) + .Returns(expectedDto); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.IsValid.Should().BeFalse(); + result.PdfACompliant.Should().BeFalse(); + result.PdfAVersion.Should().BeNull(); + result.Errors.Should().HaveCount(2); + result.Errors.Should().Contain("Missing XMP metadata"); + result.Warnings.Should().HaveCount(1); + + _mockPdfProcessor.Verify(x => x.ValidatePdfAAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_EncryptedPdf_ReturnsEncryptedFlag() + { + // Arrange + var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF" + var query = new ValidatePdfAQuery { PdfBytes = pdfBytes }; + + var domainMetadata = new PdfAMetadata( + isValid: true, + pdfVersion: "1.7", + pageCount: 1, + fileSizeBytes: 512, + encrypted: true, + pdfaVersion: null, + pdfaCompliant: false, + errors: new List { "Encrypted PDFs cannot be PDF/A compliant" }, + warnings: new List() + ); + + var expectedDto = new PdfAValidationResult + { + IsValid = true, + PdfVersion = "1.7", + PageCount = 1, + FileSize = 512, + Encrypted = true, + PdfAVersion = null, + PdfACompliant = false, + Errors = new List { "Encrypted PDFs cannot be PDF/A compliant" }, + Warnings = new List() + }; + + _mockPdfProcessor + .Setup(x => x.ValidatePdfAAsync(It.IsAny())) + .ReturnsAsync(domainMetadata); + + _mockMapper + .Setup(x => x.Map(domainMetadata)) + .Returns(expectedDto); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Encrypted.Should().BeTrue(); + result.PdfACompliant.Should().BeFalse(); + result.Errors.Should().Contain("Encrypted PDFs cannot be PDF/A compliant"); + + _mockPdfProcessor.Verify(x => x.ValidatePdfAAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_PdfProcessorThrowsException_PropagatesException() + { + // Arrange + var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF" + var query = new ValidatePdfAQuery { PdfBytes = pdfBytes }; + + _mockPdfProcessor + .Setup(x => x.ValidatePdfAAsync(It.IsAny())) + .ThrowsAsync(new PdfProcessingException("Invalid PDF format")); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _handler.Handle(query, CancellationToken.None) + ); + + exception.Message.Should().Be("Invalid PDF format"); + _mockPdfProcessor.Verify(x => x.ValidatePdfAAsync(It.IsAny()), Times.Once); + } +}