diff --git a/DocumentService.Client/Clients/WorkflowsClient.cs b/DocumentService.Client/Clients/WorkflowsClient.cs index 58a37a4..bc3e17a 100644 --- a/DocumentService.Client/Clients/WorkflowsClient.cs +++ b/DocumentService.Client/Clients/WorkflowsClient.cs @@ -1,5 +1,6 @@ using DocumentService.Application.Common.DTOs; using DocumentService.Client.Interfaces; +using DocumentService.Client.Models.Results; namespace DocumentService.Client.Clients; @@ -10,75 +11,63 @@ namespace DocumentService.Client.Clients; public class WorkflowsClient(IPdfValidationClient validation, ISwissQrCodeClient swissQrCode) : IWorkflowsClient { /// - public async Task ExtractSwissQrCodeAsync( + public async Task InspectSwissQrCodeAsync( byte[] pdfBytes, bool raw = false, - bool throwIfInvalid = true, CancellationToken cancellationToken = default) { var validationResult = await validation.ValidateAsync(pdfBytes, cancellationToken); - - if (!validationResult.IsValid) - return throwIfInvalid - ? throw new InvalidOperationException( - $"PDF validation failed (IsEncrypted={validationResult.IsEncrypted}, " + - $"PageCount={validationResult.PageCount}, FileSize={validationResult.FileSizeBytes}).") - : (SwissQrCodeExtractionResult?)null; - - return await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken); + var qrCode = validationResult.IsValid + ? await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken) + : null; + return new SwissQrCodeResult(qrCode, validationResult); } /// - public async Task ExtractSwissQrCodeAsync( + public async Task InspectSwissQrCodeAsync( Stream pdfStream, bool raw = false, - bool throwIfInvalid = true, CancellationToken cancellationToken = default) { var validationResult = await validation.ValidateAsync(pdfStream, cancellationToken); if (!validationResult.IsValid) - return throwIfInvalid - ? throw new InvalidOperationException( - $"PDF validation failed (IsEncrypted={validationResult.IsEncrypted}, " + - $"PageCount={validationResult.PageCount}, FileSize={validationResult.FileSizeBytes}).") - : (SwissQrCodeExtractionResult?)null; + return new SwissQrCodeResult(null, validationResult); // 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) pdfStream.Position = 0; - return await swissQrCode.ExtractAsync(pdfStream, raw, cancellationToken); - } - - /// - public async Task ExtractSwissQrCodeAsync( - string filePath, - bool raw = false, - bool throwIfInvalid = true, - CancellationToken cancellationToken = default) - { -#if NETFRAMEWORK - using var stream = File.OpenRead(filePath); - var validationResult = await validation.ValidateAsync(stream, cancellationToken); -#else - var pdfBytes = await File.ReadAllBytesAsync(filePath, cancellationToken); - var validationResult = await validation.ValidateAsync(pdfBytes, cancellationToken); -#endif - - if (!validationResult.IsValid) - return throwIfInvalid - ? throw new InvalidOperationException( - $"PDF validation failed (IsEncrypted={validationResult.IsEncrypted}, " + - $"PageCount={validationResult.PageCount}, FileSize={validationResult.FileSizeBytes}).") - : (SwissQrCodeExtractionResult?)null; - -#if NETFRAMEWORK - using var extractStream = File.OpenRead(filePath); - return await swissQrCode.ExtractAsync(extractStream, raw, cancellationToken); -#else - return await swissQrCode.ExtractAsync(pdfBytes, raw, cancellationToken); -#endif + var qrCode = await swissQrCode.ExtractAsync(pdfStream, raw, cancellationToken); + return new SwissQrCodeResult(qrCode, validationResult); + } +} + +/// +/// Convenience extensions for that accept a file path as input. +/// Defined alongside so they are discoverable without an additional using directive. +/// +public static class WorkflowsClientExtensions +{ + /// + /// Validates the PDF at and attempts Swiss QR Code extraction + /// in a single call. Opens a and delegates to + /// . + /// + /// The workflows client. + /// Absolute or relative path to the PDF file. + /// + /// false (default) – returns a parsed Bill object.
+ /// true – returns raw QR text lines without parsing. + /// + /// Cancellation token. + public static async Task InspectSwissQrCodeAsync( + this IWorkflowsClient client, + string filePath, + bool raw = false, + CancellationToken cancellationToken = default) + { + using var stream = File.OpenRead(filePath); + return await client.InspectSwissQrCodeAsync(stream, raw, cancellationToken); } } diff --git a/DocumentService.Client/Interfaces/IWorkflowsClient.cs b/DocumentService.Client/Interfaces/IWorkflowsClient.cs index 25b957f..93400cc 100644 --- a/DocumentService.Client/Interfaces/IWorkflowsClient.cs +++ b/DocumentService.Client/Interfaces/IWorkflowsClient.cs @@ -1,4 +1,5 @@ using DocumentService.Application.Common.DTOs; +using DocumentService.Client.Models.Results; namespace DocumentService.Client.Interfaces; @@ -9,51 +10,33 @@ namespace DocumentService.Client.Interfaces; public interface IWorkflowsClient { /// - /// Validates the PDF and extracts its Swiss QR Code in a single call. - /// Validation runs first; extraction is only attempted on valid documents. + /// Validates the PDF and attempts Swiss QR Code extraction in a single call, + /// returning both results together. The method never throws on invalid documents – + /// the caller receives the full regardless of outcome, + /// enabling detailed logging, diagnostics, or batch processing without try/catch. /// /// PDF file as byte array. /// /// false (default) – returns a parsed object.
/// true – returns raw QR text lines without parsing. /// - /// - /// true (default) – throws when the PDF fails validation.
- /// false – returns null instead, allowing the caller to handle the case without a try/catch. - /// /// Cancellation token. /// - /// Swiss QR Code extraction result, or null if the document is invalid and - /// is false. + /// A containing the + /// (always populated) and the (null if the + /// document was invalid). /// - /// - /// Thrown when the PDF fails validation and is true. - /// - Task ExtractSwissQrCodeAsync( + Task InspectSwissQrCodeAsync( byte[] pdfBytes, bool raw = false, - bool throwIfInvalid = true, CancellationToken cancellationToken = default); - /// + /// /// PDF file as a stream. - /// - /// + /// /// Cancellation token. - Task ExtractSwissQrCodeAsync( + Task InspectSwissQrCodeAsync( Stream pdfStream, bool raw = false, - bool throwIfInvalid = true, - CancellationToken cancellationToken = default); - - /// - /// Absolute or relative path to the PDF file. - /// - /// - /// Cancellation token. - Task ExtractSwissQrCodeAsync( - string filePath, - bool raw = false, - bool throwIfInvalid = true, CancellationToken cancellationToken = default); } diff --git a/DocumentService.Client/Models/Results/SwissQrCodeResult.cs b/DocumentService.Client/Models/Results/SwissQrCodeResult.cs new file mode 100644 index 0000000..9656353 --- /dev/null +++ b/DocumentService.Client/Models/Results/SwissQrCodeResult.cs @@ -0,0 +1,21 @@ +using DocumentService.Application.Common.DTOs; + +namespace DocumentService.Client.Models.Results; + +/// +/// Combined result of a PDF validation followed by a Swiss QR Code extraction. +/// Both operations are always executed; is null when +/// the document fails validation. +/// +/// +/// Extracted Swiss QR Code data, or null if the PDF was invalid or did not +/// contain a recognisable Swiss QR Code. +/// +/// +/// PDF validation result. Always populated regardless of whether extraction succeeded. +/// Use +/// to determine whether the extraction was attempted. +/// +public record SwissQrCodeResult( + SwissQrCodeExtractionResult? QrCode, + PdfValidationResult Validation); diff --git a/DocumentService.Tests/Unit/Client/WorkflowsClientTests.cs b/DocumentService.Tests/Unit/Client/WorkflowsClientTests.cs index 6dffc01..86343e8 100644 --- a/DocumentService.Tests/Unit/Client/WorkflowsClientTests.cs +++ b/DocumentService.Tests/Unit/Client/WorkflowsClientTests.cs @@ -20,6 +20,8 @@ public class WorkflowsClientTests private readonly Mock _validation = new(); private readonly Mock _swissQrCode = new(); 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 ───────────────────────────────────────────────────────── @@ -75,12 +77,9 @@ public class WorkflowsClientTests private static SwissQrCodeExtractionResult RawQrResult() => new(Bill: (SwissQrBillDto?)null, RawLines: ["SPC", "0200", "1", "CH9300762011623852957"]); - // ═════════════════════════════════════════════════════════════════════════ - // byte[] overload - // ═════════════════════════════════════════════════════════════════════════ [Fact] - public async Task Bytes_ValidPdf_CallsValidationThenExtraction_ReturnsResult() + public async Task Inspect_Bytes_ValidPdf_ReturnsBothValidationAndQrCode() { // Arrange var pdfBytes = ValidPdfBytes(); @@ -90,18 +89,56 @@ public class WorkflowsClientTests .ReturnsAsync(QrResult()); // Act - var result = await Sut.ExtractSwissQrCodeAsync(pdfBytes); + var result = await Sut.InspectSwissQrCodeAsync(pdfBytes); // Assert - result.Should().NotBeNull(); - result!.Bill.Should().NotBeNull(); - result.Bill!.Currency.Should().Be("CHF"); + result.Validation.IsValid.Should().BeTrue(); + result.QrCode.Should().NotBeNull(); + result.QrCode!.Bill!.Currency.Should().Be("CHF"); _validation.Verify(v => v.ValidateAsync(pdfBytes, It.IsAny()), Times.Once); _swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, false, It.IsAny()), Times.Once); } [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())) + .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(), It.IsAny(), It.IsAny()), 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())) + .ReturnsAsync(ValidResult()); + _swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, false, It.IsAny())) + .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 var pdfBytes = QrPdfBytes(); @@ -111,109 +148,15 @@ public class WorkflowsClientTests .ReturnsAsync(RawQrResult()); // Act - var result = await Sut.ExtractSwissQrCodeAsync(pdfBytes, raw: true); + var result = await Sut.InspectSwissQrCodeAsync(pdfBytes, raw: true); // Assert - result.Should().NotBeNull(); - result!.RawLines.Should().Contain("SPC"); + result.QrCode!.RawLines.Should().Contain("SPC"); _swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, true, It.IsAny()), Times.Once); } [Fact] - public async Task Bytes_InvalidPdf_ThrowIfInvalidTrue_ThrowsInvalidOperationException() - { - // Arrange - var pdfBytes = EncryptedPdfBytes(); - _validation.Setup(v => v.ValidateAsync(pdfBytes, It.IsAny())) - .ReturnsAsync(InvalidResult_Encrypted()); - - // Act - var act = async () => await Sut.ExtractSwissQrCodeAsync(pdfBytes, throwIfInvalid: true); - - // Assert - await act.Should().ThrowAsync() - .WithMessage("*PDF validation failed*"); - _swissQrCode.Verify(q => q.ExtractAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task Bytes_InvalidPdf_ThrowIfInvalidFalse_ReturnsNull() - { - // Arrange - var pdfBytes = EncryptedPdfBytes(); - _validation.Setup(v => v.ValidateAsync(pdfBytes, It.IsAny())) - .ReturnsAsync(InvalidResult_Encrypted()); - - // Act - var result = await Sut.ExtractSwissQrCodeAsync(pdfBytes, throwIfInvalid: false); - - // Assert - result.Should().BeNull(); - _swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(pdfBytes, It.IsAny())) - .ReturnsAsync(InvalidResult_EmptyFile()); - - // Act & Assert - await Sut.Invoking(s => s.ExtractSwissQrCodeAsync(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.ValidateAsync(pdfBytes, It.IsAny())) - .ReturnsAsync(InvalidResult_Encrypted()); - - // Act - var act = async () => await Sut.ExtractSwissQrCodeAsync(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.ValidateAsync(pdfBytes, It.IsAny())) - .ThrowsAsync(new HttpRequestException("API unreachable")); - - // Act & Assert - await Sut.Invoking(s => s.ExtractSwissQrCodeAsync(pdfBytes)) - .Should().ThrowAsync() - .WithMessage("*API unreachable*"); - _swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(pdfBytes, It.IsAny())) - .ReturnsAsync(ValidResult()); - _swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, false, It.IsAny())) - .ThrowsAsync(new HttpRequestException("Extraction failed")); - - // Act & Assert - await Sut.Invoking(s => s.ExtractSwissQrCodeAsync(pdfBytes)) - .Should().ThrowAsync() - .WithMessage("*Extraction failed*"); - } - - [Fact] - public async Task Bytes_ValidPdf_CancellationToken_IsForwardedToBothClients() + public async Task Inspect_Bytes_CancellationToken_ForwardedToBothClients() { // Arrange var pdfBytes = ValidPdfBytes(); @@ -223,21 +166,17 @@ public class WorkflowsClientTests _swissQrCode.Setup(q => q.ExtractAsync(pdfBytes, false, token)).ReturnsAsync(QrResult()); // Act - await Sut.ExtractSwissQrCodeAsync(pdfBytes, cancellationToken: token); + await Sut.InspectSwissQrCodeAsync(pdfBytes, cancellationToken: token); // Assert _validation.Verify(v => v.ValidateAsync(pdfBytes, token), Times.Once); _swissQrCode.Verify(q => q.ExtractAsync(pdfBytes, false, token), Times.Once); } - // ═════════════════════════════════════════════════════════════════════════ - // Stream overload - // ═════════════════════════════════════════════════════════════════════════ - [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(), It.IsAny())) .ReturnsAsync(ValidResult()); _swissQrCode.Setup(q => q.ExtractAsync(It.IsAny(), false, It.IsAny())) @@ -246,53 +185,15 @@ public class WorkflowsClientTests using var pdfStream = new MemoryStream(ValidPdfBytes()); // Act - var result = await Sut.ExtractSwissQrCodeAsync(pdfStream); + var result = await Sut.InspectSwissQrCodeAsync(pdfStream); // Assert - result.Should().NotBeNull(); - _validation.Verify(v => v.ValidateAsync(It.IsAny(), It.IsAny()), Times.Once); - _swissQrCode.Verify(q => q.ExtractAsync(It.IsAny(), false, It.IsAny()), Times.Once); + result.Validation.IsValid.Should().BeTrue(); + result.QrCode.Should().NotBeNull(); } [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.ValidateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(ValidResult()); - _swissQrCode.Setup(q => q.ExtractAsync(It.IsAny(), false, It.IsAny())) - .Callback((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(), It.IsAny())) - .ReturnsAsync(InvalidResult_Encrypted()); - - using var pdfStream = new MemoryStream(EncryptedPdfBytes()); - - // Act & Assert - await Sut.Invoking(s => s.ExtractSwissQrCodeAsync(pdfStream, throwIfInvalid: true)) - .Should().ThrowAsync() - .WithMessage("*PDF validation failed*"); - _swissQrCode.Verify(q => q.ExtractAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task Stream_InvalidPdf_ThrowIfInvalidFalse_ReturnsNull() + public async Task Inspect_Stream_InvalidPdf_ReturnsValidationWithNullQrCode_NeverThrows() { // Arrange _validation.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) @@ -301,173 +202,56 @@ public class WorkflowsClientTests using var pdfStream = new MemoryStream(EncryptedPdfBytes()); // Act - var result = await Sut.ExtractSwissQrCodeAsync(pdfStream, throwIfInvalid: false); + var result = await Sut.InspectSwissQrCodeAsync(pdfStream); // Assert - result.Should().BeNull(); + result.Validation.IsValid.Should().BeFalse(); + result.QrCode.Should().BeNull(); _swissQrCode.Verify(q => q.ExtractAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task Stream_ValidPdf_RawMode_PassesRawFlagToExtraction() + public async Task Inspect_FilePath_ValidPdf_ReturnsBothResults() { - // Arrange - _validation.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(ValidResult()); - _swissQrCode.Setup(q => q.ExtractAsync(It.IsAny(), true, It.IsAny())) - .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(), 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 + // Arrange – extension opens a FileStream so mock expects Stream overload var tempPath = Path.GetTempFileName(); try { await File.WriteAllBytesAsync(tempPath, ValidPdfBytes()); - - _validation.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) + _validation.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ValidResult()); - _swissQrCode.Setup(q => q.ExtractAsync(It.IsAny(), false, It.IsAny())) + _swissQrCode.Setup(q => q.ExtractAsync(It.IsAny(), false, It.IsAny())) .ReturnsAsync(QrResult()); - // Act - var result = await Sut.ExtractSwissQrCodeAsync(tempPath); + // Act – calls extension method which opens a FileStream and delegates to Stream overload + var result = await SutAsInterface.InspectSwissQrCodeAsync(tempPath); // Assert - result.Should().NotBeNull(); - _validation.Verify(v => v.ValidateAsync(It.IsAny(), It.IsAny()), Times.Once); - _swissQrCode.Verify(q => q.ExtractAsync(It.IsAny(), false, It.IsAny()), Times.Once); - } - finally - { - File.Delete(tempPath); + result.Validation.IsValid.Should().BeTrue(); + result.QrCode.Should().NotBeNull(); } + finally { File.Delete(tempPath); } } [Fact] - public async Task FilePath_InvalidPdf_ThrowIfInvalidTrue_ThrowsWithoutCallingExtraction() + public async Task Inspect_FilePath_InvalidPdf_ReturnsValidationWithNullQrCode_NeverThrows() { // Arrange var tempPath = Path.GetTempFileName(); try { await File.WriteAllBytesAsync(tempPath, EncryptedPdfBytes()); - - _validation.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(InvalidResult_Encrypted()); - - // Act & Assert - await Sut.Invoking(s => s.ExtractSwissQrCodeAsync(tempPath, throwIfInvalid: true)) - .Should().ThrowAsync(); - _swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(It.IsAny(), It.IsAny())) + _validation.Setup(v => v.ValidateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(InvalidResult_Encrypted()); // Act - var result = await Sut.ExtractSwissQrCodeAsync(tempPath, throwIfInvalid: false); + var result = await SutAsInterface.InspectSwissQrCodeAsync(tempPath); - // Assert - result.Should().BeNull(); - } - finally - { - File.Delete(tempPath); + // Assert – never throws, validation details always populated + result.Validation.IsValid.Should().BeFalse(); + result.QrCode.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.ExtractSwissQrCodeAsync(nonExistent)) - .Should().ThrowAsync(); - _validation.Verify(v => v.ValidateAsync(It.IsAny(), It.IsAny()), Times.Never); - _swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(ValidResult()); - _swissQrCode.Setup(q => q.ExtractAsync(It.IsAny(), true, It.IsAny())) - .ReturnsAsync(RawQrResult()); - - // Act - var result = await Sut.ExtractSwissQrCodeAsync(tempPath, raw: true); - - // Assert - result.Should().NotBeNull(); - _swissQrCode.Verify(q => q.ExtractAsync(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.ValidateAsync(pdfBytes, It.IsAny())) - .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(), It.IsAny(), It.IsAny()), - Times.Never, - "extraction must not be attempted on an invalid document"); - } -} +} \ No newline at end of file