Compare commits
6 Commits
077eb1e017
...
45bc90b8b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 45bc90b8b8 | |||
| 57e36fc004 | |||
| d6e3a5fda1 | |||
| b460d8df39 | |||
| 398651964e | |||
| f8690b9417 |
638
AGENTS.md
Normal file
638
AGENTS.md
Normal 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.412–10.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.
|
||||
79
DocumentOperator.API/Controllers/PdfValidationController.cs
Normal file
79
DocumentOperator.API/Controllers/PdfValidationController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
94
DocumentOperator.API/Controllers/SwissQrCodeController.cs
Normal file
94
DocumentOperator.API/Controllers/SwissQrCodeController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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,
|
||||
@@ -29,7 +29,7 @@ namespace DocumentOperator.Application.Common.DTOs;
|
||||
/// }
|
||||
/// }
|
||||
/// </example>
|
||||
public record ExtractSwissQrCodeResponse(
|
||||
public record SwissQrCodeExtractionResult(
|
||||
IReadOnlyList<string> References,
|
||||
SwissQrCodeDataDto QrCodeData
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user