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");
+ }
+}