Compare commits

..

20 Commits

Author SHA1 Message Date
9cb964379f chore: Add DocumentService.Client project reference to Tests 2026-08-11 10:30:24 +02:00
4363b5e961 test: Update integration tests to use moved request DTOs 2026-08-11 10:30:15 +02:00
0059a70055 test: Add unit tests for DocumentService.Client 2026-08-11 10:30:05 +02:00
d08e40d551 chore: Add DocumentService.Client project reference to API 2026-08-11 10:29:55 +02:00
757f56b9f8 refactor: Move request DTOs to DocumentService.Client.Models namespace 2026-08-11 10:29:44 +02:00
8c72502913 chore: Add DocumentService.Client project configuration 2026-08-11 10:29:34 +02:00
058cb9327d feat: Add specialized client implementations for PDF operations 2026-08-11 10:29:24 +02:00
14250f0b4b feat: Add DocumentService.Client base infrastructure 2026-08-11 10:29:14 +02:00
b3a07f1348 Add interfaces and DTOs for PDF operations support
Introduced multiple interfaces and DTOs to support a wide range of
PDF-related operations, including attachment handling, conversion,
validation, annotation, stamping, and metadata extraction.

Key changes:
- Added `DocumentServiceClientOptions` for HTTP client config.
- Introduced `IPdfAttachmentClient` for attachment operations.
- Added `IPdfConversionClient` for PDF/A conversion (marked obsolete).
- Added `IPdfOperationsClient` for merge, annotate, and stamp ops.
- Introduced `IPdfValidationClient` for PDF and PDF/A validation.
- Added `ISwissQrCodeClient` for Swiss QR Code extraction.
- Added `IZugferdClient` for ZUGFeRD detection and extraction.
- Created request DTOs for Base64-encoded operations.
- Added enums for annotation, stamp, and validation configurations.

These changes provide a flexible and extensible foundation for
interacting with PDF documents, supporting both multipart and
Base64-encoded inputs, and ensuring type safety with enums and records.
2026-07-30 16:59:54 +02:00
d477eb5a28 Add DocumentService.Client project to solution
Introduced a new `DocumentService.Client` project targeting `net462`, `net480`, and `net8.0`. The project includes metadata for NuGet packaging, such as `PackageId`, `Authors`, and `Company`, and references `Microsoft.Extensions.DependencyInjection` and `Microsoft.Extensions.Logging`.

Updated `DocumentService.sln` to include the new project, its build configurations, and solution folder associations.

Added `icon.png` as the NuGet package icon and included it in the project file.
2026-07-30 14:15:31 +02:00
3da6ca8323 Remove DocumentOperator.sln and associated projects
The `DocumentOperator.sln` file was completely removed, along with all associated projects (`DocumentService.API`, `DocumentService.Application`, `DocumentService.Infrastructure`, `DocumentService.Domain`, `DocumentService.Tests`) and solution folders (`Solution Items`, `fake-pfd`, `core`, `presentation`, `infrastructure`, `src`, `tests`).

Markdown files (`AGENTS.md`, `CONTROLLER_ENDPOINTS.md`, `REQUIRED_FEATURES.md`) and PDF files (`form.pdf`, `multi-page.pdf`, `one-page.pdf`, `with-image.pdf`) were also deleted.

Solution configurations, platform settings, project nesting, and the `SolutionGuid` were removed, indicating the solution is no longer part of the codebase. This change may be part of a restructuring, migration, or deprecation effort.
2026-07-30 14:04:55 +02:00
0e88b349d7 Rebrand project: DocumentOperator to DocumentService
This commit implements a complete rebranding of the project:
- Updated all namespaces from `DocumentOperator` to `DocumentService`.
- Renamed file paths, embedded resources, and test data references.
- Updated configuration keys, logging paths, and Redis instance names.
- Revised documentation to reflect the new project name.
- Modified project and solution files to align with the new structure.
- Updated class names, DTOs, commands, queries, and handlers.
- Adjusted middleware, controllers, and API endpoints.
- Updated Swagger metadata and API titles to `DocumentService API`.
- Refactored test namespaces, resource paths, and embedded resources.
- Updated build and deployment configurations for the new name.
- Replaced all references to `DocumentOperator` in comments and literals.

These changes ensure consistency across the codebase and documentation.
2026-07-30 14:02:56 +02:00
b6bb894257 Refactor: Rename DocumentOperator to DocumentService
Renamed and restructured project references across the solution
to replace `DocumentOperator` projects with `DocumentService`
projects. Updated all `.csproj` files to reflect the new project
names and references.

Modified the solution file (`DocumentOperator.sln`) to remove
`DocumentOperator` projects and add corresponding `DocumentService`
projects, including `DocumentService.API`, `DocumentService.Application`,
`DocumentService.Infrastructure`, `DocumentService.Domain`, and
`DocumentService.Tests`.

This change aligns the project structure with the new naming
convention or organizational standards.
2026-07-30 14:01:29 +02:00
9cff7ee459 Add new projects and update solution structure
Added the following projects to `DocumentOperator.sln`:
- `core`, `presentation`, `infrastructure`, `src`, `tests`
Updated `GlobalSection(NestedProjects)` to define hierarchical
relationships between projects for better organization. Nested
projects include `core`, `presentation`, and `infrastructure`
under `src`, among others.
2026-07-30 13:46:06 +02:00
a6694bfce7 Add PDF attachment and PDF/A conversion features
Introduced endpoints for embedding attachments in PDFs and converting
between standard PDFs and PDF/A formats. Added `PdfAttachmentController`
and `PdfConversionController` with multipart/form-data and Base64-based
support. Implemented commands, handlers, and validators for these
operations.

Extended `IPdfProcessor` with methods for adding attachments and
PDF/A conversion. Partially implemented functionality in
`DevExpressPdfProcessor`, including `ConvertFromPdfAAsync`.

Added `attachment.xml` and `withoutAttachment.pdf` as resources for
testing. Marked endpoints as `[Obsolete]` to indicate incomplete
implementation. Improved validation and error handling for commands.
2026-07-30 13:43:55 +02:00
a242458d2f Add support for XML file output in ZUGFeRD extraction
Enhanced `ExtractZugferdFromFile` and `ExtractZugferdFromBase64`
methods to support output as an XML file (`application/xml`)
or JSON (default). Introduced `asFile` and `format` query
parameters to control the output format. Updated XML
documentation and `ProducesResponseType` attributes to reflect
these changes. Default values for `CancellationToken` were
set to `default` for improved usability.
2026-07-30 10:47:48 +02:00
94123cd1be Add ZUGFeRD detection and extraction functionality
Introduced `ZugferdController` to handle ZUGFeRD-related operations, including detection and extraction of ZUGFeRD XML from PDFs via file upload or Base64-encoded payloads. Integrated `MediatR` for query/command handling.

Added `ZugferdSettings` for configurable file names and patterns, and updated `appsettings.json` and `Program.cs` to support this configuration.

Implemented `HasZugferdQuery` and `ExtractZugferdCommand` with their respective handlers and validators. Added DTOs (`ZugferdCheckResult`, `ZugferdExtractionResult`) for operation results.

Included `ZUGFeRD-Example.pdf` for testing and integrated `Serilog.Ui.Core.Extensions` for logging.
2026-07-30 10:16:43 +02:00
080c2ac2a0 refactor(api): Pass IConfiguration to AddApplication
- Update AddApplication call with builder.Configuration parameter
- Required for license key reading from appsettings.json
2026-07-21 16:52:32 +02:00
97122d0bbd feat(license): Configure MediatR + AutoMapper with LuckyPennySoft license
- Read LuckyPennySoftLicenseKey from appsettings.json
- Set MediatR config.LicenseKey
- Set AutoMapper cfg.LicenseKey
- AddApplication now requires IConfiguration parameter
2026-07-21 16:52:27 +02:00
e9d1586266 chore(deps): Add Microsoft.Extensions.Configuration packages
- Microsoft.Extensions.Configuration.Abstractions 10.0.10
- Microsoft.Extensions.Configuration.Binder 10.0.10
- Required for reading appsettings.json in Application layer
2026-07-21 16:52:21 +02:00
136 changed files with 5051 additions and 236 deletions

View File

