diff --git a/DocumentOperator.API/Endpoints/v1/DocumentEndpoints.cs b/DocumentOperator.API/Endpoints/v1/DocumentEndpoints.cs index 8246650..ac6f22c 100644 --- a/DocumentOperator.API/Endpoints/v1/DocumentEndpoints.cs +++ b/DocumentOperator.API/Endpoints/v1/DocumentEndpoints.cs @@ -1,4 +1,5 @@ using DocumentOperator.Application.Common.DTOs; +using DocumentOperator.Application.Features.Documents.ExtractSwissQrCode; using DocumentOperator.Application.Features.Documents.ValidatePdf; using DocumentOperator.Domain.Models.ValueObjects; using MediatR; @@ -27,6 +28,16 @@ public static class DocumentEndpoints .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status500InternalServerError); + + // POST /api/v1/documents/extract-swiss-qr-code + group.MapPost("/extract-swiss-qr-code", ExtractSwissQrCode) + .WithName("ExtractSwissQrCode") + .WithSummary("Extracts Swiss QR Code from the last page of a PDF document") + .WithDescription("Extracts and parses a Swiss QR Code (Swiss QR Bill Standard 2.0) from the last page of a PDF. Returns references (passed through) and all QR code fields.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status500InternalServerError); } /// @@ -64,4 +75,79 @@ public static class DocumentEndpoints return Results.Ok(response); } + + /// + /// Extracts Swiss QR Code from the last page of a PDF document + /// + /// References array + PDF as Base64 string + /// MediatR instance + /// Cancellation token + /// References (passed through) + Swiss QR Code data + /// Swiss QR Code extracted successfully + /// Invalid PDF or Base64 format + /// No Swiss QR Code found on the last page + /// Internal server error during extraction + private static async Task ExtractSwissQrCode( + ExtractSwissQrCodeRequest request, + IMediator mediator, + CancellationToken cancellationToken) + { + // DTO → Query (Value Objects erstellen) + var query = new ExtractSwissQrCodeQuery( + References: request.References, + PdfContent: Base64String.Create(request.Base64Pdf) + ); + + // MediatR Handler aufrufen + var result = await mediator.Send(query, cancellationToken); + + // Map Domain Value Object → DTO + var response = new ExtractSwissQrCodeResponse( + References: result.References, + QrCodeData: MapQrCodeDataToDto(result.QrCodeData) + ); + + return Results.Ok(response); + } + + /// + /// Maps SwissQrCodeData domain value object to DTO + /// + private static SwissQrCodeDataDto MapQrCodeDataToDto(Domain.ValueObjects.SwissQrCodeData qrCodeData) + { + return new SwissQrCodeDataDto( + QrType: qrCodeData.QrType, + Version: qrCodeData.Version, + CodingType: qrCodeData.CodingType, + Iban: qrCodeData.Iban, + Creditor: MapAddressToDto(qrCodeData.Creditor), + UltimateCreditor: qrCodeData.UltimateCreditor != null ? MapAddressToDto(qrCodeData.UltimateCreditor) : null, + Amount: qrCodeData.Amount, + Currency: qrCodeData.Currency, + UltimateDebtor: qrCodeData.UltimateDebtor != null ? MapAddressToDto(qrCodeData.UltimateDebtor) : null, + ReferenceType: qrCodeData.ReferenceType, + Reference: qrCodeData.Reference, + UnstructuredMessage: qrCodeData.UnstructuredMessage, + BillInformation: qrCodeData.BillInformation, + AlternativeProcedureParameters: qrCodeData.AlternativeProcedureParameters + ); + } + + /// + /// Maps AddressData domain value object to DTO + /// + private static AddressDataDto MapAddressToDto(Domain.ValueObjects.AddressData address) + { + return new AddressDataDto( + AddressType: address.AddressType, + Name: address.Name, + Street: address.Street, + BuildingNumber: address.BuildingNumber, + AddressLine1: address.AddressLine1, + AddressLine2: address.AddressLine2, + PostalCode: address.PostalCode, + City: address.City, + Country: address.Country + ); + } } diff --git a/DocumentOperator.API/Middleware/ExceptionHandlingMiddleware.cs b/DocumentOperator.API/Middleware/ExceptionHandlingMiddleware.cs index e4f1f2d..cdcccdc 100644 --- a/DocumentOperator.API/Middleware/ExceptionHandlingMiddleware.cs +++ b/DocumentOperator.API/Middleware/ExceptionHandlingMiddleware.cs @@ -1,4 +1,5 @@ using DocumentOperator.Domain.Common.Exceptions; +using DocumentOperator.Domain.Exceptions; using FluentValidation; using Microsoft.AspNetCore.Mvc; using System.Net; @@ -94,6 +95,19 @@ public class ExceptionHandlingMiddleware } ), + // Swiss QR Code Not Found Exception (404 Not Found) + SwissQrCodeNotFoundException qrNotFoundEx => ( + HttpStatusCode.NotFound, + new ProblemDetails + { + Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4", + Title = "Swiss QR Code Not Found", + Status = (int)HttpStatusCode.NotFound, + Detail = qrNotFoundEx.Message, + Instance = context.Request.Path + } + ), + // PDF Processing Exception (500 Internal Server Error) PdfProcessingException pdfEx => ( HttpStatusCode.InternalServerError, diff --git a/DocumentOperator.Tests/Integration/API/ExtractSwissQrCodeEndpointTests.cs b/DocumentOperator.Tests/Integration/API/ExtractSwissQrCodeEndpointTests.cs new file mode 100644 index 0000000..96a6897 --- /dev/null +++ b/DocumentOperator.Tests/Integration/API/ExtractSwissQrCodeEndpointTests.cs @@ -0,0 +1,123 @@ +using DocumentOperator.Application.Common.DTOs; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Xunit; + +namespace DocumentOperator.Tests.Integration.API; + +public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture> +{ + private readonly HttpClient _client; + private readonly JsonSerializerOptions _jsonOptions; + + public ExtractSwissQrCodeEndpointTests(WebApplicationFactory factory) + { + _client = factory.CreateClient(); + _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + } + + [Fact] + public async Task POST_ExtractSwissQrCode_ValidRequest_Returns200() + { + // Arrange + var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.valid.pdf"); + + var request = new ExtractSwissQrCodeRequest( + References: new List { "REF-001", "REF-002" }, + Base64Pdf: validPdfBase64 + ); + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/documents/extract-swiss-qr-code", request); + + // Assert + // Note: The test PDF (valid.pdf) may not actually contain a Swiss QR Code + // so this test might return 404. We're testing the endpoint wiring here. + // A real test would need a PDF with a Swiss QR Code on the last page. + response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound); + + if (response.StatusCode == HttpStatusCode.OK) + { + var result = await response.Content.ReadFromJsonAsync(_jsonOptions); + result.Should().NotBeNull(); + result!.References.Should().BeEquivalentTo(new[] { "REF-001", "REF-002" }); + result.QrCodeData.Should().NotBeNull(); + } + } + + [Fact] + public async Task POST_ExtractSwissQrCode_InvalidBase64_Returns400() + { + // Arrange + var request = new ExtractSwissQrCodeRequest( + References: new List { "REF-001" }, + Base64Pdf: "INVALID_BASE64!!!" + ); + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/documents/extract-swiss-qr-code", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task POST_ExtractSwissQrCode_EmptyReferences_Returns400() + { + // Arrange + var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.valid.pdf"); + + var request = new + { + References = (List?)null, + Base64Pdf = validPdfBase64 + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/documents/extract-swiss-qr-code", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task POST_ExtractSwissQrCode_EmptyPdf_Returns400() + { + // Arrange + var request = new + { + References = new List { "REF-001" }, + Base64Pdf = (string?)null + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/documents/extract-swiss-qr-code", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + /// + /// Helper method to load embedded resource as Base64 string + /// + private static string GetEmbeddedResourceAsBase64(string resourceName) + { + var assembly = typeof(ExtractSwissQrCodeEndpointTests).Assembly; + using var stream = assembly.GetManifestResourceStream(resourceName); + + if (stream == null) + { + throw new FileNotFoundException($"Embedded resource not found: {resourceName}"); + } + + using var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + return Convert.ToBase64String(memoryStream.ToArray()); + } +}