using DocumentService.Application.Common.DTOs; using DocumentService.Client.Clients; using DocumentService.Client.Interfaces; using FluentAssertions; using Moq; using System.Net.Http; using System.Reflection; namespace DocumentService.Tests.Unit.Client; /// /// Unit tests for . /// Both and are mocked – /// no real HTTP calls are made. Tests cover every overload, every branch, and all edge cases. /// public class WorkflowsClientTests { // ─── test doubles ──────────────────────────────────────────────────────── private readonly Mock _validation = new(); private readonly Mock _swissQrCode = new(); private WorkflowsClient Sut => new(_validation.Object, _swissQrCode.Object); // ─── fixture data ───────────────────────────────────────────────────────── private static byte[] ValidPdfBytes() => LoadTestPdf("valid.pdf"); private static byte[] EncryptedPdfBytes() => LoadTestPdf("encryptedPdf.pdf"); private static byte[] QrPdfBytes() => LoadTestPdf("pdfWithSwissQRCode.pdf"); private static byte[] LoadTestPdf(string filename) { var assembly = Assembly.GetExecutingAssembly(); var resourceName = $"DocumentService.Tests.TestData.Pdfs.{filename}"; using var stream = assembly.GetManifestResourceStream(resourceName) ?? throw new FileNotFoundException( $"Embedded resource '{resourceName}' not found. " + $"Available: {string.Join(", ", assembly.GetManifestResourceNames())}"); using var ms = new MemoryStream(); stream.CopyTo(ms); return ms.ToArray(); } // ─── valid PdfValidationResult helpers ─────────────────────────────────── private static PdfValidationResult ValidResult() => new(PageCount: 1, FileSizeBytes: 1024, FileSizeMB: 0.001, PdfVersion: "1.4", HasAttachments: false, AttachmentCount: 0, IsEncrypted: false); private static PdfValidationResult InvalidResult_Encrypted() => new(PageCount: 1, FileSizeBytes: 1024, FileSizeMB: 0.001, PdfVersion: "1.4", HasAttachments: false, AttachmentCount: 0, IsEncrypted: true); private static PdfValidationResult InvalidResult_EmptyFile() => new(PageCount: 0, FileSizeBytes: 0, FileSizeMB: 0, PdfVersion: "", HasAttachments: false, AttachmentCount: 0, IsEncrypted: false); private static SwissQrCodeExtractionResult QrResult() => new( Bill: new SwissQrBillDto { Version = "V2_0", Currency = "CHF", Amount = 630.20m, Account = "CH9300762011623852957", Creditor = new AddressDto { Name = "ALMAT AG", Town = "Tagelswangen", CountryCode = "CH", Type = "S", PostalCode = "8317" }, ReferenceType = "QRR", Reference = "000000000000000000252080824" }, RawLines: ["SPC", "0200", "1"] ); private static SwissQrCodeExtractionResult RawQrResult() => new(Bill: (SwissQrBillDto?)null, RawLines: ["SPC", "0200", "1", "CH9300762011623852957"]); // ═════════════════════════════════════════════════════════════════════════ // byte[] overload // ═════════════════════════════════════════════════════════════════════════ [Fact] public async Task Bytes_ValidPdf_CallsValidationThenExtraction_ReturnsResult() { // Arrange var pdfBytes = ValidPdfBytes(); _validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny())) .ReturnsAsync(ValidResult()); _swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(pdfBytes, false, It.IsAny())) .ReturnsAsync(QrResult()); // Act var result = await Sut.ExtractSwissQrCodeValidatedAsync(pdfBytes); // Assert result.Should().NotBeNull(); result!.Bill.Should().NotBeNull(); result.Bill!.Currency.Should().Be("CHF"); _validation.Verify(v => v.ValidatePdfAsync(pdfBytes, It.IsAny()), Times.Once); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(pdfBytes, false, It.IsAny()), Times.Once); } [Fact] public async Task Bytes_ValidPdf_RawMode_PassesRawFlagToExtraction() { // Arrange var pdfBytes = QrPdfBytes(); _validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny())) .ReturnsAsync(ValidResult()); _swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(pdfBytes, true, It.IsAny())) .ReturnsAsync(RawQrResult()); // Act var result = await Sut.ExtractSwissQrCodeValidatedAsync(pdfBytes, raw: true); // Assert result.Should().NotBeNull(); result!.RawLines.Should().Contain("SPC"); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(pdfBytes, true, It.IsAny()), Times.Once); } [Fact] public async Task Bytes_InvalidPdf_ThrowIfInvalidTrue_ThrowsInvalidOperationException() { // Arrange var pdfBytes = EncryptedPdfBytes(); _validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny())) .ReturnsAsync(InvalidResult_Encrypted()); // Act var act = async () => await Sut.ExtractSwissQrCodeValidatedAsync(pdfBytes, throwIfInvalid: true); // Assert await act.Should().ThrowAsync() .WithMessage("*PDF validation failed*"); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Bytes_InvalidPdf_ThrowIfInvalidFalse_ReturnsNull() { // Arrange var pdfBytes = EncryptedPdfBytes(); _validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny())) .ReturnsAsync(InvalidResult_Encrypted()); // Act var result = await Sut.ExtractSwissQrCodeValidatedAsync(pdfBytes, throwIfInvalid: false); // Assert result.Should().BeNull(); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Bytes_EmptyFilePdf_ThrowIfInvalidTrue_ThrowsInvalidOperationException() { // Arrange – PageCount=0 and FileSize=0 → IsValid=false var pdfBytes = new byte[] { 0x00 }; _validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny())) .ReturnsAsync(InvalidResult_EmptyFile()); // Act & Assert await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(pdfBytes, throwIfInvalid: true)) .Should().ThrowAsync(); } [Fact] public async Task Bytes_ExceptionMessage_ContainsValidationDetails() { // Arrange – ensure the message includes diagnostic fields var pdfBytes = ValidPdfBytes(); _validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny())) .ReturnsAsync(InvalidResult_Encrypted()); // Act var act = async () => await Sut.ExtractSwissQrCodeValidatedAsync(pdfBytes, throwIfInvalid: true); // Assert var ex = await act.Should().ThrowAsync(); ex.WithMessage("*IsEncrypted=True*"); } [Fact] public async Task Bytes_ValidationThrows_PropagatesHttpRequestException() { // Arrange – simulate network/API failure during validation var pdfBytes = ValidPdfBytes(); _validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny())) .ThrowsAsync(new HttpRequestException("API unreachable")); // Act & Assert await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(pdfBytes)) .Should().ThrowAsync() .WithMessage("*API unreachable*"); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Bytes_ExtractionThrows_PropagatesHttpRequestException() { // Arrange – validation succeeds but extraction fails var pdfBytes = QrPdfBytes(); _validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny())) .ReturnsAsync(ValidResult()); _swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(pdfBytes, false, It.IsAny())) .ThrowsAsync(new HttpRequestException("Extraction failed")); // Act & Assert await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(pdfBytes)) .Should().ThrowAsync() .WithMessage("*Extraction failed*"); } [Fact] public async Task Bytes_ValidPdf_CancellationToken_IsForwardedToBothClients() { // Arrange var pdfBytes = ValidPdfBytes(); var cts = new CancellationTokenSource(); var token = cts.Token; _validation.Setup(v => v.ValidatePdfAsync(pdfBytes, token)).ReturnsAsync(ValidResult()); _swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(pdfBytes, false, token)).ReturnsAsync(QrResult()); // Act await Sut.ExtractSwissQrCodeValidatedAsync(pdfBytes, cancellationToken: token); // Assert _validation.Verify(v => v.ValidatePdfAsync(pdfBytes, token), Times.Once); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(pdfBytes, false, token), Times.Once); } // ═════════════════════════════════════════════════════════════════════════ // Stream overload // ═════════════════════════════════════════════════════════════════════════ [Fact] public async Task Stream_ValidPdf_CallsValidationWithStreamThenExtractionWithStream_ReturnsResult() { // Arrange – validation client receives a Stream, extraction client receives the same stream reset to 0 _validation.Setup(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ValidResult()); _swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(It.IsAny(), false, It.IsAny())) .ReturnsAsync(QrResult()); using var pdfStream = new MemoryStream(ValidPdfBytes()); // Act var result = await Sut.ExtractSwissQrCodeValidatedAsync(pdfStream); // Assert result.Should().NotBeNull(); _validation.Verify(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny()), Times.Once); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), false, It.IsAny()), Times.Once); } [Fact] public async Task Stream_SeekableStream_IsResetBetweenValidationAndExtraction() { // Arrange – we verify the stream position is 0 when extraction is called Stream? streamAtExtraction = null; _validation.Setup(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ValidResult()); _swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(It.IsAny(), false, It.IsAny())) .Callback((s, _, _) => streamAtExtraction = s) .ReturnsAsync(QrResult()); using var pdfStream = new MemoryStream(ValidPdfBytes()); // Act await Sut.ExtractSwissQrCodeValidatedAsync(pdfStream); // Assert streamAtExtraction.Should().NotBeNull(); streamAtExtraction!.Position.Should().Be(0, "stream must be rewound before extraction"); } [Fact] public async Task Stream_InvalidPdf_ThrowIfInvalidTrue_ThrowsWithoutCallingExtraction() { // Arrange _validation.Setup(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(InvalidResult_Encrypted()); using var pdfStream = new MemoryStream(EncryptedPdfBytes()); // Act & Assert await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(pdfStream, throwIfInvalid: true)) .Should().ThrowAsync() .WithMessage("*PDF validation failed*"); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Stream_InvalidPdf_ThrowIfInvalidFalse_ReturnsNull() { // Arrange _validation.Setup(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(InvalidResult_Encrypted()); using var pdfStream = new MemoryStream(EncryptedPdfBytes()); // Act var result = await Sut.ExtractSwissQrCodeValidatedAsync(pdfStream, throwIfInvalid: false); // Assert result.Should().BeNull(); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Stream_ValidPdf_RawMode_PassesRawFlagToExtraction() { // Arrange _validation.Setup(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ValidResult()); _swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(It.IsAny(), true, It.IsAny())) .ReturnsAsync(RawQrResult()); using var pdfStream = new MemoryStream(QrPdfBytes()); // Act var result = await Sut.ExtractSwissQrCodeValidatedAsync(pdfStream, raw: true); // Assert result.Should().NotBeNull(); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), true, It.IsAny()), Times.Once); } // ═════════════════════════════════════════════════════════════════════════ // filePath overload // ═════════════════════════════════════════════════════════════════════════ [Fact] public async Task FilePath_ValidPdf_ReadsFileAndCallsBothClients_ReturnsResult() { // Arrange – write a real temp file so File.ReadAllBytesAsync / File.OpenRead can read it var tempPath = Path.GetTempFileName(); try { await File.WriteAllBytesAsync(tempPath, ValidPdfBytes()); _validation.Setup(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ValidResult()); _swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(It.IsAny(), false, It.IsAny())) .ReturnsAsync(QrResult()); // Act var result = await Sut.ExtractSwissQrCodeValidatedAsync(tempPath); // Assert result.Should().NotBeNull(); _validation.Verify(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny()), Times.Once); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), false, It.IsAny()), Times.Once); } finally { File.Delete(tempPath); } } [Fact] public async Task FilePath_InvalidPdf_ThrowIfInvalidTrue_ThrowsWithoutCallingExtraction() { // Arrange var tempPath = Path.GetTempFileName(); try { await File.WriteAllBytesAsync(tempPath, EncryptedPdfBytes()); _validation.Setup(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(InvalidResult_Encrypted()); // Act & Assert await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(tempPath, throwIfInvalid: true)) .Should().ThrowAsync(); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } finally { File.Delete(tempPath); } } [Fact] public async Task FilePath_InvalidPdf_ThrowIfInvalidFalse_ReturnsNull() { // Arrange var tempPath = Path.GetTempFileName(); try { await File.WriteAllBytesAsync(tempPath, EncryptedPdfBytes()); _validation.Setup(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(InvalidResult_Encrypted()); // Act var result = await Sut.ExtractSwissQrCodeValidatedAsync(tempPath, throwIfInvalid: false); // Assert result.Should().BeNull(); } finally { File.Delete(tempPath); } } [Fact] public async Task FilePath_NonExistentFile_ThrowsFileNotFoundException() { // Arrange var nonExistent = Path.Combine(Path.GetTempPath(), "does_not_exist_12345.pdf"); // Act & Assert await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(nonExistent)) .Should().ThrowAsync(); _validation.Verify(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny()), Times.Never); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task FilePath_ValidPdf_RawMode_PassesRawFlagToExtraction() { // Arrange var tempPath = Path.GetTempFileName(); try { await File.WriteAllBytesAsync(tempPath, QrPdfBytes()); _validation.Setup(v => v.ValidatePdfAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ValidResult()); _swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(It.IsAny(), true, It.IsAny())) .ReturnsAsync(RawQrResult()); // Act var result = await Sut.ExtractSwissQrCodeValidatedAsync(tempPath, raw: true); // Assert result.Should().NotBeNull(); _swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny(), true, It.IsAny()), Times.Once); } finally { File.Delete(tempPath); } } // ═════════════════════════════════════════════════════════════════════════ // Orchestration contract: extraction must NEVER be called on invalid PDF // ═════════════════════════════════════════════════════════════════════════ [Theory] [InlineData(true)] [InlineData(false)] public async Task AnyOverload_InvalidPdf_ExtractionIsNeverCalled_RegardlessOfThrowIfInvalid(bool throwIfInvalid) { // Arrange var pdfBytes = EncryptedPdfBytes(); _validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny())) .ReturnsAsync(InvalidResult_Encrypted()); // Act try { await Sut.ExtractSwissQrCodeValidatedAsync(pdfBytes, throwIfInvalid: throwIfInvalid); } catch (InvalidOperationException) { /* expected when throwIfInvalid=true */ } // Assert — extraction must never be called in either branch _swissQrCode.Verify( q => q.ExtractSwissQrCodeAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never, "extraction must not be attempted on an invalid document"); } }