@@ -1,6 +1,6 @@
# AGENTS.md
Agent guidance for DocumentOperator service. Read this before working on the codebase.
Agent guidance for DocumentService service. Read this before working on the codebase.
---
@@ -23,7 +23,7 @@ Agent guidance for DocumentOperator service. Read this before working on the cod
### Migration Required
**Existing code that needs replacement:**
- `DocumentOperator.API/Endpoints/v1/DocumentEndpoints.cs` → Delete, replace with Controllers
- `DocumentService.API/Endpoints/v1/DocumentEndpoints.cs` → Delete, replace with Controllers
- `Program.cs` line 72: `app.MapDocumentEndpoints()` → Replace with `app.MapControllers()`
- `Program.cs` line 44: Add `builder.Services.AddControllers()`
- All DTOs → Support **BOTH** `IFormFile` (multipart) AND `Base64String` (JSON)
@@ -195,7 +195,7 @@ dotnet test
**Run API (Development):**
```powershell
dotnet run --project DocumentOperator.API
dotnet run --project DocumentService.API
```
Swagger UI: `https://localhost:7186/swagger`
Serilog UI: `https://localhost:7186/serilog-ui` (Web-based log viewer)
@@ -339,7 +339,7 @@ Do NOT skip steps. Each feature is done when it's **testable in Swagger UI with
**All test PDFs are EmbeddedResource.** Access via:
```csharp
var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
.GetManifestResourceStream("DocumentService.Tests.TestData.Pdfs.valid.pdf");
```
**Do NOT commit new binary files** without marking them as `<EmbeddedResource>`.
@@ -351,7 +351,7 @@ var stream = Assembly.GetExecutingAssembly()
**3-folder structure (CORRECT approach by previous developer):**
```
DocumentOperator.Tests/
DocumentService.Tests/
├── Integration/
│ └── API/
│ ├── PdfValidationControllerTests.cs (13 tests)
@@ -520,7 +520,7 @@ public async Task<IActionResult> Validate([FromForm] PdfInputModel input, Cancel
## Configuration
**appsettings.json sections:**
- `DocumentOperatorSettings` (future: file size limits, temp paths)
- `DocumentServiceSettings` (future: file size limits, temp paths)
- `RedisSettings` (future: multi-tenancy caching)
- `ApiKeySettings` (future: authentication)

View File

@@ -1,4 +1,4 @@
# DocumentOperator - Controller & Endpoint Specification
# DocumentService - Controller & Endpoint Specification
**Project:** DocumentService (DOC)
**Ticket:** DOC-1 - GDPicture and Nutrient Replacing
@@ -9,7 +9,7 @@
## Overview
This specification defines the controller structure and REST API endpoints for the DocumentOperator service.
This specification defines the controller structure and REST API endpoints for the DocumentService service.
---
@@ -293,7 +293,7 @@ The service can be used on the client side **without manual HTTP response handli
**Example Client SDK:**
```csharp
var client = new DocumentOperatorClient("https://api.example.com");
var client = new DocumentServiceClient("https://api.example.com");
var result = await client.Pdf.Validation.ValidateAsync(pdfFile);
if (result.IsValid) { ... }
```

View File

@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace DocumentOperator.API.Configuration
namespace DocumentService.API.Configuration
{
/// <summary>
/// Swagger document filter that merges operations with same path but different [Consumes] attributes.

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.API.Configuration
namespace DocumentService.API.Configuration
{
/// <summary>
/// Placeholder class for Serilog configuration extensions.

View File

@@ -2,7 +2,7 @@
using Microsoft.OpenApi.Models;
using System.Reflection;
namespace DocumentOperator.API.Configuration
namespace DocumentService.API.Configuration
{
/// <summary>
/// Provides extension methods for configuring Swagger/OpenAPI documentation.

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.API.Configuration;
namespace DocumentService.API.Configuration;
/// <summary>
/// Configuration settings for Swagger/OpenAPI documentation.
@@ -19,7 +19,7 @@ public class SwaggerSettings
/// <summary>
/// API title displayed in Swagger UI.
/// </summary>
public string Title { get; set; } = "DocumentOperator API";
public string Title { get; set; } = "DocumentService API";
/// <summary>
/// API version.

View File

@@ -1,12 +1,14 @@
using DocumentOperator.Application.CheckPdfAttachments.Queries;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.ExtractPdfAttachments;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Application.AddAttachments;
using DocumentService.Application.CheckPdfAttachments.Queries;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.ExtractPdfAttachments;
using DocumentService.Client.Models.Requests;
using DocumentService.Domain.Common.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.API.Controllers;
namespace DocumentService.API.Controllers;
/// <summary>
/// Controller for PDF attachment operations (detection, extraction, embedding)
@@ -157,6 +159,135 @@ public class PdfAttachmentController(IMediator mediator) : ControllerBase
// Return ZIP file
return File(zipBytes, "application/zip", "attachments.zip");
}
/// <summary>
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
/// Supports multipart/form-data file upload.
/// </summary>
/// <param name="pdfFile">The PDF file to add attachments to</param>
/// <param name="attachmentFiles">Files to embed as attachments (one or more)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF with embedded attachments</returns>
/// <response code="200">Attachments added successfully - returns PDF</response>
/// <response code="400">Invalid input (file missing, not a PDF, or no attachments provided)</response>
/// <response code="500">Internal server error during PDF processing</response>
[Obsolete("This endpoint is not implemented yet.")]
[HttpPost("add")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AddAttachmentsFromFile(
IFormFile pdfFile,
List<IFormFile> attachmentFiles,
CancellationToken cancellationToken)
{
if (pdfFile == null || pdfFile.Length == 0)
{
throw new BadRequestException("PDF file is required");
}
if (attachmentFiles == null || attachmentFiles.Count == 0)
{
throw new BadRequestException("At least one attachment file is required");
}
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = pdfFile.OpenReadStream();
// Convert attachment files to AttachmentFile records
var attachments = new List<AttachmentFile>();
foreach (var file in attachmentFiles)
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms, cancellationToken);
attachments.Add(new AttachmentFile
{
FileName = file.FileName,
Content = ms.ToArray(),
MimeType = file.ContentType
});
}
// Send command to MediatR
var command = new AddAttachmentsCommand
{
PdfStream = pdfStream,
Attachments = attachments
};
byte[] resultPdf = await mediator.Send(command, cancellationToken);
// Return PDF with attachments
return File(resultPdf, "application/pdf", "with-attachments.pdf");
}
/// <summary>
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
/// Supports Base64-encoded PDF and attachments via JSON payload.
/// </summary>
/// <param name="request">Request containing Base64-encoded PDF and attachments</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF with embedded attachments</returns>
/// <response code="200">Attachments added successfully - returns PDF</response>
/// <response code="400">Invalid input (Base64 format error, not a PDF, or no attachments provided)</response>
/// <response code="500">Internal server error during PDF processing</response>
[Obsolete("This endpoint is not implemented yet.")]
[HttpPost("add")]
[Consumes("application/json")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AddAttachmentsFromBase64(
[FromBody] AddAttachmentsRequest request,
CancellationToken cancellationToken)
{
// Convert Base64 PDF to stream
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Convert Base64 attachments to AttachmentFile records
var attachments = new List<AttachmentFile>();
foreach (var att in request.Attachments)
{
byte[] attBytes;
try
{
attBytes = Convert.FromBase64String(att.Base64Content);
}
catch (FormatException ex)
{
throw new BadRequestException($"Invalid Base64 format for attachment '{att.FileName}': " + ex.Message);
}
attachments.Add(new AttachmentFile
{
FileName = att.FileName,
Content = attBytes,
MimeType = att.MimeType
});
}
// Send command to MediatR
var command = new AddAttachmentsCommand
{
PdfStream = pdfStream,
Attachments = attachments
};
byte[] resultPdf = await mediator.Send(command, cancellationToken);
// Return PDF with attachments
return File(resultPdf, "application/pdf", "with-attachments.pdf");
}
}
/// <summary>
@@ -182,3 +313,44 @@ public record ExtractPdfAttachmentsRequest
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
public required string Base64Pdf { get; init; }
}
/// <summary>
/// Request DTO for Base64-encoded PDF with attachments to add
/// </summary>
public record AddAttachmentsRequest
{
/// <summary>
/// PDF document encoded as Base64 string
/// </summary>
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
public required string Base64Pdf { get; init; }
/// <summary>
/// List of attachments to embed
/// </summary>
public required List<AttachmentRequestDto> Attachments { get; init; }
}
/// <summary>
/// DTO for attachment file in request
/// </summary>
public record AttachmentRequestDto
{
/// <summary>
/// File name (e.g., "invoice.xml", "document.pdf")
/// </summary>
/// <example>factur-x.xml</example>
public required string FileName { get; init; }
/// <summary>
/// File content encoded as Base64 string
/// </summary>
/// <example>PD94bWwgdmVyc2lvbj0iMS4wIj8+...</example>
public required string Base64Content { get; init; }
/// <summary>
/// MIME type (optional, e.g., "application/xml")
/// </summary>
/// <example>application/xml</example>
public string? MimeType { get; init; }
}

View File

@@ -0,0 +1,182 @@
using DocumentService.Application.ConvertFromPdfA;
using DocumentService.Application.ConvertToPdfA;
using DocumentService.Client.Models.Requests;
using DocumentService.Domain.Common.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace DocumentService.API.Controllers;
/// <summary>
/// Controller for PDF conversion operations (PDF ? PDF/A)
/// </summary>
[ApiController]
[Route("api/pdf/conversion")]
[Obsolete("This endpoint is not implemented yet.")]
public class PdfConversionController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Converts a standard PDF to PDF/A format.
/// Supports multipart/form-data file upload.
/// </summary>
/// <param name="file">The PDF file to convert</param>
/// <param name="pdfALevel">Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF/A compliant document</returns>
/// <response code="200">PDF converted to PDF/A successfully</response>
/// <response code="400">Invalid input (file missing, not a PDF, or invalid PDF/A level)</response>
/// <response code="500">Internal server error during PDF processing</response>
[Obsolete("This endpoint is not implemented yet.")]
[HttpPost("to-pdfa")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ConvertToPdfAFromFile(
IFormFile file,
[FromQuery] string pdfALevel = "PDF/A-3b",
CancellationToken cancellationToken = default)
{
if (file == null || file.Length == 0)
{
throw new BadRequestException("PDF file is required");
}
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Send command to MediatR
var command = new ConvertToPdfACommand
{
PdfStream = pdfStream,
PdfALevel = pdfALevel
};
byte[] resultPdf = await mediator.Send(command, cancellationToken);
// Return PDF/A file
return File(resultPdf, "application/pdf", "converted-pdfa.pdf");
}
/// <summary>
/// Converts a standard PDF to PDF/A format.
/// Supports Base64-encoded PDF via JSON payload.
/// </summary>
/// <param name="request">Request containing Base64-encoded PDF and PDF/A level</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF/A compliant document</returns>
/// <response code="200">PDF converted to PDF/A successfully</response>
/// <response code="400">Invalid input (Base64 format error, not a PDF, or invalid PDF/A level)</response>
/// <response code="500">Internal server error during PDF processing</response>
[Obsolete("This endpoint is not implemented yet.")]
[HttpPost("to-pdfa")]
[Consumes("application/json")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ConvertToPdfAFromBase64(
[FromBody] ConvertToPdfARequest request,
CancellationToken cancellationToken = default)
{
// Convert Base64 PDF to stream
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Send command to MediatR
var command = new ConvertToPdfACommand
{
PdfStream = pdfStream,
PdfALevel = request.PdfALevel ?? "PDF/A-3b"
};
byte[] resultPdf = await mediator.Send(command, cancellationToken);
// Return PDF/A file
return File(resultPdf, "application/pdf", "converted-pdfa.pdf");
}
/// <summary>
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
/// Supports multipart/form-data file upload.
/// </summary>
/// <param name="file">The PDF/A file to convert</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Standard PDF document</returns>
/// <response code="200">PDF/A converted to standard PDF successfully</response>
/// <response code="400">Invalid input (file missing, not a PDF)</response>
/// <response code="500">Internal server error during PDF processing</response>
[Obsolete("This endpoint is not implemented yet.")]
[HttpPost("from-pdfa")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ConvertFromPdfAFromFile(
IFormFile file,
CancellationToken cancellationToken = default)
{
if (file == null || file.Length == 0)
{
throw new BadRequestException("PDF/A file is required");
}
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Send command to MediatR
var command = new ConvertFromPdfACommand { PdfStream = pdfStream };
byte[] resultPdf = await mediator.Send(command, cancellationToken);
// Return standard PDF file
return File(resultPdf, "application/pdf", "converted-pdf.pdf");
}
/// <summary>
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
/// Supports Base64-encoded PDF via JSON payload.
/// </summary>
/// <param name="request">Request containing Base64-encoded PDF/A</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Standard PDF document</returns>
/// <response code="200">PDF/A converted to standard PDF successfully</response>
/// <response code="400">Invalid input (Base64 format error, not a PDF)</response>
/// <response code="500">Internal server error during PDF processing</response>
[Obsolete("This endpoint is not implemented yet.")]
[HttpPost("from-pdfa")]
[Consumes("application/json")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ConvertFromPdfAFromBase64(
[FromBody] ConvertFromPdfARequest request,
CancellationToken cancellationToken = default)
{
// Convert Base64 PDF to stream
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Send command to MediatR
var command = new ConvertFromPdfACommand { PdfStream = pdfStream };
byte[] resultPdf = await mediator.Send(command, cancellationToken);
// Return standard PDF file
return File(resultPdf, "application/pdf", "converted-pdf.pdf");
}
}

View File

@@ -1,13 +1,13 @@
using DocumentOperator.Application.AddAnnotation;
using DocumentOperator.Application.AddStamp;
using DocumentOperator.Application.MergePdfs;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Domain.Models.ValueObjects;
using DocumentService.Application.AddAnnotation;
using DocumentService.Application.AddStamp;
using DocumentService.Application.MergePdfs;
using DocumentService.Domain.Common.Exceptions;
using DocumentService.Domain.Models.ValueObjects;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.API.Controllers;
namespace DocumentService.API.Controllers;
/// <summary>
/// Controller for PDF operations (merge, stamp, annotate).

View File

@@ -1,11 +1,12 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentOperator.Application.ValidatePdfA.Queries;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.ValidatePdf.Queries;
using DocumentService.Application.ValidatePdfA.Queries;
using DocumentService.Client.Models.Requests;
using DocumentService.Domain.Common.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.API.Controllers;
namespace DocumentService.API.Controllers;
/// <summary>
/// PDF validation operations
@@ -167,27 +168,3 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
return Ok(result);
}
}
/// <summary>
/// Request DTO for Base64-encoded PDF validation
/// </summary>
public record ValidatePdfBase64Request
{
/// <summary>
/// PDF document encoded as Base64 string
/// </summary>
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
public string Base64Pdf { get; init; } = string.Empty;
}
/// <summary>
/// Request DTO for Base64-encoded PDF/A validation
/// </summary>
public record ValidatePdfABase64Request
{
/// <summary>
/// PDF document encoded as Base64 string
/// </summary>
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
public string Base64Pdf { get; init; } = string.Empty;
}

View File

@@ -1,10 +1,10 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.SwissQrCode.Queries;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.SwissQrCode.Queries;
using DocumentService.Domain.Common.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.API.Controllers;
namespace DocumentService.API.Controllers;
/// <summary>
/// Swiss QR Code extraction operations

View File

@@ -0,0 +1,179 @@
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.ExtractZugferd;
using DocumentService.Application.HasZugferd.Queries;
using DocumentService.Client.Models.Requests;
using DocumentService.Domain.Common.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace DocumentService.API.Controllers;
/// <summary>
/// Controller for ZUGFeRD operations (detection, extraction)
/// </summary>
[ApiController]
[Route("api/pdf/zugferd")]
[Produces("application/json")]
public class ZugferdController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Checks if a PDF contains ZUGFeRD XML attachment.
/// Supports multipart/form-data file upload.
/// </summary>
/// <param name="file">The PDF file to check for ZUGFeRD</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>ZUGFeRD check result with metadata</returns>
/// <response code="200">PDF successfully checked - returns ZUGFeRD status</response>
/// <response code="400">Invalid input (file missing, not a PDF, or corrupted)</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("has-zugferd")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(ZugferdCheckResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> HasZugferdFromFile(
IFormFile file,
CancellationToken cancellationToken)
{
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Send query to MediatR (ValidationBehavior runs automatically)
var query = new HasZugferdQuery { PdfStream = pdfStream };
var result = await mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
/// Checks if a PDF contains ZUGFeRD XML attachment.
/// Supports Base64-encoded PDF via JSON payload.
/// </summary>
/// <param name="request">Request containing Base64-encoded PDF</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>ZUGFeRD check result with metadata</returns>
/// <response code="200">PDF successfully checked - returns ZUGFeRD status</response>
/// <response code="400">Invalid input (Base64 format error, not a PDF, or corrupted)</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("has-zugferd")]
[Consumes("application/json")]
[ProducesResponseType(typeof(ZugferdCheckResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> HasZugferdFromBase64(
[FromBody] HasZugferdRequest request,
CancellationToken cancellationToken)
{
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Send query to MediatR (ValidationBehavior runs automatically)
var query = new HasZugferdQuery { PdfStream = pdfStream };
var result = await mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
/// Extracts ZUGFeRD XML from a PDF document.
/// Supports multipart/form-data file upload.
/// </summary>
/// <param name="file">The PDF file to extract ZUGFeRD from</param>
/// <param name="asFile">if true, 'file' (returns XML file directly); otherwise output format: 'json' (default, returns metadata + XML content)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>ZUGFeRD XML content and metadata (JSON) or XML file (application/xml)</returns>
/// <response code="200">ZUGFeRD XML extracted successfully</response>
/// <response code="400">Invalid input (file missing, not a PDF, or corrupted)</response>
/// <response code="404">PDF contains no ZUGFeRD XML</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("extract")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(ZugferdExtractionResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ExtractZugferdFromFile(
IFormFile file,
[FromQuery] bool asFile = true,
CancellationToken cancellationToken = default)
{
// Use IFormFile stream directly (no intermediate byte[] conversion)
using var pdfStream = file.OpenReadStream();
// Send command to MediatR
var command = new ExtractZugferdCommand { PdfStream = pdfStream };
var result = await mediator.Send(command, cancellationToken);
// Return as file or JSON based on format parameter
if (asFile)
{
byte[] xmlBytes = System.Text.Encoding.UTF8.GetBytes(result.XmlContent);
return File(xmlBytes, "application/xml", result.FileName);
}
return Ok(result);
}
/// <summary>
/// Extracts ZUGFeRD XML from a PDF document.
/// Supports Base64-encoded PDF via JSON payload.
/// </summary>
/// <param name="request">Request containing Base64-encoded PDF</param>
/// <param name="format">Output format: 'json' (default, returns metadata + XML content) or 'file' (returns XML file directly)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>ZUGFeRD XML content and metadata (JSON) or XML file (application/xml)</returns>
/// <response code="200">ZUGFeRD XML extracted successfully</response>
/// <response code="400">Invalid input (Base64 format error, not a PDF, or corrupted)</response>
/// <response code="404">PDF contains no ZUGFeRD XML</response>
/// <response code="500">Internal server error during PDF processing</response>
[HttpPost("extract")]
[Consumes("application/json")]
[ProducesResponseType(typeof(ZugferdExtractionResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ExtractZugferdFromBase64(
[FromBody] ExtractZugferdRequest request,
[FromQuery] string format = "json",
CancellationToken cancellationToken = default)
{
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
byte[] pdfBytes;
try
{
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
using var pdfStream = new MemoryStream(pdfBytes);
// Send command to MediatR
var command = new ExtractZugferdCommand { PdfStream = pdfStream };
var result = await mediator.Send(command, cancellationToken);
// Return as file or JSON based on format parameter
if (format.Equals("file", StringComparison.OrdinalIgnoreCase))
{
byte[] xmlBytes = System.Text.Encoding.UTF8.GetBytes(result.XmlContent);
return File(xmlBytes, "application/xml", result.FileName);
}
return Ok(result);
}
}

View File

@@ -1,6 +0,0 @@
@DocumentOperator.API_HostAddress = http://localhost:5028
GET {{DocumentOperator.API_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -32,9 +32,10 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentOperator.Application\DocumentOperator.Application.csproj" />
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentOperator.Infrastructure.csproj" />
<ProjectReference Include="..\DocumentOperator.Application\DocumentService.Application.csproj" />
<ProjectReference Include="..\DocumentOperator.Domain\DocumentService.Domain.csproj" />
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentService.Infrastructure.csproj" />
<ProjectReference Include="..\DocumentService.Client\DocumentService.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@DocumentService.API_HostAddress = http://localhost:5028
GET {{DocumentService.API_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -1,10 +1,10 @@
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Domain.Common.Exceptions;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using System.Text.Json;
namespace DocumentOperator.API.Middleware;
namespace DocumentService.API.Middleware;
/// <summary>
/// Central exception handling middleware

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.API.Middleware
namespace DocumentService.API.Middleware
{
/// <summary>
/// Placeholder middleware for HTTP request/response logging.

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.API.Middleware
namespace DocumentService.API.Middleware
{
/// <summary>
/// Placeholder middleware for multi-tenancy resolution via X-API-Key header.

View File

@@ -1,4 +1,4 @@
# ?? DocumentOperator - Phasenplan (Feature-Driven Development)
# ?? DocumentService - Phasenplan (Feature-Driven Development)
> **Stand:** 17.01.2025 | **Aktuell:** Feature 3 - ExtractAttachments ? NEXT | **Projektdauer:** 6 Wochen
@@ -132,7 +132,7 @@
- ? XML Comments aktiviert
2. **XML-Dokumentation aktiviert**
- ? `API/DocumentOperator.API.csproj`
- ? `API/DocumentService.API.csproj`
- ? `<GenerateDocumentationFile>true</GenerateDocumentationFile>`
3. **Endpoint Dokumentation**
@@ -146,12 +146,12 @@
5. **Program.cs Updates**
- ? `builder.Services.AddSwaggerDocumentation()` statt `AddSwaggerGen()`
- ? `using DocumentOperator.API.Configuration;` hinzugefügt
- ? `using DocumentService.API.Configuration;` hinzugefügt
**Akzeptanzkriterien:**
- ? Build erfolgreich
- ? Alle Tests grün (11/11)
- ? XML-Dokumentation wird generiert (`DocumentOperator.API.xml`)
- ? XML-Dokumentation wird generiert (`DocumentService.API.xml`)
- ? Swagger UI zeigt Endpoint `/api/v1/documents/validate` mit Dokumentation
- ? Request/Response-Schemas sind dokumentiert
- ? Endpoint ist im Swagger UI testbar

View File

@@ -3,11 +3,12 @@ using Serilog.Ui.Core.Extensions;
using Serilog.Ui.SqliteDataProvider.Extensions;
using Serilog.Ui.Web.Extensions;
using Scalar.AspNetCore;
using DocumentOperator.Infrastructure.Configuration;
using DocumentOperator.Application;
using DocumentOperator.Infrastructure;
using DocumentOperator.API.Middleware;
using DocumentOperator.API.Configuration;
using DocumentService.Infrastructure.Configuration;
using DocumentService.Application;
using DocumentService.Application.Common.Configuration;
using DocumentService.Infrastructure;
using DocumentService.API.Middleware;
using DocumentService.API.Configuration;
var builder = WebApplication.CreateBuilder(args);
@@ -17,20 +18,23 @@ var builder = WebApplication.CreateBuilder(args);
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "DocumentOperator")
.Enrich.WithProperty("Application", "DocumentService")
.CreateLogger();
builder.Host.UseSerilog();
Log.Information("Starting DocumentOperator API...");
Log.Information("Starting DocumentService API...");
try
{
// ========================================
// 2. Options Pattern Configuration
// ========================================
builder.Services.Configure<DocumentOperatorSettings>(
builder.Configuration.GetSection(DocumentOperatorSettings.SectionName));
builder.Services.Configure<DocumentServiceSettings>(
builder.Configuration.GetSection(DocumentServiceSettings.SectionName));
builder.Services.Configure<ZugferdSettings>(
builder.Configuration.GetSection("ZugferdSettings"));
builder.Services.Configure<RedisSettings>(
builder.Configuration.GetSection(RedisSettings.SectionName));
@@ -44,7 +48,7 @@ try
// ========================================
// 3. Services (Clean Architecture Layers)
// ========================================
builder.Services.AddApplication(); // Application Layer (MediatR, FluentValidation, Behaviors)
builder.Services.AddApplication(builder.Configuration); // Application Layer (MediatR, FluentValidation, Behaviors)
builder.Services.AddInfrastructure(); // Infrastructure Layer (DevExpress, Services)
builder.Services.AddControllers(); // Controllers (Controller-based API)
@@ -121,7 +125,7 @@ try
// ========================================
app.MapControllers(); // Maps all [ApiController] controllers
Log.Information("DocumentOperator API started successfully");
Log.Information("DocumentService API started successfully");
app.Run();
}
@@ -137,7 +141,7 @@ finally
// Make Program class accessible for Integration Tests
/// <summary>
/// Entry point class for the DocumentOperator API.
/// Entry point class for the DocumentService API.
/// Made partial and public for integration test access.
/// </summary>
public partial class Program { }

View File

@@ -9,9 +9,9 @@
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>c60bc965-d293-ea64-b153-1941f0648df4</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\DocumentOperator\PreRelease\API\net8\$(Version)\DocumentOperator.API.zip</DesktopBuildPackageLocation>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\DocumentService\PreRelease\API\net8\$(Version)\DocumentService.API.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>DocumentOperator.API</DeployIisAppPath>
<DeployIisAppPath>DocumentService.API</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>

View File

@@ -1,6 +1,6 @@
# DocumentOperator API - Manual Testing Guide
# DocumentService API - Manual Testing Guide
This guide contains manual test scenarios for validating the DocumentOperator API endpoints using Swagger UI or tools like Postman.
This guide contains manual test scenarios for validating the DocumentService API endpoints using Swagger UI or tools like Postman.
---
@@ -8,7 +8,7 @@ This guide contains manual test scenarios for validating the DocumentOperator AP
1. **Start the API:**
```powershell
dotnet run --project DocumentOperator.API
dotnet run --project DocumentService.API
```
Default URL: `https://localhost:5001` (check console output for actual port)

