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.
This commit is contained in:
2026-08-31 03:29:33 +02:00
parent 0c27e4fd91
commit f4d87b42f3

View File

@@ -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;
/// <summary>
/// Unit tests for <see cref="WorkflowsClient"/>.
/// Both <see cref="IPdfValidationClient"/> and <see cref="ISwissQrCodeClient"/> are mocked –
/// no real HTTP calls are made. Tests cover every overload, every branch, and all edge cases.
/// </summary>
public class WorkflowsClientTests
{
// ─── test doubles ────────────────────────────────────────────────────────
private readonly Mock<IPdfValidationClient> _validation = new();
private readonly Mock<ISwissQrCodeClient> _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<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(pdfBytes, false, It.IsAny<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
_swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(pdfBytes, false, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Bytes_ValidPdf_RawMode_PassesRawFlagToExtraction()
{
// Arrange
var pdfBytes = QrPdfBytes();
_validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(pdfBytes, true, It.IsAny<CancellationToken>()))
.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<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Bytes_InvalidPdf_ThrowIfInvalidTrue_ThrowsInvalidOperationException()
{
// Arrange
var pdfBytes = EncryptedPdfBytes();
_validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny<CancellationToken>()))
.ReturnsAsync(InvalidResult_Encrypted());
// Act
var act = async () => await Sut.ExtractSwissQrCodeValidatedAsync(pdfBytes, throwIfInvalid: true);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*PDF validation failed*");
_swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Bytes_InvalidPdf_ThrowIfInvalidFalse_ReturnsNull()
{
// Arrange
var pdfBytes = EncryptedPdfBytes();
_validation.Setup(v => v.ValidatePdfAsync(pdfBytes, It.IsAny<CancellationToken>()))
.ReturnsAsync(InvalidResult_Encrypted());
// Act
var result = await Sut.ExtractSwissQrCodeValidatedAsync(pdfBytes, throwIfInvalid: false);
// Assert
result.Should().BeNull();
_swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.ReturnsAsync(InvalidResult_EmptyFile());
// Act & Assert
await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(pdfBytes, throwIfInvalid: true))
.Should().ThrowAsync<InvalidOperationException>();
}
[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<CancellationToken>()))
.ReturnsAsync(InvalidResult_Encrypted());
// Act
var act = async () => await Sut.ExtractSwissQrCodeValidatedAsync(pdfBytes, throwIfInvalid: true);
// Assert
var ex = await act.Should().ThrowAsync<InvalidOperationException>();
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<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("API unreachable"));
// Act & Assert
await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(pdfBytes))
.Should().ThrowAsync<HttpRequestException>()
.WithMessage("*API unreachable*");
_swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(pdfBytes, false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Extraction failed"));
// Act & Assert
await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(pdfBytes))
.Should().ThrowAsync<HttpRequestException>()
.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<Stream>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(It.IsAny<Stream>(), false, It.IsAny<CancellationToken>()))
.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<Stream>(), It.IsAny<CancellationToken>()), Times.Once);
_swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny<Stream>(), false, It.IsAny<CancellationToken>()), 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<Stream>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(It.IsAny<Stream>(), false, It.IsAny<CancellationToken>()))
.Callback<Stream, bool, CancellationToken>((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<Stream>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(InvalidResult_Encrypted());
using var pdfStream = new MemoryStream(EncryptedPdfBytes());
// Act & Assert
await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(pdfStream, throwIfInvalid: true))
.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*PDF validation failed*");
_swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Stream_InvalidPdf_ThrowIfInvalidFalse_ReturnsNull()
{
// Arrange
_validation.Setup(v => v.ValidatePdfAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
.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<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Stream_ValidPdf_RawMode_PassesRawFlagToExtraction()
{
// Arrange
_validation.Setup(v => v.ValidatePdfAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(It.IsAny<Stream>(), true, It.IsAny<CancellationToken>()))
.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<Stream>(), true, It.IsAny<CancellationToken>()), 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<byte[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), false, It.IsAny<CancellationToken>()))
.ReturnsAsync(QrResult());
// Act
var result = await Sut.ExtractSwissQrCodeValidatedAsync(tempPath);
// Assert
result.Should().NotBeNull();
_validation.Verify(v => v.ValidatePdfAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()), Times.Once);
_swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), false, It.IsAny<CancellationToken>()), 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<byte[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(InvalidResult_Encrypted());
// Act & Assert
await Sut.Invoking(s => s.ExtractSwissQrCodeValidatedAsync(tempPath, throwIfInvalid: true))
.Should().ThrowAsync<InvalidOperationException>();
_swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), 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<byte[]>(), It.IsAny<CancellationToken>()))
.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<FileNotFoundException>();
_validation.Verify(v => v.ValidatePdfAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()), Times.Never);
_swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), 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<byte[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ValidResult());
_swissQrCode.Setup(q => q.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), true, It.IsAny<CancellationToken>()))
.ReturnsAsync(RawQrResult());
// Act
var result = await Sut.ExtractSwissQrCodeValidatedAsync(tempPath, raw: true);
// Assert
result.Should().NotBeNull();
_swissQrCode.Verify(q => q.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), true, It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.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<byte[]>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
Times.Never,
"extraction must not be attempted on an invalid document");
}
}