Refactor WorkflowsClient for unified result handling
Refactored `ExtractSwissQrCodeAsync` to `InspectSwissQrCodeAsync`, introducing `SwissQrCodeResult` to encapsulate both validation and extraction results. Removed `throwIfInvalid` parameter, ensuring validation details are always returned without exceptions. Added extension methods for file path handling to improve usability. Updated tests to reflect the new behavior, ensuring extraction is never attempted on invalid documents and validation results are always populated. Simplified API surface by removing redundant overloads and improving stream handling. Enhanced documentation and performed general code cleanup for better maintainability.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
using DocumentService.Application.Common.DTOs;
|
using DocumentService.Application.Common.DTOs;
|
||||||
using DocumentService.Client.Interfaces;
|
using DocumentService.Client.Interfaces;
|
||||||
|
using DocumentService.Client.Models.Results;
|
||||||
|
|
||||||
namespace DocumentService.Client.Clients;
|
namespace DocumentService.Client.Clients;
|
||||||
|
|
||||||
@@ -10,75 +11,63 @@ namespace DocumentService.Client.Clients;
|
|||||||
public class WorkflowsClient(IPdfValidationClient validation, ISwissQrCodeClient swissQrCode) : IWorkflowsClient
|
public class WorkflowsClient(IPdfValidationClient validation, ISwissQrCodeClient swissQrCode) : IWorkflowsClient
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
|
public async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
|
||||||
byte[] pdfBytes,
|
byte[] pdfBytes,
|
||||||
bool raw = false,
|
bool raw = false,
|
||||||
bool throwIfInvalid = true,
|
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var validationResult = await validation.ValidateAsync(pdfBytes, cancellationToken);
|
var validationResult = await validation.ValidateAsync(pdfBytes, cancellationToken);
|
||||||
|
var qrCode = validationResult.IsValid
|
||||||
if (!validationResult.IsValid)
|
? await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken)
|
||||||
return throwIfInvalid
|
: null;
|
||||||
? throw new InvalidOperationException(
|
return new SwissQrCodeResult(qrCode, validationResult);
|
||||||
$"PDF validation failed (IsEncrypted={validationResult.IsEncrypted}, " +
|
|
||||||
$"PageCount={validationResult.PageCount}, FileSize={validationResult.FileSizeBytes}).")
|
|
||||||
: (SwissQrCodeExtractionResult?)null;
|
|
||||||
|
|
||||||
return await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
|
public async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
|
||||||
Stream pdfStream,
|
Stream pdfStream,
|
||||||
bool raw = false,
|
bool raw = false,
|
||||||
bool throwIfInvalid = true,
|
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var validationResult = await validation.ValidateAsync(pdfStream, cancellationToken);
|
var validationResult = await validation.ValidateAsync(pdfStream, cancellationToken);
|
||||||
|
|
||||||
if (!validationResult.IsValid)
|
if (!validationResult.IsValid)
|
||||||
return throwIfInvalid
|
return new SwissQrCodeResult(null, validationResult);
|
||||||
? throw new InvalidOperationException(
|
|
||||||
$"PDF validation failed (IsEncrypted={validationResult.IsEncrypted}, " +
|
|
||||||
$"PageCount={validationResult.PageCount}, FileSize={validationResult.FileSizeBytes}).")
|
|
||||||
: (SwissQrCodeExtractionResult?)null;
|
|
||||||
|
|
||||||
// Stream was consumed by validation – reset if possible, otherwise re-open is caller's responsibility.
|
// Stream was consumed by validation – reset if possible, otherwise re-open is caller's responsibility.
|
||||||
// Since callers typically pass a FileStream or MemoryStream, Position reset covers the common case.
|
|
||||||
if (pdfStream.CanSeek)
|
if (pdfStream.CanSeek)
|
||||||
pdfStream.Position = 0;
|
pdfStream.Position = 0;
|
||||||
|
|
||||||
return await swissQrCode.ExtractAsync(pdfStream, raw, cancellationToken);
|
var qrCode = await swissQrCode.ExtractAsync(pdfStream, raw, cancellationToken);
|
||||||
}
|
return new SwissQrCodeResult(qrCode, validationResult);
|
||||||
|
}
|
||||||
/// <inheritdoc />
|
}
|
||||||
public async Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
|
|
||||||
string filePath,
|
/// <summary>
|
||||||
bool raw = false,
|
/// Convenience extensions for <see cref="IWorkflowsClient"/> that accept a file path as input.
|
||||||
bool throwIfInvalid = true,
|
/// Defined alongside <see cref="WorkflowsClient"/> so they are discoverable without an additional using directive.
|
||||||
CancellationToken cancellationToken = default)
|
/// </summary>
|
||||||
{
|
public static class WorkflowsClientExtensions
|
||||||
#if NETFRAMEWORK
|
{
|
||||||
using var stream = File.OpenRead(filePath);
|
/// <summary>
|
||||||
var validationResult = await validation.ValidateAsync(stream, cancellationToken);
|
/// Validates the PDF at <paramref name="filePath"/> and attempts Swiss QR Code extraction
|
||||||
#else
|
/// in a single call. Opens a <see cref="FileStream"/> and delegates to
|
||||||
var pdfBytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
|
/// <see cref="IWorkflowsClient.InspectSwissQrCodeAsync(Stream, bool, CancellationToken)"/>.
|
||||||
var validationResult = await validation.ValidateAsync(pdfBytes, cancellationToken);
|
/// </summary>
|
||||||
#endif
|
/// <param name="client">The workflows client.</param>
|
||||||
|
/// <param name="filePath">Absolute or relative path to the PDF file.</param>
|
||||||
if (!validationResult.IsValid)
|
/// <param name="raw">
|
||||||
return throwIfInvalid
|
/// <c>false</c> (default) – returns a parsed Bill object.<br/>
|
||||||
? throw new InvalidOperationException(
|
/// <c>true</c> – returns raw QR text lines without parsing.
|
||||||
$"PDF validation failed (IsEncrypted={validationResult.IsEncrypted}, " +
|
/// </param>
|
||||||
$"PageCount={validationResult.PageCount}, FileSize={validationResult.FileSizeBytes}).")
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
: (SwissQrCodeExtractionResult?)null;
|
public static async Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
|
||||||
|
this IWorkflowsClient client,
|
||||||
#if NETFRAMEWORK
|
string filePath,
|
||||||
using var extractStream = File.OpenRead(filePath);
|
bool raw = false,
|
||||||
return await swissQrCode.ExtractAsync(extractStream, raw, cancellationToken);
|
CancellationToken cancellationToken = default)
|
||||||
#else
|
{
|
||||||
return await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken);
|
using var stream = File.OpenRead(filePath);
|
||||||
#endif
|
return await client.InspectSwissQrCodeAsync(stream, raw, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using DocumentService.Application.Common.DTOs;
|
using DocumentService.Application.Common.DTOs;
|
||||||
|
using DocumentService.Client.Models.Results;
|
||||||
|
|
||||||
namespace DocumentService.Client.Interfaces;
|
namespace DocumentService.Client.Interfaces;
|
||||||
|
|
||||||
@@ -9,51 +10,33 @@ namespace DocumentService.Client.Interfaces;
|
|||||||
public interface IWorkflowsClient
|
public interface IWorkflowsClient
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates the PDF and extracts its Swiss QR Code in a single call.
|
/// Validates the PDF and attempts Swiss QR Code extraction in a single call,
|
||||||
/// Validation runs first; extraction is only attempted on valid documents.
|
/// returning both results together. The method never throws on invalid documents –
|
||||||
|
/// the caller receives the full <see cref="PdfValidationResult"/> regardless of outcome,
|
||||||
|
/// enabling detailed logging, diagnostics, or batch processing without try/catch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pdfBytes">PDF file as byte array.</param>
|
/// <param name="pdfBytes">PDF file as byte array.</param>
|
||||||
/// <param name="raw">
|
/// <param name="raw">
|
||||||
/// <c>false</c> (default) – returns a parsed <see cref="SwissQrCodeExtractionResult.Bill"/> object.<br/>
|
/// <c>false</c> (default) – returns a parsed <see cref="SwissQrCodeExtractionResult.Bill"/> object.<br/>
|
||||||
/// <c>true</c> – returns raw QR text lines without parsing.
|
/// <c>true</c> – returns raw QR text lines without parsing.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="throwIfInvalid">
|
|
||||||
/// <c>true</c> (default) – throws <see cref="InvalidOperationException"/> when the PDF fails validation.<br/>
|
|
||||||
/// <c>false</c> – returns <c>null</c> instead, allowing the caller to handle the case without a try/catch.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// Swiss QR Code extraction result, or <c>null</c> if the document is invalid and
|
/// A <see cref="SwissQrCodeResult"/> containing the <see cref="PdfValidationResult"/>
|
||||||
/// <paramref name="throwIfInvalid"/> is <c>false</c>.
|
/// (always populated) and the <see cref="SwissQrCodeExtractionResult"/> (<c>null</c> if the
|
||||||
|
/// document was invalid).
|
||||||
/// </returns>
|
/// </returns>
|
||||||
/// <exception cref="InvalidOperationException">
|
Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
|
||||||
/// Thrown when the PDF fails validation and <paramref name="throwIfInvalid"/> is <c>true</c>.
|
|
||||||
/// </exception>
|
|
||||||
Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
|
|
||||||
byte[] pdfBytes,
|
byte[] pdfBytes,
|
||||||
bool raw = false,
|
bool raw = false,
|
||||||
bool throwIfInvalid = true,
|
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)"/>
|
/// <inheritdoc cref="InspectSwissQrCodeAsync(byte[], bool, CancellationToken)"/>
|
||||||
/// <param name="pdfStream">PDF file as a stream.</param>
|
/// <param name="pdfStream">PDF file as a stream.</param>
|
||||||
/// <param name="raw"><inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)" path="/param[@name='raw']"/></param>
|
/// <param name="raw"><inheritdoc cref="InspectSwissQrCodeAsync(byte[], bool, CancellationToken)" path="/param[@name=''raw'']"/></param>
|
||||||
/// <param name="throwIfInvalid"><inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)" path="/param[@name='throwIfInvalid']"/></param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
|
Task<SwissQrCodeResult> InspectSwissQrCodeAsync(
|
||||||
Stream pdfStream,
|
Stream pdfStream,
|
||||||
bool raw = false,
|
bool raw = false,
|
||||||
bool throwIfInvalid = true,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)"/>
|
|
||||||
/// <param name="filePath">Absolute or relative path to the PDF file.</param>
|
|
||||||
/// <param name="raw"><inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)" path="/param[@name='raw']"/></param>
|
|
||||||
/// <param name="throwIfInvalid"><inheritdoc cref="ExtractSwissQrCodeAsync(byte[], bool, bool, CancellationToken)" path="/param[@name='throwIfInvalid']"/></param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
Task<SwissQrCodeExtractionResult?> ExtractSwissQrCodeAsync(
|
|
||||||
string filePath,
|
|
||||||
bool raw = false,
|
|
||||||
bool throwIfInvalid = true,
|
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
21
DocumentService.Client/Models/Results/SwissQrCodeResult.cs
Normal file
21
DocumentService.Client/Models/Results/SwissQrCodeResult.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using DocumentService.Application.Common.DTOs;
|
||||||
|
|
||||||
|
namespace DocumentService.Client.Models.Results;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Combined result of a PDF validation followed by a Swiss QR Code extraction.
|
||||||
|
/// Both operations are always executed; <see cref="QrCode"/> is <c>null</c> when
|
||||||
|
/// the document fails validation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="QrCode">
|
||||||
|
/// Extracted Swiss QR Code data, or <c>null</c> if the PDF was invalid or did not
|
||||||
|
/// contain a recognisable Swiss QR Code.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Validation">
|
||||||
|
/// PDF validation result. Always populated regardless of whether extraction succeeded.
|
||||||
|
/// Use <see cref="DocumentService.Application.Common.DTOs.PdfValidationResult.IsValid"/>
|
||||||
|
/// to determine whether the extraction was attempted.
|
||||||
|
/// </param>
|
||||||
|
public record SwissQrCodeResult(
|
||||||
|
SwissQrCodeExtractionResult? QrCode,
|
||||||
|
PdfValidationResult Validation);
|
||||||
@@ -20,6 +20,8 @@ public class WorkflowsClientTests
|
|||||||
private readonly Mock<IPdfValidationClient> _validation = new();
|
private readonly Mock<IPdfValidationClient> _validation = new();
|
||||||
private readonly Mock<ISwissQrCodeClient> _swissQrCode = new();
|
private readonly Mock<ISwissQrCodeClient> _swissQrCode = new();
|
||||||
private WorkflowsClient Sut => new(_validation.Object, _swissQrCode.Object);
|
private WorkflowsClient Sut => new(_validation.Object, _swissQrCode.Object);
|
||||||
|
// Extension methods are defined on IWorkflowsClient — use this for filePath overload tests.
|
||||||
|
private IWorkflowsClient SutAsInterface => Sut;
|
||||||
|
|
||||||
// ─── fixture data ─────────────────────────────────────────────────────────
|
// ─── fixture data ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -75,12 +77,9 @@ public class WorkflowsClientTests
|
|||||||
private static SwissQrCodeExtractionResult RawQrResult() =>
|
private static SwissQrCodeExtractionResult RawQrResult() =>
|
||||||
new(Bill: (SwissQrBillDto?)null, RawLines: ["SPC", "0200", "1", "CH9300762011623852957"]);
|
new(Bill: (SwissQrBillDto?)null, RawLines: ["SPC", "0200", "1", "CH9300762011623852957"]);
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════
|
|
||||||
// byte[] overload
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Bytes_ValidPdf_CallsValidationThenExtraction_ReturnsResult()
|
public async Task Inspect_Bytes_ValidPdf_ReturnsBothValidationAndQrCode()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var pdfBytes = ValidPdfBytes();
|
var pdfBytes = ValidPdfBytes();
|
||||||
@@ -90,18 +89,56 @@ public class WorkflowsClientTests
|
|||||||
.ReturnsAsync(QrResult());
|
.ReturnsAsync(QrResult());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await Sut.ExtractSwissQrCodeAsync(pdfBytes);
|
var result = await Sut.InspectSwissQrCodeAsync(pdfBytes);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Validation.IsValid.Should().BeTrue();
|
||||||
result!.Bill.Should().NotBeNull();
|
result.QrCode.Should().NotBeNull();
|
||||||
result.Bill!.Currency.Should().Be("CHF");
|
result.QrCode!.Bill!.Currency.Should().Be("CHF");
|
||||||
_validation.Verify(v => v.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()), Times.Once);
|
_validation.Verify(v => v.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, false, It.IsAny<CancellationToken>()), Times.Once);
|
_swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, false, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Bytes_ValidPdf_RawMode_PassesRawFlagToExtraction()
|
public async Task Inspect_Bytes_InvalidPdf_ReturnsValidationWithNullQrCode_NeverThrows()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var pdfBytes = EncryptedPdfBytes();
|
||||||
|
_validation.Setup(v => v.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(InvalidResult_Encrypted());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await Sut.InspectSwissQrCodeAsync(pdfBytes);
|
||||||
|
|
||||||
|
// Assert – no exception, validation details always available
|
||||||
|
result.Validation.IsValid.Should().BeFalse();
|
||||||
|
result.Validation.IsEncrypted.Should().BeTrue();
|
||||||
|
result.QrCode.Should().BeNull("extraction must not run on an invalid document");
|
||||||
|
_swissQrCode.Verify(q => q.ExtractAsync(It.IsAny<byte[]>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Inspect_Bytes_ValidationAlwaysPopulated_RegardlessOfQrOutcome()
|
||||||
|
{
|
||||||
|
// Arrange – valid PDF but QR extraction returns empty lines (no QR code found)
|
||||||
|
var pdfBytes = ValidPdfBytes();
|
||||||
|
var emptyQr = new SwissQrCodeExtractionResult(Bill: (SwissQrBillDto?)null, RawLines: []);
|
||||||
|
_validation.Setup(v => v.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(ValidResult());
|
||||||
|
_swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, false, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(emptyQr);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await Sut.InspectSwissQrCodeAsync(pdfBytes);
|
||||||
|
|
||||||
|
// Assert – validation is always there even when QR is empty
|
||||||
|
result.Validation.Should().NotBeNull();
|
||||||
|
result.Validation.PageCount.Should().Be(1);
|
||||||
|
result.QrCode.Should().NotBeNull(); // extraction was attempted
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Inspect_Bytes_RawMode_PassesRawFlagToExtraction()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var pdfBytes = QrPdfBytes();
|
var pdfBytes = QrPdfBytes();
|
||||||
@@ -111,109 +148,15 @@ public class WorkflowsClientTests
|
|||||||
.ReturnsAsync(RawQrResult());
|
.ReturnsAsync(RawQrResult());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await Sut.ExtractSwissQrCodeAsync(pdfBytes, raw: true);
|
var result = await Sut.InspectSwissQrCodeAsync(pdfBytes, raw: true);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.QrCode!.RawLines.Should().Contain("SPC");
|
||||||
result!.RawLines.Should().Contain("SPC");
|
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, true, It.IsAny<CancellationToken>()), Times.Once);
|
_swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, true, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Bytes_InvalidPdf_ThrowIfInvalidTrue_ThrowsInvalidOperationException()
|
public async Task Inspect_Bytes_CancellationToken_ForwardedToBothClients()
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var pdfBytes = EncryptedPdfBytes();
|
|
||||||
_validation.Setup(v => v.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(InvalidResult_Encrypted());
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var act = async () => await Sut.ExtractSwissQrCodeAsync(pdfBytes, throwIfInvalid: true);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
await act.Should().ThrowAsync<InvalidOperationException>()
|
|
||||||
.WithMessage("*PDF validation failed*");
|
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(InvalidResult_Encrypted());
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await Sut.ExtractSwissQrCodeAsync(pdfBytes, throwIfInvalid: false);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().BeNull();
|
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(InvalidResult_EmptyFile());
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await Sut.Invoking(s => s.ExtractSwissQrCodeAsync(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.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(InvalidResult_Encrypted());
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var act = async () => await Sut.ExtractSwissQrCodeAsync(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.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
|
|
||||||
.ThrowsAsync(new HttpRequestException("API unreachable"));
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await Sut.Invoking(s => s.ExtractSwissQrCodeAsync(pdfBytes))
|
|
||||||
.Should().ThrowAsync<HttpRequestException>()
|
|
||||||
.WithMessage("*API unreachable*");
|
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(ValidResult());
|
|
||||||
_swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, false, It.IsAny<CancellationToken>()))
|
|
||||||
.ThrowsAsync(new HttpRequestException("Extraction failed"));
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await Sut.Invoking(s => s.ExtractSwissQrCodeAsync(pdfBytes))
|
|
||||||
.Should().ThrowAsync<HttpRequestException>()
|
|
||||||
.WithMessage("*Extraction failed*");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Bytes_ValidPdf_CancellationToken_IsForwardedToBothClients()
|
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var pdfBytes = ValidPdfBytes();
|
var pdfBytes = ValidPdfBytes();
|
||||||
@@ -223,21 +166,17 @@ public class WorkflowsClientTests
|
|||||||
_swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, false, token)).ReturnsAsync(QrResult());
|
_swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, false, token)).ReturnsAsync(QrResult());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await Sut.ExtractSwissQrCodeAsync(pdfBytes, cancellationToken: token);
|
await Sut.InspectSwissQrCodeAsync(pdfBytes, cancellationToken: token);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
_validation.Verify(v => v.ValidateAsync(pdfBytes, token), Times.Once);
|
_validation.Verify(v => v.ValidateAsync(pdfBytes, token), Times.Once);
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, false, token), Times.Once);
|
_swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, false, token), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════
|
|
||||||
// Stream overload
|
|
||||||
// ═════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Stream_ValidPdf_CallsValidationWithStreamThenExtractionWithStream_ReturnsResult()
|
public async Task Inspect_Stream_ValidPdf_ReturnsBothResults()
|
||||||
{
|
{
|
||||||
// Arrange – validation client receives a Stream, extraction client receives the same stream reset to 0
|
// Arrange
|
||||||
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(ValidResult());
|
.ReturnsAsync(ValidResult());
|
||||||
_swissQrCode.Setup(q => q.ExtractAsync(It.IsAny<Stream>(), false, It.IsAny<CancellationToken>()))
|
_swissQrCode.Setup(q => q.ExtractAsync(It.IsAny<Stream>(), false, It.IsAny<CancellationToken>()))
|
||||||
@@ -246,53 +185,15 @@ public class WorkflowsClientTests
|
|||||||
using var pdfStream = new MemoryStream(ValidPdfBytes());
|
using var pdfStream = new MemoryStream(ValidPdfBytes());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await Sut.ExtractSwissQrCodeAsync(pdfStream);
|
var result = await Sut.InspectSwissQrCodeAsync(pdfStream);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Validation.IsValid.Should().BeTrue();
|
||||||
_validation.Verify(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()), Times.Once);
|
result.QrCode.Should().NotBeNull();
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(It.IsAny<Stream>(), false, It.IsAny<CancellationToken>()), Times.Once);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Stream_SeekableStream_IsResetBetweenValidationAndExtraction()
|
public async Task Inspect_Stream_InvalidPdf_ReturnsValidationWithNullQrCode_NeverThrows()
|
||||||
{
|
|
||||||
// Arrange – we verify the stream position is 0 when extraction is called
|
|
||||||
Stream? streamAtExtraction = null;
|
|
||||||
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(ValidResult());
|
|
||||||
_swissQrCode.Setup(q => q.ExtractAsync(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.ExtractSwissQrCodeAsync(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.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(InvalidResult_Encrypted());
|
|
||||||
|
|
||||||
using var pdfStream = new MemoryStream(EncryptedPdfBytes());
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await Sut.Invoking(s => s.ExtractSwissQrCodeAsync(pdfStream, throwIfInvalid: true))
|
|
||||||
.Should().ThrowAsync<InvalidOperationException>()
|
|
||||||
.WithMessage("*PDF validation failed*");
|
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Stream_InvalidPdf_ThrowIfInvalidFalse_ReturnsNull()
|
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||||
@@ -301,173 +202,56 @@ public class WorkflowsClientTests
|
|||||||
using var pdfStream = new MemoryStream(EncryptedPdfBytes());
|
using var pdfStream = new MemoryStream(EncryptedPdfBytes());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await Sut.ExtractSwissQrCodeAsync(pdfStream, throwIfInvalid: false);
|
var result = await Sut.InspectSwissQrCodeAsync(pdfStream);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().BeNull();
|
result.Validation.IsValid.Should().BeFalse();
|
||||||
|
result.QrCode.Should().BeNull();
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
_swissQrCode.Verify(q => q.ExtractAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Stream_ValidPdf_RawMode_PassesRawFlagToExtraction()
|
public async Task Inspect_FilePath_ValidPdf_ReturnsBothResults()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange – extension opens a FileStream so mock expects Stream overload
|
||||||
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(ValidResult());
|
|
||||||
_swissQrCode.Setup(q => q.ExtractAsync(It.IsAny<Stream>(), true, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(RawQrResult());
|
|
||||||
|
|
||||||
using var pdfStream = new MemoryStream(QrPdfBytes());
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await Sut.ExtractSwissQrCodeAsync(pdfStream, raw: true);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(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();
|
var tempPath = Path.GetTempFileName();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await File.WriteAllBytesAsync(tempPath, ValidPdfBytes());
|
await File.WriteAllBytesAsync(tempPath, ValidPdfBytes());
|
||||||
|
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||||
_validation.Setup(v => v.ValidateAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(ValidResult());
|
.ReturnsAsync(ValidResult());
|
||||||
_swissQrCode.Setup(q => q.ExtractAsync(It.IsAny<byte[]>(), false, It.IsAny<CancellationToken>()))
|
_swissQrCode.Setup(q => q.ExtractAsync(It.IsAny<Stream>(), false, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(QrResult());
|
.ReturnsAsync(QrResult());
|
||||||
|
|
||||||
// Act
|
// Act – calls extension method which opens a FileStream and delegates to Stream overload
|
||||||
var result = await Sut.ExtractSwissQrCodeAsync(tempPath);
|
var result = await SutAsInterface.InspectSwissQrCodeAsync(tempPath);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Validation.IsValid.Should().BeTrue();
|
||||||
_validation.Verify(v => v.ValidateAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()), Times.Once);
|
result.QrCode.Should().NotBeNull();
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(It.IsAny<byte[]>(), false, It.IsAny<CancellationToken>()), Times.Once);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(tempPath);
|
|
||||||
}
|
}
|
||||||
|
finally { File.Delete(tempPath); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FilePath_InvalidPdf_ThrowIfInvalidTrue_ThrowsWithoutCallingExtraction()
|
public async Task Inspect_FilePath_InvalidPdf_ReturnsValidationWithNullQrCode_NeverThrows()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var tempPath = Path.GetTempFileName();
|
var tempPath = Path.GetTempFileName();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await File.WriteAllBytesAsync(tempPath, EncryptedPdfBytes());
|
await File.WriteAllBytesAsync(tempPath, EncryptedPdfBytes());
|
||||||
|
_validation.Setup(v => v.ValidateAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
|
||||||
_validation.Setup(v => v.ValidateAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(InvalidResult_Encrypted());
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await Sut.Invoking(s => s.ExtractSwissQrCodeAsync(tempPath, throwIfInvalid: true))
|
|
||||||
.Should().ThrowAsync<InvalidOperationException>();
|
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(InvalidResult_Encrypted());
|
.ReturnsAsync(InvalidResult_Encrypted());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await Sut.ExtractSwissQrCodeAsync(tempPath, throwIfInvalid: false);
|
var result = await SutAsInterface.InspectSwissQrCodeAsync(tempPath);
|
||||||
|
|
||||||
// Assert
|
// Assert – never throws, validation details always populated
|
||||||
result.Should().BeNull();
|
result.Validation.IsValid.Should().BeFalse();
|
||||||
}
|
result.QrCode.Should().BeNull();
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(tempPath);
|
|
||||||
}
|
}
|
||||||
|
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.ExtractSwissQrCodeAsync(nonExistent))
|
|
||||||
.Should().ThrowAsync<FileNotFoundException>();
|
|
||||||
_validation.Verify(v => v.ValidateAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(ValidResult());
|
|
||||||
_swissQrCode.Setup(q => q.ExtractAsync(It.IsAny<byte[]>(), true, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(RawQrResult());
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await Sut.ExtractSwissQrCodeAsync(tempPath, raw: true);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
_swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(pdfBytes, It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(InvalidResult_Encrypted());
|
|
||||||
|
|
||||||
// Act
|
|
||||||
try { await Sut.ExtractSwissQrCodeAsync(pdfBytes, throwIfInvalid: throwIfInvalid); }
|
|
||||||
catch (InvalidOperationException) { /* expected when throwIfInvalid=true */ }
|
|
||||||
|
|
||||||
// Assert — extraction must never be called in either branch
|
|
||||||
_swissQrCode.Verify(
|
|
||||||
q => q.ExtractAsync(It.IsAny<byte[]>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
|
||||||
Times.Never,
|
|
||||||
"extraction must not be attempted on an invalid document");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user