View File

@@ -5,8 +5,8 @@
}
},
"DocumentOperatorSettings": {
"TempFolderPath": "C:\\Temp\\DocumentOperator\\Dev",
"DocumentServiceSettings": {
"TempFolderPath": "C:\\Temp\\DocumentService\\Dev",
"EnableDetailedLogging": true
},

View File

@@ -8,12 +8,12 @@
"AllowedHosts": "*",
"Application": {
"LogDirectory": "E:\\LogFiles\\Digital Data\\DocumentOperator.API"
"LogDirectory": "E:\\LogFiles\\Digital Data\\DocumentService.API"
},
"SwaggerSettings": {
"EnableInProduction": true,
"Title": "DocumentOperator API",
"Title": "DocumentService API",
"Version": "v1",
"Description": "PDF document processing service using DevExpress"
},
@@ -45,7 +45,7 @@
{
"Name": "SQLite",
"Args": {
"sqliteDbPath": "E:\\LogFiles\\Digital Data\\DocumentOperator.API\\logs.db",
"sqliteDbPath": "E:\\LogFiles\\Digital Data\\DocumentService.API\\logs.db",
"tableName": "Logs",
"storeTimestampInUtc": true
}
@@ -54,16 +54,32 @@
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
},
"DocumentOperatorSettings": {
"TempFolderPath": "C:\\Temp\\DocumentOperator",
"DocumentServiceSettings": {
"TempFolderPath": "C:\\Temp\\DocumentService",
"TempFileRetentionHours": 24,
"MaxPdfSizeMB": 50,
"EnableDetailedLogging": true
},
"ZugferdSettings": {
"ZugferdFileNames": [
"factur-x.xml",
"zugferd-invoice.xml",
"ZUGFeRD-invoice.xml",
"xrechnung.xml",
"XRechnung.xml"
],
"ZugferdFileNamePatterns": [
"factur",
"zugferd",
"xrechnung",
"peppol"
]
},
"RedisSettings": {
"ConnectionString": "localhost:6379",
"InstanceName": "DocumentOperator:",
"InstanceName": "DocumentService:",
"CacheExpirationMinutes": 60
},

View File

@@ -1,9 +1,9 @@
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Models.ValueObjects;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Models.ValueObjects;
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.AddAnnotation;
namespace DocumentService.Application.AddAnnotation;
/// <summary>
/// Command to add an annotation to a PDF document.

View File

@@ -0,0 +1,90 @@
using DocumentService.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentService.Application.AddAttachments;
/// <summary>
/// Command to add one or more attachments to a PDF document (supports PDF/A-3)
/// </summary>
public record AddAttachmentsCommand : IRequest<byte[]>
{
/// <summary>
/// PDF document stream. Must be positioned at the beginning (Position = 0).
/// </summary>
public required Stream PdfStream { get; init; }
/// <summary>
/// List of attachments to embed (filename, content, optional MIME type)
/// </summary>
public required IReadOnlyList<AttachmentFile> Attachments { get; init; }
}
/// <summary>
/// Represents a file to be attached to a PDF
/// </summary>
public record AttachmentFile
{
/// <summary>
/// File name (e.g., "invoice.xml", "document.pdf")
/// </summary>
public required string FileName { get; init; }
/// <summary>
/// File content as byte array
/// </summary>
public required byte[] Content { get; init; }
/// <summary>
/// MIME type (optional, e.g., "application/xml", "application/pdf")
/// If not provided, will be inferred from file extension
/// </summary>
public string? MimeType { get; init; }
}
/// <summary>
/// Handler for AddAttachmentsCommand
/// </summary>
public class AddAttachmentsCommandHandler(IPdfProcessor pdfProcessor)
: IRequestHandler<AddAttachmentsCommand, byte[]>
{
public async Task<byte[]> Handle(AddAttachmentsCommand request, CancellationToken cancellationToken)
{
// Convert to tuple list for IPdfProcessor
var attachmentTuples = request.Attachments
.Select(a => (a.FileName, a.Content, a.MimeType))
.ToList();
return await pdfProcessor.AddAttachmentsAsync(request.PdfStream, attachmentTuples);
}
}
/// <summary>
/// Validator for AddAttachmentsCommand
/// </summary>
public class AddAttachmentsCommandValidator : AbstractValidator<AddAttachmentsCommand>
{
public AddAttachmentsCommandValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
RuleFor(x => x.Attachments)
.NotNull()
.NotEmpty()
.WithMessage("At least one attachment is required");
RuleForEach(x => x.Attachments).ChildRules(attachment =>
{
attachment.RuleFor(a => a.FileName)
.NotEmpty()
.WithMessage("Attachment file name is required");
attachment.RuleFor(a => a.Content)
.NotNull()
.NotEmpty()
.WithMessage("Attachment content is required");
});
}
}

View File

@@ -1,7 +1,7 @@
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.AddStamp;
namespace DocumentService.Application.AddStamp;
/// <summary>
/// Command to add a stamp (text, image, or predefined) to PDF pages.

View File

@@ -1,9 +1,9 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.CheckPdfAttachments.Queries;
namespace DocumentService.Application.CheckPdfAttachments.Queries;
/// <summary>
/// Query for checking PDF attachments (Stream-based)

View File

@@ -1,6 +1,6 @@
using FluentValidation;
namespace DocumentOperator.Application.CheckPdfAttachments.Queries;
namespace DocumentService.Application.CheckPdfAttachments.Queries;
/// <summary>
/// Validator for CheckPdfAttachmentsQuery

View File

@@ -2,7 +2,7 @@ using MediatR;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace DocumentOperator.Application.Common.Behaviors;
namespace DocumentService.Application.Common.Behaviors;
/// <summary>
/// MediatR Pipeline Behavior that logs requests and tracks performance

View File

@@ -1,7 +1,7 @@
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.Common.Behaviors;
namespace DocumentService.Application.Common.Behaviors;
/// <summary>
/// MediatR Pipeline Behavior that validates requests using FluentValidation

View File

@@ -0,0 +1,29 @@
namespace DocumentService.Application.Common.Configuration;
/// <summary>
/// Configuration settings for ZUGFeRD/Factur-X/XRechnung detection
/// </summary>
public class ZugferdSettings
{
/// <summary>
/// Exact ZUGFeRD/Factur-X/XRechnung file names to check (case-insensitive)
/// </summary>
public List<string> ZugferdFileNames { get; set; } = new()
{
"factur-x.xml",
"zugferd-invoice.xml",
"ZUGFeRD-invoice.xml",
"xrechnung.xml"
};
/// <summary>
/// Partial filename patterns for ZUGFeRD detection (case-insensitive)
/// </summary>
public List<string> ZugferdFileNamePatterns { get; set; } = new()
{
"factur",
"zugferd",
"xrechnung",
"peppol"
};
}

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Application.Common.DTOs;
namespace DocumentService.Application.Common.DTOs;
/// <summary>
/// DTO for attachment check result returned to API layer

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Application.Common.DTOs;
namespace DocumentService.Application.Common.DTOs;
/// <summary>
/// Represents complete attachment information for a PDF document.

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Application.Common.DTOs;
namespace DocumentService.Application.Common.DTOs;
/// <summary>
/// Represents metadata of a single PDF attachment (embedded file).

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Application.Common.DTOs;
namespace DocumentService.Application.Common.DTOs;
/// <summary>
/// PDF/A validation metadata including conformance level and validation errors/warnings

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Application.Common.DTOs;
namespace DocumentService.Application.Common.DTOs;
/// <summary>
/// PDF/A validation result including conformance level and validation errors/warnings

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Application.Common.DTOs;
namespace DocumentService.Application.Common.DTOs;
public sealed class PdfMetadata(
int pageCount,

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Application.Common.DTOs;
namespace DocumentService.Application.Common.DTOs;
/// <summary>
/// Response mit PDF-Metadaten

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Application.Common.DTOs;
namespace DocumentService.Application.Common.DTOs;
/// <summary>
/// Swiss QR Bill data transfer object (mapped from Codecrete Bill)

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Application.Common.DTOs;
namespace DocumentService.Application.Common.DTOs;
/// <summary>
/// Response containing extracted Swiss QR Code in dual format.

View File

@@ -0,0 +1,27 @@
namespace DocumentService.Application.Common.DTOs;
/// <summary>
/// DTO for ZUGFeRD check result
/// </summary>
public record ZugferdCheckResult
{
/// <summary>
/// Indicates whether the PDF contains ZUGFeRD XML attachment
/// </summary>
public bool HasZugferd { get; init; }
/// <summary>
/// ZUGFeRD XML file name (e.g., "factur-x.xml")
/// </summary>
public string? ZugferdFileName { get; init; }
/// <summary>
/// ZUGFeRD XML file size in bytes
/// </summary>
public long? ZugferdFileSize { get; init; }
/// <summary>
/// MIME type of the ZUGFeRD XML file
/// </summary>
public string? ZugferdMimeType { get; init; }
}

View File

@@ -1,6 +1,6 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentService.Application.Common.DTOs;
namespace DocumentOperator.Application.Common.Interfaces;
namespace DocumentService.Application.Common.Interfaces;
public interface IPdfProcessor
{
@@ -145,4 +145,47 @@ public interface IPdfProcessor
Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground,
byte[]? imageBytes = null,
Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null);
/// <summary>
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
/// </summary>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <param name="attachments">List of files to embed (filename, content, optional MIME type)</param>
/// <returns>PDF with embedded attachments as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid, or attachments list is empty
/// </exception>
Task<byte[]> AddAttachmentsAsync(
Stream pdfStream,
IReadOnlyList<(string FileName, byte[] Content, string? MimeType)> attachments);
/// <summary>
/// Converts a standard PDF to PDF/A format.
/// </summary>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <param name="pdfALevel">Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")</param>
/// <returns>PDF/A compliant document as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid or PDF/A level is unsupported
/// </exception>
Task<byte[]> ConvertToPdfAAsync(Stream pdfStream, string pdfALevel);
/// <summary>
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
/// </summary>
/// <param name="pdfStream">
/// PDF/A document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <returns>Standard PDF document as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid
/// </exception>
Task<byte[]> ConvertFromPdfAAsync(Stream pdfStream);
}

View File

@@ -1,6 +1,6 @@
using Codecrete.SwissQRBill.Generator;
namespace DocumentOperator.Application.Common.Interfaces;
namespace DocumentService.Application.Common.Interfaces;
/// <summary>
/// Interface for Swiss QR Code processing operations.

View File

@@ -1,9 +1,9 @@
using AutoMapper;
using Codecrete.SwissQRBill.Generator;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Domain.Models.ValueObjects;
using DocumentService.Application.Common.DTOs;
using DocumentService.Domain.Models.ValueObjects;
namespace DocumentOperator.Application.Common.Mapping;
namespace DocumentService.Application.Common.Mapping;
/// <summary>
/// AutoMapper profile for mapping domain entities and external models to DTOs.

View File

@@ -0,0 +1,41 @@
using DocumentService.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentService.Application.ConvertFromPdfA;
/// <summary>
/// Command to convert a PDF/A document to a standard PDF (removes PDF/A restrictions)
/// </summary>
public record ConvertFromPdfACommand : IRequest<byte[]>
{
/// <summary>
/// PDF/A document stream. Must be positioned at the beginning (Position = 0).
/// </summary>
public required Stream PdfStream { get; init; }
}
/// <summary>
/// Handler for ConvertFromPdfACommand
/// </summary>
public class ConvertFromPdfACommandHandler(IPdfProcessor pdfProcessor)
: IRequestHandler<ConvertFromPdfACommand, byte[]>
{
public async Task<byte[]> Handle(ConvertFromPdfACommand request, CancellationToken cancellationToken)
{
return await pdfProcessor.ConvertFromPdfAAsync(request.PdfStream);
}
}
/// <summary>
/// Validator for ConvertFromPdfACommand
/// </summary>
public class ConvertFromPdfACommandValidator : AbstractValidator<ConvertFromPdfACommand>
{
public ConvertFromPdfACommandValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
}
}

