Compare commits

...

6 Commits

Author SHA1 Message Date
45bc90b8b8 test: Update integration tests for Controller-based API
Integration test updates:

  - PdfValidationControllerTests.cs (new)

    - Test /api/pdf/validation/validate endpoint

    - Test BOTH multipart/form-data AND Base64 JSON

  - ExtractSwissQrCodeEndpointTests.cs (updated)

    - Update endpoint path to /api/swissqrcode/extract

    - Test BOTH input formats

Unit test updates:

  - ValidatePdfHandlerTests.cs:

    - Update for Query + Handler co-location

    - Test AutoMapper integration

  - ExtractSwissQrCodeHandlerTests.cs:

    - Update for Query + Handler co-location

    - Test AutoMapper integration

Deleted:

  - DocumentEndpointsTests.cs (Minimal API tests, no longer relevant)

Result: 20/20 tests passing, Controller endpoint coverage
2026-07-07 19:01:35 +02:00
57e36fc004 refactor: Migrate from Minimal API to Controller-based API
Architecture decision change:

  - Previous: Minimal API (DocumentEndpoints.cs) - WRONG approach

  - Required: Controller-based API (per CONTROLLER_ENDPOINTS.md)

New controllers:

  - PdfValidationController:

    - POST /api/pdf/validation/validate

    - Accepts BOTH IFormFile (multipart) AND Base64 JSON

    - Returns PdfValidationResult

  - SwissQrCodeController:

    - POST /api/swissqrcode/extract

    - Accepts BOTH IFormFile (multipart) AND Base64 JSON

    - Returns SwissQrCodeExtractionResult

