- Add unit tests for ValidatePdfAQueryHandler (4 tests) - Add integration tests for PDF/A validation endpoint (6 tests) - Fix FluentValidation: Add Base64 format validation to ValidatePdfAQueryValidator - Update AGENTS.md: Document 3-folder test structure rationale and test count (30 tests) - Add DocumentOperator.API/README.md: 16 manual test scenarios for all endpoints Test coverage: - Unit: ValidatePdfAQueryHandler (compliant, non-compliant, encrypted, exceptions) - Integration: PDF/A endpoint (multipart + Base64, validation, error handling) - Manual: Step-by-step Swagger UI testing guide for all features All 30 automated tests passing.
24 KiB
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 ControllersProgram.csline 72:app.MapDocumentEndpoints()→ Replace withapp.MapControllers()Program.csline 44: Addbuilder.Services.AddControllers()- All DTOs → Support BOTH
IFormFile(multipart) ANDBase64String(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 pattern library
Flow:
- FluentValidation (DTO level) → ValidationException → 400
- Domain validation → DomainValidationException → 400
- Business logic → DomainException → 400/404
- 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 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, SwissQrCode, PdfOperations, PdfConversion), then add multi-tenancy to ALL endpoints in one refactoring phase.
Build, Test, Run
Build:
dotnet build
Run tests (30 tests as of Feature 3 - PDF/A Validation):
dotnet test
Run API (Development):
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 pattern. Use exceptions + central middleware.
Flow:
- FluentValidation validates request DTOs → throws
ValidationException→ HTTP 400 - Domain validation in Value Objects → throws
DomainValidationException→ HTTP 400 - Business logic errors → throws
DomainExceptionsubtypes → HTTP 400/404/500 - 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):
PdfValidationController– 2 endpointsPOST /api/pdf/validation/validate(Basic PDF validation)POST /api/pdf/validation/validate-pdfa(PDF/A conformance)
PdfAttachmentController– check endpointPOST /api/pdf/attachments/check(Attachment detection)
SwissQrCodeController– extract endpointPOST /api/swissqrcode/extract(Swiss QR Bill extraction)
PdfAttachmentController– extract endpointPOST /api/pdf/attachments/extract(Extract attachments as ZIP)
PdfOperationsController– merge endpointPOST /api/pdf/operations/merge(Merge multiple PDFs)
Phase 2:
6. PdfOperationsController – stamp & annotate
POST /api/pdf/operations/stamp(Add stamps)POST /api/pdf/operations/annotate(Add annotations)
PdfAttachmentController– add attachmentPOST /api/pdf/attachments/add(Embed attachments in PDF/A-3)
Phase 3:
8. PdfConversionController – PDF ↔ PDF/A conversion
POST /api/pdf/conversion/to-pdfa(Convert to PDF/A)POST /api/pdf/conversion/from-pdfa(Convert from PDF/A)
Removed:
PdfRenderController– Moved to .NET client library (WinForms/WPF DevExpress controls)
Current Status
| Controller | Status | Tests |
|---|---|---|
| PdfValidationController | ✅ DONE | 13 (7 validate + 6 validate-pdfa) |
| SwissQrCodeController | ✅ DONE | 2 |
| PdfAttachmentController | ⏳ Pending | 0 |
| PdfOperationsController | ⏳ Pending | 0 |
| PdfConversionController | ⏳ Pending | 0 |
Note: PdfRenderController removed - moved to .NET client library.
Adding a New Feature
Required steps (follow CONTROLLER_ENDPOINTS.md):
- Domain: Value Objects, Exceptions (if needed)
- Infrastructure: Service interface + DevExpress implementation + unit tests
- Application: Query/Command + Handler + FluentValidator + DTOs + unit tests
- API: Controller + actions + integration tests
- 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:
var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
Do NOT commit new binary files without marking them as <EmbeddedResource>.
Test Structure & Strategy
3-folder structure (CORRECT approach by previous developer):
DocumentOperator.Tests/
├── Integration/
│ └── API/
│ ├── PdfValidationControllerTests.cs (13 tests)
│ └── ExtractSwissQrCodeEndpointTests.cs (2 tests)
├── TestData/
│ └── Pdfs/ (EmbeddedResource PDFs)
├── Unit/
│ ├── Application/
│ │ └── Features/
│ │ ├── ValidatePdf/
│ │ │ └── ValidatePdfHandlerTests.cs (2 tests)
│ │ ├── ValidatePdfA/
│ │ │ └── ValidatePdfAQueryHandlerTests.cs (4 tests)
│ │ └── ExtractSwissQrCode/
│ │ └── ExtractSwissQrCodeHandlerTests.cs (2 tests)
│ └── Infrastructure/
│ └── Services/
│ └── PdfProcessing/
│ └── DevExpressPdfProcessorTests.cs (7 tests)
✅ Why this structure is CORRECT:
-
Integration vs Unit separation:
- Integration: WebApplicationFactory → REAL API calls (HTTP, middleware, MediatR pipeline, DevExpress)
- Unit: Mock-based ISOLATED tests (Handler only depends on mocked IPdfProcessor)
-
TestData centralization:
- All 3 layers share same EmbeddedResource PDFs (no duplication)
- Accessed via
Assembly.GetManifestResourceStream()
-
Vertical Slice compliance:
Unit/Application/Features/ValidatePdf/→ Each feature's tests co-located- Matches Application layer structure exactly
-
Test Pyramid:
- Unit tests (15): Fast, isolated, many scenarios
- Integration tests (15): Slower, full pipeline, critical paths only
Test count: 30 tests total (as of Feature 3 - PDF/A Validation)
FluentValidation in tests:
- Base64 format validation happens in
ValidatePdfQueryValidatorandValidatePdfAQueryValidator - Prevents
FormatExceptionfrom reaching handler (caught as 400 Bad Request, not 500) - Unit tests verify handler behavior with valid inputs only
- Integration tests verify full validation pipeline (including FluentValidation)
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.PdfDocumentProcessorto render last page as image - Use
ZXingto decode QR code from image - Use
Codecrete.SwissQRBill.Generatorto 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:
- ValidationBehavior (runs first): Executes all
IValidator<TRequest>and throwsValidationExceptionif invalid - 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)
[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
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:
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:
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:
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:
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.
public record ValidatePdfRequest
{
public byte[]? PdfBytes { get; init; }
public string? Base64Pdf { get; init; }
}
FluentValidation: Ensure exactly ONE is provided:
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:
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.
// Handler
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// If Base64 is invalid, FormatException → Middleware → 400 Bad Request
Middleware handles:
FormatException→ 400 Bad RequestValidationException→ 400 Bad RequestDomainException→ 400/404PdfProcessingException→ 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:
- Complete one logical change (e.g., "Add PdfValidationController")
- Stage only related files:
git add <specific-files> - Ask user: "Ready to commit 'Add PdfValidationController'?"
- After approval:
git commit -m "Add PdfValidationController with dual input support" - Repeat for next logical change
❌ Wrong approach:
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 supportrefactor: Replace Base64String value object with direct string usagetest: Add integration tests for PdfValidationControllerdocs: Add AGENTS.md with architecture guidancechore: 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 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
ZXingwithTryHarderhint enabled
Attachment count wrong:
- Search entire PDF stream, not just first 1000 chars (see ROADMAP.md fix log 17.01.2025)
- Count
/EmbeddedFilesobject 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.