View File

@@ -0,0 +1,59 @@
using DocumentService.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentService.Application.ConvertToPdfA;
/// <summary>
/// Command to convert a standard PDF to PDF/A format
/// </summary>
public record ConvertToPdfACommand : IRequest<byte[]>
{
/// <summary>
/// PDF document stream. Must be positioned at the beginning (Position = 0).
/// </summary>
public required Stream PdfStream { get; init; }
/// <summary>
/// Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")
/// </summary>
public required string PdfALevel { get; init; }
}
/// <summary>
/// Handler for ConvertToPdfACommand
/// </summary>
public class ConvertToPdfACommandHandler(IPdfProcessor pdfProcessor)
: IRequestHandler<ConvertToPdfACommand, byte[]>
{
public async Task<byte[]> Handle(ConvertToPdfACommand request, CancellationToken cancellationToken)
{
return await pdfProcessor.ConvertToPdfAAsync(request.PdfStream, request.PdfALevel);
}
}
/// <summary>
/// Validator for ConvertToPdfACommand
/// </summary>
public class ConvertToPdfACommandValidator : AbstractValidator<ConvertToPdfACommand>
{
private static readonly string[] ValidPdfALevels =
{
"PDF/A-1b", "PDF/A-1a",
"PDF/A-2b", "PDF/A-2u", "PDF/A-2a",
"PDF/A-3b", "PDF/A-3u", "PDF/A-3a"
};
public ConvertToPdfACommandValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
RuleFor(x => x.PdfALevel)
.NotEmpty()
.WithMessage("PDF/A level is required")
.Must(level => ValidPdfALevels.Contains(level, StringComparer.OrdinalIgnoreCase))
.WithMessage($"Invalid PDF/A level. Valid values: {string.Join(", ", ValidPdfALevels)}");
}
}

View File