Controller best practices:

  - Primary constructors (C# 12)

  - Thin controllers (pass request to MediatR directly)

  - No manual mapping (AutoMapper handles domain -> DTO)

  - XML documentation for Swagger

  - [ProducesResponseType] attributes

Deleted:

  - API/Endpoints/v1/DocumentEndpoints.cs (Minimal API)

  - ROADMAP.md (conflicting guidance with CONTROLLER_ENDPOINTS.md)

Result: Controller-based API, dual input support (multipart + JSON)
2026-07-07 19:01:20 +02:00
d6e3a5fda1 refactor: Restructure Application layer with vertical slices and AutoMapper
Vertical slice architecture:

  - Move Features/Documents/{UseCase}/ to {UseCase}/Queries/

  - Query + Handler in SAME file (co-located)

  - Validator in separate file (single responsibility)

New structure:

  - ValidatePdf/Queries/ValidatePdfQuery.cs (Query + Handler)

  - ValidatePdf/Queries/ValidatePdfQueryValidator.cs

  - SwissQrCode/Queries/ExtractSwissQrCodeQuery.cs (Query + Handler)

  - SwissQrCode/Queries/ExtractSwissQrCodeQueryValidator.cs

AutoMapper integration:

  - Add Common/Mapping/MappingProfile.cs

  - Map PdfMetadata -> PdfValidationResult (domain -> DTO)

  - Map SwissQrCodeData -> SwissQrCodeExtractionResult (domain -> DTO)

  - Controllers now thin: pass request to MediatR, AutoMapper handles mapping

DTO improvements:

  - Rename: ValidatePdfResponse -> PdfValidationResult (business-friendly)

  - Rename: ExtractSwissQrCodeResponse -> SwissQrCodeExtractionResult

  - Support BOTH byte[] and Base64Pdf string (XOR validation)

  - Use modern C# 12 collection expressions

Code quality:

  - Use PascalCase for primary constructor parameters

  - Fix LoggingBehavior logging format

Deleted old structure:

  - Features/Documents/ValidatePdf/ (old horizontal structure)

  - Features/Documents/ExtractSwissQrCode/ (old horizontal structure)

  - Common/DTOs/{Request|Response} (replaced with {Result})

Result: Vertical slices, AutoMapper v16.2.0, thin controllers
2026-07-07 19:01:05 +02:00
b460d8df39 refactor: Remove Base64String value object per YAGNI principle
Remove unnecessary Base64String value object:

  - Performance overhead (validation runs twice: value object + FluentValidation)

  - Unnecessary abstraction (Convert.FromBase64String already validates)

  - YAGNI principle: use string directly + extension methods if needed

Replaced with:

  - Direct string usage in DTOs

  - FluentValidation for Base64 format validation

  - FormatException handling in ExceptionHandlingMiddleware (maps to 400)

Result: Simpler code, better performance, same validation coverage
2026-07-07 19:00:43 +02:00
398651964e chore: Upgrade AutoMapper to v16.2.0 and fix DI registration
- Upgrade AutoMapper from 12.0.1 to 16.2.0

- Remove deprecated AutoMapper.Extensions.Microsoft.DependencyInjection v12.0.1

  (deprecated 25 May 2023, DI moved to main package in v13.0+)

- Update DI registration: AddAutoMapper(cfg => {}, typeof(MappingProfile))

  (v13.0+ requires Action<IMapperConfigurationExpression> + marker type)

Security fix:

  - Resolves NU1903 vulnerability (GHSA-rvv3-g6hj-g44x DoS in v12.0.1)

Result: AutoMapper v16.2.0, 0 security warnings, all tests passing
2026-07-07 19:00:29 +02:00
f8690b9417 docs: Add AGENTS.md architecture guidance for AI agents
Comprehensive architecture documentation including:

  - Clean Architecture with Controller-based API (NOT Minimal API)

  - Vertical slice architecture pattern

  - Exception-based error handling (no Result<T>)

  - Feature-driven development approach

  - Primary constructor coding standards

  - Git commit guidelines

  - Swiss QR Bill backward compatibility decisions

Key decisions documented:

  - Windows-only targeting (no Linux support needed)

  - Support BOTH multipart/form-data AND Base64 JSON

  - Separate endpoints for Combined Address (K-Type) legacy support

  - Multi-tenancy deferred until after all sync features complete
2026-07-07 19:00:12 +02:00
29 changed files with 1304 additions and 1734 deletions

638
AGENTS.md Normal file
View File

@@ -0,0 +1,638 @@
# AGENTS.md
Agent guidance for DocumentOperator service. Read this before working on the codebase.
---
## ⚠️ CRITICAL: Architecture Decision Change
**Previous developer used Minimal API** (`DocumentEndpoints.cs`), but this is **WRONG**.
**YOU MUST use Controller-based approach** as specified in `CONTROLLER_ENDPOINTS.md`.
### Key Differences
| Previous Approach (WRONG) | Required Approach (CORRECT) |
|---------------------------|------------------------------|
| Minimal API (`DocumentEndpoints.cs`) | **Controllers** (`PdfValidationController`, etc.) |
| Only Base64 JSON | **Both multipart/form-data AND Base64 JSON** |
| `/api/v1/documents/validate` | **`/api/pdf/validation/validate`** |
**Do NOT follow ROADMAP.md's "Minimal API" guidance.** It conflicts with the requirements.
### Migration Required
**Existing code that needs replacement:**
- `DocumentOperator.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)
**Dual Input Support Required:**
- Controllers must accept **BOTH** file upload (multipart/form-data) and Base64 JSON
- Each endpoint should have overloads or flexible parameter binding
- Preserve existing Base64 functionality while adding file upload support
---
## Architecture & Development Approach
**Clean Architecture with Controller-Based API:**
- 4 layers: API → Application → Infrastructure → Domain
- Domain has **ZERO** external dependencies (only standard .NET)
- Feature-driven development: complete one feature end-to-end before starting the next
- Feature = Domain + Infrastructure + Application + **Controller** + Tests + Swagger (all layers)
**Dependency flow (enforced):**
```
API → Application → Domain
API → Infrastructure → Application
Infrastructure → Application (for interfaces only)
Domain → NOTHING
```
**Vertical Slice structure** (NOT horizontal layers):
```
Features/Documents/
├── ValidatePdf/
│ ├── ValidatePdfQuery.cs (request)
│ ├── ValidatePdfHandler.cs (logic)
│ └── ValidatePdfValidator.cs (validation)
└── ExtractSwissQrCode/
├── ExtractSwissQrCodeQuery.cs
├── ExtractSwissQrCodeHandler.cs
└── ExtractSwissQrCodeValidator.cs
```
All files for a feature live together. Do NOT create separate Commands/, Handlers/, Validators/ folders.
---
## 🏗️ Architecture Principles
### Clean Architecture (Pragmatic)
**4 Layers with strict dependency rules:**
- API → Application → Domain
- Infrastructure → Application (interfaces only)
- Domain → NOTHING (zero external dependencies)
**Key Principles:**
- ✅ Testability (Application layer mocks Infrastructure services)
- ✅ Replaceability (swap DevExpress without touching Application)
- ✅ Separation of Concerns
- ❌ NO overengineering (only what we need, YAGNI principle)
- ❌ NO speculative abstractions (wait for 2nd use case)
### CQRS with MediatR
**Why MediatR:**
- 1 Command/Query = 1 Handler = 1 Responsibility
- Isolated, testable handlers
- Pipeline Behaviors (Validation, Logging) run centrally
- Avoids bloated services with 20+ methods
**Pattern:**
- **Command:** Modifies data (ApplyStamp, EmbedCertificate)
- **Query:** Reads data (ValidatePdf returns metadata only)
**Pipeline:** ValidationBehavior → LoggingBehavior → Handler
### Vertical Slice Architecture
**NOT Horizontal** (Commands/, Handlers/, Validators/ folders)
**YES Vertical** (all files for one feature together)
**Benefits:**
- Related code stays together (high cohesion)
- Easier to find ("Where's ValidatePdf?" → one folder!)
- Easier to modify (all files in same folder)
- Fewer merge conflicts in teams
### Exception-Based Error Handling
**NO Result<T> pattern library**
**Flow:**
1. FluentValidation (DTO level) → ValidationException → 400
2. Domain validation → DomainValidationException → 400
3. Business logic → DomainException → 400/404
4. Infrastructure → PdfProcessingException → 500
**Middleware:** Central exception handler maps exceptions to HTTP status codes
**Why exceptions:**
- Simpler code (no `if (result.IsSuccess)` everywhere)
- Less boilerplate (no Result<T> wrapping)
- Standard .NET exception flow
- Centralized error handling (one place to maintain)
### Feature-Driven Development
**Feature-Driven (NOT Layer-by-Layer):**
- Complete one feature end-to-end before starting next
- Feature = Domain + Infrastructure + Application + API + Tests + Swagger
- Feature is DONE when testable in Swagger UI
**Why:**
- Faster value delivery (Feature 1 done in ~1 day)
- Clear definition of done (Swagger testable)
- Less complexity (not all layers in parallel)
- Better learning (pattern repeats)
**Alternative rejected:** Complete all Domain → all Infrastructure → all Application → all API
**Problem:** Too much speculative code without visible results
### Test-Driven Development (TDD)
**Flow:** Red → Green → Refactor
**Test Pyramid:**
- **Unit Tests (many):** Value Objects, Handlers, Services
- **Integration Tests (some):** Endpoints, MediatR Pipeline
- **E2E Tests (few/none):** API is already top-level
**Why TDD:**
- Tests as documentation
- Tests as safety net for refactoring
- Better design (testable = good code)
- No forgotten tests (test comes FIRST)
### Cross-Cutting Concerns Timing
**Multi-Tenancy Implementation Deferred**
**Decision:** Implement multi-tenancy (X-API-Key header, tenant database, Redis cache) AFTER all synchronous PDF operation features are complete.
**Why:**
- Multi-tenancy affects ALL endpoints
- Better to implement once for all features (avoid repetition)
- Easier to test features first without tenancy, then add tenancy layer
- Cleaner separation: Features first, then cross-cutting concerns
**Impact on current architecture:**
- ❌ NO Entity Framework yet (tenant database comes with multi-tenancy)
- ❌ NO Redis yet (API key caching comes with multi-tenancy)
- ❌ NO X-API-Key authentication yet (comes with multi-tenancy)
- ✅ All features currently work without authentication
**When to implement:**
After completing all Phase 1-3 controllers (PdfValidation, PdfAttachment, PdfOperations, PdfRender, PdfConversion), then add multi-tenancy to ALL endpoints in one refactoring phase.
---
## Build, Test, Run
**Build:**
```powershell
dotnet build
```
**Run tests (19 tests as of Feature 2):**
```powershell
dotnet test
```
**Run API (Development):**
```powershell
dotnet run --project DocumentOperator.API
```
Swagger UI: `https://localhost:<port>/swagger`
**Target framework:** .NET 8.0
**SDK required:** 8.0.412 or later (repo has 8.0.41210.0.203 available)
---
## Key Libraries & Their Roles
| Library | Purpose | Where Used |
|---------|---------|------------|
| **DevExpress.Document.Processor** (26.1.3) | PDF operations (validation, QR extraction, attachments) | Infrastructure layer only |
| **Codecrete.SwissQRBill.Generator** (3.4.0) | Swiss QR Bill parsing (Standard 2.0) | Infrastructure.Services.QrCodeProcessing |
| **ZXing.Net.Bindings.Windows.Compatibility** (0.16.14) | QR code image decoding | Infrastructure.Services.QrCodeProcessing |
| **MediatR** (14.1.0) | CQRS: 1 handler per feature | Application layer |
| **FluentValidation** (12.1.1) | Request validation (runs via ValidationBehavior before handlers) | Application layer |
| **Serilog.AspNetCore** (10.0.0) | Structured logging | API layer |
**Critical:** DevExpress requires a license. All PDF operations use `DevExpress.Pdf.PdfDocumentProcessor`.
---
## Exception Handling Strategy
**No Result<T> pattern.** Use exceptions + central middleware.
**Flow:**
1. FluentValidation validates request DTOs → throws `ValidationException` → HTTP 400
2. Domain validation in Value Objects → throws `DomainValidationException` → HTTP 400
3. Business logic errors → throws `DomainException` subtypes → HTTP 400/404/500
4. Infrastructure errors (e.g., PDF parsing) → throws `PdfProcessingException` → HTTP 500
**Middleware maps exceptions to HTTP status codes** (`ExceptionHandlingMiddleware.cs`).
Do NOT add `if (result.IsSuccess)` checks. Throw exceptions for errors. The middleware handles the rest.
---
## Required Controllers & Endpoints
**See `CONTROLLER_ENDPOINTS.md` for complete specification.**
### Priority Order
**Phase 1 (PRIORITY):**
1. `PdfValidationController` 2 endpoints
- `POST /api/pdf/validation/validate` (Basic PDF validation)
- `POST /api/pdf/validation/validate-pdfa` (PDF/A conformance)
2. `PdfAttachmentController` 2 endpoints
- `POST /api/pdf/attachments/check` (Attachment detection)
- `POST /api/pdf/attachments/extract` (Extract attachments)
3. `PdfOperationsController` Merge endpoint
- `POST /api/pdf/operations/merge` (Merge multiple PDFs)
**Phase 2:**
4. `PdfOperationsController` Stamp & Annotate
5. `PdfRenderController` Preview
**Phase 3:**
6. `PdfConversionController` PDF ↔ PDF/A conversion
### Current Status
| Controller | Status | Tests |
|-----------|--------|-------|
| **PdfValidationController** | ⏳ Pending | 0 |
| **PdfAttachmentController** | ⏳ Pending | 0 |
| **PdfOperationsController** | ⏳ Pending | 0 |
| **PdfRenderController** | ⏳ Pending | 0 |
| **PdfConversionController** | ⏳ Pending | 0 |
**Legacy code exists** (`DocumentEndpoints.cs` with Minimal API + Base64 JSON), but **must be replaced** with Controllers + multipart/form-data.
---
## Adding a New Feature
**Required steps (follow CONTROLLER_ENDPOINTS.md):**
1. **Domain:** Value Objects, Exceptions (if needed)
2. **Infrastructure:** Service interface + DevExpress implementation + unit tests
3. **Application:** Query/Command + Handler + FluentValidator + DTOs + unit tests
4. **API:** **Controller** + actions + integration tests
5. **Swagger:** XML comments on controller actions + DTOs
**Example (PdfValidationController):**
```
Step 1: Application/Features/Documents/ValidatePdf/
- ValidatePdfCommand.cs (record)
- ValidatePdfHandler.cs (IRequestHandler)
- ValidatePdfValidator.cs (AbstractValidator)
Step 2: API/Controllers/PdfValidationController.cs
- [HttpPost("validate")] action
- Accepts IFormFile (multipart/form-data)
- Returns ValidatePdfResponse
Step 3: XML comments + [ProducesResponseType] attributes
```
**CRITICAL: Support BOTH multipart/form-data AND Base64 JSON for all file-based endpoints.**
**Input Flexibility:**
- Primary: `IFormFile` (multipart/form-data) - for direct file uploads
- Secondary: `Base64String` (application/json) - for API clients that can't send multipart
Do NOT skip steps. Each feature is done when it's **testable in Swagger UI with both input methods**.
---
## Test Data
**Embedded test PDFs:**
- `TestData/Pdfs/valid.pdf` (simple PDF for validation)
- `TestData/Pdfs/pdfWithSwissQRCode.pdf` (Swiss QR Code on last page)
- `TestData/Pdfs/pdfWithMoreThanOneAttachment.pdf` (6 attachments)
**All test PDFs are EmbeddedResource.** Access via:
```csharp
var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
```
**Do NOT commit new binary files** without marking them as `<EmbeddedResource>`.
---
## Swiss QR Code Feature (Feature 2)
**Swiss QR Bill Standard 2.0** requires:
- QR code is on the **last page** of the PDF (not first!)
- Use `DevExpress.Pdf.PdfDocumentProcessor` to render last page as image
- Use `ZXing` to decode QR code from image
- Use `Codecrete.SwissQRBill.Generator` to parse Swiss QR Bill payload
**Known quirks:**
- PDF must be rendered at **300 DPI** for reliable QR detection
- Alternative procedure parameters (AV1, AV2) are split by newline, not semicolon
---
## MediatR Pipeline Behaviors
**Two behaviors run for EVERY request:**
1. **ValidationBehavior** (runs first): Executes all `IValidator<TRequest>` and throws `ValidationException` if invalid
2. **LoggingBehavior** (runs second): Logs request name + execution time
**Registered in:** `Application/DependencyInjection.cs`
Do NOT manually call validators in handlers. The pipeline does it.
---
## Controller Pattern (CORRECT Approach)
**Controllers must support BOTH file upload and Base64 input.**
### Option 1: Separate Endpoints (Recommended)
```csharp
[ApiController]
[Route("api/pdf/validation")]
public class PdfValidationController : ControllerBase
{
private readonly IMediator _mediator;
public PdfValidationController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// Validates a PDF document (multipart/form-data)
/// </summary>
[HttpPost("validate")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(ValidatePdfResponse), 200)]
[ProducesResponseType(typeof(ProblemDetails), 400)]
public async Task<IActionResult> ValidateFromFile(IFormFile file, CancellationToken ct)
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms, ct);
byte[] pdfBytes = ms.ToArray();
var command = new ValidatePdfCommand(pdfBytes);
var result = await _mediator.Send(command, ct);
return Ok(result);
}
/// <summary>
/// Validates a PDF document (Base64 JSON)
/// </summary>
[HttpPost("validate")]
[Consumes("application/json")]
[ProducesResponseType(typeof(ValidatePdfResponse), 200)]
[ProducesResponseType(typeof(ProblemDetails), 400)]
public async Task<IActionResult> ValidateFromBase64(
[FromBody] ValidatePdfRequest request,
CancellationToken ct)
{
var query = new ValidatePdfQuery(Base64String.Create(request.Base64Pdf));
var result = await _mediator.Send(query, ct);
return Ok(result);
}
}
```
### Option 2: Single Endpoint with Model Binding
```csharp
public class PdfInputModel
{
public IFormFile? File { get; set; }
public string? Base64Pdf { get; set; }
}
[HttpPost("validate")]
public async Task<IActionResult> Validate([FromForm] PdfInputModel input, CancellationToken ct)
{
byte[] pdfBytes = input.File != null
? await GetBytesFromFile(input.File)
: Base64String.Create(input.Base64Pdf!).ToByteArray();
// Process...
}
```
**Use Controllers, NOT Minimal API endpoints.**
---
## Configuration
**appsettings.json sections:**
- `DocumentOperatorSettings` (future: file size limits, temp paths)
- `RedisSettings` (future: multi-tenancy caching)
- `ApiKeySettings` (future: authentication)
**Currently:** All features work without authentication. Multi-tenancy is deferred until after all sync features are complete.
---
## Coding Standards
### Primary Constructors
**ALWAYS use primary constructors** (C# 12 feature) unless there's a technical limitation.
**✅ Correct:**
```csharp
public class PdfValidationController(IMediator mediator, ILogger<PdfValidationController> logger) : ControllerBase
{
// Use parameters directly, no field declarations needed
public async Task<IActionResult> Validate(...)
{
await mediator.Send(...);
}
}
```
**❌ Wrong:**
```csharp
public class PdfValidationController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<PdfValidationController> _logger;
public PdfValidationController(IMediator mediator, ILogger<PdfValidationController> logger)
{
_mediator = mediator;
_logger = logger;
}
}
```
### Controller Responsibilities
**Controllers should be thin.** Do NOT add mapping logic.
**✅ Correct:**
```csharp
public async Task<IActionResult> Validate([FromBody] ValidatePdfRequest request, CancellationToken ct)
{
// Direct pass-through to MediatR
var result = await mediator.Send(request, ct);
return Ok(result);
}
```
**❌ Wrong:**
```csharp
public async Task<IActionResult> Validate([FromBody] ValidatePdfRequest request, CancellationToken ct)
{
// Manual mapping (WRONG!)
var command = new ValidatePdfCommand(request.Base64Pdf);
var metadata = await mediator.Send(command, ct);
var response = new ValidatePdfResponse(metadata.PageCount, ...);
return Ok(response);
}
```
**If mapping is absolutely necessary:** Use AutoMapper.
### Request DTOs - Flexible Input
**Support BOTH `byte[]` and `Base64String` in requests.**
```csharp
public record ValidatePdfRequest
{
public byte[]? PdfBytes { get; init; }
public string? Base64Pdf { get; init; }
}
```
**FluentValidation:** Ensure exactly ONE is provided:
```csharp
public class ValidatePdfRequestValidator : AbstractValidator<ValidatePdfRequest>
{
public ValidatePdfRequestValidator()
{
RuleFor(x => x)
.Must(x => (x.PdfBytes != null && x.PdfBytes.Length > 0) ^
(!string.IsNullOrWhiteSpace(x.Base64Pdf)))
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
}
}
```
**Handler:** Use `byte[]` if available, otherwise convert Base64:
```csharp
public async Task<PdfMetadata> Handle(ValidatePdfRequest request, CancellationToken ct)
{
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
return await _processor.ValidateAsync(pdfBytes);
}
```
### No Unnecessary Value Objects
**Do NOT create value objects for simple types** (e.g., Base64String).
**❌ Wrong:** Creating `Base64String` value object just to wrap `string`
**✅ Correct:** Use `string` directly + extension methods if needed
**Why:**
- Performance overhead (validation runs twice: once in value object, once in FluentValidation)
- Unnecessary abstraction (YAGNI principle)
- `Convert.FromBase64String()` already validates format
### Exception Handling in Controllers
**Let FormatException bubble up naturally.** ExceptionHandlingMiddleware will catch it.
```csharp
// Handler
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// If Base64 is invalid, FormatException → Middleware → 400 Bad Request
```
**Middleware handles:**
- `FormatException` → 400 Bad Request
- `ValidationException` → 400 Bad Request
- `DomainException` → 400/404
- `PdfProcessingException` → 500
---
## Git Commit Guidelines
### ⚠️ CRITICAL: Never Commit Without Approval
**NEVER run `git commit` without explicit user approval.**
### Systematic Commits
**Do NOT commit everything in one giant commit.**
**✅ Correct approach:**
1. Complete one logical change (e.g., "Add PdfValidationController")
2. Stage only related files: `git add <specific-files>`
3. Ask user: "Ready to commit 'Add PdfValidationController'?"
4. After approval: `git commit -m "Add PdfValidationController with dual input support"`
5. Repeat for next logical change
**❌ Wrong approach:**
```bash
git add -A
git commit -m "Migrate everything to controllers, update tests, add AGENTS.md, delete ROADMAP.md"
# This is TOO MUCH in one commit!
```
**Good commit messages:**
- `feat: Add PdfValidationController with multipart/form-data support`
- `refactor: Replace Base64String value object with direct string usage`
- `test: Add integration tests for PdfValidationController`
- `docs: Add AGENTS.md with architecture guidance`
- `chore: Delete deprecated ROADMAP.md`
**Commit size guideline:** 1-5 files per commit, one logical change
---
## What NOT to Do
- ❌ Do NOT create horizontal folders (Commands/, Handlers/, Validators/)
- ❌ Do NOT add Entity Framework until multi-tenancy phase
- ❌ Do NOT use Minimal API endpoints (use Controllers instead)
- ❌ Do NOT support only ONE input type (must support BOTH multipart AND Base64)
- ❌ Do NOT use Result<T> pattern (use exceptions)
- ❌ Do NOT skip tests (TDD: write test first, then implementation)
- ❌ Do NOT add dependencies to Domain layer (keep it clean!)
- ❌ Do NOT commit without user approval
- ❌ Do NOT use old-style constructors (use primary constructors)
- ❌ Do NOT add mapping logic in controllers (keep them thin)
- ❌ Do NOT create unnecessary value objects (YAGNI principle)
---
## Debugging Tips
**DevExpress PDF errors:**
- Check if file is actually a valid PDF (magic bytes: `%PDF-`)
- DevExpress throws generic exceptions; wrap in try-catch and add context
**Swiss QR Code not found:**
- Verify QR is on **last page** (not first)
- Check DPI setting (300 DPI required, see ROADMAP.md Feature 2)
- Use `ZXing` with `TryHarder` hint enabled
**Attachment count wrong:**
- Search entire PDF stream, not just first 1000 chars (see ROADMAP.md fix log 17.01.2025)
- Count `/EmbeddedFiles` object references correctly (not divided by 2)
---
## References
- **CONTROLLER_ENDPOINTS.md** **PRIMARY SOURCE** for API specification (all planned endpoints)
- **REQUIRED_FEATURES.md** Business requirements (what PDF operations are needed and why)
- **ROADMAP.md** Feature-by-feature implementation plan (1101 lines, detailed) **NOTE: Uses Minimal API, which is incorrect. Follow CONTROLLER_ENDPOINTS.md instead.**
- **DevExpress Docs** https://docs.devexpress.com/OfficeFileAPI/
- **Swiss QR Bill Standard** https://www.ferd-net.de/ (ZUGFeRD/XRechnung context)
**When implementing endpoints:** Follow CONTROLLER_ENDPOINTS.md, NOT ROADMAP.md's Minimal API approach.

View File

@@ -0,0 +1,79 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.ValidatePdf.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.API.Controllers;
/// <summary>
/// PDF validation operations
/// </summary>
[ApiController]
[Route("api/pdf/validation")]
[Produces("application/json")]
public class PdfValidationController(IMediator Mediator) : ControllerBase
{
/// <summary>
/// Validates a PDF document and returns metadata (multipart/form-data)
/// </summary>
/// <param name="file">PDF file to validate</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF metadata (page count, file size, PDF version, attachments)</returns>
/// <response code="200">PDF is valid, metadata returned</response>
/// <response code="400">Invalid PDF or file format</response>
/// <response code="500">Internal server error during validation</response>
[HttpPost("validate")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(PdfValidationResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ValidateFromFile(
IFormFile file,
CancellationToken cancellationToken)
{
if (file == null || file.Length == 0)
{
return BadRequest(new ProblemDetails
{
Title = "Invalid file",
Detail = "File is required and cannot be empty",
Status = StatusCodes.Status400BadRequest
});
}
// Convert IFormFile to byte array
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, cancellationToken);
byte[] pdfBytes = memoryStream.ToArray();
// Direct pass-through to MediatR
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
/// Validates a PDF document and returns metadata (Base64 JSON)
/// </summary>
/// <param name="query">PDF as Base64 string</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF metadata (page count, file size, PDF version, attachments)</returns>
/// <response code="200">PDF is valid, metadata returned</response>
/// <response code="400">Invalid PDF or Base64 format</response>
/// <response code="500">Internal server error during validation</response>
[HttpPost("validate")]
[Consumes("application/json")]
[ProducesResponseType(typeof(PdfValidationResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ValidateFromBase64(
[FromBody] ValidatePdfQuery query,
CancellationToken cancellationToken)
{
// Direct pass-through to MediatR
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
}
}

View File

@@ -0,0 +1,94 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.SwissQrCode.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.API.Controllers;
/// <summary>
/// Swiss QR Code extraction operations
/// </summary>
[ApiController]
[Route("api/pdf/qr-code")]
[Produces("application/json")]
public class SwissQrCodeController(IMediator Mediator) : ControllerBase
{
/// <summary>
/// Extracts Swiss QR Code from the last page of a PDF document (multipart/form-data)
/// </summary>
/// <param name="file">PDF file containing Swiss QR Code</param>
/// <param name="references">Optional references (comma-separated)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Swiss QR Code data (IBAN, amount, creditor, debtor, reference, etc.)</returns>
/// <response code="200">Swiss QR Code extracted successfully</response>
/// <response code="400">Invalid PDF or file format</response>
/// <response code="404">No Swiss QR Code found on the last page</response>
/// <response code="500">Internal server error during extraction</response>
[HttpPost("extract-swiss")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(SwissQrCodeExtractionResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ExtractFromFile(
IFormFile file,
[FromForm] string? references,
CancellationToken cancellationToken)
{
if (file == null || file.Length == 0)
{
return BadRequest(new ProblemDetails
{
Title = "Invalid file",
Detail = "File is required and cannot be empty",
Status = StatusCodes.Status400BadRequest
});
}
// Convert IFormFile to byte array
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, cancellationToken);
byte[] pdfBytes = memoryStream.ToArray();
// Parse references (comma-separated or empty)
var referencesList = string.IsNullOrWhiteSpace(references)
? new List<string>()
: references.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
// Direct pass-through to MediatR
var query = new ExtractSwissQrCodeQuery
{
PdfBytes = pdfBytes,
References = referencesList
};
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
}
/// <summary>
/// Extracts Swiss QR Code from the last page of a PDF document (Base64 JSON)
/// </summary>
/// <param name="query">References array + PDF as Base64 string</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Swiss QR Code data (IBAN, amount, creditor, debtor, reference, etc.)</returns>
/// <response code="200">Swiss QR Code extracted successfully</response>
/// <response code="400">Invalid PDF or Base64 format</response>
/// <response code="404">No Swiss QR Code found on the last page</response>
/// <response code="500">Internal server error during extraction</response>
[HttpPost("extract-swiss")]
[Consumes("application/json")]
[ProducesResponseType(typeof(SwissQrCodeExtractionResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ExtractFromBase64(
[FromBody] ExtractSwissQrCodeQuery query,
CancellationToken cancellationToken)
{
// Direct pass-through to MediatR
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
}
}

View File

@@ -1,165 +0,0 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Features.Documents.ExtractSwissQrCode;
using DocumentOperator.Application.Features.Documents.ValidatePdf;
using DocumentOperator.Domain.Models.ValueObjects;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.API.Endpoints.v1;
/// <summary>
/// Document endpoints (Minimal API)
/// </summary>
public static class DocumentEndpoints
{
/// <summary>
/// Maps all document-related endpoints
/// </summary>
public static void MapDocumentEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/documents")
.WithTags("Documents");
// POST /api/v1/documents/validate
group.MapPost("/validate", ValidatePdf)
.WithName("ValidatePdf")
.WithSummary("Validates a PDF document and returns metadata")
.WithDescription("Validates the PDF format and extracts metadata (page count, file size, PDF version, attachments)")
.Produces<ValidatePdfResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// POST /api/v1/documents/extract-swiss-qr-code
group.MapPost("/extract-swiss-qr-code", ExtractSwissQrCode)
.WithName("ExtractSwissQrCode")
.WithSummary("Extracts Swiss QR Code from the last page of a PDF document")
.WithDescription(@"Extracts and parses a Swiss QR Code (Swiss QR Bill Standard 2.0) from the last page of a PDF document.
**Requirements:**
- PDF must contain a valid Swiss QR Code on the last page
- QR Code must conform to Swiss QR Bill Standard 2.0
- References array is required (can be empty)
**Returns:**
- All QR code fields (IBAN, amount, creditor, debtor, reference, etc.)
- References array (passed through from request)
**Use Case:**
Extract payment information from Swiss QR invoices for automated processing.")
.Produces<ExtractSwissQrCodeResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
}
/// <summary>
/// Validates a PDF document and returns metadata
/// </summary>
/// <param name="request">PDF as Base64 string</param>
/// <param name="mediator">MediatR instance</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>PDF metadata (page count, file size, etc.)</returns>
/// <response code="200">PDF is valid, metadata returned</response>
/// <response code="400">Invalid PDF or Base64 format</response>
/// <response code="500">Internal server error during validation</response>
private static async Task<IResult> ValidatePdf(
ValidatePdfRequest request,
IMediator mediator,
CancellationToken cancellationToken)
{
// DTO → Query (Value Objects erstellen - kann DomainValidationException werfen!)
var query = new ValidatePdfQuery(
Base64String.Create(request.Base64Pdf)
);
// MediatR Handler aufrufen (ValidationBehavior → Handler)
var metadata = await mediator.Send(query, cancellationToken);
// PdfMetadata → Response DTO
var response = new ValidatePdfResponse(
metadata.PageCount,
metadata.FileSizeBytes,
metadata.FileSizeMB,
metadata.PdfVersion,
metadata.HasAttachments,
metadata.AttachmentCount
);
return Results.Ok(response);
}
/// <summary>
/// Extracts Swiss QR Code from the last page of a PDF document
/// </summary>
/// <param name="request">References array + PDF as Base64 string</param>
/// <param name="mediator">MediatR instance</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>References (passed through) + Swiss QR Code data</returns>
/// <response code="200">Swiss QR Code extracted successfully</response>
/// <response code="400">Invalid PDF or Base64 format</response>
/// <response code="404">No Swiss QR Code found on the last page</response>
/// <response code="500">Internal server error during extraction</response>
private static async Task<IResult> ExtractSwissQrCode(
ExtractSwissQrCodeRequest request,
IMediator mediator,
CancellationToken cancellationToken)
{
// DTO → Query (Value Objects erstellen)
var query = new ExtractSwissQrCodeQuery(
References: request.References,
PdfContent: Base64String.Create(request.Base64Pdf)
);
// MediatR Handler aufrufen
var result = await mediator.Send(query, cancellationToken);
// Map Domain Value Object → DTO
var response = new ExtractSwissQrCodeResponse(
References: result.References,
QrCodeData: MapQrCodeDataToDto(result.QrCodeData)
);
return Results.Ok(response);
}
/// <summary>
/// Maps SwissQrCodeData domain value object to DTO
/// </summary>
private static SwissQrCodeDataDto MapQrCodeDataToDto(Domain.ValueObjects.SwissQrCodeData qrCodeData)
{
return new SwissQrCodeDataDto(
QrType: qrCodeData.QrType,
Version: qrCodeData.Version,
CodingType: qrCodeData.CodingType,
Iban: qrCodeData.Iban,
Creditor: MapAddressToDto(qrCodeData.Creditor),
UltimateCreditor: qrCodeData.UltimateCreditor != null ? MapAddressToDto(qrCodeData.UltimateCreditor) : null,
Amount: qrCodeData.Amount,
Currency: qrCodeData.Currency,
UltimateDebtor: qrCodeData.UltimateDebtor != null ? MapAddressToDto(qrCodeData.UltimateDebtor) : null,
ReferenceType: qrCodeData.ReferenceType,
Reference: qrCodeData.Reference,
UnstructuredMessage: qrCodeData.UnstructuredMessage,
BillInformation: qrCodeData.BillInformation,
AlternativeProcedureParameters: qrCodeData.AlternativeProcedureParameters
);
}
/// <summary>
/// Maps AddressData domain value object to DTO
/// </summary>
private static AddressDataDto MapAddressToDto(Domain.ValueObjects.AddressData address)
{
return new AddressDataDto(
AddressType: address.AddressType,
Name: address.Name,
Street: address.Street,
BuildingNumber: address.BuildingNumber,
AddressLine1: address.AddressLine1,
AddressLine2: address.AddressLine2,
PostalCode: address.PostalCode,
City: address.City,
Country: address.Country
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,16 +8,9 @@ namespace DocumentOperator.Application.Common.Behaviors;
/// MediatR Pipeline Behavior that logs requests and tracks performance
/// Executes AFTER ValidationBehavior, BEFORE Handler
/// </summary>
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
public class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> Logger) : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
@@ -25,21 +18,18 @@ public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest,
{
var requestName = typeof(TRequest).Name;
// Request Start
_logger.LogInformation("Handling {RequestName}: {@Request}", requestName, request);
// Performance Tracking
var stopwatch = Stopwatch.StartNew();
try
{
// Handler ausführen
var response = await next();
var response = await next(cancellationToken);
stopwatch.Stop();
// Request Success
_logger.LogInformation(
Logger.LogInformation(
"Handled {RequestName} in {ElapsedMs}ms",
requestName,
stopwatch.ElapsedMilliseconds
@@ -52,7 +42,7 @@ public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest,
stopwatch.Stop();
// Request Failed
_logger.LogError(
Logger.LogError(
ex,
"Error handling {RequestName} after {ElapsedMs}ms: {ErrorMessage}",
requestName,

View File

@@ -1,18 +0,0 @@
namespace DocumentOperator.Application.Common.DTOs;
/// <summary>
/// Request to extract Swiss QR Code from a PDF document.
/// The QR code must be located on the last page of the document.
/// </summary>
/// <param name="References">Array of reference strings to pass through in the response</param>
/// <param name="Base64Pdf">PDF document encoded as Base64 string</param>
/// <example>
/// {
/// "references": ["REF-001", "REF-002"],
/// "base64Pdf": "JVBERi0xLjQK..."
/// }
/// </example>
public record ExtractSwissQrCodeRequest(
IReadOnlyList<string> References,
string Base64Pdf
);

View File

@@ -4,12 +4,12 @@ namespace DocumentOperator.Application.Common.DTOs;
/// Response mit PDF-Metadaten
/// </summary>
/// <param name="PageCount">Anzahl der Seiten</param>
/// <param name="FileSizeBytes">Dateigröße in Bytes</param>
/// <param name="FileSizeMB">Dateigröße in MB (gerundet auf 2 Dezimalstellen)</param>
/// <param name="FileSizeBytes">Dateigröße in Bytes</param>
/// <param name="FileSizeMB">Dateigröße in MB (gerundet auf 2 Dezimalstellen)</param>
/// <param name="PdfVersion">PDF-Version (z.B. "1.4")</param>
/// <param name="HasAttachments">Hat das PDF Anhänge?</param>
/// <param name="AttachmentCount">Anzahl der Anhänge</param>
public record ValidatePdfResponse(
/// <param name="HasAttachments">Hat das PDF Anhänge?</param>
/// <param name="AttachmentCount">Anzahl der Anhänge</param>
public record PdfValidationResult(
int PageCount,
long FileSizeBytes,
double FileSizeMB,

View File

@@ -29,7 +29,7 @@ namespace DocumentOperator.Application.Common.DTOs;
/// }
/// }
/// </example>
public record ExtractSwissQrCodeResponse(
public record SwissQrCodeExtractionResult(
IReadOnlyList<string> References,
SwissQrCodeDataDto QrCodeData
);

View File

@@ -1,7 +0,0 @@
namespace DocumentOperator.Application.Common.DTOs;
/// <summary>
/// Request für PDF-Validierung
/// </summary>
/// <param name="Base64Pdf">Base64-encodiertes PDF-Dokument</param>
public record ValidatePdfRequest(string Base64Pdf);

View File

@@ -0,0 +1,24 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Domain.Models.ValueObjects;
using DocumentOperator.Domain.ValueObjects;
namespace DocumentOperator.Application.Common.Mapping;
/// <summary>
/// AutoMapper profile for mapping domain entities to DTOs
/// </summary>
public class MappingProfile : Profile
{
public MappingProfile()
{
// PdfMetadata -> PdfValidationResult
CreateMap<PdfMetadata, PdfValidationResult>();
// SwissQrCodeData -> SwissQrCodeDataDto
CreateMap<SwissQrCodeData, SwissQrCodeDataDto>();
// AddressData -> AddressDataDto
CreateMap<AddressData, AddressDataDto>();
}
}

View File

@@ -9,7 +9,7 @@ namespace DocumentOperator.Application;
public static class DependencyInjection
{
/// <summary>
/// Registers Application Layer services (MediatR, FluentValidation, Behaviors)
/// Registers Application Layer services (MediatR, FluentValidation, AutoMapper, Behaviors)
/// </summary>
public static IServiceCollection AddApplication(this IServiceCollection services)
{
@@ -28,6 +28,9 @@ public static class DependencyInjection
// Register FluentValidation (scannt Assembly nach Validators)
services.AddValidatorsFromAssembly(assembly);
// Register AutoMapper (scannt Assembly nach Profiles)
services.AddAutoMapper(cfg => { }, typeof(Common.Mapping.MappingProfile));
return services;
}
}

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.2.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="MediatR" Version="14.1.0" />

View File

@@ -1,33 +0,0 @@
using DocumentOperator.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode;
/// <summary>
/// Handles extraction of Swiss QR Code from PDF documents.
/// Uses ISwissQrCodeProcessor to extract QR code from the last page and parse it.
/// </summary>
public sealed class ExtractSwissQrCodeHandler : IRequestHandler<ExtractSwissQrCodeQuery, ExtractSwissQrCodeResult>
{
private readonly ISwissQrCodeProcessor _qrCodeProcessor;
public ExtractSwissQrCodeHandler(ISwissQrCodeProcessor qrCodeProcessor)
{
_qrCodeProcessor = qrCodeProcessor;
}
public async Task<ExtractSwissQrCodeResult> Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken)
{
// Convert Base64 string to byte array
byte[] pdfBytes = request.PdfContent.ToByteArray();
// Extract and parse Swiss QR Code from last page
var qrCodeData = await _qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, cancellationToken);
// Return references (passed through) + QR code data
return new ExtractSwissQrCodeResult(
References: request.References,
QrCodeData: qrCodeData
);
}
}

View File

@@ -1,26 +0,0 @@
using DocumentOperator.Domain.Models.ValueObjects;
using DocumentOperator.Domain.ValueObjects;
using MediatR;
namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode;
/// <summary>
/// Query to extract Swiss QR Code data from a PDF document.
/// Returns references (passed through) and parsed QR code data.
/// </summary>
/// <param name="References">Array of reference strings to pass through in the response</param>
/// <param name="PdfContent">PDF document content as Base64 string</param>
public record ExtractSwissQrCodeQuery(
IReadOnlyList<string> References,
Base64String PdfContent
) : IRequest<ExtractSwissQrCodeResult>;
/// <summary>
/// Result containing passed-through references and extracted Swiss QR Code data
/// </summary>
/// <param name="References">Reference strings passed through from request</param>
/// <param name="QrCodeData">Parsed Swiss QR Code data from the last page of the PDF</param>
public record ExtractSwissQrCodeResult(
IReadOnlyList<string> References,
SwissQrCodeData QrCodeData
);

View File

@@ -1,21 +0,0 @@
using FluentValidation;
namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode;
/// <summary>
/// Validates ExtractSwissQrCodeQuery before handler execution.
/// Ensures references array and PDF content are provided.
/// </summary>
public sealed class ExtractSwissQrCodeValidator : AbstractValidator<ExtractSwissQrCodeQuery>
{
public ExtractSwissQrCodeValidator()
{
RuleFor(x => x.References)
.NotNull()
.WithMessage("References array is required.");
RuleFor(x => x.PdfContent)
.NotNull()
.WithMessage("PDF content is required.");
}
}

View File

@@ -1,33 +0,0 @@
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Models.ValueObjects;
using MediatR;
namespace DocumentOperator.Application.Features.Documents.ValidatePdf;
/// <summary>
/// Handler for ValidatePdfQuery
/// Orchestrates PDF validation using IPdfProcessor
/// </summary>
public class ValidatePdfHandler : IRequestHandler<ValidatePdfQuery, PdfMetadata>
{
private readonly IPdfProcessor _pdfProcessor;
public ValidatePdfHandler(IPdfProcessor pdfProcessor)
{
_pdfProcessor = pdfProcessor;
}
/// <summary>
/// Validates PDF and returns metadata
/// </summary>
public async Task<PdfMetadata> Handle(ValidatePdfQuery request, CancellationToken cancellationToken)
{
// Value Object ? Byte Array
byte[] pdfBytes = request.PdfContent.ToByteArray();
// DevExpress Service aufrufen (kann PdfProcessingException werfen)
var metadata = await _pdfProcessor.ValidateAsync(pdfBytes);
return metadata;
}
}

View File

@@ -1,10 +0,0 @@
using DocumentOperator.Domain.Models.ValueObjects;
using MediatR;
namespace DocumentOperator.Application.Features.Documents.ValidatePdf;
/// <summary>
/// Query to validate a PDF document and return metadata
/// </summary>
/// <param name="PdfContent">PDF content as Base64 string (validated by Value Object)</param>
public record ValidatePdfQuery(Base64String PdfContent) : IRequest<PdfMetadata>;

View File

@@ -1,17 +0,0 @@
using FluentValidation;
namespace DocumentOperator.Application.Features.Documents.ValidatePdf;
/// <summary>
/// Validator for ValidatePdfQuery
/// Validates that PdfContent is not null (Base64String already validates format in its constructor)
/// </summary>
public class ValidatePdfValidator : AbstractValidator<ValidatePdfQuery>
{
public ValidatePdfValidator()
{
RuleFor(x => x.PdfContent)
.NotNull()
.WithMessage("PDF content is required");
}
}

View File

@@ -0,0 +1,56 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.SwissQrCode.Queries;
/// <summary>
/// Query for extracting Swiss QR Code from PDF (supports both byte array and Base64 input)
/// </summary>
public record ExtractSwissQrCodeQuery : IRequest<SwissQrCodeExtractionResult>
{
/// <summary>
/// PDF as byte array (direct upload)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (API clients)
/// </summary>
public string? Base64Pdf { get; init; }
/// <summary>
/// Optional reference strings (passed through to response for external tracking)
/// </summary>
public IReadOnlyList<string>? References { get; init; }
}
/// <summary>
/// Handler for ExtractSwissQrCodeQuery
/// Orchestrates Swiss QR Code extraction using ISwissQrCodeProcessor and AutoMapper
/// </summary>
public class ExtractSwissQrCodeQueryHandler(ISwissQrCodeProcessor qrCodeProcessor, IMapper mapper)
: IRequestHandler<ExtractSwissQrCodeQuery, SwissQrCodeExtractionResult>
{
/// <summary>
/// Extracts and parses Swiss QR Code from the last page of the PDF
/// </summary>
public async Task<SwissQrCodeExtractionResult> Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken)
{
// Use byte[] if available, otherwise convert Base64
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// Extract and parse Swiss QR Code from last page (can throw PdfProcessingException or QrCodeNotFoundException)
var qrCodeData = await qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, cancellationToken);
// Map domain value object to DTO using AutoMapper
var qrCodeDto = mapper.Map<SwissQrCodeDataDto>(qrCodeData);
// Return references (passed through) + QR code data
return new SwissQrCodeExtractionResult(
References: request.References ?? [],
QrCodeData: qrCodeDto
);
}
}

View File

@@ -0,0 +1,59 @@
using DocumentOperator.Application.SwissQrCode.Queries;
using FluentValidation;
namespace DocumentOperator.Application.SwissQrCode.Queries;
/// <summary>
/// Validates ExtractSwissQrCodeQuery before handler execution.
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none).
/// </summary>
public sealed class ExtractSwissQrCodeQueryValidator : AbstractValidator<ExtractSwissQrCodeQuery>
{
public ExtractSwissQrCodeQueryValidator()
{
RuleFor(x => x)
.Must(HasExactlyOneInput)
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
// Validate Base64 format if provided
When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () =>
{
RuleFor(x => x.Base64Pdf)
.Must(BeValidBase64)
.WithMessage("Invalid Base64 format");
});
// Validate byte array if provided
When(x => x.PdfBytes != null, () =>
{
RuleFor(x => x.PdfBytes)
.NotEmpty()
.WithMessage("PdfBytes cannot be empty");
});
}
private static bool HasExactlyOneInput(ExtractSwissQrCodeQuery request)
{
var hasPdfBytes = request.PdfBytes != null && request.PdfBytes.Length > 0;
var hasBase64 = !string.IsNullOrWhiteSpace(request.Base64Pdf);
// XOR: exactly one must be true
return hasPdfBytes ^ hasBase64;
}
private static bool BeValidBase64(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return false;
try
{
Convert.FromBase64String(base64);
return true;
}
catch (FormatException)
{
return false;
}
}
}

View File

@@ -0,0 +1,45 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.ValidatePdf.Queries;
/// <summary>
/// Query for PDF validation (supports both byte array and Base64 input)
/// </summary>
public record ValidatePdfQuery : IRequest<PdfValidationResult>
{
/// <summary>
/// PDF as byte array (direct upload)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (API clients)
/// </summary>
public string? Base64Pdf { get; init; }
}
/// <summary>
/// Handler for ValidatePdfQuery
/// Orchestrates PDF validation using IPdfProcessor and AutoMapper
/// </summary>
public class ValidatePdfQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper)
: IRequestHandler<ValidatePdfQuery, PdfValidationResult>
{
/// <summary>
/// Validates PDF and returns metadata
/// </summary>
public async Task<PdfValidationResult> Handle(ValidatePdfQuery request, CancellationToken cancellationToken)
{
// Use byte[] if available, otherwise convert Base64
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// Call DevExpress service (can throw PdfProcessingException)
var metadata = await PdfProcessor.ValidateAsync(pdfBytes);
// Map domain entity to DTO using AutoMapper
return Mapper.Map<PdfValidationResult>(metadata);
}
}

View File

@@ -0,0 +1,59 @@
using DocumentOperator.Application.ValidatePdf.Queries;
using FluentValidation;
namespace DocumentOperator.Application.ValidatePdf.Queries;
/// <summary>
/// Validator for ValidatePdfQuery
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none)
/// </summary>
public class ValidatePdfQueryValidator : AbstractValidator<ValidatePdfQuery>
{
public ValidatePdfQueryValidator()
{
RuleFor(x => x)
.Must(HasExactlyOneInput)
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
// Validate Base64 format if provided
When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () =>
{
RuleFor(x => x.Base64Pdf)
.Must(BeValidBase64)
.WithMessage("Invalid Base64 format");
});
// Validate byte array if provided
When(x => x.PdfBytes != null, () =>
{
RuleFor(x => x.PdfBytes)
.NotEmpty()
.WithMessage("PdfBytes cannot be empty");
});
}
private static bool HasExactlyOneInput(ValidatePdfQuery request)
{
var hasPdfBytes = request.PdfBytes != null && request.PdfBytes.Length > 0;
var hasBase64 = !string.IsNullOrWhiteSpace(request.Base64Pdf);
// XOR: exactly one must be true
return hasPdfBytes ^ hasBase64;
}
private static bool BeValidBase64(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return false;
try
{
Convert.FromBase64String(base64);
return true;
}
catch (FormatException)
{
return false;
}
}
}

View File

@@ -1,59 +0,0 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
public sealed class Base64String
{
public string Value { get; }
private Base64String(string value)
{
Value = value;
}
public static Base64String Create(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new Common.Exceptions.DomainValidationException("Base64 string cannot be empty.");
// Validierung: Ist es gültiges Base64?
try
{
Convert.FromBase64String(value);
}
catch (FormatException)
{
throw new Common.Exceptions.DomainValidationException("Invalid Base64 format.");
}
return new Base64String(value);
}
public static Base64String FromByteArray(byte[] bytes)
{
if (bytes == null || bytes.Length == 0)
throw new Common.Exceptions.DomainValidationException("Byte array cannot be null or empty.");
var base64 = Convert.ToBase64String(bytes);
return new Base64String(base64);
}
public byte[] ToByteArray()
{
return Convert.FromBase64String(Value);
}
public override string ToString() => Value;
// Equality (wichtig für Value Objects!)
public override bool Equals(object? obj)
{
if (obj is not Base64String other)
return false;
return Value == other.Value;
}
public override int GetHashCode()
{
return Value.GetHashCode();
}
}

View File

@@ -1,89 +0,0 @@
using DocumentOperator.Application.Common.DTOs;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using Xunit;
namespace DocumentOperator.Tests.Integration.API;
public class DocumentEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public DocumentEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
[Fact]
public async Task POST_ValidatePdf_ValidPdf_Returns200()
{
// Arrange
// Verwende ein echtes Test-PDF (embedded resource aus Unit Tests)
var assembly = typeof(DocumentEndpointsTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
{
if (stream == null)
{
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
pdfBytes = ms.ToArray();
}
var base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ValidatePdfRequest(base64Pdf);
// Act
var response = await _client.PostAsJsonAsync("/api/v1/documents/validate", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ValidatePdfResponse>();
result.Should().NotBeNull();
result!.PageCount.Should().BeGreaterThan(0);
result.FileSizeBytes.Should().BeGreaterThan(0);
result.PdfVersion.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task POST_ValidatePdf_InvalidBase64_Returns400()
{
// Arrange
var request = new ValidatePdfRequest("invalid-base64!!!"); // Kein gültiges Base64
// Act
var response = await _client.PostAsJsonAsync("/api/v1/documents/validate", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Base64");
}
[Fact]
public async Task POST_ValidatePdf_EmptyPdf_Returns400()
{
// Arrange
var request = new ValidatePdfRequest(string.Empty); // Leerer String
// Act
var response = await _client.PostAsJsonAsync("/api/v1/documents/validate", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("cannot be empty");
}
}

View File

@@ -1,4 +1,5 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.SwissQrCode.Queries;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
@@ -28,13 +29,14 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
// Arrange
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
var request = new ExtractSwissQrCodeRequest(
References: new List<string> { "REF-001", "REF-002" },
Base64Pdf: validPdfBase64
);
var request = new ExtractSwissQrCodeQuery
{
References = new List<string> { "REF-001", "REF-002" },
Base64Pdf = validPdfBase64
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/documents/extract-swiss-qr-code", request);
var response = await _client.PostAsJsonAsync("/api/pdf/qr-code/extract-swiss", request);
// Assert
// Note: The test PDF (valid.pdf) may not actually contain a Swiss QR Code
@@ -44,7 +46,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
if (response.StatusCode == HttpStatusCode.OK)
{
var result = await response.Content.ReadFromJsonAsync<ExtractSwissQrCodeResponse>(_jsonOptions);
var result = await response.Content.ReadFromJsonAsync<SwissQrCodeExtractionResult>(_jsonOptions);
result.Should().NotBeNull();
result!.References.Should().BeEquivalentTo(new[] { "REF-001", "REF-002" });
result.QrCodeData.Should().NotBeNull();
@@ -55,35 +57,46 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
public async Task POST_ExtractSwissQrCode_InvalidBase64_Returns400()
{
// Arrange
var request = new ExtractSwissQrCodeRequest(
References: new List<string> { "REF-001" },
Base64Pdf: "INVALID_BASE64!!!"
);
var request = new ExtractSwissQrCodeQuery
{
References = new List<string> { "REF-001" },
Base64Pdf = "INVALID_BASE64!!!"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/documents/extract-swiss-qr-code", request);
var response = await _client.PostAsJsonAsync("/api/pdf/qr-code/extract-swiss", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task POST_ExtractSwissQrCode_EmptyReferences_Returns400()
public async Task POST_ExtractSwissQrCode_NullReferences_AcceptedByValidation()
{
// Arrange
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
// 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 request = new
{
References = (List<string>?)null,
References = (List<string>?)null, // Optional field - should not cause 400
Base64Pdf = validPdfBase64
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/documents/extract-swiss-qr-code", request);
var response = await _client.PostAsJsonAsync("/api/pdf/qr-code/extract-swiss", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
// Assert: Should NOT be 400 (validation error), should be 200 or 404
response.StatusCode.Should().NotBe(HttpStatusCode.BadRequest,
"null References should be accepted (optional field)");
// If extraction succeeds, verify empty references array
if (response.StatusCode == HttpStatusCode.OK)
{
var result = await response.Content.ReadFromJsonAsync<SwissQrCodeExtractionResult>();
result.Should().NotBeNull();
result!.References.Should().BeEmpty(); // Null input → empty output array
}
}
[Fact]
@@ -97,7 +110,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/documents/extract-swiss-qr-code", request);
var response = await _client.PostAsJsonAsync("/api/pdf/qr-code/extract-swiss", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

View File

@@ -0,0 +1,169 @@
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.ValidatePdf.Queries;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using Xunit;
namespace DocumentOperator.Tests.Integration.API;
public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public PdfValidationControllerTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
#region Base64 JSON Tests
[Fact]
public async Task POST_ValidatePdf_Base64_ValidPdf_Returns200()
{
// Arrange
// Verwende ein echtes Test-PDF (embedded resource aus Unit Tests)
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
{
if (stream == null)
{
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
pdfBytes = ms.ToArray();
}
var base64Pdf = Convert.ToBase64String(pdfBytes);
var request = new ValidatePdfQuery { Base64Pdf = base64Pdf };
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<PdfValidationResult>();
result.Should().NotBeNull();
result!.PageCount.Should().BeGreaterThan(0);
result.FileSizeBytes.Should().BeGreaterThan(0);
result.PdfVersion.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task POST_ValidatePdf_Base64_InvalidBase64_Returns400()
{
// Arrange
var request = new ValidatePdfQuery { Base64Pdf = "invalid-base64!!!" }; // Kein gültiges Base64
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Base64");
}
[Fact]
public async Task POST_ValidatePdf_Base64_EmptyPdf_Returns400()
{
// Arrange
var request = new ValidatePdfQuery { Base64Pdf = string.Empty }; // Leerer String
// Act
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
}
#endregion
#region Multipart/Form-Data Tests
[Fact]
public async Task POST_ValidatePdf_Multipart_ValidPdf_Returns200()
{
// Arrange
var assembly = typeof(PdfValidationControllerTests).Assembly;
var resourceName = "DocumentOperator.Tests.TestData.Pdfs.valid.pdf";
byte[] pdfBytes;
using (var stream = assembly.GetManifestResourceStream(resourceName))
{
if (stream == null)
{
throw new InvalidOperationException($"Test resource '{resourceName}' not found");
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
pdfBytes = ms.ToArray();
}
// Create multipart/form-data content
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(pdfBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "test.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/validation/validate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<PdfValidationResult>();
result.Should().NotBeNull();
result!.PageCount.Should().BeGreaterThan(0);
result.FileSizeBytes.Should().BeGreaterThan(0);
result.PdfVersion.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task POST_ValidatePdf_Multipart_EmptyFile_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(Array.Empty<byte>());
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "empty.pdf");
// Act
var response = await _client.PostAsync("/api/pdf/validation/validate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problemDetails = await response.Content.ReadAsStringAsync();
problemDetails.Should().Contain("cannot be empty");
}
[Fact]
public async Task POST_ValidatePdf_Multipart_NoFile_Returns400()
{
// Arrange
using var content = new MultipartFormDataContent();
// Act
var response = await _client.PostAsync("/api/pdf/validation/validate", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
}

View File

@@ -1,95 +0,0 @@
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Application.Features.Documents.ExtractSwissQrCode;
using DocumentOperator.Domain.Models.ValueObjects;
using DocumentOperator.Domain.ValueObjects;
using FluentAssertions;
using Moq;
using Xunit;
namespace DocumentOperator.Tests.Unit.Application.Features.ExtractSwissQrCode;
public sealed class ExtractSwissQrCodeHandlerTests
{
private readonly Mock<ISwissQrCodeProcessor> _mockQrCodeProcessor;
private readonly ExtractSwissQrCodeHandler _handler;
public ExtractSwissQrCodeHandlerTests()
{
_mockQrCodeProcessor = new Mock<ISwissQrCodeProcessor>();
_handler = new ExtractSwissQrCodeHandler(_mockQrCodeProcessor.Object);
}
[Fact]
public async Task Handle_ValidRequest_ReturnsQrCodeDataAndReferences()
{
// Arrange
var references = new List<string> { "REF-001", "REF-002" };
var pdfBase64 = "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovUGFnZXMgMiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDEKPj4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL1BhZ2UKL1BhcmVudCAyIDAgUgovTWVkaWFCb3ggWzAgMCA2MTIgNzkyXQovQ29udGVudHMgNCAwIFIKPj4KZW5kb2JqCjQgMCBvYmoKPDwKL0xlbmd0aCAzMgo+PgpzdHJlYW0KQlQKL0YxIDEyIFRmCjEwMCA3MDAgVGQKKEhlbGxvKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA1CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxNSAwMDAwMCBuIAowMDAwMDAwMDY0IDAwMDAwIG4gCjAwMDAwMDAxMjEgMDAwMDAgbiAKMDAwMDAwMDIxMyAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDUKL1Jvb3QgMSAwIFIKPj4Kc3RhcnR4cmVmCjI5NAolJUVPRgo=";
var base64String = Base64String.Create(pdfBase64);
var query = new ExtractSwissQrCodeQuery(references, base64String);
var expectedQrCodeData = new SwissQrCodeData
{
QrType = "SPC",
Version = "0200",
CodingType = "1",
Iban = "CH4431999123000889012",
Creditor = new AddressData
{
AddressType = "S",
Name = "Robert Schneider AG",
Street = "Rue du Lac",
BuildingNumber = "1268",
PostalCode = "2501",
City = "Biel",
Country = "CH"
},
UltimateCreditor = null,
Amount = 1949.75m,
Currency = "CHF",
UltimateDebtor = null,
ReferenceType = "QRR",
Reference = "210000000003139471430009017",
UnstructuredMessage = "Order from 15.01.2025",
BillInformation = null,
AlternativeProcedureParameters = null
};
_mockQrCodeProcessor
.Setup(x => x.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedQrCodeData);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.References.Should().BeEquivalentTo(references);
result.QrCodeData.Should().BeEquivalentTo(expectedQrCodeData);
_mockQrCodeProcessor.Verify(
x => x.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Handle_QrCodeProcessorThrowsException_PropagatesException()
{
// Arrange
var references = new List<string> { "REF-001" };
var pdfBase64 = "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovUGFnZXMgMiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDEKPj4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL1BhZ2UKL1BhcmVudCAyIDAgUgovTWVkaWFCb3ggWzAgMCA2MTIgNzkyXQovQ29udGVudHMgNCAwIFIKPj4KZW5kb2JqCjQgMCBvYmoKPDwKL0xlbmd0aCAzMgo+PgpzdHJlYW0KQlQKL0YxIDEyIFRmCjEwMCA3MDAgVGQKKEhlbGxvKSBUagpFVAplbmRzdHJlYW0KZW5kb2JqCnhyZWYKMCA1CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAxNSAwMDAwMCBuIAowMDAwMDAwMDY0IDAwMDAwIG4gCjAwMDAwMDAxMjEgMDAwMDAgbiAKMDAwMDAwMDIxMyAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDUKL1Jvb3QgMSAwIFIKPj4Kc3RhcnR4cmVmCjI5NAolJUVPRgo=";
var base64String = Base64String.Create(pdfBase64);
var query = new ExtractSwissQrCodeQuery(references, base64String);
_mockQrCodeProcessor
.Setup(x => x.ExtractSwissQrCodeAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("QR Code processing failed"));
// Act
Func<Task> act = async () => await _handler.Handle(query, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("QR Code processing failed");
}
}

View File

@@ -1,5 +1,7 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Application.Features.Documents.ValidatePdf;
using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Domain.Models.ValueObjects;
using FluentAssertions;
@@ -11,23 +13,24 @@ namespace DocumentOperator.Tests.Unit.Application.Features.ValidatePdf;
public class ValidatePdfHandlerTests
{
private readonly Mock<IPdfProcessor> _mockPdfProcessor;
private readonly ValidatePdfHandler _handler;
private readonly Mock<IMapper> _mockMapper;
private readonly ValidatePdfQueryHandler _handler;
public ValidatePdfHandlerTests()
{
_mockPdfProcessor = new Mock<IPdfProcessor>();
_handler = new ValidatePdfHandler(_mockPdfProcessor.Object);
_mockMapper = new Mock<IMapper>();
_handler = new ValidatePdfQueryHandler(_mockPdfProcessor.Object, _mockMapper.Object);
}
[Fact]
public async Task Handle_ValidPdf_ReturnsPdfMetadata()
{
// Arrange
var base64Pdf = Convert.ToBase64String(new byte[] { 0x25, 0x50, 0x44, 0x46 }); // "%PDF"
var pdfContent = Base64String.Create(base64Pdf);
var query = new ValidatePdfQuery(pdfContent);
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
var expectedMetadata = new PdfMetadata(
var domainMetadata = new PdfMetadata(
pageCount: 5,
fileSizeBytes: 1024,
pdfVersion: "1.4",
@@ -35,9 +38,22 @@ public class ValidatePdfHandlerTests
attachmentCount: 0
);
var expectedDto = new PdfValidationResult(
PageCount: 5,
FileSizeBytes: 1024,
FileSizeMB: 0.00,
PdfVersion: "1.4",
HasAttachments: false,
AttachmentCount: 0
);
_mockPdfProcessor
.Setup(x => x.ValidateAsync(It.IsAny<byte[]>()))
.ReturnsAsync(expectedMetadata);
.ReturnsAsync(domainMetadata);
_mockMapper
.Setup(x => x.Map<PdfValidationResult>(domainMetadata))
.Returns(expectedDto);
// Act
var result = await _handler.Handle(query, CancellationToken.None);
@@ -50,29 +66,27 @@ public class ValidatePdfHandlerTests
result.HasAttachments.Should().BeFalse();
result.AttachmentCount.Should().Be(0);
_mockPdfProcessor.Verify(
x => x.ValidateAsync(It.IsAny<byte[]>()),
Times.Once
);
_mockPdfProcessor.Verify(x => x.ValidateAsync(It.IsAny<byte[]>()), Times.Once);
_mockMapper.Verify(x => x.Map<PdfValidationResult>(domainMetadata), Times.Once);
}
[Fact]
public async Task Handle_PdfProcessorThrowsException_PropagatesException()
{
// Arrange
var base64Pdf = Convert.ToBase64String(new byte[] { 0x25, 0x50, 0x44, 0x46 });
var pdfContent = Base64String.Create(base64Pdf);
var query = new ValidatePdfQuery(pdfContent);
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
_mockPdfProcessor
.Setup(x => x.ValidateAsync(It.IsAny<byte[]>()))
.ThrowsAsync(new PdfProcessingException("Invalid PDF format"));
// Act
Func<Task> act = async () => await _handler.Handle(query, CancellationToken.None);
// Act & Assert
var exception = await Assert.ThrowsAsync<PdfProcessingException>(
() => _handler.Handle(query, CancellationToken.None)
);
// Assert
await act.Should().ThrowAsync<PdfProcessingException>()
.WithMessage("Invalid PDF format");
exception.Message.Should().Be("Invalid PDF format");
_mockPdfProcessor.Verify(x => x.ValidateAsync(It.IsAny<byte[]>()), Times.Once);
}
}