From f4d87b42f343f8c8a32f07898966db44fd972093 Mon Sep 17 00:00:00 2001 From: TekH Date: Mon, 31 Aug 2026 03:29:33 +0200 Subject: [PATCH] Add unit tests for WorkflowsClient validation/extraction Added `WorkflowsClientTests` to validate the behavior of the `WorkflowsClient` class, covering PDF validation and Swiss QR code extraction. Introduced test doubles for `IPdfValidationClient` and `ISwissQrCodeClient` using `Moq`. Implemented tests for all overloads of `ExtractSwissQrCodeValidatedAsync`: - Byte array, stream, and file path inputs. - Valid, invalid, and empty PDFs. - Raw mode and cancellation token handling. - Exception propagation and orchestration contracts. Ensured comprehensive test coverage for all branches and edge cases. --- .../Unit/Client/WorkflowsClientTests.cs | 473 ++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 DocumentService.Tests/Unit/Client/WorkflowsClientTests.cs diff --git a/DocumentService.Tests/Unit/Client/WorkflowsClientTests.cs b/DocumentService.Tests/Unit/Client/WorkflowsClientTests.cs new file mode 100644 index 0000000..5d129ed --- /dev/null +++ b/DocumentService.Tests/Unit/Client/WorkflowsClientTests.cs @@ -0,0 +1,473 @@ +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"); + } +}