@@ -1,7 +1,8 @@
using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace DocumentOperator.Application;
namespace DocumentService.Application;
/// <summary>
/// Dependency Injection configuration for Application Layer
@@ -11,13 +12,18 @@ public static class DependencyInjection
/// <summary>
/// Registers Application Layer services (MediatR, FluentValidation, AutoMapper, Behaviors)
/// </summary>
public static IServiceCollection AddApplication(this IServiceCollection services)
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
{
var assembly = typeof(DependencyInjection).Assembly;
// Read LuckyPennySoft license key from appsettings.json
var licenseKey = configuration.GetValue<string>("LuckyPennySoftLicenseKey")
?? throw new InvalidOperationException("LuckyPennySoftLicenseKey not found in configuration");
// Register MediatR (scannt Assembly nach Handlers)
services.AddMediatR(config =>
{
config.LicenseKey = licenseKey;
config.RegisterServicesFromAssembly(assembly);
// Pipeline Behaviors (Reihenfolge wichtig!)
@@ -29,7 +35,9 @@ public static class DependencyInjection
services.AddValidatorsFromAssembly(assembly);
// Register AutoMapper (scannt Assembly nach Profiles)
services.AddAutoMapper(cfg => { }, typeof(Common.Mapping.MappingProfile));
services.AddAutoMapper(cfg => {
cfg.LicenseKey = licenseKey;
}, typeof(Common.Mapping.MappingProfile));
return services;
}

View File

@@ -21,10 +21,12 @@
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="MediatR" Version="14.1.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
<ProjectReference Include="..\DocumentOperator.Domain\DocumentService.Domain.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,8 +1,8 @@
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.ExtractPdfAttachments;
namespace DocumentService.Application.ExtractPdfAttachments;
/// <summary>
/// Command to extract all embedded files from a PDF document and return as ZIP archive.

View File

@@ -0,0 +1,113 @@
using DocumentService.Application.Common.Configuration;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.Options;
namespace DocumentService.Application.ExtractZugferd;
/// <summary>
/// Command to extract ZUGFeRD XML from a PDF document
/// </summary>
public record ExtractZugferdCommand : IRequest<ZugferdExtractionResult>
{
/// <summary>
/// PDF document stream. Must be positioned at the beginning (Position = 0).
/// </summary>
public required Stream PdfStream { get; init; }
}
/// <summary>
/// Handler for ExtractZugferdCommand.
/// Extracts ZUGFeRD XML from PDF and returns XML content
/// </summary>
public class ExtractZugferdCommandHandler(
IPdfProcessor pdfProcessor,
IOptions<ZugferdSettings> settings)
: IRequestHandler<ExtractZugferdCommand, ZugferdExtractionResult>
{
private readonly ZugferdSettings _settings = settings.Value;
public async Task<ZugferdExtractionResult> Handle(ExtractZugferdCommand request, CancellationToken cancellationToken)
{
// Get all attachments
var attachmentInfo = await pdfProcessor.CheckAttachmentsAsync(request.PdfStream);
// Find ZUGFeRD XML file using configured names and patterns
var zugferdAttachment = attachmentInfo.Attachments.FirstOrDefault(a =>
_settings.ZugferdFileNames.Any(name =>
a.FileName.Equals(name, StringComparison.OrdinalIgnoreCase)) ||
_settings.ZugferdFileNamePatterns.Any(pattern =>
a.FileName.Contains(pattern, StringComparison.OrdinalIgnoreCase)));
if (zugferdAttachment == null)
{
throw new NotFoundException("ZUGFeRD XML not found in PDF attachments");
}
// Reset stream position for extraction
request.PdfStream.Position = 0;
// Extract all attachments as ZIP
byte[] zipBytes = await pdfProcessor.ExtractAttachmentsAsync(request.PdfStream);
// Find ZUGFeRD XML in ZIP
using var zipStream = new MemoryStream(zipBytes);
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
var zugferdEntry = zipArchive.Entries.FirstOrDefault(e =>
e.Name.Equals(zugferdAttachment.FileName, StringComparison.OrdinalIgnoreCase));
if (zugferdEntry == null)
{
throw new NotFoundException($"ZUGFeRD XML '{zugferdAttachment.FileName}' not found in extracted attachments");
}
// Read XML content
using var entryStream = zugferdEntry.Open();
using var reader = new StreamReader(entryStream);
string xmlContent = await reader.ReadToEndAsync(cancellationToken);
return new ZugferdExtractionResult
{
FileName = zugferdAttachment.FileName,
XmlContent = xmlContent,
FileSize = zugferdAttachment.SizeBytes
};
}
}
/// <summary>
/// Validator for ExtractZugferdCommand.
/// </summary>
public class ExtractZugferdCommandValidator : AbstractValidator<ExtractZugferdCommand>
{
public ExtractZugferdCommandValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
}
}
/// <summary>
/// Result DTO for ZUGFeRD extraction
/// </summary>
public record ZugferdExtractionResult
{
/// <summary>
/// ZUGFeRD XML file name
/// </summary>
public required string FileName { get; init; }
/// <summary>
/// ZUGFeRD XML content as string
/// </summary>
public required string XmlContent { get; init; }
/// <summary>
/// File size in bytes
/// </summary>
public long FileSize { get; init; }
}

View File

@@ -0,0 +1,62 @@
using DocumentService.Application.Common.Configuration;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using MediatR;
using Microsoft.Extensions.Options;
namespace DocumentService.Application.HasZugferd.Queries;
/// <summary>
/// Query for checking if PDF contains ZUGFeRD XML attachment
/// </summary>
public record HasZugferdQuery : IRequest<ZugferdCheckResult>
{
/// <summary>
/// PDF as stream (caller is responsible for disposal)
/// </summary>
public required Stream PdfStream { get; init; }
}
/// <summary>
/// Handler for HasZugferdQuery
/// Checks if PDF contains ZUGFeRD/Factur-X XML attachment
/// </summary>
public class HasZugferdQueryHandler(
IPdfProcessor pdfProcessor,
IOptions<ZugferdSettings> settings)
: IRequestHandler<HasZugferdQuery, ZugferdCheckResult>
{
private readonly ZugferdSettings _settings = settings.Value;
/// <summary>
/// Checks if PDF contains ZUGFeRD XML and returns metadata
/// </summary>
public async Task<ZugferdCheckResult> Handle(HasZugferdQuery request, CancellationToken cancellationToken)
{
// Get all attachments
var attachmentInfo = await pdfProcessor.CheckAttachmentsAsync(request.PdfStream);
// Check for ZUGFeRD/Factur-X XML files using configured names and patterns
var zugferdAttachment = attachmentInfo.Attachments.FirstOrDefault(a =>
_settings.ZugferdFileNames.Any(name =>
a.FileName.Equals(name, StringComparison.OrdinalIgnoreCase)) ||
_settings.ZugferdFileNamePatterns.Any(pattern =>
a.FileName.Contains(pattern, StringComparison.OrdinalIgnoreCase)));
if (zugferdAttachment != null)
{
return new ZugferdCheckResult
{
HasZugferd = true,
ZugferdFileName = zugferdAttachment.FileName,
ZugferdFileSize = zugferdAttachment.SizeBytes,
ZugferdMimeType = zugferdAttachment.MimeType
};
}
return new ZugferdCheckResult
{
HasZugferd = false
};
}
}

View File

@@ -0,0 +1,16 @@
using FluentValidation;
namespace DocumentService.Application.HasZugferd.Queries;
/// <summary>
/// Validator for HasZugferdQuery
/// </summary>
public class HasZugferdQueryValidator : AbstractValidator<HasZugferdQuery>
{
public HasZugferdQueryValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
}
}

View File

@@ -1,8 +1,8 @@
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.MergePdfs;
namespace DocumentService.Application.MergePdfs;
/// <summary>
/// Command to merge multiple PDF documents into a single PDF.

View File

@@ -1,9 +1,9 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.SwissQrCode.Queries;
namespace DocumentService.Application.SwissQrCode.Queries;
/// <summary>
/// Query for extracting Swiss QR Code from PDF (Stream-based)

View File

@@ -1,7 +1,7 @@
using DocumentOperator.Application.SwissQrCode.Queries;
using DocumentService.Application.SwissQrCode.Queries;
using FluentValidation;
namespace DocumentOperator.Application.SwissQrCode.Queries;
namespace DocumentService.Application.SwissQrCode.Queries;
/// <summary>
/// Validates ExtractSwissQrCodeQuery before handler execution.

View File

@@ -1,9 +1,9 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.ValidatePdf.Queries;
namespace DocumentService.Application.ValidatePdf.Queries;
/// <summary>
/// Query for PDF validation (Stream-based)

View File

@@ -1,7 +1,7 @@
using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentService.Application.ValidatePdf.Queries;
using FluentValidation;
namespace DocumentOperator.Application.ValidatePdf.Queries;
namespace DocumentService.Application.ValidatePdf.Queries;
/// <summary>
/// Validator for ValidatePdfQuery

View File

@@ -1,9 +1,9 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.ValidatePdfA.Queries;
namespace DocumentService.Application.ValidatePdfA.Queries;
/// <summary>
/// Query for PDF/A validation (Stream-based)

View File

@@ -1,6 +1,6 @@
using FluentValidation;
namespace DocumentOperator.Application.ValidatePdfA.Validators;
namespace DocumentService.Application.ValidatePdfA.Validators;
/// <summary>
/// Validator for ValidatePdfAQuery

View File

