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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user