@@ -5,7 +5,7 @@ using System.Runtime.Serialization;
using System.Text;
using System.Threading.Tasks;
namespace DocumentOperator.Domain.Common.Exceptions;
namespace DocumentService.Domain.Common.Exceptions;
public class BadRequestException : Exception
{

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Domain.Common.Exceptions;
namespace DocumentService.Domain.Common.Exceptions;
/// <summary>
/// Base exception for all domain-related exceptions.

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Domain.Common.Exceptions;
namespace DocumentService.Domain.Common.Exceptions;
/// <summary>
/// Exception thrown when domain validation fails (e.g., invalid Value Objects).

View File

@@ -1,6 +1,6 @@
using System.Runtime.Serialization;
namespace DocumentOperator.Domain.Common.Exceptions;
namespace DocumentService.Domain.Common.Exceptions;
/// <summary>
/// Exception thrown when a requested resource is not found.

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Coordinate origin point for PDF annotations.

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Supported PDF annotation types

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Predefined stamp types with standard text and styling.

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Specifies whether the stamp should appear in the foreground (on top of content) or background (behind content).

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Specifies the type of stamp to add to a PDF document.

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
namespace DocumentService.Domain.Models.ValueObjects;
public sealed class TenantId
{

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
namespace DocumentService.Domain.Models.ValueObjects;
/// <summary>
/// Text markup annotation style (highlight, underline, strikeout)

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Infrastructure.Configuration;
namespace DocumentService.Infrastructure.Configuration;
public class ApiKeySettings
{

View File

@@ -1,8 +1,8 @@
namespace DocumentOperator.Infrastructure.Configuration;
namespace DocumentService.Infrastructure.Configuration;
public class DocumentOperatorSettings
public class DocumentServiceSettings
{
public const string SectionName = "DocumentOperatorSettings";
public const string SectionName = "DocumentServiceSettings";
public string TempFolderPath { get; set; } = string.Empty;
public int TempFileRetentionHours { get; set; }

View File

@@ -1,10 +1,10 @@
namespace DocumentOperator.Infrastructure.Configuration;
namespace DocumentService.Infrastructure.Configuration;
public class RedisSettings
{
public const string SectionName = "RedisSettings";
public string ConnectionString { get; set; } = "localhost:6379";
public string InstanceName { get; set; } = "DocumentOperator:";
public string InstanceName { get; set; } = "DocumentService:";
public int CacheExpirationMinutes { get; set; } = 60;
}

View File

@@ -1,4 +1,4 @@
namespace DocumentOperator.Infrastructure.Configuration;
namespace DocumentService.Infrastructure.Configuration;
public class TenantInfo
{

View File

@@ -1,9 +1,9 @@
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Infrastructure.Services.PdfProcessing;
using DocumentOperator.Infrastructure.Services.QrCodeProcessing;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Infrastructure.Services.PdfProcessing;
using DocumentService.Infrastructure.Services.QrCodeProcessing;
using Microsoft.Extensions.DependencyInjection;
namespace DocumentOperator.Infrastructure;
namespace DocumentService.Infrastructure;
/// <summary>
/// Dependency Injection configuration for Infrastructure Layer

View File

@@ -27,8 +27,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentOperator.Application\DocumentOperator.Application.csproj" />
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
<ProjectReference Include="..\DocumentOperator.Application\DocumentService.Application.csproj" />
<ProjectReference Include="..\DocumentOperator.Domain\DocumentService.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,11 +1,11 @@
using DevExpress.Pdf;
using DevExpress.Drawing;
using System.Drawing;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
namespace DocumentOperator.Infrastructure.Services.PdfProcessing;
namespace DocumentService.Infrastructure.Services.PdfProcessing;
/// <summary>
/// PDF processor implementation using DevExpress.Pdf library.
@@ -1093,4 +1093,113 @@ public class DevExpressPdfProcessor : IPdfProcessor
}
#endregion
#region Add Attachments (Phase 2)
/// <summary>
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
/// </summary>
public async Task<byte[]> AddAttachmentsAsync(
Stream pdfStream,
IReadOnlyList<(string FileName, byte[] Content, string? MimeType)> attachments)
{
// 1. Validate input
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
ArgumentNullException.ThrowIfNull(attachments, nameof(attachments));
if (pdfStream.Length == 0)
throw new BadRequestException("PDF stream cannot be empty");
if (pdfStream.Position != 0)
throw new BadRequestException("PDF stream must be at position 0");
if (attachments.Count == 0)
throw new BadRequestException("At least one attachment is required");
// TODO: Implement AddFileAttachment using DevExpress.Pdf low-level API
// Current limitation: DevExpress.Pdf.PdfDocumentProcessor doesn't directly support adding attachments
// Workaround options:
// 1. Use PdfDocumentProcessor.Document to manipulate PDF structure directly (advanced)
// 2. Use third-party library for this specific operation
// 3. Wait for DevExpress API update
throw new NotImplementedException(
"Add attachments feature is not yet implemented. " +
"DevExpress.Pdf high-level API doesn't directly support adding file attachments. " +
"This requires low-level PDF structure manipulation.");
}
private string InferMimeType(string fileName)
{
string extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".xml" => "application/xml",
".pdf" => "application/pdf",
".json" => "application/json",
".txt" => "text/plain",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
_ => "application/octet-stream"
};
}
#endregion
#region PDF Conversion (Phase 3)
/// <summary>
/// Converts a standard PDF to PDF/A format.
/// </summary>
public async Task<byte[]> ConvertToPdfAAsync(Stream pdfStream, string pdfALevel)
{
// 1. Validate input
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
if (pdfStream.Length == 0)
throw new BadRequestException("PDF stream cannot be empty");
if (pdfStream.Position != 0)
throw new BadRequestException("PDF stream must be at position 0");
if (string.IsNullOrWhiteSpace(pdfALevel))
throw new BadRequestException("PDF/A level is required");
// TODO: Implement PDF to PDF/A conversion using DevExpress
// Current limitation: DevExpress.Pdf.PdfDocumentProcessor doesn't directly support PDF/A conversion
// Requires using specialized PDF/A conversion libraries or low-level PDF manipulation
throw new NotImplementedException(
$"PDF to PDF/A conversion ({pdfALevel}) is not yet implemented. " +
"DevExpress.Pdf high-level API doesn't directly support PDF/A conversion. " +
"This requires specialized PDF/A conversion logic.");
}
/// <summary>
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
/// </summary>
public async Task<byte[]> ConvertFromPdfAAsync(Stream pdfStream)
{
// 1. Validate input
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
if (pdfStream.Length == 0)
throw new BadRequestException("PDF stream cannot be empty");
if (pdfStream.Position != 0)
throw new BadRequestException("PDF stream must be at position 0");
// 2. Load PDF/A
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
// 3. Save as standard PDF
// DevExpress SaveDocument without special options creates standard PDF
using var outputStream = new MemoryStream();
processor.SaveDocument(outputStream);
return await Task.FromResult(outputStream.ToArray());
}
#endregion
}

View File

@@ -1,13 +1,13 @@
using Codecrete.SwissQRBill.Generator;
using DevExpress.Drawing;
using DevExpress.Pdf;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
using SkiaSharp;
using SkiaSharp.QrCode;
using System.Collections.Concurrent;
namespace DocumentOperator.Infrastructure.Services.QrCodeProcessing;
namespace DocumentService.Infrastructure.Services.QrCodeProcessing;
/// <summary>
/// Swiss QR Code processor using DevExpress PDF API for image extraction,

View File

@@ -1,6 +1,6 @@
using System.Xml.Serialization;
namespace DocumentOperator.Infrastructure.Services;
namespace DocumentService.Infrastructure.Services;
public static class StringExtensions
{

View File

@@ -39,10 +39,11 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DocumentOperator.API\DocumentOperator.API.csproj" />
<ProjectReference Include="..\DocumentOperator.Domain\DocumentOperator.Domain.csproj" />
<ProjectReference Include="..\DocumentOperator.Application\DocumentOperator.Application.csproj" />
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentOperator.Infrastructure.csproj" />
<ProjectReference Include="..\DocumentOperator.API\DocumentService.API.csproj" />
<ProjectReference Include="..\DocumentOperator.Domain\DocumentService.Domain.csproj" />
<ProjectReference Include="..\DocumentOperator.Application\DocumentService.Application.csproj" />
<ProjectReference Include="..\DocumentOperator.Infrastructure\DocumentService.Infrastructure.csproj" />
<ProjectReference Include="..\DocumentService.Client\DocumentService.Client.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,5 @@
using DocumentOperator.API.Controllers; // For ExtractSwissQrCodeBase64Request DTO
using DocumentOperator.Application.Common.DTOs;
using DocumentService.API.Controllers; // For ExtractSwissQrCodeBase64Request DTO
using DocumentService.Application.Common.DTOs;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
@@ -7,7 +7,7 @@ using System.Net.Http.Json;
using System.Text.Json;
using Xunit;
namespace DocumentOperator.Tests.Integration.API;
namespace DocumentService.Tests.Integration.API;
public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
@@ -27,7 +27,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
public async Task POST_ExtractSwissQrCode_ValidRequest_Returns200()
{
// Arrange
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentService.Tests.TestData.Pdfs.valid.pdf");
var request = new ExtractSwissQrCodeBase64Request
{
@@ -77,7 +77,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
{
// Arrange: References are OPTIONAL - null should not cause validation error (400)
// Using pdfWithSwissQRCode.pdf which actually has a QR code, so we get 200
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.pdfWithSwissQRCode.pdf");
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentService.Tests.TestData.Pdfs.pdfWithSwissQRCode.pdf");
var request = new
{

View File

@@ -1,12 +1,12 @@
using DocumentOperator.API.Controllers; // For CheckPdfAttachmentsRequest DTO
using DocumentOperator.Application.Common.DTOs;
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Models.Requests;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using Xunit;
namespace DocumentOperator.Tests.Integration.API;
namespace DocumentService.Tests.Integration.API;
/// <summary>
/// Integration tests for PdfAttachmentController.
@@ -31,7 +31,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
private static async Task<byte[]> LoadTestPdfAsync(string filename)
{
var assembly = typeof(PdfAttachmentControllerTests).Assembly;
var resourceName = $"DocumentOperator.Tests.TestData.Pdfs.{filename}";
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{filename}";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)

View File

@@ -3,12 +3,12 @@ using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using DocumentOperator.API.Controllers;
using DocumentOperator.Domain.Models.ValueObjects;
using DocumentService.API.Controllers;
using DocumentService.Domain.Models.ValueObjects;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
namespace DocumentOperator.Tests.Integration.API;
namespace DocumentService.Tests.Integration.API;
public class PdfOperationsControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
@@ -22,7 +22,7 @@ public class PdfOperationsControllerTests : IClassFixture<WebApplicationFactory<
private static Stream LoadTestPdfAsStream(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentOperator.Tests.TestData.Pdfs.{fileName}";
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{fileName}";
return assembly.GetManifestResourceStream(resourceName)
?? throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
}

View File

@@ -1,12 +1,12 @@
using DocumentOperator.API.Controllers; // For Base64Request DTOs
using DocumentOperator.Application.Common.DTOs;
using DocumentService.Application.Common.DTOs;
using DocumentService.Client.Models.Requests;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using Xunit;
namespace DocumentOperator.Tests.Integration.API;
namespace DocumentService.Tests.Integration.API;
public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
@@ -27,7 +27,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
// Arrange
// Verwende ein echtes Test-PDF (embedded resource aus Unit Tests)
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
@@ -101,7 +101,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
{
// Arrange
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
@@ -176,7 +176,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
{
// Arrange
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
@@ -250,7 +250,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
{
// Arrange
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
var resourceName = "DocumentService.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))

View File

@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100 ../../../schemas/UN_CEFACT/CrossIndustryInvoice_100pD16B.xsd">
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:ID>2021_10</ram:ID>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">20210924</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>1</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>Project management</ram:Name>
<ram:Description/>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>500.000000</ram:ChargeAmount>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="C62">2.00</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>1000.00</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>2</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>Consulting</ram:Name>
<ram:Description/>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>40.000000</ram:ChargeAmount>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="C62">5.00</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>200.00</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:ApplicableHeaderTradeAgreement>
<ram:BuyerReference>139877</ram:BuyerReference>
<ram:SellerTradeParty>
<ram:Name>Webware Internet Solutions GmbH</ram:Name>
<ram:SpecifiedLegalOrganization>
<ram:ID>HRB 15635</ram:ID>
</ram:SpecifiedLegalOrganization>
<ram:DefinedTradeContact>
<ram:PersonName>John Doe</ram:PersonName>
<ram:TelephoneUniversalCommunication>
<ram:CompleteNumber>+49(0)561-560123456</ram:CompleteNumber>
</ram:TelephoneUniversalCommunication>
<ram:EmailURIUniversalCommunication>
<ram:URIID>johndoe@webware24.de</ram:URIID>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
<ram:PostalTradeAddress>
<ram:PostcodeCode>34130</ram:PostcodeCode>
<ram:LineOne>Teichstr. 14-16</ram:LineOne>
<ram:CityName>Kassel</ram:CityName>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:URIUniversalCommunication>
<ram:URIID schemeID="9930">DE279247134</ram:URIID>
</ram:URIUniversalCommunication>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="FC">262/481/0918</ram:ID>
</ram:SpecifiedTaxRegistration>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">DE279247134</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>Agoratech</ram:Name>
<ram:PostalTradeAddress>
<ram:PostcodeCode>34130</ram:PostcodeCode>
<ram:LineOne>Teichstr. 14-16</ram:LineOne>
<ram:CityName>Kassel</ram:CityName>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:URIUniversalCommunication>
<ram:URIID schemeID="9930">DE319642369</ram:URIID>
</ram:URIUniversalCommunication>
</ram:BuyerTradeParty>
</ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeDelivery>
<ram:ActualDeliverySupplyChainEvent>
<ram:OccurrenceDateTime>
<udt:DateTimeString format="102">20211101</udt:DateTimeString>
</ram:OccurrenceDateTime>
</ram:ActualDeliverySupplyChainEvent>
</ram:ApplicableHeaderTradeDelivery>
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementPaymentMeans>
<ram:TypeCode>42</ram:TypeCode>
<ram:PayeePartyCreditorFinancialAccount>
<ram:IBANID/>
</ram:PayeePartyCreditorFinancialAccount>
<ram:PayeeSpecifiedCreditorFinancialInstitution>
<ram:BICID/>
</ram:PayeeSpecifiedCreditorFinancialInstitution>
</ram:SpecifiedTradeSettlementPaymentMeans>
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>228</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:BasisAmount>1200</ram:BasisAmount>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>19.00</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount>1200</ram:LineTotalAmount>
<ram:ChargeTotalAmount>0</ram:ChargeTotalAmount>
<ram:AllowanceTotalAmount>0</ram:AllowanceTotalAmount>
<ram:TaxBasisTotalAmount>1200.00</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount currencyID="EUR">228.00</ram:TaxTotalAmount>
<ram:GrandTotalAmount>1428.00</ram:GrandTotalAmount>
<ram:TotalPrepaidAmount>0.00</ram:TotalPrepaidAmount>
<ram:DuePayableAmount>1428.00</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>

View File

@@ -1,12 +1,12 @@
using AutoMapper;
using DocumentOperator.Application.CheckPdfAttachments.Queries;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentService.Application.CheckPdfAttachments.Queries;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using FluentAssertions;
using Moq;
namespace DocumentOperator.Tests.Unit.Application.CheckPdfAttachments;
namespace DocumentService.Tests.Unit.Application.CheckPdfAttachments;
/// <summary>
/// Unit tests for CheckPdfAttachmentsQueryHandler.

View File

@@ -1,13 +1,13 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Application.ValidatePdf.Queries;
using DocumentService.Domain.Common.Exceptions;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentOperator.Tests.Unit.Application.Features.ValidatePdf;
namespace DocumentService.Tests.Unit.Application.Features.ValidatePdf;
public class ValidatePdfHandlerTests
{

View File

@@ -1,14 +1,14 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Application.ValidatePdfA.Queries;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Application.ValidatePdfA.Queries;
using DocumentService.Domain.Common.Exceptions;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentOperator.Tests.Unit.Application.Features.ValidatePdfA;
namespace DocumentService.Tests.Unit.Application.Features.ValidatePdfA;
public class ValidatePdfAQueryHandlerTests
{

View File

@@ -0,0 +1,56 @@
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Reusable fake <see cref="HttpMessageHandler"/> for unit-testing HTTP clients.
/// Captures the outgoing request and returns the configured response.
/// </summary>
internal sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly HttpResponseMessage _response;
/// <summary>The last request that was sent through this handler.</summary>
public HttpRequestMessage? LastRequest { get; private set; }
public MockHttpMessageHandler(HttpResponseMessage response)
{
_response = response;
}
// ?? convenience factories ????????????????????????????????????????????????
/// <summary>Creates a handler that returns 200 OK with a JSON-serialised body.</summary>
public static MockHttpMessageHandler ReturningJson<T>(T body, HttpStatusCode status = HttpStatusCode.OK)
{
var json = JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var response = new HttpResponseMessage(status)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
return new MockHttpMessageHandler(response);
}
/// <summary>Creates a handler that returns 200 OK with raw bytes as the body.</summary>
public static MockHttpMessageHandler ReturningBytes(byte[] bytes, string mediaType = "application/octet-stream", HttpStatusCode status = HttpStatusCode.OK)
{
var response = new HttpResponseMessage(status)
{
Content = new ByteArrayContent(bytes) { Headers = { ContentType = new(mediaType) } }
};
return new MockHttpMessageHandler(response);
}
/// <summary>Creates a handler that returns the given status code with no body.</summary>
public static MockHttpMessageHandler ReturningStatus(HttpStatusCode status)
=> new(new HttpResponseMessage(status));
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(_response);
}
}

View File

@@ -0,0 +1,221 @@
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using DocumentService.Client.Models.Requests;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Unit tests for <see cref="PdfAttachmentClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made.
/// </summary>
public class PdfAttachmentClientTests
{
// ?? helpers ?????????????????????????????????????????????????????????????
private static (PdfAttachmentClient client, MockHttpMessageHandler handler) BuildJson<T>(T body)
{
var handler = MockHttpMessageHandler.ReturningJson(body);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance);
return (client, handler);
}
private static (PdfAttachmentClient client, MockHttpMessageHandler handler) BuildBytes(byte[] bytes, string mediaType = "application/zip")
{
var handler = MockHttpMessageHandler.ReturningBytes(bytes, mediaType);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance);
return (client, handler);
}
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
/// <summary>Builds a minimal valid ZIP containing the given entries.</summary>
private static byte[] BuildZip(Dictionary<string, string> entries)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var (name, content) in entries)
{
var entry = archive.CreateEntry(name);
using var writer = new StreamWriter(entry.Open());
writer.Write(content);
}
}
return ms.ToArray();
}
// ?? CheckAttachmentsAsync (Stream) ???????????????????????????????????????
[Fact]
public async Task CheckAttachmentsAsync_Stream_SendsMultipartPost()
{
// Arrange
var expected = new AttachmentCheckResult
{
HasAttachments = true,
AttachmentCount = 1,
Attachments = new List<AttachmentMetadata>
{
new() { FileName = "factur-x.xml", MimeType = "application/xml", Size = 512 }
}
};
var (client, handler) = BuildJson(expected);
// Act
var result = await client.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.HasAttachments.Should().BeTrue();
result.AttachmentCount.Should().Be(1);
result.Attachments[0].FileName.Should().Be("factur-x.xml");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/attachments/check");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task CheckAttachmentsAsync_Stream_PdfWithNoAttachments_ReturnsFalse()
{
// Arrange
var expected = new AttachmentCheckResult { HasAttachments = false, AttachmentCount = 0 };
var (client, _) = BuildJson(expected);
// Act
var result = await client.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
result.Attachments.Should().BeEmpty();
}
// ?? CheckAttachmentsAsync (byte[]) ???????????????????????????????????????
[Fact]
public async Task CheckAttachmentsAsync_Bytes_SendsJsonWithBase64()
{
// Arrange
var expected = new AttachmentCheckResult { HasAttachments = false };
var (client, handler) = BuildJson(expected);
// Act
await client.CheckAttachmentsAsync(FakePdfBytes());
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
}
// ?? ExtractAttachmentsAsync (Stream) — ZIP unzip ?????????????????????????
[Fact]
public async Task ExtractAttachmentsAsync_Stream_UnzipsAndReturnsDictionary()
{
// Arrange
var zipBytes = BuildZip(new Dictionary<string, string>
{
["factur-x.xml"] = "<invoice>test</invoice>",
["readme.txt"] = "Hello World"
});
var (client, handler) = BuildBytes(zipBytes);
// Act
var result = await client.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.Should().HaveCount(2);
result.Should().ContainKey("factur-x.xml");
result.Should().ContainKey("readme.txt");
using var xmlStream = result["factur-x.xml"];
var xmlContent = await new StreamReader(xmlStream).ReadToEndAsync();
xmlContent.Should().Be("<invoice>test</invoice>");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/attachments/extract");
// Cleanup
foreach (var s in result.Values) s.Dispose();
}
[Fact]
public async Task ExtractAttachmentsAsync_Stream_WithSingleEntry_ReturnsOneItem()
{
// Arrange
var zipBytes = BuildZip(new Dictionary<string, string>
{
["data.xml"] = "<root/>"
});
var (client, _) = BuildBytes(zipBytes);
// Act
var result = await client.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.Should().HaveCount(1);
result.Should().ContainKey("data.xml");
foreach (var s in result.Values) s.Dispose();
}
// ?? ExtractAttachmentsAsync (byte[]) — ZIP unzip ?????????????????????????
[Fact]
public async Task ExtractAttachmentsAsync_Bytes_SendsJsonAndUnzips()
{
// Arrange
var zipBytes = BuildZip(new Dictionary<string, string>
{
["invoice.xml"] = "<invoice/>"
});
var (client, handler) = BuildBytes(zipBytes);
// Act
var result = await client.ExtractAttachmentsAsync(FakePdfBytes());
// Assert
result.Should().ContainKey("invoice.xml");
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
foreach (var s in result.Values) s.Dispose();
}
// ?? HTTP error propagation ???????????????????????????????????????????????
[Fact]
public async Task CheckAttachmentsAsync_WhenApiReturns404_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.NotFound);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance);
// Act & Assert
await client.Invoking(c => c.CheckAttachmentsAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task ExtractAttachmentsAsync_WhenApiReturns500_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfAttachmentClient(httpClient, NullLogger<PdfAttachmentClient>.Instance);
// Act & Assert
await client.Invoking(c => c.ExtractAttachmentsAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -0,0 +1,236 @@
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using DocumentService.Client.Models.Requests;
using DocumentService.Client.Models.ValueObjects;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Unit tests for <see cref="PdfOperationsClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made.
/// </summary>
public class PdfOperationsClientTests
{
// ?? helpers ?????????????????????????????????????????????????????????????
private static (PdfOperationsClient client, MockHttpMessageHandler handler) BuildBytes(byte[] bytes = null!)
{
var handler = MockHttpMessageHandler.ReturningBytes(bytes ?? "merged-pdf"u8.ToArray(), "application/pdf");
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfOperationsClient(httpClient, NullLogger<PdfOperationsClient>.Instance);
return (client, handler);
}
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
private static AddAnnotationBase64Request FakeAnnotationRequest() => new()
{
Base64Pdf = string.Empty,
AnnotationType = AnnotationType.TextMarkup,
PageNumber = 1,
X1 = 10, Y1 = 20, Width = 100, Height = 30,
Color = "FFFF00",
TextMarkupStyle = TextMarkupStyle.Highlight,
Origin = AnnotationOrigin.TopLeft
};
private static AddStampBase64Request FakeStampRequest() => new()
{
Base64Pdf = string.Empty,
StampType = StampType.Text,
X = 100, Y = 50,
Text = "CONFIDENTIAL",
FontSize = 24,
Color = "FF0000",
Opacity = 0.5,
Placement = StampPlacement.Foreground,
Origin = AnnotationOrigin.BottomLeft
};
// ?? MergeAsync (Streams) ?????????????????????????????????????????????????
[Fact]
public async Task MergeAsync_Streams_SendsMultipartWithAllFiles()
{
// Arrange
var (client, handler) = BuildBytes();
var streams = new List<Stream>
{
new MemoryStream(FakePdfBytes()),
new MemoryStream(FakePdfBytes())
};
// Act
using var result = await client.MergeAsync(streams);
// Assert
result.Should().NotBeNull();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/operations/merge");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
foreach (var s in streams) s.Dispose();
}
[Fact]
public async Task MergeAsync_Streams_ReturnsMergedPdfStream()
{
// Arrange
var expectedBytes = "merged-content"u8.ToArray();
var (client, _) = BuildBytes(expectedBytes);
// Act
using var result = await client.MergeAsync(new[] { new MemoryStream(FakePdfBytes()), new MemoryStream(FakePdfBytes()) });
var actualBytes = await result.ReadAllBytesAsync();
// Assert
actualBytes.Should().BeEquivalentTo(expectedBytes);
}
// ?? MergeAsync (byte[][]) ????????????????????????????????????????????????
[Fact]
public async Task MergeAsync_ByteArrays_SendsJsonWithBase64List()
{
// Arrange
var (client, handler) = BuildBytes();
// Act
using var result = await client.MergeAsync(new[] { FakePdfBytes(), FakePdfBytes() });
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdfs").GetArrayLength().Should().Be(2);
}
[Fact]
public async Task MergeAsync_ByteArrays_WithPageRanges_IncludesPageRangesInJson()
{
// Arrange
var (client, handler) = BuildBytes();
var pageRanges = new List<string?> { "1-2", null };
// Act
await client.MergeAsync(new[] { FakePdfBytes(), FakePdfBytes() }, pageRanges);
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
var body = await handler.LastRequest!.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("pageRanges").GetArrayLength().Should().Be(2);
}
// ?? AnnotateAsync (Stream) ????????????????????????????????????????????????
[Fact]
public async Task AnnotateAsync_Stream_SendsMultipartWithAnnotationFields()
{
// Arrange
var (client, handler) = BuildBytes();
var request = FakeAnnotationRequest();
// Act
using var result = await client.AnnotateAsync(new MemoryStream(FakePdfBytes()), request);
// Assert
result.Should().NotBeNull();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/operations/annotate");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
// ?? AnnotateAsync (byte[]) ????????????????????????????????????????????????
[Fact]
public async Task AnnotateAsync_Bytes_SendsJsonWithBase64Pdf()
{
// Arrange
var (client, handler) = BuildBytes();
var request = FakeAnnotationRequest();
// Act
await client.AnnotateAsync(FakePdfBytes(), request);
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
// annotationType serializes as integer by default (TextMarkup = 0)
doc.RootElement.GetProperty("annotationType").GetInt32().Should().Be((int)AnnotationType.TextMarkup);
}
// ?? StampAsync (Stream) ???????????????????????????????????????????????????
[Fact]
public async Task StampAsync_Stream_SendsMultipartWithStampFields()
{
// Arrange
var (client, handler) = BuildBytes();
var request = FakeStampRequest();
// Act
using var result = await client.StampAsync(new MemoryStream(FakePdfBytes()), request);
// Assert
result.Should().NotBeNull();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/operations/stamp");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
// ?? StampAsync (byte[]) ???????????????????????????????????????????????????
[Fact]
public async Task StampAsync_Bytes_SendsJsonWithBase64Pdf()
{
// Arrange
var (client, handler) = BuildBytes();
var request = FakeStampRequest();
// Act
await client.StampAsync(FakePdfBytes(), request);
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
// stampType serializes as integer by default (Text = 0)
doc.RootElement.GetProperty("stampType").GetInt32().Should().Be((int)StampType.Text);
}
// ?? HTTP error propagation ???????????????????????????????????????????????
[Fact]
public async Task MergeAsync_WhenApiReturns400_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.BadRequest);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfOperationsClient(httpClient, NullLogger<PdfOperationsClient>.Instance);
// Act & Assert
await client.Invoking(c => c.MergeAsync(new[] { new MemoryStream(FakePdfBytes()), new MemoryStream(FakePdfBytes()) }))
.Should().ThrowAsync<HttpRequestException>();
}
}
// ?? local helper extension ???????????????????????????????????????????????????
file static class StreamHelper
{
public static async Task<byte[]> ReadAllBytesAsync(this Stream stream)
{
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
return ms.ToArray();
}
}

View File

@@ -0,0 +1,168 @@
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using DocumentService.Client.Models.Requests;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Unit tests for <see cref="PdfValidationClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made.
/// </summary>
public class PdfValidationClientTests
{
// ?? helpers ?????????????????????????????????????????????????????????????
private static (PdfValidationClient client, MockHttpMessageHandler handler) Build<T>(T responseBody)
{
var handler = MockHttpMessageHandler.ReturningJson(responseBody);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfValidationClient(httpClient, NullLogger<PdfValidationClient>.Instance);
return (client, handler);
}
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
// ?? ValidatePdfAsync (Stream) ????????????????????????????????????????????
[Fact]
public async Task ValidatePdfAsync_Stream_SendsMultipartPost()
{
// Arrange
var expected = new PdfValidationResult { PageCount = 3, PdfVersion = "1.7", FileSizeBytes = 2048 };
var (client, handler) = Build(expected);
// Act
var result = await client.ValidatePdfAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.PageCount.Should().Be(3);
result.PdfVersion.Should().Be("1.7");
handler.LastRequest!.Method.Should().Be(HttpMethod.Post);
handler.LastRequest.RequestUri!.PathAndQuery.Should().Be("/api/pdf/validation/validate");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task ValidatePdfAsync_Stream_ThrowsWhenApiReturnsNull()
{
// Arrange — API returns JSON null
var handler = MockHttpMessageHandler.ReturningJson<PdfValidationResult?>(null);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfValidationClient(httpClient, NullLogger<PdfValidationClient>.Instance);
// Act & Assert
await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<InvalidOperationException>();
}
// ?? ValidatePdfAsync (byte[]) ????????????????????????????????????????????
[Fact]
public async Task ValidatePdfAsync_Bytes_SendsJsonWithBase64()
{
// Arrange
var expected = new PdfValidationResult { PageCount = 1, IsEncrypted = false };
var (client, handler) = Build(expected);
// Act
var result = await client.ValidatePdfAsync(FakePdfBytes());
// Assert
result.PageCount.Should().Be(1);
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
}
// ?? ValidatePdfAAsync (Stream) ???????????????????????????????????????????
[Fact]
public async Task ValidatePdfAAsync_Stream_SendsMultipartPost()
{
// Arrange
var expected = new PdfAValidationResult { IsValid = true, PdfAVersion = "PDF/A-3b", PageCount = 2 };
var (client, handler) = Build(expected);
// Act
var result = await client.ValidatePdfAAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.IsValid.Should().BeTrue();
result.PdfAVersion.Should().Be("PDF/A-3b");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/validation/validate-pdfa");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task ValidatePdfAAsync_Stream_WithErrors_ReturnsErrors()
{
// Arrange
var expected = new PdfAValidationResult
{
IsValid = false,
Errors = new List<string> { "Missing embedded font", "Encryption not allowed" }
};
var (client, _) = Build(expected);
// Act
var result = await client.ValidatePdfAAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().HaveCount(2).And.Contain("Missing embedded font");
}
// ?? ValidatePdfAAsync (byte[]) ???????????????????????????????????????????
[Fact]
public async Task ValidatePdfAAsync_Bytes_SendsJson()
{
// Arrange
var expected = new PdfAValidationResult { IsValid = true };
var (client, handler) = Build(expected);
// Act
await client.ValidatePdfAAsync(FakePdfBytes());
// Assert
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/validation/validate-pdfa");
handler.LastRequest.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
}
// ?? HTTP error propagation ???????????????????????????????????????????????
[Fact]
public async Task ValidatePdfAsync_WhenApiReturns400_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.BadRequest);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfValidationClient(httpClient, NullLogger<PdfValidationClient>.Instance);
// Act & Assert
await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task ValidatePdfAsync_WhenApiReturns500_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new PdfValidationClient(httpClient, NullLogger<PdfValidationClient>.Instance);
// Act & Assert
await client.Invoking(c => c.ValidatePdfAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -0,0 +1,123 @@
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Unit tests for <see cref="SwissQrCodeClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made.
/// </summary>
public class SwissQrCodeClientTests
{
// ?? helpers ?????????????????????????????????????????????????????????????
private static (SwissQrCodeClient client, MockHttpMessageHandler handler) Build<T>(T body)
{
var handler = MockHttpMessageHandler.ReturningJson(body);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new SwissQrCodeClient(httpClient, NullLogger<SwissQrCodeClient>.Instance);
return (client, handler);
}
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
// ?? ExtractSwissQrCodeAsync (Stream) — parsed Bill ???????????????????????
[Fact]
public async Task ExtractSwissQrCodeAsync_Stream_ParsedMode_SendsMultipartToCorrectEndpoint()
{
// Arrange
var expected = new SwissQrCodeExtractionResult { Bill = new { Iban = "CH93-0076-2011-6238-5295-7" } };
var (client, handler) = Build(expected);
// Act
var result = await client.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes()), raw: false);
// Assert
result.Should().NotBeNull();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=False");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task ExtractSwissQrCodeAsync_Stream_RawMode_SendsRawFlagInUrl()
{
// Arrange
var expected = new SwissQrCodeExtractionResult { RawLines = new List<string> { "SPC", "0200", "1" } };
var (client, handler) = Build(expected);
// Act
var result = await client.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes()), raw: true);
// Assert
result.RawLines.Should().HaveCount(3).And.StartWith("SPC");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=True");
}
// ?? ExtractSwissQrCodeAsync (byte[]) ?????????????????????????????????????
[Fact]
public async Task ExtractSwissQrCodeAsync_Bytes_SendsJsonWithBase64()
{
// Arrange
var expected = new SwissQrCodeExtractionResult();
var (client, handler) = Build(expected);
// Act
await client.ExtractSwissQrCodeAsync(FakePdfBytes(), raw: false);
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
}
[Fact]
public async Task ExtractSwissQrCodeAsync_Bytes_RawMode_IncludesRawFlagInUrl()
{
// Arrange
var expected = new SwissQrCodeExtractionResult { RawLines = new List<string> { "SPC" } };
var (client, handler) = Build(expected);
// Act
await client.ExtractSwissQrCodeAsync(FakePdfBytes(), raw: true);
// Assert
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/qr-code/extract-swiss?raw=True");
}
// ?? HTTP error propagation ???????????????????????????????????????????????
[Fact]
public async Task ExtractSwissQrCodeAsync_WhenApiReturns404_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.NotFound);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new SwissQrCodeClient(httpClient, NullLogger<SwissQrCodeClient>.Instance);
// Act & Assert
await client.Invoking(c => c.ExtractSwissQrCodeAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
[Fact]
public async Task ExtractSwissQrCodeAsync_WhenApiReturns500_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.InternalServerError);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new SwissQrCodeClient(httpClient, NullLogger<SwissQrCodeClient>.Instance);
// Act & Assert
await client.Invoking(c => c.ExtractSwissQrCodeAsync(FakePdfBytes()))
.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -0,0 +1,199 @@
using DocumentService.Client.Clients;
using DocumentService.Client.Interfaces;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Net.Http;
using System.Text.Json;
namespace DocumentService.Tests.Unit.Client;
/// <summary>
/// Unit tests for <see cref="ZugferdClient"/>.
/// All tests use a fake <see cref="MockHttpMessageHandler"/> — no real HTTP calls are made.
/// </summary>
public class ZugferdClientTests
{
// ?? helpers ?????????????????????????????????????????????????????????????
private static (ZugferdClient client, MockHttpMessageHandler handler) BuildJson<T>(T body)
{
var handler = MockHttpMessageHandler.ReturningJson(body);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new ZugferdClient(httpClient, NullLogger<ZugferdClient>.Instance);
return (client, handler);
}
private static (ZugferdClient client, MockHttpMessageHandler handler) BuildBytes(byte[] bytes, string mediaType = "application/xml")
{
var handler = MockHttpMessageHandler.ReturningBytes(bytes, mediaType);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new ZugferdClient(httpClient, NullLogger<ZugferdClient>.Instance);
return (client, handler);
}
private static byte[] FakePdfBytes() => "fake-pdf-content"u8.ToArray();
private static byte[] FakeXmlBytes() => "<invoice>test</invoice>"u8.ToArray();
// ?? HasZugferdAsync (Stream) ?????????????????????????????????????????????
[Fact]
public async Task HasZugferdAsync_Stream_WhenZugferdPresent_ReturnsTrue()
{
// Arrange
var expected = new ZugferdCheckResult { HasZugferd = true, Version = "2.1", Profile = "EN 16931" };
var (client, handler) = BuildJson(expected);
// Act
var result = await client.HasZugferdAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.HasZugferd.Should().BeTrue();
result.Version.Should().Be("2.1");
result.Profile.Should().Be("EN 16931");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/has-zugferd");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task HasZugferdAsync_Stream_WhenNoZugferd_ReturnsFalse()
{
// Arrange
var expected = new ZugferdCheckResult { HasZugferd = false };
var (client, _) = BuildJson(expected);
// Act
var result = await client.HasZugferdAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.HasZugferd.Should().BeFalse();
result.Version.Should().BeNull();
}
// ?? HasZugferdAsync (byte[]) ?????????????????????????????????????????????
[Fact]
public async Task HasZugferdAsync_Bytes_SendsJsonWithBase64()
{
// Arrange
var expected = new ZugferdCheckResult { HasZugferd = true };
var (client, handler) = BuildJson(expected);
// Act
await client.HasZugferdAsync(FakePdfBytes());
// Assert
handler.LastRequest!.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
var body = await handler.LastRequest.Content!.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("base64Pdf").GetString().Should().NotBeNullOrEmpty();
}
// ?? ExtractZugferdAsync (Stream) — raw XML stream ?????????????????????????
[Fact]
public async Task ExtractZugferdAsync_Stream_SendsMultipartWithAsFileTrue()
{
// Arrange
var (client, handler) = BuildBytes(FakeXmlBytes());
// Act
using var result = await client.ExtractZugferdAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.Should().NotBeNull();
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?asFile=true");
handler.LastRequest.Content.Should().BeOfType<MultipartFormDataContent>();
}
[Fact]
public async Task ExtractZugferdAsync_Stream_ReturnsXmlContent()
{
// Arrange
var xmlBytes = "<root><invoice/></root>"u8.ToArray();
var (client, _) = BuildBytes(xmlBytes);
// Act
using var result = await client.ExtractZugferdAsync(new MemoryStream(FakePdfBytes()));
var content = await new StreamReader(result).ReadToEndAsync();
// Assert
content.Should().Be("<root><invoice/></root>");
}
// ?? ExtractZugferdAsync (byte[]) ?????????????????????????????????????????
[Fact]
public async Task ExtractZugferdAsync_Bytes_SendsJsonWithFormatFile()
{
// Arrange
var (client, handler) = BuildBytes(FakeXmlBytes());
// Act
using var result = await client.ExtractZugferdAsync(FakePdfBytes());
// Assert
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?format=file");
handler.LastRequest.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
}
// ?? ExtractZugferdAsResultAsync (Stream) ?????????????????????????????????
[Fact]
public async Task ExtractZugferdAsResultAsync_Stream_ReturnsStructuredResult()
{
// Arrange
var expected = new ZugferdExtractionResult
{
FileName = "factur-x.xml",
XmlContent = "<invoice/>",
Version = "2.1",
Profile = "EN 16931"
};
var (client, handler) = BuildJson(expected);
// Act
var result = await client.ExtractZugferdAsResultAsync(new MemoryStream(FakePdfBytes()));
// Assert
result.FileName.Should().Be("factur-x.xml");
result.XmlContent.Should().Be("<invoice/>");
result.Version.Should().Be("2.1");
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?asFile=false");
}
// ?? ExtractZugferdAsResultAsync (byte[]) ?????????????????????????????????
[Fact]
public async Task ExtractZugferdAsResultAsync_Bytes_SendsJsonWithFormatJson()
{
// Arrange
var expected = new ZugferdExtractionResult { FileName = "zugferd.xml", XmlContent = "<x/>" };
var (client, handler) = BuildJson(expected);
// Act
await client.ExtractZugferdAsResultAsync(FakePdfBytes());
// Assert
handler.LastRequest!.RequestUri!.PathAndQuery.Should().Be("/api/pdf/zugferd/extract?format=json");
handler.LastRequest.Content.Should().NotBeNull();
handler.LastRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json");
}
// ?? HTTP error propagation ???????????????????????????????????????????????
[Fact]
public async Task HasZugferdAsync_WhenApiReturns400_ThrowsHttpRequestException()
{
// Arrange
var handler = MockHttpMessageHandler.ReturningStatus(HttpStatusCode.BadRequest);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
var client = new ZugferdClient(httpClient, NullLogger<ZugferdClient>.Instance);
// Act & Assert
await client.Invoking(c => c.HasZugferdAsync(new MemoryStream(FakePdfBytes())))
.Should().ThrowAsync<HttpRequestException>();
}
}

View File

@@ -1,11 +1,11 @@
using System.Reflection;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Infrastructure.Services.PdfProcessing;
using DocumentService.Application.Common.DTOs;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
using DocumentService.Infrastructure.Services.PdfProcessing;
using FluentAssertions;
namespace DocumentOperator.Tests.Unit.Infrastructure.Services.PdfProcessing;
namespace DocumentService.Tests.Unit.Infrastructure.Services.PdfProcessing;
/// <summary>
/// Unit tests for DevExpressPdfProcessor.
@@ -31,7 +31,7 @@ public class DevExpressPdfProcessorTests
private static byte[] LoadTestPdf(string filename)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentOperator.Tests.TestData.Pdfs.{filename}";
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{filename}";
using var stream = assembly.GetManifestResourceStream(resourceName);

View File

@@ -1,10 +1,10 @@
using System.Reflection;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Infrastructure.Services.QrCodeProcessing;
using DocumentService.Application.Common.Interfaces;
using DocumentService.Domain.Common.Exceptions;
using DocumentService.Infrastructure.Services.QrCodeProcessing;
using FluentAssertions;
namespace DocumentOperator.Tests.Unit.Infrastructure.Services.QrCodeProcessing;
namespace DocumentService.Tests.Unit.Infrastructure.Services.QrCodeProcessing;
/// <summary>
/// Unit tests for DevExpressSwissQrCodeProcessor.
@@ -30,7 +30,7 @@ public class DevExpressSwissQrCodeProcessorTests
private static Stream LoadTestPdf(string filename)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"DocumentOperator.Tests.TestData.Pdfs.{filename}";
var resourceName = $"DocumentService.Tests.TestData.Pdfs.{filename}";
var stream = assembly.GetManifestResourceStream(resourceName);

Some files were not shown because too many files have changed in this diff Show More